mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
109 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93ec537095 | ||
|
|
9255212043 | ||
|
|
a03da6774a | ||
|
|
7c5bbc8048 | ||
|
|
76e6ad89d2 | ||
|
|
93d6eb611e | ||
|
|
ce5d9d6dc8 | ||
|
|
abc146b186 | ||
|
|
4300750001 | ||
|
|
0af2d8989a | ||
|
|
34e35621f0 | ||
|
|
efde557d6f | ||
|
|
50b99584a1 | ||
|
|
459af22670 | ||
|
|
12ba65d6e7 | ||
|
|
bb6d30e4f8 | ||
|
|
82a5723240 | ||
|
|
949ac8decb | ||
|
|
de99646880 | ||
|
|
170ee27813 | ||
|
|
22da0951d5 | ||
|
|
c371dc92a5 | ||
|
|
427e3daca9 | ||
|
|
f590e60617 | ||
|
|
259209b7ef | ||
|
|
93111b47ae | ||
|
|
7008803800 | ||
|
|
52b3001111 | ||
|
|
6c7ce63272 | ||
|
|
04a06197de | ||
|
|
5bd6cc11de | ||
|
|
4313e3623b | ||
|
|
36ac228bef | ||
|
|
12eade8f98 | ||
|
|
b105409844 | ||
|
|
22c46a9c73 | ||
|
|
037a6a15a4 | ||
|
|
b980abb7af | ||
|
|
0103b4da96 | ||
|
|
75dc802a3a | ||
|
|
adb511d2be | ||
|
|
9244f1fbd6 | ||
|
|
1f72ce63cd | ||
|
|
41d1fa6d97 | ||
|
|
9da5b26db0 | ||
|
|
756f9a7972 | ||
|
|
86b476e309 | ||
|
|
25a6f204a4 | ||
|
|
deeba6d6eb | ||
|
|
a644adbf9b | ||
|
|
c4bebb97ab | ||
|
|
21f191583f | ||
|
|
d9e14186ce | ||
|
|
c87c206900 | ||
|
|
b1956cffb7 | ||
|
|
fca2776e61 | ||
|
|
8ca134f1a5 | ||
|
|
ccaeb3de21 | ||
|
|
8e5d2805c1 | ||
|
|
d6f21b3568 | ||
|
|
86b291814b | ||
|
|
1b84613be1 | ||
|
|
004bb0ea33 | ||
|
|
6ff1c42132 | ||
|
|
80f0ca08f9 | ||
|
|
193c723714 | ||
|
|
a04096af44 | ||
|
|
0bbdefa7b8 | ||
|
|
b8f6115465 | ||
|
|
f1626698f4 | ||
|
|
8169134ac3 | ||
|
|
cf8393407e | ||
|
|
c5d7fa52b4 | ||
|
|
cfd061a86d | ||
|
|
c41815dcb7 | ||
|
|
67620569ab | ||
|
|
69f851aa14 | ||
|
|
e9efaa8499 | ||
|
|
4948ba581a | ||
|
|
b64fa4ca93 | ||
|
|
bf432b3d3a | ||
|
|
837d0fb387 | ||
|
|
6d4d74fa3a | ||
|
|
d771e17efd | ||
|
|
9f45aa034d | ||
|
|
8b6f4da70d | ||
|
|
46194af14a | ||
|
|
734cf7af20 | ||
|
|
6f6677fc7a | ||
|
|
972895d855 | ||
|
|
50acf2dd44 | ||
|
|
cb2e4aacfe | ||
|
|
117bbfe2f4 | ||
|
|
8bdd083512 | ||
|
|
f79468b793 | ||
|
|
018edef3bb | ||
|
|
44c32fb8fb | ||
|
|
e542854ae0 | ||
|
|
fba7c73c8b | ||
|
|
f55d0cd25f | ||
|
|
3319c2d40c | ||
|
|
bb93fc2a61 | ||
|
|
0d0f2826d9 | ||
|
|
d682c74469 | ||
|
|
f55e010a19 | ||
|
|
5f864a4dd7 | ||
|
|
6053ee7c34 | ||
|
|
3a303d2095 | ||
|
|
84079bfc36 |
25
.github/workflows/desktop-release.yml
vendored
25
.github/workflows/desktop-release.yml
vendored
@@ -20,6 +20,7 @@ jobs:
|
||||
publish-tauri:
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
@@ -44,7 +45,7 @@ jobs:
|
||||
targets: aarch64-apple-darwin
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm install --workspace=@getpaseo/app --workspace=@getpaseo/desktop --include-workspace-root
|
||||
run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -66,34 +67,42 @@ jobs:
|
||||
|
||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
||||
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
|
||||
const tauriRe = /(\"version\"\\s*:\\s*\")([^\"]+)(\")/;
|
||||
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
|
||||
if (!tauriRe.test(tauriConfText)) {
|
||||
throw new Error(`Failed to find version field in ${tauriConfPath}`);
|
||||
}
|
||||
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
|
||||
|
||||
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
|
||||
const cargoLines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\\r?\\n/);
|
||||
const cargoLines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
|
||||
let inPackage = false;
|
||||
let updated = false;
|
||||
const nextLines = cargoLines.map((line) => {
|
||||
if (/^\\[package\\]\\s*$/.test(line)) inPackage = true;
|
||||
else if (inPackage && /^\\[/.test(line)) inPackage = false;
|
||||
if (/^\[package\]\s*$/.test(line)) inPackage = true;
|
||||
else if (inPackage && /^\[/.test(line)) inPackage = false;
|
||||
|
||||
if (inPackage && /^version\\s*=\\s*\".*\"\\s*$/.test(line)) {
|
||||
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
|
||||
updated = true;
|
||||
return `version = \"${version}\"`;
|
||||
return `version = "${version}"`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
if (!updated) throw new Error(`Failed to update Cargo package version in ${cargoTomlPath}`);
|
||||
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\\n')}\\n`);
|
||||
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`);
|
||||
NODE
|
||||
|
||||
- name: Build and publish Tauri release
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
projectPath: packages/desktop
|
||||
tagName: ${{ env.RELEASE_TAG }}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
rust 1.85.1
|
||||
nodejs 22.20.0
|
||||
|
||||
99
CHANGELOG.md
Normal file
99
CHANGELOG.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Changelog
|
||||
|
||||
## [0.1.9] - 2026-02-17
|
||||
### Improved
|
||||
- Unified structured-output generation through a single shared schema-validation and retry pipeline.
|
||||
- Reused provider availability checks for structured generation fallback selection.
|
||||
- Added structured generation waterfall ordering for internal metadata and git text generation: Claude Haiku, then Codex, then OpenCode.
|
||||
|
||||
### Fixed
|
||||
- Fixed CLI `run --output-schema` to use the shared structured-output path instead of ad-hoc JSON parsing.
|
||||
- Fixed `run --output-schema` failures where providers returned empty `lastMessage` by recovering from timeline assistant output.
|
||||
- Fixed internal commit message, pull request text, and agent metadata generation to follow one consistent structured pipeline.
|
||||
|
||||
## [0.1.8] - 2026-02-17
|
||||
### Added
|
||||
- Added a cross-platform confirm dialog flow for daemon restarts.
|
||||
|
||||
### Improved
|
||||
- Simplified local speech bootstrap and daemon startup locking behavior.
|
||||
- Updated website hero copy to emphasize local execution.
|
||||
|
||||
### Fixed
|
||||
- Fixed stuck "send while running" recovery across app and server session handling.
|
||||
- Fixed Claude session identity preservation when reloading existing agents.
|
||||
- Fixed combobox option behavior and related interactions.
|
||||
- Fixed Tauri file-drop listener cleanup to avoid uncaught unlisten errors.
|
||||
- Fixed web tool-detail wheel event routing at scroll edges.
|
||||
|
||||
## [0.1.7] - 2026-02-16
|
||||
### Added
|
||||
- Improved agent workspace flows with better directory suggestions.
|
||||
- Added iOS TestFlight and Android app access request forms on the website.
|
||||
|
||||
### Improved
|
||||
- Unified daemon startup behavior between dev and CLI paths for more predictable local runs.
|
||||
- Improved website app download and update guidance.
|
||||
|
||||
### Fixed
|
||||
- Prevented an initial desktop combobox `0,0` position flash.
|
||||
- Fixed CLI version output issues.
|
||||
- Hardened server runtime loading for local speech dependencies.
|
||||
|
||||
## [0.1.6] - 2026-02-16
|
||||
### Notes
|
||||
- No major visible product changes in this patch release.
|
||||
|
||||
## [0.1.5] - 2026-02-16
|
||||
### Added
|
||||
- Added terminal reattach support and better worktree terminal handling.
|
||||
- Added global keyboard shortcut help in the app.
|
||||
- Added sidebar host filtering and improved agent workflow controls.
|
||||
|
||||
### Improved
|
||||
- Improved worktree setup visibility by streaming setup progress.
|
||||
- Improved terminal streaming reliability and lifecycle handling.
|
||||
- Preserved explorer tab state so context survives navigation better.
|
||||
|
||||
## [0.1.4] - 2026-02-14
|
||||
### Added
|
||||
- Added voice capability status reporting in the client.
|
||||
- Added background local speech model downloads with runtime gating.
|
||||
- Added adaptive dictation finish timing based on server-provided budgets.
|
||||
- Added relay reconnect behavior with grace periods and branch suggestions.
|
||||
|
||||
### Improved
|
||||
- Improved connection selection and agent hydration reliability.
|
||||
- Improved timeline loading with cursor-based fetch behavior.
|
||||
- Improved first-run experience by bootstrapping a default localhost connection.
|
||||
- Improved inline code rendering by auto-linkifying URLs.
|
||||
|
||||
### Fixed
|
||||
- Fixed Linux checkout diff watch behavior to avoid recursive watches.
|
||||
- Fixed stale relay client timer behavior.
|
||||
- Fixed unnecessary git diff header auto-scroll on collapse.
|
||||
|
||||
## [0.1.3] - 2026-02-12
|
||||
### Added
|
||||
- Added CLI onboarding command.
|
||||
- Added CLI `--output-schema` support for structured agent output.
|
||||
- Added CLI agent metadata update support for names and labels.
|
||||
- Added provider availability detection with normalization of legacy default model IDs.
|
||||
|
||||
### Improved
|
||||
- Improved file explorer refresh feedback and unresolved checkout fallback handling.
|
||||
- Added better voice interrupt handling with a speech-start grace period.
|
||||
- Improved CLI defaults to list all non-archived agents by default.
|
||||
- Improved website UX with clearer install CTA and privacy policy access.
|
||||
|
||||
### Fixed
|
||||
- Fixed dev runner entry issues and sherpa TTS initialization behavior.
|
||||
|
||||
## [0.1.2] - 2026-02-11
|
||||
### Notes
|
||||
- No major visible product changes in this patch release.
|
||||
|
||||
## [0.1.1] - 2026-02-11
|
||||
|
||||
### Added
|
||||
- Initial `0.1.x` release line.
|
||||
52
CLAUDE.md
52
CLAUDE.md
@@ -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`
|
||||
- `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`.
|
||||
|
||||
@@ -136,6 +136,28 @@ 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.
|
||||
|
||||
That workflow does:
|
||||
- Build iOS with the `production` profile
|
||||
- Build Android with the `production` profile
|
||||
- Submit each build with the `production` submit profile
|
||||
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
# List recent mobile workflow runs
|
||||
cd packages/app && npx eas workflow:runs --workflow release-mobile.yml --limit 10
|
||||
|
||||
# Inspect one run (jobs, status, outputs)
|
||||
cd packages/app && npx eas workflow:view <run-id>
|
||||
|
||||
# Stream logs for all steps in one failed job
|
||||
cd packages/app && npx eas workflow:logs <job-id> --non-interactive --all-steps
|
||||
```
|
||||
|
||||
## Testing with Playwright MCP
|
||||
|
||||
**CRITICAL:** When asked to test the app, you MUST use the Playwright MCP connecting to Metro at `http://localhost:8081`.
|
||||
@@ -148,6 +170,34 @@ Use the Playwright MCP to test the app in Metro web. Navigate to `http://localho
|
||||
|
||||
Run `npx expo-doctor` to diagnose version mismatches and native module issues.
|
||||
|
||||
## Release playbook
|
||||
|
||||
Use the scripted release flow from repo root. Avoid manual version bumps, manual tags, or ad hoc publish commands unless debugging.
|
||||
|
||||
```bash
|
||||
# Recommended: full patch release (bump, check, publish, push branch+tag)
|
||||
npm run release:patch
|
||||
|
||||
# Manual, step-by-step fallback:
|
||||
npm run version:all:patch # npm version across all workspaces (creates commit + local tag)
|
||||
npm run release:check
|
||||
npm run release:publish
|
||||
npm run release:push # pushes HEAD and current version tag (triggers desktop + EAS mobile workflows)
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `version:all:*` bumps the root package version and runs the root `version` lifecycle script to sync workspace versions and internal `@getpaseo/*` dependency versions before the release commit/tag is created.
|
||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale local package types during release checks.
|
||||
- If `release:publish` fails after a successful publish of one workspace, re-run `npm run release:publish`; npm will skip already-published versions and continue where possible.
|
||||
- If a user asks to "release paseo" (without specifying major/minor), treat it as a patch release and run `npm run release:patch`.
|
||||
- All workspaces share one version by design. Keep versions synchronized and release together.
|
||||
- The website Mac download CTA URL is derived from `packages/website/package.json` version at build time, so no manual update is required after release.
|
||||
|
||||
Release completion checklist:
|
||||
- `npm run release:patch` completes successfully.
|
||||
- GitHub `Desktop Release` workflow for the new `v*` tag is green.
|
||||
- EAS `release-mobile.yml` workflow for the same tag is green (Expo queues can take longer on the free plan).
|
||||
|
||||
## Orchestrator Mode
|
||||
|
||||
- **When agent control tool calls fail**, make sure you list agents before trying to launch another one. It could just be a wait timeout.
|
||||
|
||||
81
README.md
81
README.md
@@ -17,73 +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.
|
||||
|
||||
## Features
|
||||
|
||||
- **Self-hosted:** The daemon runs on your laptop, home server, or VPS
|
||||
- **Multi-provider:** Works with Claude Code, Codex, and OpenCode from one interface
|
||||
- **Multi-host:** Connect to multiple daemons and see all your agents in one place
|
||||
- **Voice input:** Dictate prompts when you're away from your keyboard
|
||||
- **Optional relay:** Use the hosted end-to-end encrypted relay, or connect directly
|
||||
- **Cross-device:** iOS, Android, desktop, web, and CLI
|
||||
- **Git integration:** Manage agents in isolated worktrees, review diffs, ship from the app
|
||||
- **Open source:** Free and open source under MIT license
|
||||
|
||||
## Quick Start
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
npm install -g @getpaseo/cli && paseo
|
||||
npm install -g @getpaseo/cli
|
||||
paseo
|
||||
```
|
||||
|
||||
Then open the app and connect to your daemon.
|
||||
|
||||
## Local speech (STT/TTS)
|
||||
For full setup and configuration, see:
|
||||
- [Docs](https://paseo.sh/docs)
|
||||
- [Configuration reference](https://paseo.sh/docs/configuration)
|
||||
|
||||
Paseo can run dictation + voice mode STT/TTS fully locally via `sherpa-onnx`.
|
||||
## Development
|
||||
|
||||
When the daemon starts with a local speech provider selected, it will download any missing model files automatically (unless `PASEO_SHERPA_ONNX_AUTO_DOWNLOAD=0`).
|
||||
Quick monorepo package map:
|
||||
- `packages/server`: Paseo daemon (agent process orchestration, WebSocket API, MCP server)
|
||||
- `packages/app`: Expo client (iOS, Android, web)
|
||||
- `packages/cli`: `paseo` CLI for daemon and agent workflows
|
||||
- `packages/desktop`: Tauri desktop app
|
||||
- `packages/relay`: Relay package for remote connectivity
|
||||
- `packages/website`: Marketing site and documentation (`paseo.sh`)
|
||||
|
||||
Common commands:
|
||||
|
||||
```bash
|
||||
npm run speech:download --workspace=@getpaseo/server
|
||||
# run all local dev services
|
||||
npm run dev
|
||||
|
||||
# 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
|
||||
git tag v0.1.0
|
||||
git push origin v0.1.0
|
||||
```
|
||||
|
||||
If you prefer, `npm version` can be used to create and push a version tag.
|
||||
|
||||
This triggers the `Desktop Release` workflow (`.github/workflows/desktop-release.yml`).
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
601
package-lock.json
generated
601
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.12",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -56,6 +56,22 @@
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/openai": {
|
||||
"version": "2.0.52",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-2.0.52.tgz",
|
||||
"integrity": "sha512-n1arAo4+63e6/FFE6z/1ZsZbiOl4cfsoZ3F4i2X7LPIEea786Y2yd7Qdr7AdB4HTLVo3OSb1PHVIcQmvYIhmEA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "2.0.0",
|
||||
"@ai-sdk/provider-utils": "3.0.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/provider": {
|
||||
"version": "2.0.0",
|
||||
"license": "Apache-2.0",
|
||||
@@ -1385,6 +1401,27 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@clack/core": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.0.0.tgz",
|
||||
"integrity": "sha512-Orf9Ltr5NeiEuVJS8Rk2XTw3IxNC2Bic3ash7GgYeA8LJ/zmSNpSQ/m5UAhe03lA6KFgklzZ5KTHs4OAMA/SAQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picocolors": "^1.0.0",
|
||||
"sisteransi": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@clack/prompts": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.0.0.tgz",
|
||||
"integrity": "sha512-rWPXg9UaCFqErJVQ+MecOaWsozjaxol4yjnmYcGNipAWzdaWa2x+VJmKfGq7L0APwBohQOYdHC+9RO4qRXej+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clack/core": "1.0.0",
|
||||
"picocolors": "^1.0.0",
|
||||
"sisteransi": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudflare/kv-asset-handler": {
|
||||
"version": "0.4.1",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
@@ -3679,6 +3716,20 @@
|
||||
"@hapi/hoek": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.9",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
|
||||
"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hono": "^4"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"dev": true,
|
||||
@@ -4157,6 +4208,446 @@
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
|
||||
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"content-type": "^1.0.5",
|
||||
"cors": "^2.8.5",
|
||||
"cross-spawn": "^7.0.5",
|
||||
"eventsource": "^3.0.2",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"hono": "^4.11.4",
|
||||
"jose": "^6.1.3",
|
||||
"json-schema-typed": "^8.0.2",
|
||||
"pkce-challenge": "^5.0.0",
|
||||
"raw-body": "^3.0.0",
|
||||
"zod": "^3.25 || ^4.0",
|
||||
"zod-to-json-schema": "^3.25.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cfworker/json-schema": "^4.1.1",
|
||||
"zod": "^3.25 || ^4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@cfworker/json-schema": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mime-types": "^3.0.0",
|
||||
"negotiator": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv-formats": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ajv": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^1.0.5",
|
||||
"debug": "^4.4.3",
|
||||
"http-errors": "^2.0.0",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.14.1",
|
||||
"raw-body": "^3.0.1",
|
||||
"type-is": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
||||
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/express": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
"content-disposition": "^1.0.0",
|
||||
"content-type": "^1.0.5",
|
||||
"cookie": "^0.7.1",
|
||||
"cookie-signature": "^1.2.1",
|
||||
"debug": "^4.4.0",
|
||||
"depd": "^2.0.0",
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"etag": "^1.8.1",
|
||||
"finalhandler": "^2.1.0",
|
||||
"fresh": "^2.0.0",
|
||||
"http-errors": "^2.0.0",
|
||||
"merge-descriptors": "^2.0.0",
|
||||
"mime-types": "^3.0.0",
|
||||
"on-finished": "^2.4.1",
|
||||
"once": "^1.4.0",
|
||||
"parseurl": "^1.3.3",
|
||||
"proxy-addr": "^2.0.7",
|
||||
"qs": "^6.14.0",
|
||||
"range-parser": "^1.2.1",
|
||||
"router": "^2.2.0",
|
||||
"send": "^1.1.0",
|
||||
"serve-static": "^2.2.0",
|
||||
"statuses": "^2.0.1",
|
||||
"type-is": "^2.0.1",
|
||||
"vary": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/express-rate-limit": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
|
||||
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ip-address": "10.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
||||
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"parseurl": "^1.3.3",
|
||||
"statuses": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/fresh": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mime-db": "^1.54.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"etag": "^1.8.1",
|
||||
"fresh": "^2.0.0",
|
||||
"http-errors": "^2.0.1",
|
||||
"mime-types": "^3.0.2",
|
||||
"ms": "^2.1.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"range-parser": "^1.2.1",
|
||||
"statuses": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
|
||||
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"parseurl": "^1.3.3",
|
||||
"send": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"content-type": "^1.0.5",
|
||||
"media-typer": "^1.1.0",
|
||||
"mime-types": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"dev": true,
|
||||
@@ -6648,6 +7139,12 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/headless": {
|
||||
"version": "6.0.0",
|
||||
"license": "MIT",
|
||||
@@ -6655,6 +7152,15 @@
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/@yarnpkg/lockfile": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
@@ -11796,6 +12302,17 @@
|
||||
"version": "16.13.1",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.11.9",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz",
|
||||
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hosted-git-info": {
|
||||
"version": "7.0.2",
|
||||
"license": "ISC",
|
||||
@@ -12028,6 +12545,17 @@
|
||||
"loose-envify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
||||
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"license": "MIT",
|
||||
@@ -12784,6 +13312,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
|
||||
"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/joycon": {
|
||||
"version": "3.1.1",
|
||||
"license": "MIT",
|
||||
@@ -12851,6 +13390,14 @@
|
||||
"version": "0.4.1",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-typed": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
|
||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/json-stable-stringify": {
|
||||
"version": "1.3.0",
|
||||
"dev": true,
|
||||
@@ -13134,6 +13681,15 @@
|
||||
"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": {
|
||||
"version": "1.4.2",
|
||||
"license": "Apache-2.0",
|
||||
@@ -19788,7 +20344,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.12",
|
||||
"dependencies": {
|
||||
"@boudra/expo-two-way-audio": "^0.1.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -19796,7 +20352,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/server": "0.1.2",
|
||||
"@getpaseo/server": "0.1.12",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
@@ -19815,6 +20371,8 @@
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tauri-apps/api": "^2.9.1",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"base64-js": "^1.5.1",
|
||||
"buffer": "^6.0.3",
|
||||
"expo": "^54.0.18",
|
||||
@@ -19841,6 +20399,7 @@
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo-updates": "~29.0.12",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"lezer-elixir": "^1.1.2",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"react": "19.1.0",
|
||||
@@ -19897,10 +20456,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.12",
|
||||
"dependencies": {
|
||||
"@getpaseo/relay": "0.1.2",
|
||||
"@getpaseo/server": "0.1.2",
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.12",
|
||||
"@getpaseo/server": "0.1.12",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -19950,14 +20510,14 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.12",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.6"
|
||||
}
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.12",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -19973,12 +20533,12 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.12",
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/relay": "0.1.2",
|
||||
"@getpaseo/relay": "0.1.12",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/css": "^1.3.0",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
@@ -19996,6 +20556,7 @@
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.18.2",
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"lezer-elixir": "^1.1.2",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"node-pty": "^1.0.0",
|
||||
"onnxruntime-node": "^1.23.0",
|
||||
@@ -20026,22 +20587,6 @@
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@ai-sdk/openai": {
|
||||
"version": "2.0.52",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-2.0.52.tgz",
|
||||
"integrity": "sha512-n1arAo4+63e6/FFE6z/1ZsZbiOl4cfsoZ3F4i2X7LPIEea786Y2yd7Qdr7AdB4HTLVo3OSb1PHVIcQmvYIhmEA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "2.0.0",
|
||||
"@ai-sdk/provider-utils": "3.0.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.20.1",
|
||||
"license": "MIT",
|
||||
@@ -20345,7 +20890,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.12",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
20
package.json
20
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.12",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/server",
|
||||
@@ -12,7 +12,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "./scripts/dev.sh",
|
||||
"dev:server": "NODE_ENV=development tsx packages/server/scripts/dev-runner.ts",
|
||||
"dev:server": "NODE_ENV=development tsx packages/server/scripts/daemon-runner.ts --dev",
|
||||
"dev:app": "npm run start --workspace=@getpaseo/app",
|
||||
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
||||
"postinstall": "node scripts/postinstall-patches.mjs",
|
||||
@@ -32,13 +32,19 @@
|
||||
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
||||
"build:desktop": "npm run build --workspace=@getpaseo/desktop",
|
||||
"cli": "npx tsx packages/cli/src/index.js",
|
||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||
"version:all:patch": "npm version patch --workspaces --include-workspace-root --no-git-tag-version && npm run version:sync-internal && npm install --package-lock-only",
|
||||
"version:all:minor": "npm version minor --workspaces --include-workspace-root --no-git-tag-version && npm run version:sync-internal && npm install --package-lock-only",
|
||||
"version:all:major": "npm version major --workspaces --include-workspace-root --no-git-tag-version && npm run version:sync-internal && npm install --package-lock-only",
|
||||
"release:check": "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:prepare": "npm install --workspaces --include-workspace-root",
|
||||
"version:all:patch": "npm version patch --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 --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: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",
|
||||
"release:push": "node scripts/push-current-release-tag.mjs",
|
||||
"release:patch": "npm run version:all:patch && npm run release:check && npm run release:publish && npm run release:push",
|
||||
"release:minor": "npm run version:all:minor && npm run release:check && npm run release:publish && npm run release:push",
|
||||
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1",
|
||||
|
||||
38
packages/app/.eas/workflows/release-mobile.yml
Normal file
38
packages/app/.eas/workflows/release-mobile.yml
Normal file
@@ -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 appVariant = process.env.APP_VARIANT ?? "production";
|
||||
|
||||
function resolveSecretFile(params) {
|
||||
const fromEnv = process.env[params.envKey];
|
||||
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
|
||||
return fromEnv.trim();
|
||||
}
|
||||
|
||||
const fallbackAbsolutePath = path.resolve(__dirname, params.fallbackRelativePath);
|
||||
if (fs.existsSync(fallbackAbsolutePath)) {
|
||||
return params.fallbackRelativePath;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const variants = {
|
||||
production: {
|
||||
name: "Paseo",
|
||||
packageId: "sh.paseo",
|
||||
googleServicesFile: resolveSecretFile({
|
||||
envKey: "GOOGLE_SERVICES_FILE_PROD",
|
||||
fallbackRelativePath: "./.secrets/google-services.prod.json",
|
||||
}),
|
||||
googleServiceInfoPlist: resolveSecretFile({
|
||||
envKey: "GOOGLE_SERVICE_INFO_PLIST_PROD",
|
||||
fallbackRelativePath: "./.secrets/GoogleService-Info.prod.plist",
|
||||
}),
|
||||
},
|
||||
development: {
|
||||
name: "Paseo Debug",
|
||||
packageId: "sh.paseo.debug",
|
||||
googleServicesFile: resolveSecretFile({
|
||||
envKey: "GOOGLE_SERVICES_FILE_DEBUG",
|
||||
fallbackRelativePath: "./.secrets/google-services.debug.json",
|
||||
}),
|
||||
googleServiceInfoPlist: resolveSecretFile({
|
||||
envKey: "GOOGLE_SERVICE_INFO_PLIST_DEBUG",
|
||||
fallbackRelativePath: "./.secrets/GoogleService-Info.debug.plist",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -38,6 +70,9 @@ export default {
|
||||
ITSAppUsesNonExemptEncryption: false,
|
||||
},
|
||||
bundleIdentifier: variant.packageId,
|
||||
...(variant.googleServiceInfoPlist
|
||||
? { googleServicesFile: variant.googleServiceInfoPlist }
|
||||
: {}),
|
||||
},
|
||||
android: {
|
||||
adaptiveIcon: {
|
||||
@@ -57,6 +92,9 @@ export default {
|
||||
"android.permission.CAMERA",
|
||||
],
|
||||
package: variant.packageId,
|
||||
...(variant.googleServicesFile
|
||||
? { googleServicesFile: variant.googleServicesFile }
|
||||
: {}),
|
||||
},
|
||||
web: {
|
||||
output: "single",
|
||||
@@ -112,6 +150,6 @@ export default {
|
||||
projectId: "0e7f65ce-0367-46c8-a238-2b65963d235a",
|
||||
},
|
||||
},
|
||||
owner: "moboudra",
|
||||
owner: "getpaseo",
|
||||
},
|
||||
};
|
||||
|
||||
34
packages/app/e2e/agent-timeline-hydration.spec.ts
Normal file
34
packages/app/e2e/agent-timeline-hydration.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test('agent timeline hydrates after reload via fetch_agent_timeline_request', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const marker = 'TIMELINE_HYDRATION_OK';
|
||||
const prompt = `Respond with exactly: ${marker}`;
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, prompt);
|
||||
|
||||
const assistantMessage = page
|
||||
.getByTestId('assistant-message')
|
||||
.filter({ hasText: marker })
|
||||
.first();
|
||||
await expect(assistantMessage).toBeVisible({ timeout: 120000 });
|
||||
|
||||
await page.reload({ waitUntil: 'commit' });
|
||||
await expect(page).toHaveURL(/\/agent\//);
|
||||
await expect(page.getByTestId('agent-loading')).toHaveCount(0, { timeout: 30000 });
|
||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId('assistant-message').filter({ hasText: marker }).first()
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -153,6 +153,9 @@ export default async function globalSetup() {
|
||||
PASEO_LISTEN: `0.0.0.0:${port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
||||
// Keep e2e bootstrap fast and deterministic; terminal/sidebar tests do not need speech.
|
||||
PASEO_DICTATION_ENABLED: "0",
|
||||
PASEO_VOICE_MODE_ENABLED: "0",
|
||||
NODE_ENV: 'development',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
|
||||
@@ -168,11 +168,13 @@ export const openSettings = async (page: Page) => {
|
||||
};
|
||||
|
||||
export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
const workingDirectoryLabel = page.getByText('WORKING DIRECTORY', { exact: true }).first();
|
||||
await expect(workingDirectoryLabel).toBeVisible();
|
||||
const workingDirectorySelect = page.getByTestId('working-directory-select').first();
|
||||
const workingDirectorySelect = page
|
||||
.locator('[data-testid="working-directory-select"]:visible')
|
||||
.first();
|
||||
await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const input = page.getByRole('textbox', { name: '/path/to/project' });
|
||||
const legacyInput = page.getByRole('textbox', { name: '/path/to/project' }).first();
|
||||
const directorySearchInput = page.getByRole('textbox', { name: /search directories/i }).first();
|
||||
const worktreePicker = page.getByTestId('worktree-attach-picker');
|
||||
const worktreeSheetTitle = page.getByText('Select worktree', { exact: true });
|
||||
const closeBottomSheet = async () => {
|
||||
@@ -217,29 +219,54 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
|
||||
await closeWorktreeSheetIfOpen();
|
||||
|
||||
if (!(await input.isVisible())) {
|
||||
const pickerInputVisible = async () =>
|
||||
(await directorySearchInput.isVisible().catch(() => false)) ||
|
||||
(await legacyInput.isVisible().catch(() => false));
|
||||
|
||||
if (!(await pickerInputVisible())) {
|
||||
await closeBottomSheet();
|
||||
await workingDirectorySelect.click({ force: true });
|
||||
if (!(await input.isVisible())) {
|
||||
if (!(await pickerInputVisible())) {
|
||||
await closeBottomSheet();
|
||||
await workingDirectorySelect.click({ force: true });
|
||||
}
|
||||
await expect(input).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => pickerInputVisible(), { timeout: 10000 })
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
const trimmedDirectory = directory.replace(/\/+$/, '');
|
||||
await input.fill(trimmedDirectory);
|
||||
await input.press('Enter');
|
||||
const activeInput =
|
||||
(await directorySearchInput.isVisible().catch(() => false))
|
||||
? directorySearchInput
|
||||
: legacyInput;
|
||||
|
||||
// Desktop web supports selecting via Enter; mobile may require clicking the explicit option.
|
||||
const useOption = page.getByTestId('working-directory-custom-option').first();
|
||||
if (await useOption.isVisible().catch(() => false)) {
|
||||
await expect(useOption).toContainText(`Use "${trimmedDirectory}"`);
|
||||
await useOption.click({ force: true });
|
||||
await activeInput.fill(trimmedDirectory);
|
||||
|
||||
if (activeInput === directorySearchInput) {
|
||||
// Combobox custom rows can be either plain path labels or prefixed labels.
|
||||
const plainOption = page
|
||||
.getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, 'i'))
|
||||
.first();
|
||||
const prefixedUseOption = page
|
||||
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, 'i'))
|
||||
.first();
|
||||
|
||||
if (await plainOption.isVisible().catch(() => false)) {
|
||||
await plainOption.click({ force: true });
|
||||
} else if (await prefixedUseOption.isVisible().catch(() => false)) {
|
||||
await prefixedUseOption.click({ force: true });
|
||||
} else {
|
||||
// Fallback: accept highlighted option (directory suggestion).
|
||||
await activeInput.press('Enter');
|
||||
}
|
||||
} else {
|
||||
// Legacy path picker fallback.
|
||||
await activeInput.press('Enter');
|
||||
}
|
||||
|
||||
// Wait for the sheet to close after clicking "Use"
|
||||
await expect(input).not.toBeVisible({ timeout: 10000 });
|
||||
// Wait for picker to close.
|
||||
await expect(activeInput).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
const directoryCandidates = new Set<string>([trimmedDirectory]);
|
||||
if (trimmedDirectory.startsWith('/var/')) {
|
||||
@@ -333,11 +360,9 @@ export const createAgent = async (page: Page, message: string) => {
|
||||
|
||||
// Expo Router navigations can be "same-document" updates, so avoid waiting for a full `load`.
|
||||
await page.waitForURL(/\/agent\//, { waitUntil: 'commit' });
|
||||
const userBubble = page
|
||||
.getByRole('button', { name: 'Copy message' })
|
||||
.filter({ hasText: message })
|
||||
.first();
|
||||
await expect(userBubble).toBeVisible();
|
||||
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
};
|
||||
|
||||
export interface AgentConfig {
|
||||
|
||||
17
packages/app/e2e/keyboard-shortcuts-dialog.spec.ts
Normal file
17
packages/app/e2e/keyboard-shortcuts-dialog.spec.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoHome } from "./helpers/app";
|
||||
|
||||
test("question mark opens keyboard shortcuts dialog", async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
await page.getByTestId("menu-button").first().focus();
|
||||
|
||||
await page.keyboard.press("Shift+/");
|
||||
|
||||
const dialog = page.getByTestId("keyboard-shortcuts-dialog");
|
||||
const content = page.getByTestId("keyboard-shortcuts-dialog-content");
|
||||
|
||||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||||
await expect(content).toBeVisible({ timeout: 10000 });
|
||||
await expect(content).toContainText("Show keyboard shortcuts");
|
||||
await expect(content).toContainText("Toggle left sidebar");
|
||||
});
|
||||
130
packages/app/e2e/sidebar-project-filter-flash.spec.ts
Normal file
130
packages/app/e2e/sidebar-project-filter-flash.spec.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoHome } from "./helpers/app";
|
||||
|
||||
test("project filter dropdown never appears visibly at 0,0 on open", async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
|
||||
const trigger = page.getByText("Project", { exact: true }).first();
|
||||
await expect(trigger).toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
(window as any).__projectFilterFlashProbe = new Promise<{
|
||||
targetFound: boolean;
|
||||
visibleAtOrigin: boolean;
|
||||
records: Array<{ left: number; top: number; opacity: number }>;
|
||||
}>((resolve) => {
|
||||
let target: HTMLElement | null = null;
|
||||
const records: Array<{ left: number; top: number; opacity: number }> = [];
|
||||
|
||||
const capture = () => {
|
||||
if (!target) return;
|
||||
const style = getComputedStyle(target);
|
||||
records.push({
|
||||
left: Number.parseFloat(style.left || "0"),
|
||||
top: Number.parseFloat(style.top || "0"),
|
||||
opacity: Number.parseFloat(style.opacity || "1"),
|
||||
});
|
||||
};
|
||||
|
||||
const getContainer = (node: HTMLElement): HTMLElement | null => {
|
||||
let current: HTMLElement | null = node;
|
||||
while (current && current !== document.body) {
|
||||
const style = getComputedStyle(current);
|
||||
if (style.position === "absolute" && style.backgroundColor !== "rgba(0, 0, 0, 0)") {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const tryResolveTarget = (root: HTMLElement) => {
|
||||
const stack = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
|
||||
for (const element of stack) {
|
||||
if (!element.textContent?.includes("No projects")) continue;
|
||||
const container = getContainer(element);
|
||||
if (!container) continue;
|
||||
target = container;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
observer.disconnect();
|
||||
if (!target) {
|
||||
resolve({
|
||||
targetFound: false,
|
||||
visibleAtOrigin: false,
|
||||
records: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleAtOrigin = records.some(
|
||||
(entry) => entry.left <= 1 && entry.top <= 1 && entry.opacity > 0.01
|
||||
);
|
||||
|
||||
resolve({
|
||||
targetFound: true,
|
||||
visibleAtOrigin,
|
||||
records,
|
||||
});
|
||||
};
|
||||
|
||||
const sampleFrames = () => {
|
||||
capture();
|
||||
requestAnimationFrame(() => {
|
||||
capture();
|
||||
requestAnimationFrame(() => {
|
||||
capture();
|
||||
requestAnimationFrame(() => {
|
||||
capture();
|
||||
requestAnimationFrame(() => {
|
||||
capture();
|
||||
finish();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const added of Array.from(mutation.addedNodes)) {
|
||||
if (!(added instanceof HTMLElement)) continue;
|
||||
if (tryResolveTarget(added)) {
|
||||
sampleFrames();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
mutation.type === "attributes" &&
|
||||
mutation.target instanceof HTMLElement &&
|
||||
!target &&
|
||||
tryResolveTarget(mutation.target)
|
||||
) {
|
||||
sampleFrames();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["style"],
|
||||
});
|
||||
|
||||
setTimeout(() => finish(), 2500);
|
||||
});
|
||||
});
|
||||
|
||||
await trigger.click();
|
||||
const probe = await page.evaluate(() => (window as any).__projectFilterFlashProbe);
|
||||
|
||||
expect(probe.targetFound).toBe(true);
|
||||
expect(probe.visibleAtOrigin).toBe(false);
|
||||
});
|
||||
@@ -14,7 +14,7 @@ test('sidebar toggle shows tooltip on the right', async ({ page }) => {
|
||||
const tooltip = page.getByTestId('menu-button-tooltip');
|
||||
await expect(tooltip).toBeVisible();
|
||||
await expect(tooltip).toContainText('Toggle sidebar');
|
||||
await expect(tooltip).toContainText(/⌘B|Ctrl\+B/);
|
||||
await expect(tooltip).toContainText(/⌘B|Ctrl\+\./);
|
||||
await page.waitForTimeout(250);
|
||||
await expect(tooltip).toBeVisible();
|
||||
|
||||
|
||||
621
packages/app/e2e/terminal-pane.spec.ts
Normal file
621
packages/app/e2e/terminal-pane.spec.ts
Normal file
@@ -0,0 +1,621 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import {
|
||||
createAgent,
|
||||
ensureHostSelected,
|
||||
gotoHome,
|
||||
setWorkingDirectory,
|
||||
} from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
function parseAgentFromUrl(url: string): { serverId: string; agentId: string } {
|
||||
const pathname = (() => {
|
||||
try {
|
||||
return new URL(url).pathname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
})();
|
||||
|
||||
const modernMatch = pathname.match(/\/h\/([^/]+)\/agent\/([^/?#]+)/);
|
||||
if (modernMatch) {
|
||||
return {
|
||||
serverId: decodeURIComponent(modernMatch[1]),
|
||||
agentId: decodeURIComponent(modernMatch[2]),
|
||||
};
|
||||
}
|
||||
|
||||
const legacyMatch = pathname.match(/\/agent\/([^/]+)\/([^/?#]+)/);
|
||||
if (legacyMatch) {
|
||||
return {
|
||||
serverId: decodeURIComponent(legacyMatch[1]),
|
||||
agentId: decodeURIComponent(legacyMatch[2]),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Expected /h/:serverId/agent/:agentId URL, got ${url}`);
|
||||
}
|
||||
|
||||
async function openAgentFromSidebar(page: Page, serverId: string, agentId: string): Promise<void> {
|
||||
await gotoHome(page);
|
||||
const row = page.getByTestId(`agent-row-${serverId}-${agentId}`).first();
|
||||
await expect(row).toBeVisible({ timeout: 30000 });
|
||||
await row.click();
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
try {
|
||||
const parsed = parseAgentFromUrl(page.url());
|
||||
return `${parsed.serverId}:${parsed.agentId}`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
{ timeout: 30000 }
|
||||
)
|
||||
.toBe(`${serverId}:${agentId}`);
|
||||
}
|
||||
|
||||
async function openNewAgentDraft(page: Page): Promise<void> {
|
||||
await gotoHome(page);
|
||||
const newAgentButton = page.getByTestId("sidebar-new-agent").first();
|
||||
await expect(newAgentButton).toBeVisible({ timeout: 30000 });
|
||||
await newAgentButton.click();
|
||||
await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 });
|
||||
await expect(
|
||||
page.locator('[data-testid="working-directory-select"]:visible').first()
|
||||
).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async function openTerminalsPanel(page: Page): Promise<void> {
|
||||
let header = page.locator('[data-testid="explorer-header"]:visible').first();
|
||||
if (!(await header.isVisible().catch(() => false))) {
|
||||
const toggle = page.getByRole("button", {
|
||||
name: /open explorer|close explorer|toggle explorer/i,
|
||||
});
|
||||
if (await toggle.first().isVisible().catch(() => false)) {
|
||||
await toggle.first().click();
|
||||
}
|
||||
}
|
||||
|
||||
header = page.locator('[data-testid="explorer-header"]:visible').first();
|
||||
await expect(header).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const terminalsTab = page.getByTestId("explorer-tab-terminals").first();
|
||||
await expect(terminalsTab).toBeVisible({ timeout: 30000 });
|
||||
await terminalsTab.click();
|
||||
|
||||
await expect(page.getByTestId("terminals-header").first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("terminal-surface").first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async function openFilesPanel(page: Page): Promise<void> {
|
||||
const filesTab = page.getByTestId("explorer-tab-files").first();
|
||||
await expect(filesTab).toBeVisible({ timeout: 30000 });
|
||||
await filesTab.click();
|
||||
await expect(page.getByTestId("files-pane-header").first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async function getDesktopAgentSidebarOpen(page: Page): Promise<boolean | null> {
|
||||
return await page.evaluate(() => {
|
||||
const raw = localStorage.getItem("panel-state");
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
state?: { desktop?: { agentListOpen?: boolean } };
|
||||
};
|
||||
const value = parsed?.state?.desktop?.agentListOpen;
|
||||
return typeof value === "boolean" ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async function selectNewestTerminalTab(page: Page): Promise<void> {
|
||||
const tabs = page.locator('[data-testid^="terminal-tab-"]');
|
||||
await expect(tabs.first()).toBeVisible({ timeout: 30000 });
|
||||
await expect
|
||||
.poll(async () => await tabs.count(), { timeout: 30000 })
|
||||
.toBeGreaterThanOrEqual(2);
|
||||
await tabs.last().click();
|
||||
}
|
||||
|
||||
async function getFirstTerminalTabTestId(page: Page): Promise<string> {
|
||||
const firstTab = page.locator('[data-testid^="terminal-tab-"]').first();
|
||||
await expect(firstTab).toBeVisible({ timeout: 30000 });
|
||||
const value = await firstTab.getAttribute("data-testid");
|
||||
if (!value) {
|
||||
throw new Error("Expected terminal tab test id");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function runTerminalCommand(page: Page, command: string, expectedText: string): Promise<void> {
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
await page.keyboard.type(command, { delay: 1 });
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(surface).toContainText(expectedText, {
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async function runTerminalCommandWithPreEnterEcho(
|
||||
page: Page,
|
||||
command: string,
|
||||
expectedText: string
|
||||
): Promise<void> {
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
await page.keyboard.type(command, { delay: 1 });
|
||||
await expect(surface).toContainText(command, {
|
||||
timeout: 30000,
|
||||
});
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(surface).toContainText(expectedText, {
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectAnsiColorApplied(page: Page, marker: string): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
await page.evaluate((target) => {
|
||||
const terminal = (window as any).__paseoTerminal;
|
||||
if (!terminal?.buffer?.active?.getLine || !terminal?.buffer?.active?.getNullCell) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const buffer = terminal.buffer.active;
|
||||
const nullCell = buffer.getNullCell();
|
||||
const lineCount = buffer.length ?? 0;
|
||||
const cols = terminal.cols ?? 0;
|
||||
|
||||
for (let y = 0; y < lineCount; y += 1) {
|
||||
const line = buffer.getLine(y);
|
||||
if (!line) continue;
|
||||
const lineText = line.translateToString(true);
|
||||
const index = lineText.indexOf(target);
|
||||
if (index === -1) continue;
|
||||
|
||||
for (let x = index; x < index + target.length && x < cols; x += 1) {
|
||||
const cell = line.getCell(x, nullCell);
|
||||
if (!cell) continue;
|
||||
if (!cell.isFgDefault()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, marker),
|
||||
{ timeout: 30000 }
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test("Terminals tab creates multiple terminals and streams command output", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-terminals-");
|
||||
|
||||
try {
|
||||
await openNewAgentDraft(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, "Reply with exactly: terminal smoke");
|
||||
|
||||
await openTerminalsPanel(page);
|
||||
await expect(page.locator('[data-testid^="terminal-tab-"]').first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
const preEnterEchoMarker = `typed-echo-${Date.now()}`;
|
||||
await runTerminalCommandWithPreEnterEcho(
|
||||
page,
|
||||
`echo ${preEnterEchoMarker}`,
|
||||
preEnterEchoMarker
|
||||
);
|
||||
|
||||
const ansiMarker = `ansi-red-${Date.now()}`;
|
||||
await runTerminalCommand(
|
||||
page,
|
||||
`printf '\\033[31m${ansiMarker}\\033[0m\\n'`,
|
||||
ansiMarker
|
||||
);
|
||||
await expectAnsiColorApplied(page, ansiMarker);
|
||||
|
||||
const markerOne = `terminal-smoke-one-${Date.now()}`;
|
||||
await runTerminalCommand(page, `echo ${markerOne}`, markerOne);
|
||||
|
||||
await page.getByTestId("terminals-create-button").first().click();
|
||||
await selectNewestTerminalTab(page);
|
||||
|
||||
const markerTwo = `terminal-smoke-two-${Date.now()}`;
|
||||
await runTerminalCommand(page, `echo ${markerTwo}`, markerTwo);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal reattaches cleanly after heavy output and tab switches", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-terminal-reattach-");
|
||||
|
||||
try {
|
||||
await openNewAgentDraft(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, "hello");
|
||||
|
||||
await openTerminalsPanel(page);
|
||||
await runTerminalCommand(
|
||||
page,
|
||||
"for i in $(seq 1 12000); do echo reattach-$i; done",
|
||||
"reattach-12000"
|
||||
);
|
||||
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
await openFilesPanel(page);
|
||||
await openTerminalsPanel(page);
|
||||
await expect(page.getByText("Terminal stream ended. Reconnecting…")).toHaveCount(0, {
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(page.getByTestId("terminal-attach-loading")).toHaveCount(0, {
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
const marker = `reattach-health-${Date.now()}`;
|
||||
await runTerminalCommand(page, `echo ${marker}`, marker);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal keeps prompt echo visible after enter and backspace churn", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-terminal-echo-churn-");
|
||||
|
||||
try {
|
||||
await openNewAgentDraft(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, "hello");
|
||||
|
||||
await openTerminalsPanel(page);
|
||||
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
|
||||
for (let iteration = 0; iteration < 40; iteration += 1) {
|
||||
await page.keyboard.press("Enter");
|
||||
}
|
||||
|
||||
const markerAfterEnters = `echo-visible-${Date.now()}`;
|
||||
await page.keyboard.type(`echo ${markerAfterEnters}`, { delay: 0 });
|
||||
await expect(surface).toContainText(`echo ${markerAfterEnters}`, {
|
||||
timeout: 30000,
|
||||
});
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(surface).toContainText(markerAfterEnters, {
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
const longSuffix = "x".repeat(120);
|
||||
await page.keyboard.type(`echo ${longSuffix}`, { delay: 0 });
|
||||
for (let iteration = 0; iteration < longSuffix.length; iteration += 1) {
|
||||
await page.keyboard.press("Backspace");
|
||||
}
|
||||
|
||||
const markerAfterBackspace = `echo-backspace-${Date.now()}`;
|
||||
await page.keyboard.type(markerAfterBackspace, { delay: 0 });
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(surface).toContainText(markerAfterBackspace, {
|
||||
timeout: 30000,
|
||||
});
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal remains interactive after alternate-screen enter/exit", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-terminal-alt-screen-");
|
||||
|
||||
try {
|
||||
await openNewAgentDraft(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, "hello");
|
||||
|
||||
await openTerminalsPanel(page);
|
||||
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
|
||||
await page.keyboard.type(
|
||||
"printf '\\033[?1049h\\033[2J\\033[HALT\\033[?1049l\\n'",
|
||||
{ delay: 0 }
|
||||
);
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
const marker = `post-alt-screen-${Date.now()}`;
|
||||
await page.keyboard.type(`echo ${marker}`, { delay: 0 });
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(surface).toContainText(marker, {
|
||||
timeout: 30000,
|
||||
});
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal tab is removed when shell exits", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-terminal-exit-");
|
||||
|
||||
try {
|
||||
await openNewAgentDraft(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, "Terminal exit flow");
|
||||
|
||||
await openTerminalsPanel(page);
|
||||
|
||||
const exitedTabTestId = await getFirstTerminalTabTestId(page);
|
||||
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
await page.keyboard.type("exit", { delay: 1 });
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(page.getByTestId(exitedTabTestId)).toHaveCount(0, {
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await expect(page.locator('[data-testid^="terminal-tab-"]').first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
const nextTabTestId = await getFirstTerminalTabTestId(page);
|
||||
expect(nextTabTestId).not.toBe(exitedTabTestId);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test("terminals are shared by agents on the same cwd", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-terminal-share-");
|
||||
|
||||
try {
|
||||
await openNewAgentDraft(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, "Agent one");
|
||||
const first = parseAgentFromUrl(page.url());
|
||||
|
||||
await openNewAgentDraft(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, "Agent two");
|
||||
const second = parseAgentFromUrl(page.url());
|
||||
|
||||
expect(first.serverId).toBe(second.serverId);
|
||||
expect(first.agentId).not.toBe(second.agentId);
|
||||
|
||||
await openAgentFromSidebar(page, first.serverId, first.agentId);
|
||||
await openTerminalsPanel(page);
|
||||
await page.getByTestId("terminals-create-button").first().click();
|
||||
await selectNewestTerminalTab(page);
|
||||
|
||||
await openAgentFromSidebar(page, second.serverId, second.agentId);
|
||||
await openTerminalsPanel(page);
|
||||
await selectNewestTerminalTab(page);
|
||||
|
||||
const sharedMarker = `shared-terminal-${Date.now()}`;
|
||||
await runTerminalCommand(page, `echo ${sharedMarker}`, sharedMarker);
|
||||
|
||||
await openAgentFromSidebar(page, first.serverId, first.agentId);
|
||||
await openTerminalsPanel(page);
|
||||
await selectNewestTerminalTab(page);
|
||||
await expect(page.getByTestId("terminal-surface").first()).toContainText(sharedMarker, {
|
||||
timeout: 30000,
|
||||
});
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal captures escape and ctrl+c key input", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-terminal-keys-");
|
||||
|
||||
try {
|
||||
await openNewAgentDraft(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, "Terminal key combo capture");
|
||||
|
||||
await openTerminalsPanel(page);
|
||||
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
|
||||
await page.keyboard.type("cat -v", { delay: 1 });
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(surface).toContainText("cat -v", { timeout: 30000 });
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(surface).toContainText("^[", { timeout: 30000 });
|
||||
|
||||
await page.keyboard.press("Control+C");
|
||||
await expect(surface).toContainText("^C", { timeout: 30000 });
|
||||
|
||||
await page.keyboard.press("Control+B");
|
||||
await expect(surface).toContainText("^B", { timeout: 30000 });
|
||||
|
||||
const marker = `terminal-key-capture-${Date.now()}`;
|
||||
await page.keyboard.type(`echo ${marker}`, { delay: 1 });
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(surface).toContainText(marker, { timeout: 30000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("Cmd+B toggles sidebar even when terminal is focused", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-terminal-cmd-b-");
|
||||
|
||||
try {
|
||||
await openNewAgentDraft(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, "Terminal Cmd+B");
|
||||
|
||||
await openTerminalsPanel(page);
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
|
||||
await expect
|
||||
.poll(async () => await getDesktopAgentSidebarOpen(page), { timeout: 30000 })
|
||||
.toBe(true);
|
||||
|
||||
await page.keyboard.press("Meta+B");
|
||||
await expect
|
||||
.poll(async () => await getDesktopAgentSidebarOpen(page), { timeout: 30000 })
|
||||
.toBe(false);
|
||||
|
||||
await page.keyboard.press("Meta+B");
|
||||
await expect
|
||||
.poll(async () => await getDesktopAgentSidebarOpen(page), { timeout: 30000 })
|
||||
.toBe(true);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function getTerminalRows(page: Page): Promise<number> {
|
||||
return await page.evaluate(() => {
|
||||
const terminal = (window as { __paseoTerminal?: { rows?: unknown } }).__paseoTerminal;
|
||||
return typeof terminal?.rows === "number" ? terminal.rows : 0;
|
||||
});
|
||||
}
|
||||
|
||||
async function setExplorerContentBottomPadding(page: Page, padding: number): Promise<void> {
|
||||
await page.evaluate((nextPadding) => {
|
||||
const container = document.querySelector<HTMLElement>(
|
||||
'[data-testid="explorer-content-area"]'
|
||||
);
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
container.style.boxSizing = "border-box";
|
||||
container.style.paddingBottom = nextPadding + "px";
|
||||
}, padding);
|
||||
}
|
||||
|
||||
async function getTerminalScrollbackDistance(page: Page): Promise<number> {
|
||||
return await page.evaluate(() => {
|
||||
const terminal = (
|
||||
window as {
|
||||
__paseoTerminal?: {
|
||||
buffer?: { active?: { baseY?: unknown; viewportY?: unknown } };
|
||||
};
|
||||
}
|
||||
).__paseoTerminal;
|
||||
const baseY = terminal?.buffer?.active?.baseY;
|
||||
const viewportY = terminal?.buffer?.active?.viewportY;
|
||||
if (typeof baseY !== "number" || typeof viewportY !== "number") {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, baseY - viewportY);
|
||||
});
|
||||
}
|
||||
|
||||
test("terminal viewport resizes and uses xterm scrollback", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-terminal-viewport-");
|
||||
|
||||
try {
|
||||
await openNewAgentDraft(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, "Viewport and scrollback test");
|
||||
|
||||
await openTerminalsPanel(page);
|
||||
|
||||
const initialViewport = page.viewportSize();
|
||||
if (!initialViewport) {
|
||||
throw new Error("Expected a viewport size");
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(() => getTerminalRows(page), { timeout: 30000 })
|
||||
.toBeGreaterThan(0);
|
||||
const initialRows = await getTerminalRows(page);
|
||||
|
||||
await setExplorerContentBottomPadding(page, 220);
|
||||
await expect
|
||||
.poll(() => getTerminalRows(page), { timeout: 30000 })
|
||||
.toBeLessThan(initialRows);
|
||||
|
||||
await setExplorerContentBottomPadding(page, 0);
|
||||
await expect
|
||||
.poll(() => getTerminalRows(page), { timeout: 30000 })
|
||||
.toBeGreaterThanOrEqual(initialRows);
|
||||
|
||||
const reducedHeight = Math.max(520, initialViewport.height - 220);
|
||||
await page.setViewportSize({
|
||||
width: initialViewport.width,
|
||||
height: reducedHeight,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => getTerminalRows(page), { timeout: 30000 })
|
||||
.toBeLessThan(initialRows);
|
||||
|
||||
await page.setViewportSize(initialViewport);
|
||||
|
||||
await expect
|
||||
.poll(() => getTerminalRows(page), { timeout: 30000 })
|
||||
.toBeGreaterThanOrEqual(initialRows);
|
||||
|
||||
const scrollbackMarker = `scrollback-${Date.now()}`;
|
||||
await runTerminalCommand(
|
||||
page,
|
||||
`for i in $(seq 1 180); do echo ${scrollbackMarker}-$i; done`,
|
||||
`${scrollbackMarker}-180`
|
||||
);
|
||||
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
await surface.hover();
|
||||
await page.mouse.wheel(0, -3000);
|
||||
|
||||
await expect
|
||||
.poll(() => getTerminalScrollbackDistance(page), { timeout: 30000 })
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
const distanceAfterScrollUp = await getTerminalScrollbackDistance(page);
|
||||
|
||||
await surface.hover();
|
||||
await page.mouse.wheel(0, 3000);
|
||||
|
||||
await expect
|
||||
.poll(() => getTerminalScrollbackDistance(page), { timeout: 30000 })
|
||||
.toBeLessThan(distanceAfterScrollUp);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
158
packages/app/e2e/working-directory-combobox-positioning.spec.ts
Normal file
158
packages/app/e2e/working-directory-combobox-positioning.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoHome } from "./helpers/app";
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
test("working directory combobox stays visually stable while typing search", async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
|
||||
const workingDirectorySelect = page
|
||||
.locator('[data-testid="working-directory-select"]:visible')
|
||||
.first();
|
||||
await expect(workingDirectorySelect).toBeVisible();
|
||||
await workingDirectorySelect.click({ force: true });
|
||||
|
||||
const searchInput = page.getByRole("textbox", { name: /search directories/i }).first();
|
||||
await expect(searchInput).toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
const trigger = document.querySelector('[data-testid="working-directory-select"]');
|
||||
const searchInput = document.querySelector('input[placeholder="Search directories..."]');
|
||||
const container = document.querySelector('[data-testid="combobox-desktop-container"]');
|
||||
if (!(trigger instanceof HTMLElement)) {
|
||||
throw new Error("Missing working-directory-select trigger.");
|
||||
}
|
||||
if (!(searchInput instanceof HTMLInputElement)) {
|
||||
throw new Error("Missing working directory search input.");
|
||||
}
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
throw new Error("Missing combobox desktop container.");
|
||||
}
|
||||
|
||||
const state = {
|
||||
samples: 0,
|
||||
underTriggerSamples: 0,
|
||||
emptyWhileSearchingSamples: 0,
|
||||
minSearchDelta: Number.POSITIVE_INFINITY,
|
||||
maxSearchDelta: Number.NEGATIVE_INFINITY,
|
||||
minContainerDelta: Number.POSITIVE_INFINITY,
|
||||
maxContainerDelta: Number.NEGATIVE_INFINITY,
|
||||
logs: [] as Array<{
|
||||
reason: string;
|
||||
query: string;
|
||||
searchDelta: number;
|
||||
containerDelta: number;
|
||||
hasEmpty: boolean;
|
||||
containerTop: number;
|
||||
containerBottom: number;
|
||||
triggerTop: number;
|
||||
searchBottom: number;
|
||||
}>,
|
||||
};
|
||||
|
||||
const sample = (reason: string) => {
|
||||
if (!document.body.contains(trigger) || !document.body.contains(searchInput) || !document.body.contains(container)) {
|
||||
return;
|
||||
}
|
||||
const query = searchInput.value.trim();
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const searchRect = searchInput.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const hasEmpty = Boolean(container.querySelector('[data-testid="combobox-empty-text"]'));
|
||||
const searchDelta = searchRect.bottom - triggerRect.top;
|
||||
const containerDelta = containerRect.bottom - triggerRect.top;
|
||||
|
||||
state.samples += 1;
|
||||
state.minSearchDelta = Math.min(state.minSearchDelta, searchDelta);
|
||||
state.maxSearchDelta = Math.max(state.maxSearchDelta, searchDelta);
|
||||
state.minContainerDelta = Math.min(state.minContainerDelta, containerDelta);
|
||||
state.maxContainerDelta = Math.max(state.maxContainerDelta, containerDelta);
|
||||
if (searchDelta > 2 || containerDelta > 2) {
|
||||
state.underTriggerSamples += 1;
|
||||
}
|
||||
if (hasEmpty) {
|
||||
state.emptyWhileSearchingSamples += 1;
|
||||
}
|
||||
if (state.logs.length < 80) {
|
||||
state.logs.push({
|
||||
reason,
|
||||
query,
|
||||
searchDelta,
|
||||
containerDelta,
|
||||
hasEmpty,
|
||||
containerTop: containerRect.top,
|
||||
containerBottom: containerRect.bottom,
|
||||
triggerTop: triggerRect.top,
|
||||
searchBottom: searchRect.bottom,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const mutationObserver = new MutationObserver(() => sample("mutation"));
|
||||
mutationObserver.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => sample("resize"));
|
||||
resizeObserver.observe(container);
|
||||
resizeObserver.observe(searchInput);
|
||||
|
||||
let rafId = 0;
|
||||
const loop = () => {
|
||||
sample("raf");
|
||||
rafId = requestAnimationFrame(loop);
|
||||
};
|
||||
rafId = requestAnimationFrame(loop);
|
||||
|
||||
(window as any).__paseoComboboxObserver = {
|
||||
stop: () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
mutationObserver.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
sample("stop");
|
||||
return {
|
||||
...state,
|
||||
minSearchDelta: state.samples > 0 ? state.minSearchDelta : 0,
|
||||
maxSearchDelta: state.samples > 0 ? state.maxSearchDelta : 0,
|
||||
minContainerDelta: state.samples > 0 ? state.minContainerDelta : 0,
|
||||
maxContainerDelta: state.samples > 0 ? state.maxContainerDelta : 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const queries = [
|
||||
"/tmp/paseo-stability-a",
|
||||
"/tmp/paseo-stability-ab",
|
||||
"/tmp/paseo-stability-abc",
|
||||
"/tmp/paseo-stability-longer-branch",
|
||||
"/tmp/paseo-stability-z",
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
await searchInput.fill("");
|
||||
await searchInput.type(query, { delay: 20 });
|
||||
const customOption = page.getByText(new RegExp(`^${escapeRegex(query)}$`)).first();
|
||||
await expect(customOption).toBeVisible();
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
const stats = await page.evaluate(() => (window as any).__paseoComboboxObserver.stop());
|
||||
const debug = JSON.stringify(stats.logs.slice(-10));
|
||||
|
||||
expect(stats.samples, debug).toBeGreaterThan(20);
|
||||
expect(stats.underTriggerSamples, debug).toBe(0);
|
||||
expect(stats.emptyWhileSearchingSamples, debug).toBe(0);
|
||||
expect(stats.maxSearchDelta - stats.minSearchDelta, debug).toBeLessThanOrEqual(3);
|
||||
expect(stats.maxContainerDelta - stats.minContainerDelta, debug).toBeLessThanOrEqual(3);
|
||||
expect(stats.maxContainerDelta, debug).toBeLessThanOrEqual(2);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 14.0.0"
|
||||
"version": ">= 14.0.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
@@ -18,10 +19,28 @@
|
||||
"channel": "production",
|
||||
"env": {
|
||||
"APP_VARIANT": "production"
|
||||
},
|
||||
"android": {
|
||||
"autoIncrement": "versionCode"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"extends": "production",
|
||||
"distribution": "internal",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"gradleCommand": ":app:assembleRelease -x lint -x lintVitalAnalyzeRelease -x lintVitalRelease -x generateReleaseLintModel -x generateReleaseLintVitalModel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
"production": {
|
||||
"ios": {
|
||||
"ascAppId": "6758887924"
|
||||
},
|
||||
"android": {
|
||||
"releaseStatus": "draft"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.12",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
@@ -30,7 +30,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/server": "0.1.2",
|
||||
"@getpaseo/server": "0.1.12",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
@@ -49,6 +49,8 @@
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tauri-apps/api": "^2.9.1",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"base64-js": "^1.5.1",
|
||||
"buffer": "^6.0.3",
|
||||
"expo": "^54.0.18",
|
||||
@@ -75,6 +77,7 @@
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo-updates": "~29.0.12",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"lezer-elixir": "^1.1.2",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"react": "19.1.0",
|
||||
|
||||
@@ -36,13 +36,18 @@ import {
|
||||
import { getIsTauri, getIsTauriMac } from "@/constants/layout";
|
||||
import { useTrafficLightPadding } from "@/utils/tauri-window";
|
||||
import { CommandCenter } from "@/components/command-center";
|
||||
import { useGlobalKeyboardNav } from "@/hooks/use-global-keyboard-nav";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
type WebNotificationClickDetail,
|
||||
} from "@/utils/os-notifications";
|
||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||
import {
|
||||
buildHostAgentDraftRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
polyfillCrypto();
|
||||
|
||||
@@ -140,8 +145,10 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
? mobileView === "agent-list"
|
||||
: desktopAgentListOpen
|
||||
: false;
|
||||
const openGestureEnabled =
|
||||
chromeEnabled && isMobile && mobileView === "agent";
|
||||
|
||||
useGlobalKeyboardNav({
|
||||
useKeyboardShortcuts({
|
||||
enabled: chromeEnabled,
|
||||
isMobile,
|
||||
toggleAgentList,
|
||||
@@ -165,7 +172,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
const openGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.enabled(chromeEnabled && isMobile && !isOpen)
|
||||
.enabled(openGestureEnabled)
|
||||
.manualActivation(true)
|
||||
// Fail if 10px vertical movement happens first (allow vertical scroll)
|
||||
.failOffsetY([-10, 10])
|
||||
@@ -221,9 +228,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
isGesturing.value = false;
|
||||
}),
|
||||
[
|
||||
chromeEnabled,
|
||||
isMobile,
|
||||
isOpen,
|
||||
openGestureEnabled,
|
||||
windowWidth,
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
@@ -251,6 +256,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
{isMobile && chromeEnabled && <SlidingSidebar selectedAgentId={selectedAgentId} />}
|
||||
<DownloadToast />
|
||||
<CommandCenter />
|
||||
<KeyboardShortcutsDialog />
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -268,53 +274,8 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
const { settings, isLoading: settingsLoading } = useAppSettings();
|
||||
const { daemons, isLoading: registryLoading, upsertDaemonFromOfferUrl } = useDaemonRegistry();
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const isLoading = settingsLoading || registryLoading;
|
||||
|
||||
const isInitialConnectionPending = useMemo(() => {
|
||||
if (daemons.length === 0) return false;
|
||||
return daemons.some((daemon) => {
|
||||
const record = connectionStates.get(daemon.serverId);
|
||||
if (!record) return true;
|
||||
if (record.hasEverReceivedAgentList) return false;
|
||||
return (
|
||||
record.status === "idle" ||
|
||||
record.status === "connecting" ||
|
||||
(record.status === "online" && !record.agentListReady)
|
||||
);
|
||||
});
|
||||
}, [daemons, connectionStates]);
|
||||
|
||||
const [connectionTimedOut, setConnectionTimedOut] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialConnectionPending) {
|
||||
setConnectionTimedOut(false);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => setConnectionTimedOut(true), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isInitialConnectionPending]);
|
||||
|
||||
const connectingMessage = useMemo(() => {
|
||||
if (!isInitialConnectionPending) return null;
|
||||
const pending = daemons.filter((daemon) => {
|
||||
const record = connectionStates.get(daemon.serverId);
|
||||
if (!record) return true;
|
||||
if (record.hasEverReceivedAgentList) return false;
|
||||
return (
|
||||
record.status === "idle" ||
|
||||
record.status === "connecting" ||
|
||||
(record.status === "online" && !record.agentListReady)
|
||||
);
|
||||
});
|
||||
if (pending.length === 1) {
|
||||
const label = pending[0].label?.trim();
|
||||
return label ? `Connecting to ${label}...` : "Connecting...";
|
||||
}
|
||||
return "Connecting...";
|
||||
}, [isInitialConnectionPending, daemons, connectionStates]);
|
||||
|
||||
// Apply theme setting on mount and when it changes
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
@@ -330,10 +291,6 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
return <LoadingView />;
|
||||
}
|
||||
|
||||
if (connectingMessage && !connectionTimedOut) {
|
||||
return <LoadingView message={connectingMessage} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<VoiceProvider>
|
||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertDaemonFromOfferUrl} />
|
||||
@@ -359,7 +316,7 @@ function OfferLinkListener({
|
||||
if (cancelled) return;
|
||||
const serverId = (profile as any)?.serverId;
|
||||
if (typeof serverId !== "string" || !serverId) return;
|
||||
router.replace({ pathname: "/", params: { serverId } });
|
||||
router.replace(buildHostAgentDraftRoute(serverId) as any);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
@@ -389,13 +346,8 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
// Parse selectedAgentKey directly from pathname
|
||||
// useLocalSearchParams doesn't update when navigating between same-pattern routes
|
||||
const selectedAgentKey = useMemo(() => {
|
||||
// Match /agent/[serverId]/[agentId] pattern
|
||||
const match = pathname.match(/^\/agent\/([^/]+)\/([^/]+)\/?$/);
|
||||
if (match) {
|
||||
const [, serverId, agentId] = match;
|
||||
return `${serverId}:${agentId}`;
|
||||
}
|
||||
return undefined;
|
||||
const match = parseHostAgentRouteFromPathname(pathname);
|
||||
return match ? `${match.serverId}:${match.agentId}` : undefined;
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
@@ -478,14 +430,14 @@ export default function RootLayout() {
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="agents" />
|
||||
<Stack.Screen name="agent" />
|
||||
<Stack.Screen name="agent/[id]" options={{ gestureEnabled: false }} />
|
||||
<Stack.Screen
|
||||
name="agent/[serverId]/[agentId]"
|
||||
name="h/[serverId]/agent/[agentId]"
|
||||
options={{ gestureEnabled: false }}
|
||||
/>
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="h/[serverId]/index" />
|
||||
<Stack.Screen name="h/[serverId]/agent/index" />
|
||||
<Stack.Screen name="h/[serverId]/agents" />
|
||||
<Stack.Screen name="h/[serverId]/settings" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack>
|
||||
</AppWithSidebar>
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { LegacyAgentIdScreen } from "@/screens/agent/legacy-agent-id-screen";
|
||||
|
||||
export default function LegacyAgentRoute() {
|
||||
const { id } = useLocalSearchParams<{ id?: string }>();
|
||||
return <LegacyAgentIdScreen agentId={typeof id === "string" ? id : ""} />;
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen";
|
||||
|
||||
export default function AgentReadyRoute() {
|
||||
const { serverId, agentId } = useLocalSearchParams<{
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
}>();
|
||||
|
||||
return (
|
||||
<AgentReadyScreen
|
||||
serverId={typeof serverId === "string" ? serverId : ""}
|
||||
agentId={typeof agentId === "string" ? agentId : ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
||||
|
||||
export default function AgentDraftRoute() {
|
||||
return <DraftAgentScreen />;
|
||||
}
|
||||
|
||||
17
packages/app/src/app/h/[serverId]/agent/[agentId].tsx
Normal file
17
packages/app/src/app/h/[serverId]/agent/[agentId].tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen";
|
||||
|
||||
export default function HostAgentReadyRoute() {
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
}>();
|
||||
|
||||
return (
|
||||
<AgentReadyScreen
|
||||
serverId={typeof params.serverId === "string" ? params.serverId : ""}
|
||||
agentId={typeof params.agentId === "string" ? params.agentId : ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
10
packages/app/src/app/h/[serverId]/agent/index.tsx
Normal file
10
packages/app/src/app/h/[serverId]/agent/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
||||
|
||||
export default function HostDraftAgentRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
|
||||
return <DraftAgentScreen forcedServerId={serverId} />;
|
||||
}
|
||||
|
||||
9
packages/app/src/app/h/[serverId]/agents.tsx
Normal file
9
packages/app/src/app/h/[serverId]/agents.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { AgentsScreen } from "@/screens/agents-screen";
|
||||
|
||||
export default function HostAgentsRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
|
||||
return <AgentsScreen serverId={serverId} />;
|
||||
}
|
||||
19
packages/app/src/app/h/[serverId]/index.tsx
Normal file
19
packages/app/src/app/h/[serverId]/index.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
||||
|
||||
export default function HostIndexRoute() {
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId) {
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostAgentDraftRoute(serverId) as any);
|
||||
}, [router, serverId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
3
packages/app/src/app/h/[serverId]/settings.tsx
Normal file
3
packages/app/src/app/h/[serverId]/settings.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import SettingsScreen from "@/screens/settings-screen";
|
||||
|
||||
export default SettingsScreen;
|
||||
@@ -1,5 +1,59 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
||||
|
||||
export default function Index() {
|
||||
return <DraftAgentScreen />;
|
||||
const router = useRouter();
|
||||
const { theme } = useUnistyles();
|
||||
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
|
||||
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
|
||||
|
||||
const targetServerId = useMemo(() => {
|
||||
if (daemons.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (preferences.serverId) {
|
||||
const match = daemons.find((daemon) => daemon.serverId === preferences.serverId);
|
||||
if (match) {
|
||||
return match.serverId;
|
||||
}
|
||||
}
|
||||
return daemons[0]?.serverId ?? null;
|
||||
}, [daemons, preferences.serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (registryLoading || preferencesLoading) {
|
||||
return;
|
||||
}
|
||||
if (!targetServerId) {
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostAgentDraftRoute(targetServerId) as any);
|
||||
}, [preferencesLoading, registryLoading, router, targetServerId]);
|
||||
|
||||
if (registryLoading || preferencesLoading) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: theme.colors.surface0,
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!targetServerId) {
|
||||
return <DraftAgentScreen />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ import { NameHostModal } from "@/components/name-host-modal";
|
||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||
import { probeConnection } from "@/utils/test-daemon-connection";
|
||||
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
||||
import {
|
||||
buildHostAgentDraftRoute,
|
||||
buildHostSettingsRoute,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
@@ -138,8 +142,14 @@ export default function PairScanScreen() {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<{ source?: string; targetServerId?: string }>();
|
||||
const params = useLocalSearchParams<{
|
||||
source?: string;
|
||||
sourceServerId?: string;
|
||||
targetServerId?: string;
|
||||
}>();
|
||||
const source = typeof params.source === "string" ? params.source : "settings";
|
||||
const sourceServerId =
|
||||
typeof params.sourceServerId === "string" ? params.sourceServerId : null;
|
||||
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
|
||||
const { daemons, upsertDaemonFromOfferUrl, updateHost } = useDaemonRegistry();
|
||||
|
||||
@@ -160,34 +170,47 @@ export default function PairScanScreen() {
|
||||
const returnToSource = useCallback(
|
||||
(serverId: string) => {
|
||||
if (source === "onboarding") {
|
||||
router.replace({ pathname: "/", params: { serverId } });
|
||||
router.replace(buildHostAgentDraftRoute(serverId) as any);
|
||||
return;
|
||||
}
|
||||
if (source === "editHost" && targetServerId) {
|
||||
router.replace({ pathname: "/settings", params: { editHost: targetServerId } });
|
||||
const settingsServerId = sourceServerId ?? targetServerId;
|
||||
router.replace({
|
||||
pathname: buildHostSettingsRoute(settingsServerId),
|
||||
params: { editHost: targetServerId },
|
||||
} as any);
|
||||
return;
|
||||
}
|
||||
// settings (default): return to previous screen
|
||||
try {
|
||||
router.back();
|
||||
} catch {
|
||||
router.replace("/settings");
|
||||
const settingsServerId = sourceServerId ?? serverId;
|
||||
router.replace(buildHostSettingsRoute(settingsServerId) as any);
|
||||
}
|
||||
},
|
||||
[router, source, targetServerId]
|
||||
[router, source, sourceServerId, targetServerId]
|
||||
);
|
||||
|
||||
const closeToSource = useCallback(() => {
|
||||
if (source === "editHost" && targetServerId) {
|
||||
router.replace({ pathname: "/settings", params: { editHost: targetServerId } });
|
||||
const settingsServerId = sourceServerId ?? targetServerId;
|
||||
router.replace({
|
||||
pathname: buildHostSettingsRoute(settingsServerId),
|
||||
params: { editHost: targetServerId },
|
||||
} as any);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
router.back();
|
||||
} catch {
|
||||
router.replace("/settings");
|
||||
if (sourceServerId) {
|
||||
router.replace(buildHostSettingsRoute(sourceServerId) as any);
|
||||
return;
|
||||
}
|
||||
router.replace("/" as any);
|
||||
}
|
||||
}, [router, source, targetServerId]);
|
||||
}, [router, source, sourceServerId, targetServerId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "web") return;
|
||||
|
||||
@@ -343,7 +343,8 @@ export function ComboSelect({
|
||||
const anchorRef = useRef<View>(null);
|
||||
|
||||
const selectedOption = options.find((opt) => opt.id === value);
|
||||
const displayValue = selectedOption?.label ?? (value || "");
|
||||
const displayValue = selectedOption?.label ?? "";
|
||||
const isEmpty = options.length === 0;
|
||||
|
||||
const handleOpen = useCallback(() => setIsOpen(true), []);
|
||||
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
|
||||
@@ -355,7 +356,7 @@ export function ComboSelect({
|
||||
value={displayValue}
|
||||
placeholder={placeholder}
|
||||
onPress={handleOpen}
|
||||
disabled={disabled}
|
||||
disabled={disabled || isEmpty}
|
||||
isLoading={isLoading}
|
||||
controlRef={anchorRef}
|
||||
icon={icon}
|
||||
@@ -567,8 +568,8 @@ export function AgentConfigRow({
|
||||
title="Select provider"
|
||||
value={selectedProvider}
|
||||
options={providerOptions}
|
||||
placeholder="Select..."
|
||||
disabled={disabled}
|
||||
placeholder={providerOptions.length > 0 ? "Select..." : "No providers available"}
|
||||
disabled={disabled || providerOptions.length === 0}
|
||||
onSelect={onSelectProvider}
|
||||
icon={<Bot size={16} color={defaultTheme.colors.foregroundMuted} />}
|
||||
showLabel={false}
|
||||
@@ -882,11 +883,12 @@ export function WorkingDirectoryDropdown({
|
||||
options={options}
|
||||
value={workingDir}
|
||||
onSelect={onSelectPath}
|
||||
searchPlaceholder="/path/to/project"
|
||||
searchPlaceholder="Search directories..."
|
||||
emptyText={emptyText}
|
||||
allowCustomValue
|
||||
customValuePrefix="Use"
|
||||
customValueDescription="Launch the agent in this directory"
|
||||
customValuePrefix=""
|
||||
customValueKind="directory"
|
||||
optionsPosition="above-search"
|
||||
title="Working directory"
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
|
||||
@@ -28,9 +28,10 @@ import { Theme } from "@/styles/theme";
|
||||
import { CommandAutocomplete } from "./command-autocomplete";
|
||||
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import { useVoiceOptional } from "@/contexts/voice-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
|
||||
@@ -73,12 +74,17 @@ export function AgentInputArea({
|
||||
const insets = useSafeAreaInsets();
|
||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||
const isScreenFocused = useIsFocused();
|
||||
const focusChatInputRequest = useKeyboardNavStore((s) => s.focusChatInputRequest);
|
||||
const clearFocusChatInputRequest = useKeyboardNavStore((s) => s.clearFocusChatInputRequest);
|
||||
const messageInputActionRequest = useKeyboardShortcutsStore(
|
||||
(s) => s.messageInputActionRequest
|
||||
);
|
||||
const clearMessageInputActionRequest = useKeyboardShortcutsStore(
|
||||
(s) => s.clearMessageInputActionRequest
|
||||
);
|
||||
|
||||
const client = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.client ?? null
|
||||
);
|
||||
const toast = useToast();
|
||||
const voice = useVoiceOptional();
|
||||
const isConnected = client?.isConnected ?? false;
|
||||
|
||||
@@ -106,7 +112,7 @@ export function AgentInputArea({
|
||||
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const [commandSelectedIndex, setCommandSelectedIndex] = useState(0);
|
||||
const lastHandledFocusRequestIdRef = useRef<number | null>(null);
|
||||
const lastHandledMessageInputActionRequestIdRef = useRef<number | null>(null);
|
||||
|
||||
// Command autocomplete logic
|
||||
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
|
||||
@@ -227,15 +233,38 @@ export function AgentInputArea({
|
||||
}, [onSubmitMessage]);
|
||||
|
||||
const isAgentRunning = agent?.status === "running";
|
||||
const agentUpdatedAtMs = agent?.updatedAt?.getTime() ?? 0;
|
||||
|
||||
const prevIsAgentRunningRef = useRef(isAgentRunning);
|
||||
const latestAgentUpdatedAtRef = useRef(agentUpdatedAtMs);
|
||||
useEffect(() => {
|
||||
const previousUpdatedAt = latestAgentUpdatedAtRef.current;
|
||||
if (agentUpdatedAtMs < previousUpdatedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
if (!wasRunning && isAgentRunning && isProcessing) {
|
||||
latestAgentUpdatedAtRef.current = agentUpdatedAtMs;
|
||||
|
||||
if (shouldClearProcessing) {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [isAgentRunning, isProcessing]);
|
||||
}, [agentUpdatedAtMs, isAgentRunning, isProcessing]);
|
||||
|
||||
const updateQueue = useCallback(
|
||||
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => {
|
||||
@@ -276,13 +305,11 @@ export function AgentInputArea({
|
||||
imageAttachments?: ImageAttachment[],
|
||||
forceSend?: boolean
|
||||
) {
|
||||
const socketConnected = isConnected;
|
||||
const trimmedMessage = message.trim();
|
||||
if (!trimmedMessage) return;
|
||||
// When the parent controls submission (e.g. draft agent creation), let it
|
||||
// decide what to do even if the socket is currently disconnected (so we
|
||||
// don't no-op and lose deterministic error handling in the UI/tests).
|
||||
if (!onSubmitMessageRef.current && !socketConnected) return;
|
||||
if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
|
||||
|
||||
if (agent?.status === "running" && !forceSend) {
|
||||
@@ -400,21 +427,36 @@ export function AgentInputArea({
|
||||
saveDraftInput(agentId, { text: userInput, images: selectedImages });
|
||||
}, [agentId, userInput, selectedImages, getDraftInput, saveDraftInput]);
|
||||
|
||||
// When switching agents from the command center, auto-focus the input on web.
|
||||
// Keyboard-dispatched message-input actions are routed through store requests.
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") return;
|
||||
if (!isScreenFocused) return;
|
||||
if (!focusChatInputRequest) return;
|
||||
if (!messageInputActionRequest) return;
|
||||
|
||||
const currentKey = `${serverId}:${agentId}`;
|
||||
if (focusChatInputRequest.agentKey !== currentKey) {
|
||||
if (messageInputActionRequest.agentKey !== currentKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastHandledFocusRequestIdRef.current === focusChatInputRequest.id) {
|
||||
if (
|
||||
lastHandledMessageInputActionRequestIdRef.current ===
|
||||
messageInputActionRequest.id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastHandledMessageInputActionRequestIdRef.current =
|
||||
messageInputActionRequest.id;
|
||||
|
||||
if (messageInputActionRequest.kind !== "focus") {
|
||||
messageInputRef.current?.runKeyboardAction(messageInputActionRequest.kind);
|
||||
clearMessageInputActionRequest(messageInputActionRequest.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.OS !== "web") {
|
||||
messageInputRef.current?.focus();
|
||||
clearMessageInputActionRequest(messageInputActionRequest.id);
|
||||
return;
|
||||
}
|
||||
lastHandledFocusRequestIdRef.current = focusChatInputRequest.id;
|
||||
|
||||
return focusWithRetries({
|
||||
focus: () => messageInputRef.current?.focus(),
|
||||
@@ -424,14 +466,16 @@ export function AgentInputArea({
|
||||
typeof document !== "undefined" ? document.activeElement : null;
|
||||
return Boolean(el) && active === el;
|
||||
},
|
||||
onSuccess: () => clearFocusChatInputRequest(),
|
||||
onTimeout: () => clearFocusChatInputRequest(),
|
||||
onSuccess: () =>
|
||||
clearMessageInputActionRequest(messageInputActionRequest.id),
|
||||
onTimeout: () =>
|
||||
clearMessageInputActionRequest(messageInputActionRequest.id),
|
||||
});
|
||||
}, [
|
||||
agentId,
|
||||
clearFocusChatInputRequest,
|
||||
focusChatInputRequest,
|
||||
clearMessageInputActionRequest,
|
||||
isScreenFocused,
|
||||
messageInputActionRequest,
|
||||
serverId,
|
||||
]);
|
||||
|
||||
@@ -471,8 +515,17 @@ export function AgentInputArea({
|
||||
}
|
||||
void voice.startVoice(serverId, agentId).catch((error) => {
|
||||
console.error("[AgentInputArea] Failed to start voice mode", error);
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: null;
|
||||
if (message && message.trim().length > 0) {
|
||||
toast.error(message);
|
||||
}
|
||||
});
|
||||
}, [agentId, isConnected, serverId, voice]);
|
||||
}, [agentId, isConnected, serverId, toast, voice]);
|
||||
|
||||
function handleEditQueuedMessage(id: string) {
|
||||
const item = queuedMessages.find((q) => q.id === id);
|
||||
@@ -485,7 +538,7 @@ export function AgentInputArea({
|
||||
|
||||
async function handleSendQueuedNow(id: string) {
|
||||
const item = queuedMessages.find((q) => q.id === id);
|
||||
if (!item || !isConnected) return;
|
||||
if (!item) return;
|
||||
if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
|
||||
|
||||
updateQueue((current) => current.filter((q) => q.id !== id));
|
||||
@@ -581,7 +634,7 @@ export function AgentInputArea({
|
||||
]
|
||||
);
|
||||
|
||||
const cancelButton = isAgentRunning && !hasSendableContent ? (
|
||||
const cancelButton = isAgentRunning && !hasSendableContent && !isProcessing ? (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleCancelAgent}
|
||||
@@ -725,6 +778,7 @@ export function AgentInputArea({
|
||||
voiceAgentId={agentId}
|
||||
isAgentRunning={isAgentRunning}
|
||||
onQueue={handleQueue}
|
||||
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
|
||||
onKeyPress={handleCommandKeyPress}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -28,6 +28,10 @@ import {
|
||||
buildAgentNavigationKey,
|
||||
startNavigationTiming,
|
||||
} from "@/utils/navigation-timing";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
interface AgentListProps {
|
||||
agents: AggregatedAgent[];
|
||||
@@ -101,12 +105,6 @@ export function AgentList({
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear attention flag when opening agent
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
if (session?.client) {
|
||||
session.client.clearAgentAttention(agentId);
|
||||
}
|
||||
|
||||
const navigationKey = buildAgentNavigationKey(serverId, agentId);
|
||||
startNavigationTiming(navigationKey, {
|
||||
from: "home",
|
||||
@@ -114,12 +112,12 @@ export function AgentList({
|
||||
params: { serverId, agentId },
|
||||
});
|
||||
|
||||
const shouldReplace = pathname.startsWith("/agent/");
|
||||
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
|
||||
const navigate = shouldReplace ? router.replace : router.push;
|
||||
|
||||
onAgentSelect?.();
|
||||
|
||||
navigate(`/agent/${serverId}/${agentId}` as any);
|
||||
navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
|
||||
},
|
||||
[isActionSheetVisible, pathname, onAgentSelect]
|
||||
);
|
||||
|
||||
@@ -17,6 +17,14 @@ interface AgentStatusBarProps {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
function normalizeModelId(modelId: string | null | undefined): string | null {
|
||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
||||
if (!normalized || normalized.toLowerCase() === "default") {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
@@ -61,14 +69,16 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedRuntimeModelId = normalizeModelId(agent.runtimeInfo?.model);
|
||||
const normalizedConfiguredModelId = normalizeModelId(agent.model);
|
||||
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId;
|
||||
const selectedModel = useMemo(() => {
|
||||
if (!models || !agent.model) return null;
|
||||
return models.find((m) => m.id === agent.model) ?? null;
|
||||
}, [models, agent.model]);
|
||||
if (!models || !preferredModelId) return null;
|
||||
return models.find((m) => m.id === preferredModelId) ?? null;
|
||||
}, [models, preferredModelId]);
|
||||
|
||||
const displayModel = selectedModel
|
||||
? selectedModel.label
|
||||
: agent.model ?? "default";
|
||||
const activeModelId = selectedModel?.id ?? preferredModelId ?? null;
|
||||
const displayModel = selectedModel ? selectedModel.label : preferredModelId ?? "Auto";
|
||||
|
||||
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
|
||||
const explicitThinkingId =
|
||||
@@ -156,7 +166,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
testID="agent-model-menu"
|
||||
>
|
||||
{models?.map((model) => {
|
||||
const isActive = model.id === agent.model;
|
||||
const isActive = model.id === activeModelId;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
@@ -297,7 +307,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
{models?.map((model) => {
|
||||
const isActive = model.id === agent.model;
|
||||
const isActive = model.id === activeModelId;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
|
||||
@@ -103,7 +103,7 @@ export function AgentStreamView({
|
||||
const streamItemCountRef = useRef(0);
|
||||
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
|
||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
|
||||
// Get serverId (fallback to agent's serverId if not provided)
|
||||
const resolvedServerId = serverId ?? agent.serverId ?? "";
|
||||
@@ -154,7 +154,12 @@ export function AgentStreamView({
|
||||
requestFilePreview(agentId, normalized.file);
|
||||
}
|
||||
|
||||
setExplorerTab("files");
|
||||
setExplorerTabForCheckout({
|
||||
serverId: resolvedServerId,
|
||||
cwd: agent.cwd,
|
||||
isGit: agent.projectPlacement?.checkout?.isGit ?? true,
|
||||
tab: "files",
|
||||
});
|
||||
openFileExplorer();
|
||||
},
|
||||
[
|
||||
@@ -163,7 +168,7 @@ export function AgentStreamView({
|
||||
requestDirectoryListing,
|
||||
requestFilePreview,
|
||||
selectExplorerEntry,
|
||||
setExplorerTab,
|
||||
setExplorerTabForCheckout,
|
||||
openFileExplorer,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -48,9 +48,13 @@ export function CommandCenter() {
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={handleClose} />
|
||||
|
||||
<View style={[styles.panel, { borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 }]}>
|
||||
<View
|
||||
testID="command-center-panel"
|
||||
style={[styles.panel, { borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 }]}
|
||||
>
|
||||
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}>
|
||||
<TextInput
|
||||
testID="command-center-input"
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { View, Text, Pressable, ActivityIndicator } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { X, ArrowUp, RefreshCcw, Check, Mic } from "lucide-react-native";
|
||||
import { X, ArrowUp, RefreshCcw, Check, Mic, Pencil } from "lucide-react-native";
|
||||
import { VolumeMeter } from "./volume-meter";
|
||||
import { FOOTER_HEIGHT } from "@/constants/layout";
|
||||
import type { DictationStatus } from "@/hooks/use-dictation";
|
||||
@@ -233,7 +233,7 @@ export function DictationOverlay({
|
||||
{ backgroundColor: "rgba(255, 255, 255, 0.25)" },
|
||||
]}
|
||||
>
|
||||
<Check
|
||||
<Pencil
|
||||
size={20}
|
||||
color={theme.colors.palette.white}
|
||||
strokeWidth={2.5}
|
||||
|
||||
@@ -129,6 +129,12 @@ const styles = StyleSheet.create((theme) => {
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foreground,
|
||||
...(Platform.OS === "web"
|
||||
? {
|
||||
whiteSpace: "pre",
|
||||
overflowWrap: "normal",
|
||||
}
|
||||
: null),
|
||||
},
|
||||
headerLine: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
|
||||
@@ -8,6 +8,7 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
import { X } from "lucide-react-native";
|
||||
import {
|
||||
usePanelStore,
|
||||
@@ -17,19 +18,31 @@ import {
|
||||
} from "@/stores/panel-store";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||
import { GitDiffPane } from "./git-diff-pane";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
import { TerminalPane } from "./terminal-pane";
|
||||
|
||||
const MIN_CHAT_WIDTH = 400;
|
||||
const IOS_KEYBOARD_INSET_MIN_HEIGHT = 120;
|
||||
|
||||
function resolveKeyboardShift(rawHeight: number, inset: number): number {
|
||||
"worklet";
|
||||
// iOS can report a small accessory/prediction bar height during touch focus.
|
||||
// Treat that as non-keyboard so terminal scroll gestures don't "bounce" the layout.
|
||||
if (Platform.OS === "ios" && rawHeight < IOS_KEYBOARD_INSET_MIN_HEIGHT) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, rawHeight - inset);
|
||||
}
|
||||
|
||||
interface ExplorerSidebarProps {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
isGit: boolean;
|
||||
}
|
||||
|
||||
export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps) {
|
||||
export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSidebarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile =
|
||||
@@ -39,9 +52,17 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||
const explorerWidth = usePanelStore((state) => state.explorerWidth);
|
||||
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||
const bottomInset = useSharedValue(insets.bottom);
|
||||
const closeTouchStartX = useSharedValue(0);
|
||||
const closeTouchStartY = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
bottomInset.value = insets.bottom;
|
||||
}, [bottomInset, insets.bottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
@@ -77,11 +98,13 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
||||
closeToAgent();
|
||||
}, [closeToAgent]);
|
||||
|
||||
const enableSidebarCloseGesture = isMobile && isOpen;
|
||||
|
||||
const handleTabPress = useCallback(
|
||||
(tab: ExplorerTab) => {
|
||||
setExplorerTab(tab);
|
||||
setExplorerTabForCheckout({ serverId, cwd, isGit, tab });
|
||||
},
|
||||
[setExplorerTab]
|
||||
[cwd, isGit, serverId, setExplorerTabForCheckout]
|
||||
);
|
||||
|
||||
// Swipe gesture to close (swipe right on mobile)
|
||||
@@ -89,12 +112,45 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.withRef(closeGestureRef)
|
||||
.enabled(isMobile && isOpen)
|
||||
// Only activate on rightward swipe (positive X), fail on leftward or vertical
|
||||
// This allows ScrollViews using waitFor to scroll left normally
|
||||
.activeOffsetX(15)
|
||||
.failOffsetX(-10)
|
||||
.failOffsetY([-10, 10])
|
||||
.enabled(enableSidebarCloseGesture)
|
||||
// Use manual activation so child views (e.g. WebView terminals) keep touch streams
|
||||
// unless we detect an intentional right-swipe close.
|
||||
.manualActivation(true)
|
||||
.onTouchesDown((event) => {
|
||||
const touch = event.changedTouches[0];
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
closeTouchStartX.value = touch.absoluteX;
|
||||
closeTouchStartY.value = touch.absoluteY;
|
||||
})
|
||||
.onTouchesMove((event, stateManager) => {
|
||||
const touch = event.changedTouches[0];
|
||||
if (!touch || event.numberOfTouches !== 1) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = touch.absoluteX - closeTouchStartX.value;
|
||||
const deltaY = touch.absoluteY - closeTouchStartY.value;
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
|
||||
// Fail quickly on clear leftward or vertical intent so child views keep control.
|
||||
if (deltaX <= -10) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
// Activate only on intentional rightward movement.
|
||||
if (deltaX >= 15 && absDeltaX > absDeltaY) {
|
||||
stateManager.activate();
|
||||
}
|
||||
})
|
||||
.onStart(() => {
|
||||
isGesturing.value = true;
|
||||
})
|
||||
@@ -120,8 +176,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
||||
isGesturing.value = false;
|
||||
}),
|
||||
[
|
||||
isMobile,
|
||||
isOpen,
|
||||
enableSidebarCloseGesture,
|
||||
windowWidth,
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
@@ -130,6 +185,8 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
||||
handleClose,
|
||||
isGesturing,
|
||||
closeGestureRef,
|
||||
closeTouchStartX,
|
||||
closeTouchStartY,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -171,6 +228,14 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
||||
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
|
||||
}));
|
||||
|
||||
const mobileKeyboardInsetStyle = useAnimatedStyle(() => {
|
||||
const absoluteHeight = Math.abs(keyboardHeight.value);
|
||||
const shift = resolveKeyboardShift(absoluteHeight, bottomInset.value);
|
||||
return {
|
||||
paddingBottom: bottomInset.value + shift,
|
||||
};
|
||||
});
|
||||
|
||||
const resizeAnimatedStyle = useAnimatedStyle(() => ({
|
||||
width: resizeWidth.value,
|
||||
}));
|
||||
@@ -190,8 +255,9 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.mobileSidebar,
|
||||
{ width: windowWidth, paddingTop: insets.top, paddingBottom: insets.bottom },
|
||||
{ width: windowWidth, paddingTop: insets.top },
|
||||
sidebarAnimatedStyle,
|
||||
mobileKeyboardInsetStyle,
|
||||
]}
|
||||
pointerEvents="auto"
|
||||
>
|
||||
@@ -202,6 +268,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
cwd={cwd}
|
||||
isGit={isGit}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</Animated.View>
|
||||
@@ -234,6 +301,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
cwd={cwd}
|
||||
isGit={isGit}
|
||||
isMobile={false}
|
||||
/>
|
||||
</Animated.View>
|
||||
@@ -247,6 +315,7 @@ interface SidebarContentProps {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
isGit: boolean;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
@@ -257,44 +326,62 @@ function SidebarContent({
|
||||
serverId,
|
||||
agentId,
|
||||
cwd,
|
||||
isGit,
|
||||
isMobile,
|
||||
}: SidebarContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { status } = useCheckoutStatusQuery({ serverId, cwd });
|
||||
const isGit = status?.isGit ?? false;
|
||||
const resolvedTab: ExplorerTab =
|
||||
!isGit && activeTab === "changes" ? "files" : activeTab;
|
||||
|
||||
return (
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
{/* Header with tabs and close button */}
|
||||
<View style={styles.header} testID="explorer-header">
|
||||
<View style={styles.tabsContainer}>
|
||||
<Pressable
|
||||
style={[styles.tab, activeTab === "changes" && styles.tabActive]}
|
||||
onPress={() => onTabPress("changes")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "changes" && styles.tabTextActive,
|
||||
!isGit && styles.tabTextMuted,
|
||||
]}
|
||||
{isGit && (
|
||||
<Pressable
|
||||
testID="explorer-tab-changes"
|
||||
style={[styles.tab, resolvedTab === "changes" && styles.tabActive]}
|
||||
onPress={() => onTabPress("changes")}
|
||||
>
|
||||
Changes
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
resolvedTab === "changes" && styles.tabTextActive,
|
||||
]}
|
||||
>
|
||||
Changes
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable
|
||||
style={[styles.tab, activeTab === "files" && styles.tabActive]}
|
||||
testID="explorer-tab-files"
|
||||
style={[styles.tab, resolvedTab === "files" && styles.tabActive]}
|
||||
onPress={() => onTabPress("files")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "files" && styles.tabTextActive,
|
||||
resolvedTab === "files" && styles.tabTextActive,
|
||||
]}
|
||||
>
|
||||
Files
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
testID="explorer-tab-terminals"
|
||||
style={[styles.tab, resolvedTab === "terminals" && styles.tabActive]}
|
||||
onPress={() => onTabPress("terminals")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
resolvedTab === "terminals" && styles.tabTextActive,
|
||||
]}
|
||||
>
|
||||
Terminals
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.headerRightSection}>
|
||||
{isMobile && (
|
||||
@@ -307,12 +394,15 @@ function SidebarContent({
|
||||
|
||||
{/* Content based on active tab */}
|
||||
<View style={styles.contentArea} testID="explorer-content-area">
|
||||
{activeTab === "changes" && (
|
||||
{resolvedTab === "changes" && (
|
||||
<GitDiffPane serverId={serverId} agentId={agentId} cwd={cwd} />
|
||||
)}
|
||||
{activeTab === "files" && (
|
||||
{resolvedTab === "files" && (
|
||||
<FileExplorerPane serverId={serverId} agentId={agentId} />
|
||||
)}
|
||||
{resolvedTab === "terminals" && (
|
||||
<TerminalPane serverId={serverId} cwd={cwd} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
@@ -13,9 +14,13 @@ import {
|
||||
import { ScrollView, Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
@@ -33,6 +38,7 @@ import {
|
||||
FolderOpen,
|
||||
Image as ImageIcon,
|
||||
MoreVertical,
|
||||
RotateCw,
|
||||
X,
|
||||
} from "lucide-react-native";
|
||||
import type {
|
||||
@@ -263,6 +269,78 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
setSortOption(SORT_OPTIONS[nextIndex].value);
|
||||
}, [sortOption, setSortOption]);
|
||||
|
||||
const { refetch: refetchExplorer, isFetching: isRefreshFetching } = useQuery({
|
||||
queryKey: ["fileExplorerRefresh", serverId, agentId],
|
||||
queryFn: async () => {
|
||||
if (!agentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directoryPaths = Array.from(expandedPaths);
|
||||
if (!directoryPaths.includes(".")) {
|
||||
directoryPaths.unshift(".");
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
...directoryPaths.map((path) =>
|
||||
requestDirectoryListing(agentId, path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
})
|
||||
),
|
||||
...(selectedEntryPath ? [requestFilePreview(agentId, selectedEntryPath)] : []),
|
||||
]);
|
||||
return null;
|
||||
},
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refetchExplorer();
|
||||
}, [refetchExplorer]);
|
||||
const refreshIconRotation = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRefreshFetching) {
|
||||
refreshIconRotation.value = 0;
|
||||
refreshIconRotation.value = withRepeat(
|
||||
withTiming(360, {
|
||||
duration: 700,
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
cancelAnimation(refreshIconRotation);
|
||||
const remainder = refreshIconRotation.value % 360;
|
||||
if (Math.abs(remainder) < 0.001) {
|
||||
refreshIconRotation.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const remaining = 360 - remainder;
|
||||
const duration = Math.max(80, Math.round((remaining / 360) * 700));
|
||||
refreshIconRotation.value = withTiming(
|
||||
360,
|
||||
{
|
||||
duration,
|
||||
easing: Easing.linear,
|
||||
},
|
||||
(finished) => {
|
||||
if (finished) {
|
||||
refreshIconRotation.value = 0;
|
||||
}
|
||||
}
|
||||
);
|
||||
}, [isRefreshFetching, refreshIconRotation]);
|
||||
|
||||
const refreshIconAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ rotate: `${refreshIconRotation.value}deg` }],
|
||||
}));
|
||||
|
||||
const currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name";
|
||||
|
||||
const treeRows = useMemo(() => {
|
||||
@@ -603,9 +681,27 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
</GestureDetector>
|
||||
<View style={styles.paneHeader} testID="files-pane-header">
|
||||
<View style={styles.paneHeaderLeft} />
|
||||
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
|
||||
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
|
||||
</Pressable>
|
||||
<View style={styles.paneHeaderRight}>
|
||||
<Pressable
|
||||
onPress={handleRefresh}
|
||||
disabled={isRefreshFetching}
|
||||
hitSlop={8}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.iconButton,
|
||||
(hovered || pressed) && styles.iconButtonHovered,
|
||||
pressed && styles.iconButtonPressed,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Refresh files"
|
||||
>
|
||||
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
|
||||
<RotateCw size={16} color={theme.colors.foregroundMuted} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
|
||||
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<FlatList
|
||||
style={styles.treeList}
|
||||
@@ -622,9 +718,27 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
|
||||
<View style={[styles.treePane, styles.treePaneFill]}>
|
||||
<View style={styles.paneHeader} testID="files-pane-header">
|
||||
<View style={styles.paneHeaderLeft} />
|
||||
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
|
||||
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
|
||||
</Pressable>
|
||||
<View style={styles.paneHeaderRight}>
|
||||
<Pressable
|
||||
onPress={handleRefresh}
|
||||
disabled={isRefreshFetching}
|
||||
hitSlop={8}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.iconButton,
|
||||
(hovered || pressed) && styles.iconButtonHovered,
|
||||
pressed && styles.iconButtonPressed,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Refresh files"
|
||||
>
|
||||
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
|
||||
<RotateCw size={16} color={theme.colors.foregroundMuted} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
|
||||
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<FlatList
|
||||
style={styles.treeList}
|
||||
@@ -1023,6 +1137,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
paneHeaderRight: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
flexShrink: 0,
|
||||
},
|
||||
previewHeaderRight: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -1166,6 +1286,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
iconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
iconButtonPressed: {
|
||||
opacity: 0.8,
|
||||
transform: [{ scale: 0.96 }],
|
||||
},
|
||||
refreshIcon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
previewContent: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
Pressable,
|
||||
FlatList,
|
||||
Platform,
|
||||
type LayoutChangeEvent,
|
||||
type NativeSyntheticEvent,
|
||||
type NativeScrollEvent,
|
||||
} from "react-native";
|
||||
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import * as Linking from "expo-linking";
|
||||
import {
|
||||
Archive,
|
||||
ChevronDown,
|
||||
@@ -40,6 +40,7 @@ import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-contex
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
|
||||
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -49,6 +50,8 @@ import {
|
||||
type ActionStatus,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
// =============================================================================
|
||||
// Git Actions Data Structure
|
||||
@@ -82,11 +85,7 @@ interface GitActions {
|
||||
}
|
||||
|
||||
function openURLInNewTab(url: string): void {
|
||||
if (Platform.OS === "web") {
|
||||
window.open(url, "_blank", "noopener");
|
||||
} else {
|
||||
void Linking.openURL(url);
|
||||
}
|
||||
void openExternalUrl(url);
|
||||
}
|
||||
|
||||
const DIFF_PANE_LOG_TAG = "[GitDiffPane]";
|
||||
@@ -506,6 +505,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
});
|
||||
const {
|
||||
status: prStatus,
|
||||
githubFeaturesEnabled,
|
||||
payloadError: prPayloadError,
|
||||
refresh: refreshPrStatus,
|
||||
} = useCheckoutPrStatusQuery({
|
||||
@@ -517,6 +517,8 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
const [expandedByPath, setExpandedByPath] = useState<Record<string, boolean>>({});
|
||||
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
|
||||
const diffListScrollOffsetRef = useRef(0);
|
||||
const diffListViewportHeightRef = useRef(0);
|
||||
const headerHeightByPathRef = useRef<Record<string, number>>({});
|
||||
const bodyHeightByPathRef = useRef<Record<string, number>>({});
|
||||
const defaultHeaderHeightRef = useRef<number>(44);
|
||||
@@ -621,6 +623,18 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
bodyHeightByPathRef.current[path] = height;
|
||||
}, []);
|
||||
|
||||
const handleDiffListScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
||||
}, []);
|
||||
|
||||
const handleDiffListLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
const height = event.nativeEvent.layout.height;
|
||||
if (!Number.isFinite(height) || height <= 0) {
|
||||
return;
|
||||
}
|
||||
diffListViewportHeightRef.current = height;
|
||||
}, []);
|
||||
|
||||
const computeHeaderOffset = useCallback(
|
||||
(path: string): number => {
|
||||
const defaultHeaderHeight = defaultHeaderHeightRef.current;
|
||||
@@ -644,9 +658,19 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
const isCurrentlyExpanded = expandedByPath[path] ?? false;
|
||||
const nextExpanded = !isCurrentlyExpanded;
|
||||
const targetOffset = isCurrentlyExpanded ? computeHeaderOffset(path) : null;
|
||||
const headerHeight = headerHeightByPathRef.current[path] ?? defaultHeaderHeightRef.current;
|
||||
const shouldAnchor =
|
||||
isCurrentlyExpanded &&
|
||||
targetOffset !== null &&
|
||||
shouldAnchorHeaderBeforeCollapse({
|
||||
headerOffset: targetOffset,
|
||||
headerHeight,
|
||||
viewportOffset: diffListScrollOffsetRef.current,
|
||||
viewportHeight: diffListViewportHeightRef.current,
|
||||
});
|
||||
|
||||
// Anchor to the clicked header before collapsing so visual context is preserved.
|
||||
if (isCurrentlyExpanded && targetOffset !== null) {
|
||||
if (shouldAnchor && targetOffset !== null) {
|
||||
diffListRef.current?.scrollToOffset({
|
||||
offset: targetOffset,
|
||||
animated: false,
|
||||
@@ -799,7 +823,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
setActionError(null);
|
||||
void runArchiveWorktree({ serverId, cwd, worktreePath })
|
||||
.then(() => {
|
||||
router.replace("/agent" as any);
|
||||
router.replace(buildHostAgentDraftRoute(serverId) as any);
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to archive worktree";
|
||||
@@ -840,7 +864,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
const diffErrorMessage =
|
||||
diffPayloadError?.message ??
|
||||
(isDiffError && diffError instanceof Error ? diffError.message : null);
|
||||
const prErrorMessage = prPayloadError?.message ?? null;
|
||||
const prErrorMessage = githubFeaturesEnabled ? prPayloadError?.message ?? null : null;
|
||||
const branchLabel =
|
||||
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
|
||||
? gitStatus.currentBranch
|
||||
@@ -930,6 +954,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
testID="git-diff-scroll"
|
||||
onLayout={handleDiffListLayout}
|
||||
onScroll={handleDiffListScroll}
|
||||
scrollEventThrottle={16}
|
||||
onRefresh={handleRefresh}
|
||||
refreshing={isManualRefresh && isDiffFetching}
|
||||
// Mixed-height rows (header + potentially very large body) are prone to clipping artifacts.
|
||||
@@ -993,7 +1020,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
}
|
||||
|
||||
// View PR - when PR exists
|
||||
if (hasPullRequest && prStatus?.url) {
|
||||
if (githubFeaturesEnabled && hasPullRequest && prStatus?.url) {
|
||||
const prUrl = prStatus.url;
|
||||
allActions.set("view-pr", {
|
||||
id: "view-pr",
|
||||
@@ -1008,7 +1035,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
}
|
||||
|
||||
// Create PR - when ahead of base and no PR
|
||||
if (aheadCount > 0 && !hasPullRequest) {
|
||||
if (githubFeaturesEnabled && aheadCount > 0 && !hasPullRequest) {
|
||||
allActions.set("create-pr", {
|
||||
id: "create-pr",
|
||||
label: "Create PR",
|
||||
@@ -1112,7 +1139,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
|
||||
return { primary, secondary, menu };
|
||||
}, [
|
||||
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch,
|
||||
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
|
||||
hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel,
|
||||
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
|
||||
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,
|
||||
|
||||
@@ -11,7 +11,11 @@ interface BackHeaderProps {
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
|
||||
export function BackHeader({
|
||||
title,
|
||||
rightContent,
|
||||
onBack,
|
||||
}: BackHeaderProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,48 +1,95 @@
|
||||
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 { Menu, PanelLeft } from "lucide-react-native";
|
||||
import { PanelLeft } from "lucide-react-native";
|
||||
import { ScreenHeader } from "./screen-header";
|
||||
import { HeaderToggleButton } from "./header-toggle-button";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
|
||||
interface MenuHeaderProps {
|
||||
title?: string;
|
||||
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 isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
|
||||
|
||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||
const MenuIcon = isMobile ? Menu : PanelLeft;
|
||||
const menuIconColor = !isMobile && isOpen
|
||||
? theme.colors.foreground
|
||||
: 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 (
|
||||
<ScreenHeader
|
||||
left={
|
||||
<>
|
||||
<HeaderToggleButton
|
||||
onPress={toggleAgentList}
|
||||
tooltipLabel="Toggle sidebar"
|
||||
tooltipKeys={["mod", "B"]}
|
||||
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>
|
||||
<SidebarMenuToggle />
|
||||
{title && (
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{title}
|
||||
@@ -69,4 +116,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
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,
|
||||
* 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 isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
||||
|
||||
96
packages/app/src/components/keyboard-shortcuts-dialog.tsx
Normal file
96
packages/app/src/components/keyboard-shortcuts-dialog.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { getIsTauri } from "@/constants/layout";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts";
|
||||
|
||||
export function KeyboardShortcutsDialog() {
|
||||
const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen);
|
||||
const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen);
|
||||
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
const isTauri = getIsTauri();
|
||||
const sections = useMemo(
|
||||
() => buildKeyboardShortcutHelpSections({ isMac, isTauri }),
|
||||
[isMac, isTauri]
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
title="Keyboard shortcuts"
|
||||
visible={open}
|
||||
onClose={() => setOpen(false)}
|
||||
testID="keyboard-shortcuts-dialog"
|
||||
snapPoints={["70%", "92%"]}
|
||||
>
|
||||
<View testID="keyboard-shortcuts-dialog-content" style={styles.content}>
|
||||
{sections.map((section) => (
|
||||
<View key={section.title} style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||
<View style={styles.rows}>
|
||||
{section.rows.map((row) => (
|
||||
<View key={row.id} style={styles.row}>
|
||||
<View style={styles.rowText}>
|
||||
<Text style={styles.rowLabel}>{row.label}</Text>
|
||||
{row.note ? <Text style={styles.rowNote}>{row.note}</Text> : null}
|
||||
</View>
|
||||
<Shortcut keys={row.keys} style={styles.rowShortcut} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
content: {
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
section: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
rows: {
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
overflow: "hidden",
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderBottomWidth: theme.borderWidth[1],
|
||||
borderBottomColor: theme.colors.surface2,
|
||||
},
|
||||
rowText: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
rowLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
rowNote: {
|
||||
marginTop: 2,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
rowShortcut: {
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
}));
|
||||
@@ -30,10 +30,13 @@ import { useDictation } from "@/hooks/use-dictation";
|
||||
import { DictationOverlay } from "./dictation-controls";
|
||||
import { RealtimeVoiceOverlay } from "./realtime-voice-overlay";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useVoiceOptional } from "@/contexts/voice-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
|
||||
export interface ImageAttachment {
|
||||
uri: string;
|
||||
@@ -72,6 +75,8 @@ export interface MessageInputProps {
|
||||
isAgentRunning?: boolean;
|
||||
/** Callback for queue button when agent is running */
|
||||
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. */
|
||||
onKeyPress?: (event: { key: string; preventDefault: () => void }) => boolean;
|
||||
}
|
||||
@@ -79,6 +84,7 @@ export interface MessageInputProps {
|
||||
export interface MessageInputRef {
|
||||
focus: () => void;
|
||||
blur: () => void;
|
||||
runKeyboardAction: (action: MessageInputKeyboardActionKind) => void;
|
||||
/**
|
||||
* Web-only: return the underlying DOM element for focus assertions/retries.
|
||||
* May return null if not mounted or on native.
|
||||
@@ -128,14 +134,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
voiceAgentId,
|
||||
isAgentRunning = false,
|
||||
onQueue,
|
||||
onSubmitLoadingPress,
|
||||
onKeyPress: onKeyPressCallback,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const voice = useVoiceOptional();
|
||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
||||
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
||||
const textInputRef = useRef<
|
||||
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
|
||||
@@ -149,6 +155,40 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
blur: () => {
|
||||
textInputRef.current?.blur?.();
|
||||
},
|
||||
runKeyboardAction: (action) => {
|
||||
if (action === "focus") {
|
||||
textInputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "voice-toggle") {
|
||||
handleToggleRealtimeVoiceShortcut();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "voice-mute-toggle") {
|
||||
if (isRealtimeVoiceForCurrentAgent) {
|
||||
voice?.toggleMute();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "dictation-cancel") {
|
||||
if (isDictatingRef.current) {
|
||||
cancelDictation();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "dictation-toggle") {
|
||||
if (isDictatingRef.current) {
|
||||
sendAfterTranscriptRef.current = true;
|
||||
confirmDictation();
|
||||
} else {
|
||||
void startDictationIfAvailable();
|
||||
}
|
||||
}
|
||||
},
|
||||
getNativeElement: () => {
|
||||
if (!IS_WEB) return null;
|
||||
const current = textInputRef.current as
|
||||
@@ -166,6 +206,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const overlayTransition = useSharedValue(0);
|
||||
const sendAfterTranscriptRef = useRef(false);
|
||||
const valueRef = useRef(value);
|
||||
const serverInfo = useSessionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!voiceServerId) {
|
||||
return null;
|
||||
}
|
||||
return state.sessions[voiceServerId]?.serverInfo ?? null;
|
||||
},
|
||||
[voiceServerId]
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
valueRef.current = value;
|
||||
@@ -211,10 +262,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
console.error("[MessageInput] Dictation error:", error);
|
||||
}, []);
|
||||
|
||||
const dictationUnavailableMessage = resolveVoiceUnavailableMessage({
|
||||
serverInfo,
|
||||
mode: "dictation",
|
||||
});
|
||||
|
||||
const canStartDictation = useCallback(() => {
|
||||
const socketConnected = client?.isConnected ?? false;
|
||||
return socketConnected && !disabled;
|
||||
}, [client, disabled]);
|
||||
return socketConnected && !disabled && !dictationUnavailableMessage;
|
||||
}, [client, disabled, dictationUnavailableMessage]);
|
||||
|
||||
const canConfirmDictation = useCallback(() => {
|
||||
const socketConnected = client?.isConnected ?? false;
|
||||
@@ -267,110 +323,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
sendAfterTranscriptRef.current = false;
|
||||
}, [dictationStatus, isDictating, isDictationProcessing]);
|
||||
|
||||
// Cmd+D to start/submit dictation, Cmd+Shift+D toggles realtime voice, Escape cancels dictation
|
||||
useEffect(() => {
|
||||
if (!IS_WEB) return;
|
||||
const toggleRealtimeVoice = () => {
|
||||
if (!voice || !voiceServerId || !voiceAgentId || !isConnected || disabled) {
|
||||
return;
|
||||
}
|
||||
if (voice.isVoiceSwitching) {
|
||||
return;
|
||||
}
|
||||
if (voice.isVoiceModeForAgent(voiceServerId, voiceAgentId)) {
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
if (isAgentRunning && client) {
|
||||
tasks.push(client.cancelAgent(voiceAgentId));
|
||||
}
|
||||
tasks.push(voice.stopVoice());
|
||||
void Promise.allSettled(tasks).then((results) => {
|
||||
results.forEach((result) => {
|
||||
if (result.status === "rejected") {
|
||||
console.error(
|
||||
"[MessageInput] Failed to stop realtime voice",
|
||||
result.reason
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
void voice.startVoice(voiceServerId, voiceAgentId).catch((error) => {
|
||||
console.error("[MessageInput] Failed to start realtime voice", error);
|
||||
});
|
||||
};
|
||||
|
||||
const resolveNativeInput = (): unknown => {
|
||||
const current = textInputRef.current as any;
|
||||
if (!current) return null;
|
||||
if (typeof current.getNativeRef === "function") {
|
||||
return current.getNativeRef();
|
||||
}
|
||||
return current;
|
||||
};
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const nativeInput = resolveNativeInput();
|
||||
const isFromInput = Boolean(nativeInput && event.target === nativeInput);
|
||||
if (!isScreenFocused && !isInputFocusedRef.current && !isFromInput) {
|
||||
return;
|
||||
}
|
||||
const isMod = event.metaKey || event.ctrlKey;
|
||||
const isKeyD = event.code === "KeyD" || event.key.toLowerCase() === "d";
|
||||
|
||||
if (isMod && event.shiftKey && isKeyD && !event.repeat) {
|
||||
event.preventDefault();
|
||||
toggleRealtimeVoice();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isRealtimeVoiceForCurrentAgent &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.code === "Space" || event.key === " ") &&
|
||||
!event.repeat
|
||||
) {
|
||||
event.preventDefault();
|
||||
voice?.toggleMute();
|
||||
return;
|
||||
}
|
||||
|
||||
const dictating = isDictatingRef.current;
|
||||
// Cmd+D: start dictation or submit if already dictating
|
||||
if (isMod && isKeyD) {
|
||||
event.preventDefault();
|
||||
if (dictating) {
|
||||
sendAfterTranscriptRef.current = true;
|
||||
confirmDictation();
|
||||
} else {
|
||||
startDictation();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Escape: cancel dictation
|
||||
if (event.key === "Escape" && dictating) {
|
||||
event.preventDefault();
|
||||
cancelDictation();
|
||||
}
|
||||
const startDictationIfAvailable = useCallback(async () => {
|
||||
if (dictationUnavailableMessage) {
|
||||
toast.error(dictationUnavailableMessage);
|
||||
return;
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [
|
||||
cancelDictation,
|
||||
client,
|
||||
confirmDictation,
|
||||
disabled,
|
||||
isAgentRunning,
|
||||
isConnected,
|
||||
isRealtimeVoiceForCurrentAgent,
|
||||
isScreenFocused,
|
||||
startDictation,
|
||||
voiceAgentId,
|
||||
voiceServerId,
|
||||
voice,
|
||||
]);
|
||||
await startDictation();
|
||||
}, [dictationUnavailableMessage, startDictation, toast]);
|
||||
|
||||
// Animate overlay
|
||||
useEffect(() => {
|
||||
@@ -397,13 +356,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
if (isDictating) {
|
||||
await cancelDictation();
|
||||
} else {
|
||||
await startDictation();
|
||||
await startDictationIfAvailable();
|
||||
}
|
||||
}, [
|
||||
cancelDictation,
|
||||
isDictating,
|
||||
isRealtimeVoiceForCurrentAgent,
|
||||
startDictation,
|
||||
startDictationIfAvailable,
|
||||
voice,
|
||||
]);
|
||||
|
||||
@@ -461,11 +420,21 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
}
|
||||
void voice.startVoice(voiceServerId, voiceAgentId).catch((error) => {
|
||||
console.error("[MessageInput] Failed to start realtime voice", error);
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: null;
|
||||
if (message && message.trim().length > 0) {
|
||||
toast.error(message);
|
||||
}
|
||||
});
|
||||
}, [
|
||||
disabled,
|
||||
handleStopRealtimeVoice,
|
||||
isConnected,
|
||||
toast,
|
||||
voice,
|
||||
voiceAgentId,
|
||||
voiceServerId,
|
||||
@@ -588,60 +557,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
|
||||
const { shiftKey, metaKey, ctrlKey } = event.nativeEvent;
|
||||
|
||||
const key = event.nativeEvent.key.toLowerCase();
|
||||
|
||||
// Cmd+B or Ctrl+B: toggle sidebar
|
||||
if ((metaKey || ctrlKey) && key === "b") {
|
||||
event.preventDefault();
|
||||
toggleAgentList();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+E or Ctrl+E: toggle explorer sidebar
|
||||
if ((metaKey || ctrlKey) && key === "e") {
|
||||
event.preventDefault();
|
||||
toggleFileExplorer();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+Shift+D or Ctrl+Shift+D: toggle realtime voice mode
|
||||
if ((metaKey || ctrlKey) && shiftKey && key === "d") {
|
||||
event.preventDefault();
|
||||
handleToggleRealtimeVoiceShortcut();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+D or Ctrl+D: start dictation or submit if already dictating
|
||||
if ((metaKey || ctrlKey) && key === "d") {
|
||||
event.preventDefault();
|
||||
if (isDictating) {
|
||||
sendAfterTranscriptRef.current = true;
|
||||
confirmDictation();
|
||||
} else {
|
||||
startDictation();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape: cancel dictation
|
||||
if (event.nativeEvent.key === "Escape" && isDictating) {
|
||||
event.preventDefault();
|
||||
cancelDictation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isRealtimeVoiceForCurrentAgent &&
|
||||
!metaKey &&
|
||||
!ctrlKey &&
|
||||
!shiftKey &&
|
||||
event.nativeEvent.key === " "
|
||||
) {
|
||||
event.preventDefault();
|
||||
voice?.toggleMute();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.nativeEvent.key !== "Enter") return;
|
||||
|
||||
// Shift+Enter: add newline (default behavior, don't intercept)
|
||||
@@ -664,9 +579,19 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const hasImages = images.length > 0;
|
||||
const hasSendableContent = value.trim().length > 0 || hasImages;
|
||||
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 (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.container} testID="message-input-root">
|
||||
{/* Regular input */}
|
||||
<Animated.View style={[styles.inputWrapper, inputAnimatedStyle]}>
|
||||
{/* Image preview pills */}
|
||||
@@ -731,7 +656,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
editable={
|
||||
!isDictating && !isRealtimeVoiceForCurrentAgent && isConnected && !disabled
|
||||
!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled
|
||||
}
|
||||
onKeyPress={
|
||||
shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined
|
||||
@@ -812,7 +737,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{rightContent}
|
||||
{shouldShowSendButton && isAgentRunning && onQueue && (
|
||||
{hasSendableContent && isAgentRunning && onQueue && (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleQueueMessage}
|
||||
@@ -837,22 +762,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
{shouldShowSendButton && (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleSendMessage}
|
||||
disabled={
|
||||
!isConnected ||
|
||||
isSubmitDisabled ||
|
||||
isSubmitLoading ||
|
||||
disabled
|
||||
onPress={
|
||||
canPressLoadingButton ? onSubmitLoadingPress : handleSendMessage
|
||||
}
|
||||
accessibilityLabel={isAgentRunning ? "Send and interrupt" : "Send message"}
|
||||
disabled={isSendButtonDisabled}
|
||||
accessibilityLabel={submitAccessibilityLabel}
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
styles.sendButton,
|
||||
(!isConnected ||
|
||||
isSubmitDisabled ||
|
||||
isSubmitLoading ||
|
||||
disabled) &&
|
||||
styles.buttonDisabled,
|
||||
isSendButtonDisabled && styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
{isSubmitLoading ? (
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
} from "react";
|
||||
import type { ReactNode, ComponentType } from "react";
|
||||
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
||||
import * as Linking from "expo-linking";
|
||||
import MaskedView from "@react-native-masked-view/masked-view";
|
||||
import {
|
||||
Circle,
|
||||
@@ -71,6 +70,7 @@ import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
|
||||
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
|
||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
export type { InlinePathTarget } from "@/utils/inline-path";
|
||||
import { useToolCallSheet } from "./tool-call-sheet";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
@@ -117,6 +117,8 @@ const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
|
||||
}
|
||||
`;
|
||||
let webToolCallShimmerRegistered = false;
|
||||
const SCROLL_EDGE_EPSILON = 0.5;
|
||||
type ScrollAxis = "x" | "y";
|
||||
|
||||
function ensureWebToolCallShimmerKeyframes() {
|
||||
if (Platform.OS !== "web") {
|
||||
@@ -143,6 +145,113 @@ function ensureWebToolCallShimmerKeyframes() {
|
||||
webToolCallShimmerRegistered = true;
|
||||
}
|
||||
|
||||
function getWheelEventElementTarget(
|
||||
event: WheelEvent,
|
||||
fallback: HTMLElement
|
||||
): HTMLElement {
|
||||
const { target } = event;
|
||||
if (target instanceof HTMLElement) {
|
||||
return target;
|
||||
}
|
||||
if (target instanceof Node && target.parentElement) {
|
||||
return target.parentElement;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function canElementScrollInDirection(
|
||||
element: HTMLElement,
|
||||
axis: ScrollAxis,
|
||||
delta: number
|
||||
): boolean {
|
||||
if (delta === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const computedStyle = window.getComputedStyle(element);
|
||||
const overflow = axis === "x" ? computedStyle.overflowX : computedStyle.overflowY;
|
||||
const isScrollableOverflow =
|
||||
overflow === "auto" || overflow === "scroll" || overflow === "overlay";
|
||||
if (!isScrollableOverflow) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const scrollPosition = axis === "x" ? element.scrollLeft : element.scrollTop;
|
||||
const scrollSize =
|
||||
axis === "x" ? element.scrollWidth - element.clientWidth : element.scrollHeight - element.clientHeight;
|
||||
if (scrollSize <= SCROLL_EDGE_EPSILON) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (delta > 0) {
|
||||
return scrollPosition < scrollSize - SCROLL_EDGE_EPSILON;
|
||||
}
|
||||
return scrollPosition > SCROLL_EDGE_EPSILON;
|
||||
}
|
||||
|
||||
function canScrollInsideDetailFromTarget(
|
||||
detailRoot: HTMLElement,
|
||||
startElement: HTMLElement,
|
||||
axis: ScrollAxis,
|
||||
delta: number
|
||||
): boolean {
|
||||
if (delta === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let current: HTMLElement | null = startElement;
|
||||
while (current) {
|
||||
if (canElementScrollInDirection(current, axis, delta)) {
|
||||
return true;
|
||||
}
|
||||
if (current === detailRoot) {
|
||||
break;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldStopDetailWheelPropagation(
|
||||
detailRoot: HTMLElement,
|
||||
event: WheelEvent
|
||||
): boolean {
|
||||
const startElement = getWheelEventElementTarget(event, detailRoot);
|
||||
const verticalDelta = event.deltaY;
|
||||
const horizontalDelta =
|
||||
event.deltaX !== 0 ? event.deltaX : (event.shiftKey ? event.deltaY : 0);
|
||||
|
||||
const hasVerticalIntent = Math.abs(verticalDelta) > SCROLL_EDGE_EPSILON;
|
||||
const hasHorizontalIntent = Math.abs(horizontalDelta) > SCROLL_EDGE_EPSILON;
|
||||
if (!hasVerticalIntent && !hasHorizontalIntent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const canScrollVertically = hasVerticalIntent
|
||||
? canScrollInsideDetailFromTarget(detailRoot, startElement, "y", verticalDelta)
|
||||
: false;
|
||||
const canScrollHorizontally = hasHorizontalIntent
|
||||
? canScrollInsideDetailFromTarget(
|
||||
detailRoot,
|
||||
startElement,
|
||||
"x",
|
||||
horizontalDelta
|
||||
)
|
||||
: false;
|
||||
|
||||
if (hasVerticalIntent && hasHorizontalIntent) {
|
||||
const isVerticalDominant = Math.abs(verticalDelta) >= Math.abs(horizontalDelta);
|
||||
return isVerticalDominant
|
||||
? canScrollVertically || canScrollHorizontally
|
||||
: canScrollHorizontally || canScrollVertically;
|
||||
}
|
||||
|
||||
if (hasVerticalIntent) {
|
||||
return canScrollVertically;
|
||||
}
|
||||
return canScrollHorizontally;
|
||||
}
|
||||
|
||||
const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
@@ -272,6 +381,10 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: 13,
|
||||
},
|
||||
markdownCodeInlineLink: {
|
||||
color: theme.colors.primary,
|
||||
textDecorationLine: "underline",
|
||||
},
|
||||
// Used in custom markdownRules for path chip styling
|
||||
pathChip: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
@@ -288,6 +401,30 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function getInlineCodeAutoLinkUrl(
|
||||
markdownParser: ReturnType<typeof MarkdownIt>,
|
||||
content: string
|
||||
): string | null {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matches = markdownParser.linkify.match(trimmed) as
|
||||
| Array<{ index: number; lastIndex: number; url: string }>
|
||||
| null;
|
||||
if (!matches || matches.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [match] = matches;
|
||||
if (!match || match.index !== 0 || match.lastIndex !== trimmed.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match.url;
|
||||
}
|
||||
|
||||
const turnCopyButtonStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
alignSelf: "flex-start",
|
||||
@@ -512,12 +649,10 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
);
|
||||
|
||||
const handleLinkPress = useCallback((url: string) => {
|
||||
if (Platform.OS === "web") {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
void Linking.openURL(url);
|
||||
}
|
||||
return true;
|
||||
void openExternalUrl(url);
|
||||
// react-native-markdown-display opens the link itself when this returns true.
|
||||
// We already handled it above, so return false to avoid duplicate opens.
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const markdownRules = useMemo(() => {
|
||||
@@ -584,13 +719,35 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
? parseInlinePathToken(content)
|
||||
: null;
|
||||
|
||||
if (!parsed) {
|
||||
if (parsed) {
|
||||
return (
|
||||
<Text
|
||||
key={node.key}
|
||||
onPress={() => parsed && onInlinePathPress?.(parsed)}
|
||||
selectable={false}
|
||||
style={[
|
||||
assistantMessageStylesheet.pathChip,
|
||||
assistantMessageStylesheet.pathChipText,
|
||||
]}
|
||||
>
|
||||
{content}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const inlineCodeLinkUrl = getInlineCodeAutoLinkUrl(markdownParser, content);
|
||||
if (inlineCodeLinkUrl) {
|
||||
return (
|
||||
<Text
|
||||
key={node.key}
|
||||
accessibilityRole="link"
|
||||
onPress={() => {
|
||||
handleLinkPress(inlineCodeLinkUrl);
|
||||
}}
|
||||
style={[
|
||||
inheritedStyles,
|
||||
assistantMessageStylesheet.markdownCodeInline,
|
||||
assistantMessageStylesheet.markdownCodeInlineLink,
|
||||
]}
|
||||
>
|
||||
{content}
|
||||
@@ -601,11 +758,9 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
return (
|
||||
<Text
|
||||
key={node.key}
|
||||
onPress={() => parsed && onInlinePathPress?.(parsed)}
|
||||
selectable={false}
|
||||
style={[
|
||||
assistantMessageStylesheet.pathChip,
|
||||
assistantMessageStylesheet.pathChipText,
|
||||
inheritedStyles,
|
||||
assistantMessageStylesheet.markdownCodeInline,
|
||||
]}
|
||||
>
|
||||
{content}
|
||||
@@ -658,7 +813,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
);
|
||||
},
|
||||
};
|
||||
}, [onInlinePathPress]);
|
||||
}, [handleLinkPress, markdownParser, onInlinePathPress]);
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -1075,6 +1230,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
const hasDetailContent = Boolean(renderDetails);
|
||||
const detailContent =
|
||||
hasDetailContent && isExpanded ? renderDetails?.() : null;
|
||||
const detailWrapperRef = useRef<View | null>(null);
|
||||
|
||||
const nativeGradientIdRef = useRef(
|
||||
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`
|
||||
@@ -1194,6 +1350,28 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
shimmerTranslateX,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = detailWrapperRef.current as unknown as HTMLElement | null;
|
||||
if (!node || typeof node.addEventListener !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const stopWheelPropagation = (event: WheelEvent) => {
|
||||
if (shouldStopDetailWheelPropagation(node, event)) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
node.addEventListener("wheel", stopWheelPropagation, { passive: true });
|
||||
return () => {
|
||||
node.removeEventListener("wheel", stopWheelPropagation);
|
||||
};
|
||||
}, [isExpanded, hasDetailContent]);
|
||||
|
||||
const nativeShimmerPeakStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: shimmerTranslateX.value }],
|
||||
}));
|
||||
@@ -1430,6 +1608,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
</Pressable>
|
||||
{detailContent ? (
|
||||
<Pressable
|
||||
ref={detailWrapperRef}
|
||||
style={expandableBadgeStylesheet.detailWrapper}
|
||||
onHoverIn={() => onDetailHoverChange?.(true)}
|
||||
onHoverOut={() => onDetailHoverChange?.(false)}
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { SessionProvider } from "@/contexts/session-context";
|
||||
import {
|
||||
useDaemonRegistry,
|
||||
type HostConnection,
|
||||
type HostProfile,
|
||||
} from "@/contexts/daemon-registry-context";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import {
|
||||
buildDaemonWebSocketUrl,
|
||||
buildRelayWebSocketUrl,
|
||||
} from "@/utils/daemon-endpoints";
|
||||
import { probeConnection } from "@/utils/test-daemon-connection";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { measureConnectionLatency } from "@/utils/test-daemon-connection";
|
||||
import {
|
||||
selectBestConnection,
|
||||
type ConnectionCandidate,
|
||||
type ConnectionProbeState,
|
||||
} from "@/utils/connection-selection";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import type { ActiveConnection } from "@/contexts/daemon-connections-context";
|
||||
|
||||
type Candidate = {
|
||||
connection: HostConnection;
|
||||
connectionId: string;
|
||||
url: string;
|
||||
activeConnection: ActiveConnection;
|
||||
@@ -31,28 +38,27 @@ function sortConnectionsByPreference<T extends { id: string }>(
|
||||
|
||||
function buildCandidates(host: HostProfile): Candidate[] {
|
||||
const preferred = host.preferredConnectionId ?? null;
|
||||
|
||||
const direct = sortConnectionsByPreference(
|
||||
host.connections.filter((c) => c.type === "direct"),
|
||||
preferred
|
||||
);
|
||||
const relay = sortConnectionsByPreference(
|
||||
host.connections.filter((c) => c.type === "relay"),
|
||||
preferred
|
||||
);
|
||||
const connections = sortConnectionsByPreference(host.connections, preferred);
|
||||
|
||||
const out: Candidate[] = [];
|
||||
|
||||
for (const conn of direct) {
|
||||
out.push({
|
||||
connectionId: conn.id,
|
||||
url: buildDaemonWebSocketUrl(conn.endpoint),
|
||||
activeConnection: { type: "direct", endpoint: conn.endpoint, display: conn.endpoint },
|
||||
});
|
||||
}
|
||||
for (const conn of connections) {
|
||||
if (conn.type === "direct") {
|
||||
out.push({
|
||||
connection: conn,
|
||||
connectionId: conn.id,
|
||||
url: buildDaemonWebSocketUrl(conn.endpoint),
|
||||
activeConnection: {
|
||||
type: "direct",
|
||||
endpoint: conn.endpoint,
|
||||
display: conn.endpoint,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const conn of relay) {
|
||||
out.push({
|
||||
connection: conn,
|
||||
connectionId: conn.id,
|
||||
url: buildRelayWebSocketUrl({
|
||||
endpoint: conn.relayEndpoint,
|
||||
@@ -67,123 +73,79 @@ function buildCandidates(host: HostProfile): Candidate[] {
|
||||
}
|
||||
|
||||
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const { updateHost } = useDaemonRegistry();
|
||||
|
||||
const candidates = useMemo(() => buildCandidates(daemon), [daemon]);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const active = candidates[activeIndex] ?? candidates[0] ?? null;
|
||||
const latencyQueries = useQueries({
|
||||
queries: candidates.map((candidate) => ({
|
||||
queryKey: ["connection-selection-latency", daemon.serverId, candidate.connectionId],
|
||||
queryFn: () =>
|
||||
measureConnectionLatency(candidate.connection, {
|
||||
serverId: daemon.serverId,
|
||||
}),
|
||||
refetchInterval: 10_000,
|
||||
staleTime: 9_000,
|
||||
gcTime: 60_000,
|
||||
retry: 1,
|
||||
})),
|
||||
});
|
||||
|
||||
const probeByConnectionId = useMemo(() => {
|
||||
const next = new Map<string, ConnectionProbeState>();
|
||||
|
||||
candidates.forEach((candidate, index) => {
|
||||
const query = latencyQueries[index];
|
||||
if (!query) {
|
||||
next.set(candidate.connectionId, { status: "pending", latencyMs: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (query.isSuccess && typeof query.data === "number") {
|
||||
next.set(candidate.connectionId, {
|
||||
status: "available",
|
||||
latencyMs: query.data,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (query.isError) {
|
||||
next.set(candidate.connectionId, { status: "unavailable", latencyMs: null });
|
||||
return;
|
||||
}
|
||||
|
||||
next.set(candidate.connectionId, { status: "pending", latencyMs: null });
|
||||
});
|
||||
|
||||
return next;
|
||||
}, [candidates, latencyQueries]);
|
||||
|
||||
const candidateInputs = useMemo<ConnectionCandidate[]>(
|
||||
() =>
|
||||
candidates.map((candidate) => ({
|
||||
connectionId: candidate.connectionId,
|
||||
connection: candidate.connection,
|
||||
})),
|
||||
[candidates]
|
||||
);
|
||||
|
||||
const activeConnectionId = useMemo(
|
||||
() =>
|
||||
selectBestConnection({
|
||||
candidates: candidateInputs,
|
||||
preferredConnectionId: daemon.preferredConnectionId,
|
||||
probeByConnectionId,
|
||||
}),
|
||||
[candidateInputs, daemon.preferredConnectionId, probeByConnectionId]
|
||||
);
|
||||
const active =
|
||||
candidates.find((candidate) => candidate.connectionId === activeConnectionId) ?? null;
|
||||
const activeUrl = active?.url ?? null;
|
||||
|
||||
const lastAttemptedUrlRef = useRef<string | null>(null);
|
||||
const pendingPreferenceWriteRef = useRef(false);
|
||||
const upgradeProbeInFlightRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeUrl) {
|
||||
return;
|
||||
}
|
||||
// If the active URL fell out of the candidate set (e.g. endpoints updated), snap back.
|
||||
const idx = candidates.findIndex((c) => c.url === activeUrl);
|
||||
if (idx === -1) {
|
||||
setActiveIndex(0);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [candidates.map((c) => c.url).join("|")]);
|
||||
|
||||
if (!activeUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const connection = connectionStates.get(daemon.serverId);
|
||||
const status = connection?.status ?? "idle";
|
||||
const lastError = connection?.lastError ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!connection) return;
|
||||
|
||||
if (status === "online") {
|
||||
if (!active) return;
|
||||
if (pendingPreferenceWriteRef.current) return;
|
||||
if (daemon.preferredConnectionId === active.connectionId) return;
|
||||
|
||||
pendingPreferenceWriteRef.current = true;
|
||||
void updateHost(daemon.serverId, {
|
||||
preferredConnectionId: active.connectionId,
|
||||
}).finally(() => {
|
||||
pendingPreferenceWriteRef.current = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if ((status === "error" || (status === "offline" && lastError)) && candidates.length > 1) {
|
||||
if (lastAttemptedUrlRef.current === activeUrl) {
|
||||
return;
|
||||
}
|
||||
lastAttemptedUrlRef.current = activeUrl;
|
||||
if (activeIndex < candidates.length - 1) {
|
||||
setActiveIndex((idx) => Math.min(idx + 1, candidates.length - 1));
|
||||
}
|
||||
}
|
||||
}, [
|
||||
active,
|
||||
activeIndex,
|
||||
activeUrl,
|
||||
candidates.length,
|
||||
daemon.preferredConnectionId,
|
||||
daemon.serverId,
|
||||
lastError,
|
||||
status,
|
||||
updateHost,
|
||||
connection,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connection) return;
|
||||
if (status !== "online") return;
|
||||
if (activeIndex === 0) return;
|
||||
|
||||
const best = candidates[0];
|
||||
if (!best) return;
|
||||
if (best.activeConnection.type !== "direct") return;
|
||||
|
||||
const intervalMs = 15_000;
|
||||
let cancelled = false;
|
||||
|
||||
const attemptUpgrade = async () => {
|
||||
if (cancelled) return;
|
||||
if (upgradeProbeInFlightRef.current) return;
|
||||
if (activeIndex === 0) return;
|
||||
|
||||
upgradeProbeInFlightRef.current = true;
|
||||
try {
|
||||
const { serverId } = await probeConnection(
|
||||
{ id: "probe", type: "direct", endpoint: best.activeConnection.endpoint },
|
||||
{ timeoutMs: 2000 },
|
||||
);
|
||||
if (cancelled) return;
|
||||
if (serverId !== daemon.serverId) return;
|
||||
|
||||
lastAttemptedUrlRef.current = null;
|
||||
setActiveIndex(0);
|
||||
} catch {
|
||||
// ignore - we'll retry periodically
|
||||
} finally {
|
||||
upgradeProbeInFlightRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
void attemptUpgrade();
|
||||
const interval = setInterval(() => void attemptUpgrade(), intervalMs);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [activeIndex, candidates, connection, daemon.serverId, status]);
|
||||
|
||||
return (
|
||||
<SessionProvider
|
||||
key={`${daemon.serverId}:${activeUrl}`}
|
||||
key={daemon.serverId}
|
||||
serverUrl={activeUrl}
|
||||
serverId={daemon.serverId}
|
||||
activeConnection={active?.activeConnection ?? null}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState, useEffect } from "react";
|
||||
import { useCallback, useMemo, useState, useEffect, useRef } from "react";
|
||||
import { View, Pressable, Text, Platform } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import Animated, {
|
||||
@@ -10,16 +10,36 @@ import Animated, {
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { Plus, Settings, Users } from "lucide-react-native";
|
||||
import { router } from "expo-router";
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { SidebarAgentList } from "./sidebar-agent-list";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped";
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
||||
import { deriveSidebarShortcutAgentKeys } from "@/utils/sidebar-shortcuts";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
|
||||
import {
|
||||
checkoutStatusQueryKey,
|
||||
type CheckoutStatusPayload,
|
||||
} from "@/hooks/use-checkout-status-query";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
buildNewAgentRoute,
|
||||
resolveNewAgentWorkingDir,
|
||||
resolveSelectedAgentForNewAgent,
|
||||
} from "@/utils/new-agent-routing";
|
||||
import {
|
||||
buildHostAgentsRoute,
|
||||
buildHostSettingsRoute,
|
||||
mapPathnameToServer,
|
||||
parseServerIdFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
const DESKTOP_SIDEBAR_WIDTH = 320;
|
||||
|
||||
@@ -35,17 +55,62 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||
const pathname = usePathname();
|
||||
const { daemons } = useDaemonRegistry();
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const activeServerIdFromPath = useMemo(
|
||||
() => parseServerIdFromPathname(pathname),
|
||||
[pathname]
|
||||
);
|
||||
const activeServerId = activeServerIdFromPath ?? daemons[0]?.serverId ?? null;
|
||||
const activeHostLabel = useMemo(() => {
|
||||
if (!activeServerId) return "No host";
|
||||
const daemon = daemons.find((entry) => entry.serverId === activeServerId);
|
||||
const trimmed = daemon?.label?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : activeServerId;
|
||||
}, [activeServerId, daemons]);
|
||||
const activeHostStatus = activeServerId
|
||||
? (connectionStates.get(activeServerId)?.status ?? "idle")
|
||||
: "idle";
|
||||
const activeHostStatusColor =
|
||||
activeHostStatus === "online"
|
||||
? theme.colors.palette.green[400]
|
||||
: activeHostStatus === "connecting"
|
||||
? theme.colors.palette.amber[500]
|
||||
: theme.colors.palette.red[500];
|
||||
const hostOptions = useMemo(
|
||||
() =>
|
||||
daemons.map((daemon) => ({
|
||||
id: daemon.serverId,
|
||||
label: daemon.label?.trim() || daemon.serverId,
|
||||
description: formatConnectionStatus(
|
||||
connectionStates.get(daemon.serverId)?.status ?? "idle"
|
||||
),
|
||||
})),
|
||||
[connectionStates, daemons]
|
||||
);
|
||||
const hostTriggerRef = useRef<View>(null);
|
||||
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
||||
|
||||
// Derive isOpen from the unified panel state
|
||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||
const [selectedProjectKeys, setSelectedProjectKeys] = useState<string[]>([]);
|
||||
|
||||
const {
|
||||
sections,
|
||||
checkoutByAgentKey,
|
||||
entries,
|
||||
projectOptions,
|
||||
hasMoreEntries,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
refreshAll,
|
||||
} = useSidebarAgentsGrouped({ isOpen });
|
||||
} = useSidebarAgentsGrouped({
|
||||
isOpen,
|
||||
serverId: activeServerId,
|
||||
selectedProjectKeys,
|
||||
});
|
||||
useEffect(() => {
|
||||
setSelectedProjectKeys([]);
|
||||
}, [activeServerId]);
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
@@ -55,8 +120,8 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
isGesturing,
|
||||
closeGestureRef,
|
||||
} = useSidebarAnimation();
|
||||
const trafficLightPadding = useTrafficLightPadding();
|
||||
const dragHandlers = useTauriDragHandlers();
|
||||
const trafficLightPadding = useTrafficLightPadding();
|
||||
|
||||
// Track user-initiated refresh to avoid showing spinner on background revalidation
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
@@ -73,11 +138,12 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
}
|
||||
}, [isRevalidating, isManualRefresh]);
|
||||
|
||||
const collapsedProjectKeys = useSidebarCollapsedSectionsStore((s) => s.collapsedProjectKeys);
|
||||
const setSidebarShortcutAgentKeys = useKeyboardNavStore((s) => s.setSidebarShortcutAgentKeys);
|
||||
const setSidebarShortcutAgentKeys = useKeyboardShortcutsStore(
|
||||
(s) => s.setSidebarShortcutAgentKeys
|
||||
);
|
||||
const sidebarShortcutAgentKeys = useMemo(() => {
|
||||
return deriveSidebarShortcutAgentKeys(sections, collapsedProjectKeys, 9);
|
||||
}, [collapsedProjectKeys, sections]);
|
||||
return entries.slice(0, 9).map((entry) => `${entry.agent.serverId}:${entry.agent.id}`);
|
||||
}, [entries]);
|
||||
|
||||
useEffect(() => {
|
||||
setSidebarShortcutAgentKeys(sidebarShortcutAgentKeys);
|
||||
@@ -88,8 +154,34 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
}, [closeToAgent]);
|
||||
|
||||
const handleCreateAgentClean = useCallback(() => {
|
||||
router.push("/agent" as any);
|
||||
}, []);
|
||||
let targetServerId = activeServerId;
|
||||
let targetWorkingDir: string | null = null;
|
||||
|
||||
const selectedAgent = resolveSelectedAgentForNewAgent({
|
||||
pathname,
|
||||
selectedAgentId,
|
||||
});
|
||||
if (selectedAgent) {
|
||||
targetServerId = selectedAgent.serverId;
|
||||
const agent = useSessionStore
|
||||
.getState()
|
||||
.sessions[selectedAgent.serverId]
|
||||
?.agents?.get(selectedAgent.agentId);
|
||||
const cwd = agent?.cwd?.trim();
|
||||
if (cwd) {
|
||||
const checkout =
|
||||
queryClient.getQueryData<CheckoutStatusPayload>(
|
||||
checkoutStatusQueryKey(selectedAgent.serverId, cwd)
|
||||
) ?? null;
|
||||
targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout);
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetServerId) {
|
||||
return;
|
||||
}
|
||||
router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any);
|
||||
}, [activeServerId, pathname, selectedAgentId]);
|
||||
|
||||
// Mobile: close sidebar and navigate
|
||||
const handleCreateAgentCleanMobile = useCallback(() => {
|
||||
@@ -104,14 +196,20 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
|
||||
// Mobile: close sidebar and navigate
|
||||
const handleSettingsMobile = useCallback(() => {
|
||||
if (!activeServerId) {
|
||||
return;
|
||||
}
|
||||
closeToAgent();
|
||||
router.push("/settings");
|
||||
}, [closeToAgent]);
|
||||
router.push(buildHostSettingsRoute(activeServerId) as any);
|
||||
}, [activeServerId, closeToAgent]);
|
||||
|
||||
// Desktop: just navigate, don't close
|
||||
const handleSettingsDesktop = useCallback(() => {
|
||||
router.push("/settings");
|
||||
}, []);
|
||||
if (!activeServerId) {
|
||||
return;
|
||||
}
|
||||
router.push(buildHostSettingsRoute(activeServerId) as any);
|
||||
}, [activeServerId]);
|
||||
|
||||
// Mobile: close sidebar when agent is selected
|
||||
// Snap immediately since navigation interrupts animations
|
||||
@@ -122,13 +220,56 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
}, [closeToAgent, translateX, backdropOpacity, windowWidth]);
|
||||
|
||||
const handleViewMore = useCallback(() => {
|
||||
if (!activeServerId) {
|
||||
return;
|
||||
}
|
||||
if (isMobile) {
|
||||
translateX.value = -windowWidth;
|
||||
backdropOpacity.value = 0;
|
||||
closeToAgent();
|
||||
}
|
||||
router.push("/agents");
|
||||
}, [backdropOpacity, closeToAgent, isMobile, translateX, windowWidth]);
|
||||
router.push(buildHostAgentsRoute(activeServerId) as any);
|
||||
}, [
|
||||
activeServerId,
|
||||
backdropOpacity,
|
||||
closeToAgent,
|
||||
isMobile,
|
||||
translateX,
|
||||
windowWidth,
|
||||
]);
|
||||
|
||||
const listFooterComponent = useMemo(() => {
|
||||
if (!hasMoreEntries) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable style={styles.listViewMoreButton} onPress={handleViewMore}>
|
||||
{({ hovered }) => (
|
||||
<Text
|
||||
style={[
|
||||
styles.listViewMoreButtonText,
|
||||
hovered && styles.listViewMoreButtonTextHovered,
|
||||
]}
|
||||
>
|
||||
View more
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}, [handleViewMore, hasMoreEntries]);
|
||||
|
||||
const handleHostSelect = useCallback(
|
||||
(nextServerId: string) => {
|
||||
if (!nextServerId) {
|
||||
return;
|
||||
}
|
||||
const nextPath = mapPathnameToServer(pathname, nextServerId);
|
||||
setIsHostPickerOpen(false);
|
||||
router.push(nextPath as any);
|
||||
},
|
||||
[pathname]
|
||||
);
|
||||
|
||||
// Close gesture (swipe left to close when sidebar is open)
|
||||
// Only activates on leftward swipe, fails on rightward or vertical movement
|
||||
@@ -206,18 +347,20 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
{/* Header */}
|
||||
<View style={styles.sidebarHeader}>
|
||||
<Pressable
|
||||
style={styles.newAgentButton}
|
||||
testID="sidebar-new-agent"
|
||||
onPress={handleCreateAgentCleanMobile}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<Plus size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
|
||||
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<Pressable
|
||||
style={styles.newAgentButton}
|
||||
testID="sidebar-new-agent"
|
||||
onPress={handleCreateAgentCleanMobile}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<Plus size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
|
||||
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Middle: scrollable agent list */}
|
||||
@@ -225,10 +368,13 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarAgentList
|
||||
sections={sections}
|
||||
checkoutByAgentKey={checkoutByAgentKey}
|
||||
entries={entries}
|
||||
projectOptions={projectOptions}
|
||||
selectedProjectKeys={selectedProjectKeys}
|
||||
onSelectedProjectKeysChange={setSelectedProjectKeys}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
listFooterComponent={listFooterComponent}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentSelect={handleAgentSelectMobile}
|
||||
parentGestureRef={closeGestureRef}
|
||||
@@ -237,35 +383,74 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.sidebarFooter}>
|
||||
<Pressable
|
||||
style={styles.footerButton}
|
||||
onPress={handleViewMore}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<Users size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
|
||||
<Text style={[styles.footerButtonText, hovered && styles.footerButtonTextHovered]}>
|
||||
All agents
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
<View style={styles.footerIconRow}>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-settings"
|
||||
nativeID="sidebar-settings"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Settings"
|
||||
accessibilityRole="button"
|
||||
onPress={handleSettingsMobile}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Settings size={20} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
<View style={styles.footerHostSlot}>
|
||||
<Pressable
|
||||
ref={hostTriggerRef}
|
||||
style={({ hovered = false }) => [
|
||||
styles.hostTrigger,
|
||||
hovered && styles.hostTriggerHovered,
|
||||
]}
|
||||
onPress={() => setIsHostPickerOpen(true)}
|
||||
disabled={hostOptions.length === 0}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.hostStatusDot,
|
||||
{ backgroundColor: activeHostStatusColor },
|
||||
]}
|
||||
/>
|
||||
<Text style={styles.hostTriggerText} numberOfLines={1}>
|
||||
{activeHostLabel}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.footerIconRow}>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-all-agents"
|
||||
nativeID="sidebar-all-agents"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="All agents"
|
||||
accessibilityRole="button"
|
||||
onPress={handleViewMore}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Users
|
||||
size={20}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-settings"
|
||||
nativeID="sidebar-settings"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Settings"
|
||||
accessibilityRole="button"
|
||||
onPress={handleSettingsMobile}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Settings
|
||||
size={20}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
<Combobox
|
||||
options={hostOptions}
|
||||
value={activeServerId ?? ""}
|
||||
onSelect={handleHostSelect}
|
||||
searchable={false}
|
||||
title="Switch host"
|
||||
searchPlaceholder="Search hosts..."
|
||||
open={isHostPickerOpen}
|
||||
onOpenChange={setIsHostPickerOpen}
|
||||
anchorRef={hostTriggerRef}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
@@ -281,23 +466,24 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
|
||||
return (
|
||||
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
|
||||
{/* Header: New Agent button - top padding area is draggable on Tauri */}
|
||||
<View
|
||||
style={[styles.sidebarHeader, { paddingTop: trafficLightPadding.top || styles.sidebarHeader.paddingTop }]}
|
||||
style={[styles.sidebarHeader, { paddingLeft: theme.spacing[2] + trafficLightPadding.left }]}
|
||||
{...dragHandlers}
|
||||
>
|
||||
<Pressable
|
||||
style={styles.newAgentButton}
|
||||
testID="sidebar-new-agent"
|
||||
onPress={handleCreateAgentCleanDesktop}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<Plus size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<Pressable
|
||||
style={styles.newAgentButton}
|
||||
testID="sidebar-new-agent"
|
||||
onPress={handleCreateAgentCleanDesktop}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<Plus size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
|
||||
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Middle: scrollable agent list */}
|
||||
@@ -305,30 +491,58 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarAgentList
|
||||
sections={sections}
|
||||
checkoutByAgentKey={checkoutByAgentKey}
|
||||
entries={entries}
|
||||
projectOptions={projectOptions}
|
||||
selectedProjectKeys={selectedProjectKeys}
|
||||
onSelectedProjectKeysChange={setSelectedProjectKeys}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
listFooterComponent={listFooterComponent}
|
||||
selectedAgentId={selectedAgentId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.sidebarFooter}>
|
||||
<Pressable
|
||||
style={styles.footerButton}
|
||||
onPress={handleViewMore}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<Users size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
|
||||
<Text style={[styles.footerButtonText, hovered && styles.footerButtonTextHovered]}>
|
||||
All agents
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
<View style={styles.footerHostSlot}>
|
||||
<Pressable
|
||||
ref={hostTriggerRef}
|
||||
style={({ hovered = false }) => [
|
||||
styles.hostTrigger,
|
||||
hovered && styles.hostTriggerHovered,
|
||||
]}
|
||||
onPress={() => setIsHostPickerOpen(true)}
|
||||
disabled={hostOptions.length === 0}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.hostStatusDot,
|
||||
{ backgroundColor: activeHostStatusColor },
|
||||
]}
|
||||
/>
|
||||
<Text style={styles.hostTriggerText} numberOfLines={1}>
|
||||
{activeHostLabel}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.footerIconRow}>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-all-agents"
|
||||
nativeID="sidebar-all-agents"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="All agents"
|
||||
accessibilityRole="button"
|
||||
onPress={handleViewMore}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Users
|
||||
size={20}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-settings"
|
||||
@@ -344,6 +558,17 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
<Combobox
|
||||
options={hostOptions}
|
||||
value={activeServerId ?? ""}
|
||||
onSelect={handleHostSelect}
|
||||
searchable={false}
|
||||
title="Switch host"
|
||||
searchPlaceholder="Search hosts..."
|
||||
open={isHostPickerOpen}
|
||||
onOpenChange={setIsHostPickerOpen}
|
||||
anchorRef={hostTriggerRef}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -376,19 +601,29 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
sidebarHeader: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingTop: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[3],
|
||||
height: {
|
||||
xs: HEADER_INNER_HEIGHT_MOBILE,
|
||||
md: HEADER_INNER_HEIGHT,
|
||||
},
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
justifyContent: "center",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
userSelect: "none",
|
||||
},
|
||||
sidebarHeaderRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
newAgentButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
flexShrink: 0,
|
||||
},
|
||||
newAgentButtonHovered: {},
|
||||
newAgentButtonText: {
|
||||
@@ -399,6 +634,33 @@ const styles = StyleSheet.create((theme) => ({
|
||||
newAgentButtonTextHovered: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
hostTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
gap: theme.spacing[2],
|
||||
minWidth: 0,
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
hostTriggerHovered: {
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
hostStatusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
hostTriggerText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
sidebarFooter: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -408,28 +670,43 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
footerHostSlot: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
marginRight: theme.spacing[2],
|
||||
},
|
||||
footerIconRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
footerButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
flexShrink: 0,
|
||||
},
|
||||
footerIconButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
},
|
||||
footerButtonText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
listViewMoreButton: {
|
||||
marginTop: theme.spacing[2],
|
||||
marginHorizontal: theme.spacing[2],
|
||||
marginBottom: theme.spacing[1],
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
listViewMoreButtonText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
footerButtonTextHovered: {
|
||||
listViewMoreButtonTextHovered: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
hostPickerList: {
|
||||
|
||||
178
packages/app/src/components/terminal-emulator.tsx
Normal file
178
packages/app/src/components/terminal-emulator.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use dom";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { DOMProps } from "expo/dom";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
||||
|
||||
interface TerminalEmulatorProps {
|
||||
dom?: DOMProps;
|
||||
streamKey: string;
|
||||
initialOutputText: string;
|
||||
outputChunkText: string;
|
||||
outputChunkSequence: number;
|
||||
testId?: string;
|
||||
backgroundColor?: string;
|
||||
foregroundColor?: string;
|
||||
cursorColor?: string;
|
||||
onInput?: (data: string) => Promise<void> | void;
|
||||
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
|
||||
onTerminalKey?: (input: {
|
||||
key: string;
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
meta: boolean;
|
||||
}) => Promise<void> | void;
|
||||
onPendingModifiersConsumed?: () => Promise<void> | void;
|
||||
onOutputChunkConsumed?: (sequence: number) => Promise<void> | void;
|
||||
pendingModifiers?: PendingTerminalModifiers;
|
||||
focusRequestToken?: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {}
|
||||
}
|
||||
|
||||
export default function TerminalEmulator({
|
||||
streamKey,
|
||||
initialOutputText,
|
||||
outputChunkText,
|
||||
outputChunkSequence,
|
||||
testId = "terminal-surface",
|
||||
backgroundColor = "#0b0b0b",
|
||||
foregroundColor = "#e6e6e6",
|
||||
cursorColor = "#e6e6e6",
|
||||
onInput,
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
onOutputChunkConsumed,
|
||||
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
||||
focusRequestToken = 0,
|
||||
}: TerminalEmulatorProps) {
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const root = rootRef.current;
|
||||
if (!host || !root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
runtimeRef.current = runtime;
|
||||
runtime.setCallbacks({
|
||||
callbacks: {
|
||||
onInput,
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
},
|
||||
});
|
||||
runtime.setPendingModifiers({ pendingModifiers });
|
||||
runtime.mount({
|
||||
root,
|
||||
host,
|
||||
initialOutputText,
|
||||
theme: {
|
||||
backgroundColor,
|
||||
foregroundColor,
|
||||
cursorColor,
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
runtime.unmount();
|
||||
if (runtimeRef.current === runtime) {
|
||||
runtimeRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [backgroundColor, cursorColor, foregroundColor, streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
runtimeRef.current?.setCallbacks({
|
||||
callbacks: {
|
||||
onInput,
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
},
|
||||
});
|
||||
}, [onInput, onPendingModifiersConsumed, onResize, onTerminalKey]);
|
||||
|
||||
useEffect(() => {
|
||||
runtimeRef.current?.setPendingModifiers({ pendingModifiers });
|
||||
}, [pendingModifiers]);
|
||||
|
||||
useEffect(() => {
|
||||
const runtime = runtimeRef.current;
|
||||
if (outputChunkSequence <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!runtime) {
|
||||
onOutputChunkConsumed?.(outputChunkSequence);
|
||||
return;
|
||||
}
|
||||
|
||||
if (outputChunkText.length === 0) {
|
||||
runtime.clear({
|
||||
onCommitted: () => {
|
||||
onOutputChunkConsumed?.(outputChunkSequence);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
runtime.write({
|
||||
text: outputChunkText,
|
||||
onCommitted: () => {
|
||||
onOutputChunkConsumed?.(outputChunkSequence);
|
||||
},
|
||||
});
|
||||
}, [onOutputChunkConsumed, outputChunkSequence, outputChunkText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusRequestToken <= 0) {
|
||||
return;
|
||||
}
|
||||
runtimeRef.current?.focus();
|
||||
}, [focusRequestToken]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-testid={testId}
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
backgroundColor,
|
||||
overflow: "hidden",
|
||||
overscrollBehavior: "none",
|
||||
}}
|
||||
onPointerDown={() => {
|
||||
runtimeRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={hostRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
overscrollBehavior: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1187
packages/app/src/components/terminal-pane.tsx
Normal file
1187
packages/app/src/components/terminal-pane.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -89,6 +89,43 @@ export function ToolCallDetailsContent({
|
||||
</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") {
|
||||
sections.push(
|
||||
<View
|
||||
@@ -177,7 +214,7 @@ export function ToolCallDetailsContent({
|
||||
if (plainInputText !== null) {
|
||||
sections.push(
|
||||
<View key="unknown-plain-text" style={styles.plainTextSection}>
|
||||
<Text selectable style={styles.scrollText}>{plainInputText}</Text>
|
||||
<Text selectable style={styles.plainText}>{plainInputText}</Text>
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
@@ -298,6 +335,13 @@ const styles = StyleSheet.create((theme) => {
|
||||
gap: theme.spacing[2],
|
||||
padding: theme.spacing[3],
|
||||
},
|
||||
plainText: {
|
||||
fontFamily: Fonts.sans,
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 22,
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
sectionTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
@@ -349,6 +393,12 @@ const styles = StyleSheet.create((theme) => {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 18,
|
||||
...(Platform.OS === "web"
|
||||
? {
|
||||
whiteSpace: "pre",
|
||||
overflowWrap: "normal",
|
||||
}
|
||||
: null),
|
||||
},
|
||||
shellPrompt: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
|
||||
76
packages/app/src/components/ui/combobox-options.test.ts
Normal file
76
packages/app/src/components/ui/combobox-options.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildVisibleComboboxOptions,
|
||||
getComboboxFallbackIndex,
|
||||
orderVisibleComboboxOptions,
|
||||
} from "./combobox-options";
|
||||
|
||||
describe("buildVisibleComboboxOptions", () => {
|
||||
const options = [
|
||||
{ id: "/Users/me/project-a", label: "/Users/me/project-a", kind: "directory" as const },
|
||||
{ id: "/Users/me/project-b", label: "/Users/me/project-b", kind: "directory" as const },
|
||||
];
|
||||
|
||||
it("keeps a custom row visible while searching with no matches", () => {
|
||||
const visible = buildVisibleComboboxOptions({
|
||||
options,
|
||||
searchQuery: "/tmp/new-project",
|
||||
searchable: true,
|
||||
allowCustomValue: true,
|
||||
customValuePrefix: "",
|
||||
customValueKind: "directory",
|
||||
});
|
||||
|
||||
expect(visible).toHaveLength(1);
|
||||
expect(visible[0]).toEqual({
|
||||
id: "/tmp/new-project",
|
||||
label: "/tmp/new-project",
|
||||
description: undefined,
|
||||
kind: "directory",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not duplicate a row when search exactly matches an existing option", () => {
|
||||
const visible = buildVisibleComboboxOptions({
|
||||
options,
|
||||
searchQuery: "/Users/me/project-a",
|
||||
searchable: true,
|
||||
allowCustomValue: true,
|
||||
customValuePrefix: "",
|
||||
customValueKind: "directory",
|
||||
});
|
||||
|
||||
expect(visible).toEqual([
|
||||
{ id: "/Users/me/project-a", label: "/Users/me/project-a", kind: "directory" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("combobox above-search ordering", () => {
|
||||
const visible = [
|
||||
{ id: "/tmp/new-project", label: "/tmp/new-project", kind: "directory" as const },
|
||||
{ id: "/Users/me/project-a", label: "/Users/me/project-a", kind: "directory" as const },
|
||||
{ id: "/Users/me/project-b", label: "/Users/me/project-b", kind: "directory" as const },
|
||||
];
|
||||
|
||||
it("renders first logical option closest to the search box in above-search mode", () => {
|
||||
const ordered = orderVisibleComboboxOptions(visible, "above-search");
|
||||
expect(ordered.map((option) => option.id)).toEqual([
|
||||
"/Users/me/project-b",
|
||||
"/Users/me/project-a",
|
||||
"/tmp/new-project",
|
||||
]);
|
||||
expect(getComboboxFallbackIndex(ordered.length, "above-search")).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps normal top-down order in below-search mode", () => {
|
||||
const ordered = orderVisibleComboboxOptions(visible, "below-search");
|
||||
expect(ordered.map((option) => option.id)).toEqual([
|
||||
"/tmp/new-project",
|
||||
"/Users/me/project-a",
|
||||
"/Users/me/project-b",
|
||||
]);
|
||||
expect(getComboboxFallbackIndex(ordered.length, "below-search")).toBe(0);
|
||||
});
|
||||
});
|
||||
95
packages/app/src/components/ui/combobox-options.ts
Normal file
95
packages/app/src/components/ui/combobox-options.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
export 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 {
|
||||
View,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ScrollView,
|
||||
Platform,
|
||||
StatusBar,
|
||||
useWindowDimensions,
|
||||
} from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
@@ -19,35 +20,62 @@ import {
|
||||
BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
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 { getNextActiveIndex } from "./combobox-keyboard";
|
||||
import {
|
||||
buildVisibleComboboxOptions,
|
||||
getComboboxFallbackIndex,
|
||||
orderVisibleComboboxOptions,
|
||||
shouldShowCustomComboboxOption,
|
||||
} from "./combobox-options";
|
||||
import type { ComboboxOptionModel } from "./combobox-options";
|
||||
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
|
||||
export interface ComboboxOption {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
export type ComboboxOption = ComboboxOptionModel;
|
||||
|
||||
export interface ComboboxProps {
|
||||
options: ComboboxOption[];
|
||||
value: string;
|
||||
onSelect: (id: string) => void;
|
||||
onSearchQueryChange?: (query: string) => void;
|
||||
searchable?: boolean;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
allowCustomValue?: boolean;
|
||||
customValuePrefix?: string;
|
||||
customValueDescription?: string;
|
||||
customValueKind?: "directory";
|
||||
optionsPosition?: "below-search" | "above-search";
|
||||
title?: string;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
desktopPlacement?: "top-start" | "bottom-start";
|
||||
/**
|
||||
* Prevents an initial frame at 0,0 by hiding desktop content until floating
|
||||
* coordinates resolve. This intentionally disables fade enter/exit animation
|
||||
* for that combobox instance to avoid animation overriding hidden opacity.
|
||||
*/
|
||||
desktopPreventInitialFlash?: boolean;
|
||||
anchorRef: React.RefObject<View | null>;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function toNumericStyleValue(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number.parseFloat(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
return (
|
||||
<Animated.View
|
||||
@@ -107,6 +135,7 @@ function SearchInput({
|
||||
export interface ComboboxItemProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
kind?: "directory";
|
||||
selected?: boolean;
|
||||
active?: boolean;
|
||||
onPress: () => void;
|
||||
@@ -116,6 +145,7 @@ export interface ComboboxItemProps {
|
||||
export function ComboboxItem({
|
||||
label,
|
||||
description,
|
||||
kind,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
@@ -133,6 +163,11 @@ export function ComboboxItem({
|
||||
active && styles.comboboxItemActive,
|
||||
]}
|
||||
>
|
||||
{kind === "directory" ? (
|
||||
<View style={styles.comboboxItemLeadingSlot}>
|
||||
<Folder size={16} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.comboboxItemContent}>
|
||||
<Text numberOfLines={1} style={styles.comboboxItemLabel}>{label}</Text>
|
||||
{description ? (
|
||||
@@ -149,33 +184,47 @@ export function ComboboxItem({
|
||||
}
|
||||
|
||||
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({
|
||||
options,
|
||||
value,
|
||||
onSelect,
|
||||
onSearchQueryChange,
|
||||
searchable = true,
|
||||
placeholder = "Search...",
|
||||
searchPlaceholder,
|
||||
emptyText = "No options match your search.",
|
||||
allowCustomValue = false,
|
||||
customValuePrefix = "Use",
|
||||
customValueDescription,
|
||||
customValueKind,
|
||||
optionsPosition = "below-search",
|
||||
title = "Select",
|
||||
open,
|
||||
onOpenChange,
|
||||
desktopPlacement = "top-start",
|
||||
desktopPreventInitialFlash = true,
|
||||
anchorRef,
|
||||
children,
|
||||
}: ComboboxProps): ReactElement {
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const effectiveOptionsPosition =
|
||||
isMobile ? "below-search" : optionsPosition;
|
||||
const isDesktopAboveSearch =
|
||||
!isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search";
|
||||
const { height: windowHeight } = useWindowDimensions();
|
||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null);
|
||||
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
|
||||
const [referenceTop, setReferenceTop] = useState<number | null>(null);
|
||||
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
||||
const desktopOptionsScrollRef = useRef<ScrollView>(null);
|
||||
|
||||
const isControlled = typeof open === "boolean";
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
@@ -191,16 +240,24 @@ export function Combobox({
|
||||
[isControlled, onOpenChange]
|
||||
);
|
||||
|
||||
const setSearchQueryWithCallback = useCallback(
|
||||
(nextQuery: string) => {
|
||||
setSearchQuery(nextQuery);
|
||||
onSearchQueryChange?.(nextQuery);
|
||||
},
|
||||
[onSearchQueryChange]
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
setSearchQuery("");
|
||||
}, [setOpen]);
|
||||
setSearchQueryWithCallback("");
|
||||
}, [setOpen, setSearchQueryWithCallback]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSearchQuery("");
|
||||
setSearchQueryWithCallback("");
|
||||
}
|
||||
}, [isOpen]);
|
||||
}, [isOpen, setSearchQueryWithCallback]);
|
||||
|
||||
const collisionPadding = useMemo(() => {
|
||||
const basePadding = 16;
|
||||
@@ -213,7 +270,7 @@ export function Combobox({
|
||||
() => [
|
||||
floatingOffset(Platform.OS === "web" ? 0 : 4),
|
||||
...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]),
|
||||
shift({ padding: collisionPadding }),
|
||||
...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]),
|
||||
floatingSize({
|
||||
padding: collisionPadding,
|
||||
apply({ availableWidth, availableHeight, rects }) {
|
||||
@@ -231,11 +288,11 @@ export function Combobox({
|
||||
},
|
||||
}),
|
||||
],
|
||||
[collisionPadding]
|
||||
[collisionPadding, isDesktopAboveSearch]
|
||||
);
|
||||
|
||||
const { refs, floatingStyles, update } = useFloating({
|
||||
placement: Platform.OS === "web" ? "top-start" : "bottom-start",
|
||||
placement: Platform.OS === "web" ? desktopPlacement : "bottom-start",
|
||||
middleware,
|
||||
sameScrollView: false,
|
||||
elements: {
|
||||
@@ -249,9 +306,56 @@ export function Combobox({
|
||||
setReferenceWidth(null);
|
||||
return;
|
||||
}
|
||||
const raf = requestAnimationFrame(() => update());
|
||||
const raf = requestAnimationFrame(() => void update());
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [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(() => {
|
||||
if (!isMobile) return;
|
||||
@@ -283,78 +387,106 @@ export function Combobox({
|
||||
[]
|
||||
);
|
||||
|
||||
const normalizedSearch = 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 normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : "";
|
||||
const sanitizedSearchValue = searchQuery.trim();
|
||||
const showCustomOption =
|
||||
allowCustomValue &&
|
||||
sanitizedSearchValue.length > 0 &&
|
||||
!options.some(
|
||||
(opt) =>
|
||||
opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() ||
|
||||
opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase()
|
||||
);
|
||||
const showCustomOption = useMemo(
|
||||
() =>
|
||||
shouldShowCustomComboboxOption({
|
||||
options,
|
||||
searchQuery,
|
||||
searchable,
|
||||
allowCustomValue,
|
||||
}),
|
||||
[allowCustomValue, options, searchQuery, searchable]
|
||||
);
|
||||
|
||||
const visibleOptions = useMemo(() => {
|
||||
const next: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}> = [];
|
||||
const visibleOptions = useMemo(
|
||||
() =>
|
||||
buildVisibleComboboxOptions({
|
||||
options,
|
||||
searchQuery,
|
||||
searchable,
|
||||
allowCustomValue,
|
||||
customValuePrefix,
|
||||
customValueDescription,
|
||||
customValueKind,
|
||||
}),
|
||||
[
|
||||
allowCustomValue,
|
||||
customValueDescription,
|
||||
customValueKind,
|
||||
customValuePrefix,
|
||||
options,
|
||||
searchQuery,
|
||||
searchable,
|
||||
]
|
||||
);
|
||||
|
||||
if (showCustomOption) {
|
||||
next.push({
|
||||
id: sanitizedSearchValue,
|
||||
label: `${customValuePrefix} "${sanitizedSearchValue}"`,
|
||||
description: customValueDescription,
|
||||
});
|
||||
const orderedVisibleOptions = useMemo(
|
||||
() => orderVisibleComboboxOptions(visibleOptions, effectiveOptionsPosition),
|
||||
[effectiveOptionsPosition, visibleOptions]
|
||||
);
|
||||
|
||||
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) {
|
||||
next.push({
|
||||
id: opt.id,
|
||||
label: opt.label,
|
||||
description: opt.description,
|
||||
});
|
||||
const handleDesktopOptionsContentSizeChange = useCallback(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
pinDesktopOptionsToBottom();
|
||||
}, [isOpen, pinDesktopOptionsToBottom]);
|
||||
|
||||
return next;
|
||||
}, [
|
||||
customValueDescription,
|
||||
customValuePrefix,
|
||||
filteredOptions,
|
||||
sanitizedSearchValue,
|
||||
showCustomOption,
|
||||
]);
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
pinDesktopOptionsToBottom();
|
||||
}, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen || isMobile) {
|
||||
return;
|
||||
}
|
||||
void update();
|
||||
}, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (!IS_WEB && isMobile) return;
|
||||
|
||||
if (visibleOptions.length === 0) {
|
||||
if (orderedVisibleOptions.length === 0) {
|
||||
setActiveIndex(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackIndex = getComboboxFallbackIndex(
|
||||
orderedVisibleOptions.length,
|
||||
effectiveOptionsPosition
|
||||
);
|
||||
|
||||
if (normalizedSearch) {
|
||||
setActiveIndex(0);
|
||||
setActiveIndex(fallbackIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedIndex = visibleOptions.findIndex((opt) => opt.id === value);
|
||||
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
|
||||
}, [isMobile, isOpen, normalizedSearch, value, visibleOptions]);
|
||||
const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value);
|
||||
setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex);
|
||||
}, [
|
||||
effectiveOptionsPosition,
|
||||
isMobile,
|
||||
isOpen,
|
||||
normalizedSearch,
|
||||
value,
|
||||
orderedVisibleOptions,
|
||||
]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
@@ -380,7 +512,7 @@ export function Combobox({
|
||||
setActiveIndex((currentIndex) =>
|
||||
getNextActiveIndex({
|
||||
currentIndex,
|
||||
itemCount: visibleOptions.length,
|
||||
itemCount: orderedVisibleOptions.length,
|
||||
key,
|
||||
})
|
||||
);
|
||||
@@ -388,11 +520,11 @@ export function Combobox({
|
||||
}
|
||||
|
||||
if (key === "Enter") {
|
||||
if (visibleOptions.length === 0) return;
|
||||
if (orderedVisibleOptions.length === 0) return;
|
||||
event?.preventDefault();
|
||||
const index =
|
||||
activeIndex >= 0 && activeIndex < visibleOptions.length ? activeIndex : 0;
|
||||
handleSelect(visibleOptions[index]!.id);
|
||||
activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0;
|
||||
handleSelect(orderedVisibleOptions[index]!.id);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -401,7 +533,7 @@ export function Combobox({
|
||||
handleClose();
|
||||
}
|
||||
},
|
||||
[activeIndex, handleClose, handleSelect, isMobile, isOpen, visibleOptions]
|
||||
[activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -426,7 +558,7 @@ export function Combobox({
|
||||
<SearchInput
|
||||
placeholder={searchPlaceholder ?? placeholder}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
onChangeText={setSearchQueryWithCallback}
|
||||
onSubmitEditing={handleSubmitSearch}
|
||||
autoFocus={!isMobile}
|
||||
/>
|
||||
@@ -434,12 +566,13 @@ export function Combobox({
|
||||
|
||||
const optionsList = (
|
||||
<>
|
||||
{visibleOptions.length > 0 ? (
|
||||
visibleOptions.map((opt, index) => (
|
||||
{orderedVisibleOptions.length > 0 ? (
|
||||
orderedVisibleOptions.map((opt, index) => (
|
||||
<ComboboxItem
|
||||
key={opt.id}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
kind={opt.kind}
|
||||
selected={opt.id === value}
|
||||
active={index === activeIndex}
|
||||
onPress={() => handleSelect(opt.id)}
|
||||
@@ -451,13 +584,16 @@ export function Combobox({
|
||||
</>
|
||||
);
|
||||
|
||||
const content = children ?? (
|
||||
const defaultContent = (
|
||||
<>
|
||||
{searchInput}
|
||||
{optionsList}
|
||||
{effectiveOptionsPosition === "above-search" ? optionsList : null}
|
||||
{searchable ? searchInput : null}
|
||||
{effectiveOptionsPosition === "below-search" ? optionsList : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const content = children ?? defaultContent;
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<BottomSheetModal
|
||||
@@ -499,8 +635,9 @@ export function Combobox({
|
||||
<View ref={refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}>
|
||||
<Pressable style={styles.desktopBackdrop} onPress={handleClose} />
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(100)}
|
||||
exiting={FadeOut.duration(100)}
|
||||
testID="combobox-desktop-container"
|
||||
entering={shouldUseDesktopFade ? FadeIn.duration(100) : undefined}
|
||||
exiting={shouldUseDesktopFade ? FadeOut.duration(100) : undefined}
|
||||
style={[
|
||||
styles.desktopContainer,
|
||||
{
|
||||
@@ -508,8 +645,8 @@ export function Combobox({
|
||||
minWidth: referenceWidth ?? 200,
|
||||
maxWidth: 400,
|
||||
},
|
||||
floatingStyles,
|
||||
referenceWidth === null ? { opacity: 0 } : null,
|
||||
desktopPositionStyle,
|
||||
shouldHideDesktopContent ? { opacity: 0 } : null,
|
||||
typeof availableSize?.height === "number" ? { maxHeight: Math.min(availableSize.height, 400) } : null,
|
||||
]}
|
||||
ref={refs.setFloating}
|
||||
@@ -527,15 +664,32 @@ export function Combobox({
|
||||
</ScrollView>
|
||||
) : (
|
||||
<>
|
||||
{searchInput}
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.desktopScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={styles.desktopScroll}
|
||||
>
|
||||
{optionsList}
|
||||
</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}
|
||||
{effectiveOptionsPosition === "below-search" ? (
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.desktopScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={styles.desktopScroll}
|
||||
>
|
||||
{optionsList}
|
||||
</ScrollView>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Animated.View>
|
||||
@@ -571,7 +725,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderRadius: 0,
|
||||
...(IS_WEB
|
||||
? {}
|
||||
: {
|
||||
@@ -598,6 +752,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
},
|
||||
comboboxItemLeadingSlot: {
|
||||
width: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
comboboxItemLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
@@ -660,9 +819,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
overflow: "hidden",
|
||||
},
|
||||
desktopScroll: {
|
||||
maxHeight: 400,
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
desktopScrollContent: {
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
desktopScrollContentAboveSearch: {
|
||||
flexGrow: 1,
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useSessionStore } from "@/stores/session-store";
|
||||
import { AddHostModal } from "./add-host-modal";
|
||||
import { PairLinkModal } from "./pair-link-modal";
|
||||
import { NameHostModal } from "./name-host-modal";
|
||||
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
@@ -90,7 +91,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
|
||||
const finishOnboarding = useCallback(
|
||||
(serverId: string) => {
|
||||
router.replace({ pathname: "/", params: { serverId } });
|
||||
router.replace(buildHostAgentDraftRoute(serverId) as any);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
78
packages/app/src/contexts/daemon-registry-context.test.ts
Normal file
78
packages/app/src/contexts/daemon-registry-context.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hostHasDirectEndpoint,
|
||||
registryHasDirectEndpoint,
|
||||
type HostProfile,
|
||||
} from './daemon-registry-context'
|
||||
|
||||
function makeHost(input: Partial<HostProfile> & Pick<HostProfile, 'serverId'>): HostProfile {
|
||||
const now = '2026-01-01T00:00:00.000Z'
|
||||
return {
|
||||
serverId: input.serverId,
|
||||
label: input.label ?? input.serverId,
|
||||
connections: input.connections ?? [],
|
||||
preferredConnectionId: input.preferredConnectionId ?? null,
|
||||
createdAt: input.createdAt ?? now,
|
||||
updatedAt: input.updatedAt ?? now,
|
||||
}
|
||||
}
|
||||
|
||||
describe('hostHasDirectEndpoint', () => {
|
||||
it('returns true when host has matching direct endpoint', () => {
|
||||
const host = makeHost({
|
||||
serverId: 'srv_local',
|
||||
connections: [{ id: 'direct:localhost:6767', type: 'direct', endpoint: 'localhost:6767' }],
|
||||
preferredConnectionId: 'direct:localhost:6767',
|
||||
})
|
||||
|
||||
expect(hostHasDirectEndpoint(host, 'localhost:6767')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when only relay connections exist', () => {
|
||||
const host = makeHost({
|
||||
serverId: 'srv_relay',
|
||||
connections: [
|
||||
{
|
||||
id: 'relay:relay.example:443',
|
||||
type: 'relay',
|
||||
relayEndpoint: 'relay.example:443',
|
||||
daemonPublicKeyB64: 'abcd',
|
||||
},
|
||||
],
|
||||
preferredConnectionId: 'relay:relay.example:443',
|
||||
})
|
||||
|
||||
expect(hostHasDirectEndpoint(host, 'localhost:6767')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('registryHasDirectEndpoint', () => {
|
||||
it('returns true when any host contains the direct endpoint', () => {
|
||||
const hosts: HostProfile[] = [
|
||||
makeHost({
|
||||
serverId: 'srv_one',
|
||||
connections: [{ id: 'direct:127.0.0.1:7777', type: 'direct', endpoint: '127.0.0.1:7777' }],
|
||||
preferredConnectionId: 'direct:127.0.0.1:7777',
|
||||
}),
|
||||
makeHost({
|
||||
serverId: 'srv_two',
|
||||
connections: [{ id: 'direct:localhost:6767', type: 'direct', endpoint: 'localhost:6767' }],
|
||||
preferredConnectionId: 'direct:localhost:6767',
|
||||
}),
|
||||
]
|
||||
|
||||
expect(registryHasDirectEndpoint(hosts, 'localhost:6767')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when no host has the endpoint', () => {
|
||||
const hosts: HostProfile[] = [
|
||||
makeHost({
|
||||
serverId: 'srv_one',
|
||||
connections: [{ id: 'direct:127.0.0.1:7777', type: 'direct', endpoint: '127.0.0.1:7777' }],
|
||||
preferredConnectionId: 'direct:127.0.0.1:7777',
|
||||
}),
|
||||
]
|
||||
|
||||
expect(registryHasDirectEndpoint(hosts, 'localhost:6767')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,97 +1,131 @@
|
||||
import { createContext, useCallback, useContext } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
decodeOfferFragmentPayload,
|
||||
normalizeHostPort,
|
||||
} from "@/utils/daemon-endpoints";
|
||||
import {
|
||||
ConnectionOfferSchema,
|
||||
type ConnectionOffer,
|
||||
} from "@server/shared/connection-offer";
|
||||
import { createContext, useCallback, useContext, useEffect, useRef } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { decodeOfferFragmentPayload, normalizeHostPort } from '@/utils/daemon-endpoints'
|
||||
import { probeConnection } from '@/utils/test-daemon-connection'
|
||||
import { ConnectionOfferSchema, type ConnectionOffer } from '@server/shared/connection-offer'
|
||||
|
||||
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
|
||||
const DAEMON_REGISTRY_QUERY_KEY = ["daemon-registry"];
|
||||
const REGISTRY_STORAGE_KEY = '@paseo:daemon-registry'
|
||||
const DAEMON_REGISTRY_QUERY_KEY = ['daemon-registry']
|
||||
const DEFAULT_LOCALHOST_ENDPOINT = 'localhost:6767'
|
||||
const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = '@paseo:default-localhost-bootstrap-v1'
|
||||
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500
|
||||
const E2E_STORAGE_KEY = '@paseo:e2e'
|
||||
|
||||
export type DirectHostConnection = {
|
||||
id: string;
|
||||
type: "direct";
|
||||
endpoint: string; // host:port
|
||||
};
|
||||
|
||||
export type RelayHostConnection = {
|
||||
id: string;
|
||||
type: "relay";
|
||||
relayEndpoint: string; // host:port
|
||||
daemonPublicKeyB64: string;
|
||||
};
|
||||
|
||||
export type HostConnection = DirectHostConnection | RelayHostConnection;
|
||||
|
||||
export type HostProfile = {
|
||||
serverId: string;
|
||||
label: string;
|
||||
connections: HostConnection[];
|
||||
preferredConnectionId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type UpdateHostInput = Partial<Omit<HostProfile, "serverId" | "createdAt">>;
|
||||
|
||||
interface DaemonRegistryContextValue {
|
||||
daemons: HostProfile[];
|
||||
isLoading: boolean;
|
||||
error: unknown | null;
|
||||
upsertDirectConnection: (input: {
|
||||
serverId: string;
|
||||
endpoint: string;
|
||||
label?: string;
|
||||
}) => Promise<HostProfile>;
|
||||
upsertRelayConnection: (input: {
|
||||
serverId: string;
|
||||
relayEndpoint: string;
|
||||
daemonPublicKeyB64: string;
|
||||
label?: string;
|
||||
}) => Promise<HostProfile>;
|
||||
updateHost: (serverId: string, updates: UpdateHostInput) => Promise<void>;
|
||||
removeHost: (serverId: string) => Promise<void>;
|
||||
removeConnection: (serverId: string, connectionId: string) => Promise<void>;
|
||||
upsertDaemonFromOffer: (offer: ConnectionOffer) => Promise<HostProfile>;
|
||||
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<HostProfile>;
|
||||
id: string
|
||||
type: 'direct'
|
||||
endpoint: string // host:port
|
||||
}
|
||||
|
||||
const DaemonRegistryContext = createContext<DaemonRegistryContextValue | null>(null);
|
||||
export type RelayHostConnection = {
|
||||
id: string
|
||||
type: 'relay'
|
||||
relayEndpoint: string // host:port
|
||||
daemonPublicKeyB64: string
|
||||
}
|
||||
|
||||
export type HostConnection = DirectHostConnection | RelayHostConnection
|
||||
|
||||
export type HostProfile = {
|
||||
serverId: string
|
||||
label: string
|
||||
connections: HostConnection[]
|
||||
preferredConnectionId: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type UpdateHostInput = Partial<Omit<HostProfile, 'serverId' | 'createdAt'>>
|
||||
|
||||
interface DaemonRegistryContextValue {
|
||||
daemons: HostProfile[]
|
||||
isLoading: boolean
|
||||
error: unknown | null
|
||||
upsertDirectConnection: (input: {
|
||||
serverId: string
|
||||
endpoint: string
|
||||
label?: string
|
||||
}) => Promise<HostProfile>
|
||||
upsertRelayConnection: (input: {
|
||||
serverId: string
|
||||
relayEndpoint: string
|
||||
daemonPublicKeyB64: string
|
||||
label?: string
|
||||
}) => Promise<HostProfile>
|
||||
updateHost: (serverId: string, updates: UpdateHostInput) => Promise<void>
|
||||
removeHost: (serverId: string) => Promise<void>
|
||||
removeConnection: (serverId: string, connectionId: string) => Promise<void>
|
||||
upsertDaemonFromOffer: (offer: ConnectionOffer) => Promise<HostProfile>
|
||||
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<HostProfile>
|
||||
}
|
||||
|
||||
const DaemonRegistryContext = createContext<DaemonRegistryContextValue | null>(null)
|
||||
|
||||
function normalizeEndpointOrNull(endpoint: string): string | null {
|
||||
try {
|
||||
return normalizeHostPort(endpoint)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isDefaultLocalhostConnection(connection: HostConnection): boolean {
|
||||
return connection.type === 'direct' && connection.endpoint === DEFAULT_LOCALHOST_ENDPOINT
|
||||
}
|
||||
|
||||
export function hostHasDirectEndpoint(host: HostProfile, endpoint: string): boolean {
|
||||
const normalized = normalizeEndpointOrNull(endpoint)
|
||||
if (!normalized) {
|
||||
return false
|
||||
}
|
||||
return host.connections.some(
|
||||
(connection) => connection.type === 'direct' && connection.endpoint === normalized
|
||||
)
|
||||
}
|
||||
|
||||
export function registryHasDirectEndpoint(hosts: HostProfile[], endpoint: string): boolean {
|
||||
return hosts.some((host) => hostHasDirectEndpoint(host, endpoint))
|
||||
}
|
||||
|
||||
export function useDaemonRegistry(): DaemonRegistryContextValue {
|
||||
const ctx = useContext(DaemonRegistryContext);
|
||||
const ctx = useContext(DaemonRegistryContext)
|
||||
if (!ctx) {
|
||||
throw new Error("useDaemonRegistry must be used within DaemonRegistryProvider");
|
||||
throw new Error('useDaemonRegistry must be used within DaemonRegistryProvider')
|
||||
}
|
||||
return ctx;
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: daemons = [], isPending, error } = useQuery({
|
||||
const queryClient = useQueryClient()
|
||||
const localhostBootstrapAttemptedRef = useRef(false)
|
||||
const {
|
||||
data: daemons = [],
|
||||
isPending,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: DAEMON_REGISTRY_QUERY_KEY,
|
||||
queryFn: loadDaemonRegistryFromStorage,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
})
|
||||
|
||||
const persist = useCallback(
|
||||
async (profiles: HostProfile[]) => {
|
||||
queryClient.setQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY, profiles);
|
||||
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(profiles));
|
||||
queryClient.setQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY, profiles)
|
||||
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(profiles))
|
||||
},
|
||||
[queryClient]
|
||||
);
|
||||
)
|
||||
|
||||
const readDaemons = useCallback(() => {
|
||||
return queryClient.getQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY) ?? daemons;
|
||||
}, [queryClient, daemons]);
|
||||
return queryClient.getQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY) ?? daemons
|
||||
}, [queryClient, daemons])
|
||||
|
||||
const markDefaultLocalhostBootstrapHandled = useCallback(async () => {
|
||||
await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, '1')
|
||||
}, [])
|
||||
|
||||
const updateHost = useCallback(
|
||||
async (serverId: string, updates: UpdateHostInput) => {
|
||||
@@ -103,68 +137,78 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: daemon
|
||||
);
|
||||
await persist(next);
|
||||
)
|
||||
await persist(next)
|
||||
},
|
||||
[persist, readDaemons]
|
||||
);
|
||||
)
|
||||
|
||||
const removeHost = useCallback(
|
||||
async (serverId: string) => {
|
||||
const remaining = readDaemons().filter((daemon) => daemon.serverId !== serverId);
|
||||
await persist(remaining);
|
||||
const existing = readDaemons()
|
||||
const removedHost = existing.find((daemon) => daemon.serverId === serverId) ?? null
|
||||
const remaining = existing.filter((daemon) => daemon.serverId !== serverId)
|
||||
await persist(remaining)
|
||||
if (removedHost && hostHasDirectEndpoint(removedHost, DEFAULT_LOCALHOST_ENDPOINT)) {
|
||||
await markDefaultLocalhostBootstrapHandled()
|
||||
}
|
||||
},
|
||||
[persist, readDaemons]
|
||||
);
|
||||
[markDefaultLocalhostBootstrapHandled, persist, readDaemons]
|
||||
)
|
||||
|
||||
const removeConnection = useCallback(
|
||||
async (serverId: string, connectionId: string) => {
|
||||
const now = new Date().toISOString();
|
||||
const next = readDaemons()
|
||||
const existing = readDaemons()
|
||||
const removedConnection =
|
||||
existing
|
||||
.find((daemon) => daemon.serverId === serverId)
|
||||
?.connections.find((connection) => connection.id === connectionId) ?? null
|
||||
const now = new Date().toISOString()
|
||||
const next = existing
|
||||
.map((daemon) => {
|
||||
if (daemon.serverId !== serverId) return daemon;
|
||||
const remaining = daemon.connections.filter((conn) => conn.id !== connectionId);
|
||||
if (remaining.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const preferred =
|
||||
daemon.preferredConnectionId === connectionId
|
||||
? remaining[0]?.id ?? null
|
||||
: daemon.preferredConnectionId;
|
||||
return {
|
||||
...daemon,
|
||||
connections: remaining,
|
||||
preferredConnectionId: preferred,
|
||||
updatedAt: now,
|
||||
} satisfies HostProfile;
|
||||
})
|
||||
.filter((entry): entry is HostProfile => entry !== null);
|
||||
await persist(next);
|
||||
if (daemon.serverId !== serverId) return daemon
|
||||
const remaining = daemon.connections.filter((conn) => conn.id !== connectionId)
|
||||
if (remaining.length === 0) {
|
||||
return null
|
||||
}
|
||||
const preferred =
|
||||
daemon.preferredConnectionId === connectionId
|
||||
? (remaining[0]?.id ?? null)
|
||||
: daemon.preferredConnectionId
|
||||
return {
|
||||
...daemon,
|
||||
connections: remaining,
|
||||
preferredConnectionId: preferred,
|
||||
updatedAt: now,
|
||||
} satisfies HostProfile
|
||||
})
|
||||
.filter((entry): entry is HostProfile => entry !== null)
|
||||
await persist(next)
|
||||
if (removedConnection && isDefaultLocalhostConnection(removedConnection)) {
|
||||
await markDefaultLocalhostBootstrapHandled()
|
||||
}
|
||||
},
|
||||
[persist, readDaemons]
|
||||
);
|
||||
[markDefaultLocalhostBootstrapHandled, persist, readDaemons]
|
||||
)
|
||||
|
||||
const upsertHostConnection = useCallback(
|
||||
async (
|
||||
input: {
|
||||
serverId: string;
|
||||
label?: string;
|
||||
} & (
|
||||
| { connection: DirectHostConnection }
|
||||
| { connection: RelayHostConnection }
|
||||
)
|
||||
serverId: string
|
||||
label?: string
|
||||
} & ({ connection: DirectHostConnection } | { connection: RelayHostConnection })
|
||||
) => {
|
||||
const existing = readDaemons();
|
||||
const now = new Date().toISOString();
|
||||
const serverId = input.serverId.trim();
|
||||
const existing = readDaemons()
|
||||
const now = new Date().toISOString()
|
||||
const serverId = input.serverId.trim()
|
||||
if (!serverId) {
|
||||
throw new Error("serverId is required");
|
||||
throw new Error('serverId is required')
|
||||
}
|
||||
|
||||
const labelTrimmed = input.label?.trim() ?? "";
|
||||
const derivedLabel = labelTrimmed || serverId;
|
||||
const labelTrimmed = input.label?.trim() ?? ''
|
||||
const derivedLabel = labelTrimmed || serverId
|
||||
|
||||
const idx = existing.findIndex((d) => d.serverId === serverId);
|
||||
const idx = existing.findIndex((d) => d.serverId === serverId)
|
||||
if (idx === -1) {
|
||||
const profile: HostProfile = {
|
||||
serverId,
|
||||
@@ -173,18 +217,18 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
preferredConnectionId: input.connection.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const next = [...existing, profile];
|
||||
await persist(next);
|
||||
return profile;
|
||||
}
|
||||
const next = [...existing, profile]
|
||||
await persist(next)
|
||||
return profile
|
||||
}
|
||||
|
||||
const prev = existing[idx]!;
|
||||
const connectionIdx = prev.connections.findIndex((c) => c.id === input.connection.id);
|
||||
const prev = existing[idx]!
|
||||
const connectionIdx = prev.connections.findIndex((c) => c.id === input.connection.id)
|
||||
const nextConnections =
|
||||
connectionIdx === -1
|
||||
? [...prev.connections, input.connection]
|
||||
: prev.connections.map((c, i) => (i === connectionIdx ? input.connection : c));
|
||||
: prev.connections.map((c, i) => (i === connectionIdx ? input.connection : c))
|
||||
|
||||
const nextProfile: HostProfile = {
|
||||
...prev,
|
||||
@@ -192,54 +236,118 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
connections: nextConnections,
|
||||
preferredConnectionId: prev.preferredConnectionId ?? input.connection.id,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
const next = [...existing];
|
||||
next[idx] = nextProfile;
|
||||
await persist(next);
|
||||
return nextProfile;
|
||||
const next = [...existing]
|
||||
next[idx] = nextProfile
|
||||
await persist(next)
|
||||
return nextProfile
|
||||
},
|
||||
[persist, readDaemons]
|
||||
);
|
||||
)
|
||||
|
||||
const upsertDirectConnection = useCallback(
|
||||
async (input: { serverId: string; endpoint: string; label?: string }) => {
|
||||
const endpoint = normalizeHostPort(input.endpoint);
|
||||
const endpoint = normalizeHostPort(input.endpoint)
|
||||
const connection: DirectHostConnection = {
|
||||
id: `direct:${endpoint}`,
|
||||
type: "direct",
|
||||
type: 'direct',
|
||||
endpoint,
|
||||
};
|
||||
}
|
||||
return upsertHostConnection({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
connection,
|
||||
});
|
||||
})
|
||||
},
|
||||
[upsertHostConnection]
|
||||
);
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending) return
|
||||
if (localhostBootstrapAttemptedRef.current) return
|
||||
localhostBootstrapAttemptedRef.current = true
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const bootstrapDefaultLocalhost = async () => {
|
||||
try {
|
||||
const [isE2E, alreadyHandled] = await Promise.all([
|
||||
AsyncStorage.getItem(E2E_STORAGE_KEY),
|
||||
AsyncStorage.getItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY),
|
||||
])
|
||||
if (cancelled || isE2E || alreadyHandled) {
|
||||
return
|
||||
}
|
||||
|
||||
const existing = readDaemons()
|
||||
if (registryHasDirectEndpoint(existing, DEFAULT_LOCALHOST_ENDPOINT)) {
|
||||
await markDefaultLocalhostBootstrapHandled()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { serverId, hostname } = await probeConnection(
|
||||
{
|
||||
id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`,
|
||||
type: 'direct',
|
||||
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
|
||||
},
|
||||
{ timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS }
|
||||
)
|
||||
if (cancelled) return
|
||||
|
||||
await upsertDirectConnection({
|
||||
serverId,
|
||||
endpoint: DEFAULT_LOCALHOST_ENDPOINT,
|
||||
label: hostname ?? undefined,
|
||||
})
|
||||
await markDefaultLocalhostBootstrapHandled()
|
||||
} catch {
|
||||
// Best-effort bootstrap only; keep startup resilient if localhost isn't reachable.
|
||||
}
|
||||
} catch (bootstrapError) {
|
||||
if (cancelled) return
|
||||
console.warn(
|
||||
'[DaemonRegistry] Failed to bootstrap default localhost connection',
|
||||
bootstrapError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
void bootstrapDefaultLocalhost()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isPending, markDefaultLocalhostBootstrapHandled, readDaemons, upsertDirectConnection])
|
||||
|
||||
const upsertRelayConnection = useCallback(
|
||||
async (input: { serverId: string; relayEndpoint: string; daemonPublicKeyB64: string; label?: string }) => {
|
||||
const relayEndpoint = normalizeHostPort(input.relayEndpoint);
|
||||
const daemonPublicKeyB64 = input.daemonPublicKeyB64.trim();
|
||||
async (input: {
|
||||
serverId: string
|
||||
relayEndpoint: string
|
||||
daemonPublicKeyB64: string
|
||||
label?: string
|
||||
}) => {
|
||||
const relayEndpoint = normalizeHostPort(input.relayEndpoint)
|
||||
const daemonPublicKeyB64 = input.daemonPublicKeyB64.trim()
|
||||
if (!daemonPublicKeyB64) {
|
||||
throw new Error("daemonPublicKeyB64 is required");
|
||||
throw new Error('daemonPublicKeyB64 is required')
|
||||
}
|
||||
const connection: RelayHostConnection = {
|
||||
id: `relay:${relayEndpoint}`,
|
||||
type: "relay",
|
||||
type: 'relay',
|
||||
relayEndpoint,
|
||||
daemonPublicKeyB64,
|
||||
};
|
||||
}
|
||||
return upsertHostConnection({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
connection,
|
||||
});
|
||||
})
|
||||
},
|
||||
[upsertHostConnection]
|
||||
);
|
||||
)
|
||||
|
||||
const upsertDaemonFromOffer = useCallback(
|
||||
async (offer: ConnectionOffer) => {
|
||||
@@ -247,28 +355,28 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
serverId: offer.serverId,
|
||||
relayEndpoint: offer.relay.endpoint,
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||
});
|
||||
})
|
||||
},
|
||||
[upsertRelayConnection]
|
||||
);
|
||||
)
|
||||
|
||||
const upsertDaemonFromOfferUrl = useCallback(
|
||||
async (offerUrlOrFragment: string) => {
|
||||
const marker = "#offer=";
|
||||
const idx = offerUrlOrFragment.indexOf(marker);
|
||||
const marker = '#offer='
|
||||
const idx = offerUrlOrFragment.indexOf(marker)
|
||||
if (idx === -1) {
|
||||
throw new Error("Missing #offer= fragment");
|
||||
throw new Error('Missing #offer= fragment')
|
||||
}
|
||||
const encoded = offerUrlOrFragment.slice(idx + marker.length).trim();
|
||||
const encoded = offerUrlOrFragment.slice(idx + marker.length).trim()
|
||||
if (!encoded) {
|
||||
throw new Error("Offer payload is empty");
|
||||
throw new Error('Offer payload is empty')
|
||||
}
|
||||
const payload = decodeOfferFragmentPayload(encoded);
|
||||
const offer = ConnectionOfferSchema.parse(payload);
|
||||
return upsertDaemonFromOffer(offer);
|
||||
const payload = decodeOfferFragmentPayload(encoded)
|
||||
const offer = ConnectionOfferSchema.parse(payload)
|
||||
return upsertDaemonFromOffer(offer)
|
||||
},
|
||||
[upsertDaemonFromOffer]
|
||||
);
|
||||
)
|
||||
|
||||
const value: DaemonRegistryContextValue = {
|
||||
daemons,
|
||||
@@ -281,69 +389,65 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
removeConnection,
|
||||
upsertDaemonFromOffer,
|
||||
upsertDaemonFromOfferUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<DaemonRegistryContext.Provider value={value}>
|
||||
{children}
|
||||
</DaemonRegistryContext.Provider>
|
||||
);
|
||||
return <DaemonRegistryContext.Provider value={value}>{children}</DaemonRegistryContext.Provider>
|
||||
}
|
||||
|
||||
type LegacyHostProfileV1 = {
|
||||
id: string;
|
||||
label: string;
|
||||
endpoints?: unknown;
|
||||
daemonPublicKeyB64?: unknown;
|
||||
relay?: unknown;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
id: string
|
||||
label: string
|
||||
endpoints?: unknown
|
||||
daemonPublicKeyB64?: unknown
|
||||
relay?: unknown
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
function isHostProfileV2(value: unknown): value is HostProfile {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const obj = value as Record<string, unknown>;
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const obj = value as Record<string, unknown>
|
||||
return (
|
||||
typeof obj.serverId === "string" &&
|
||||
typeof obj.label === "string" &&
|
||||
typeof obj.serverId === 'string' &&
|
||||
typeof obj.label === 'string' &&
|
||||
Array.isArray(obj.connections) &&
|
||||
typeof obj.createdAt === "string" &&
|
||||
typeof obj.updatedAt === "string"
|
||||
);
|
||||
typeof obj.createdAt === 'string' &&
|
||||
typeof obj.updatedAt === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
async function loadDaemonRegistryFromStorage(): Promise<HostProfile[]> {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY);
|
||||
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as unknown;
|
||||
const parsed = JSON.parse(stored) as unknown
|
||||
if (Array.isArray(parsed)) {
|
||||
const v2 = parsed.filter((entry) => isHostProfileV2(entry)) as HostProfile[];
|
||||
const v2 = parsed.filter((entry) => isHostProfileV2(entry)) as HostProfile[]
|
||||
if (v2.length === parsed.length) {
|
||||
return v2;
|
||||
return v2
|
||||
}
|
||||
|
||||
// Hard migration from the previous in-repo schema (v1 HostProfile with `id/endpoints/relay`).
|
||||
const migrated: HostProfile[] = parsed
|
||||
.map((entry): HostProfile | null => {
|
||||
if (!entry || typeof entry !== "object") return null;
|
||||
const obj = entry as LegacyHostProfileV1;
|
||||
if (typeof obj.id !== "string" || typeof obj.label !== "string") return null;
|
||||
if (!entry || typeof entry !== 'object') return null
|
||||
const obj = entry as LegacyHostProfileV1
|
||||
if (typeof obj.id !== 'string' || typeof obj.label !== 'string') return null
|
||||
|
||||
// Only keep stable daemon ids; discard transient entries to avoid confusing host selection.
|
||||
if (!obj.id.startsWith("srv_")) return null;
|
||||
if (!obj.id.startsWith('srv_')) return null
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const createdAt = typeof obj.createdAt === "string" ? obj.createdAt : now;
|
||||
const updatedAt = typeof obj.updatedAt === "string" ? obj.updatedAt : now;
|
||||
const now = new Date().toISOString()
|
||||
const createdAt = typeof obj.createdAt === 'string' ? obj.createdAt : now
|
||||
const updatedAt = typeof obj.updatedAt === 'string' ? obj.updatedAt : now
|
||||
|
||||
const connections: HostConnection[] = [];
|
||||
const connections: HostConnection[] = []
|
||||
|
||||
if (Array.isArray(obj.endpoints)) {
|
||||
for (const endpointRaw of obj.endpoints) {
|
||||
try {
|
||||
const endpoint = normalizeHostPort(String(endpointRaw));
|
||||
connections.push({ id: `direct:${endpoint}`, type: "direct", endpoint });
|
||||
const endpoint = normalizeHostPort(String(endpointRaw))
|
||||
connections.push({ id: `direct:${endpoint}`, type: 'direct', endpoint })
|
||||
} catch {
|
||||
// ignore invalid endpoint
|
||||
}
|
||||
@@ -351,29 +455,29 @@ async function loadDaemonRegistryFromStorage(): Promise<HostProfile[]> {
|
||||
}
|
||||
|
||||
const relayEndpointRaw =
|
||||
obj.relay && typeof (obj.relay as any)?.endpoint === "string"
|
||||
obj.relay && typeof (obj.relay as any)?.endpoint === 'string'
|
||||
? String((obj.relay as any).endpoint)
|
||||
: null;
|
||||
: null
|
||||
const daemonPublicKeyB64 =
|
||||
typeof obj.daemonPublicKeyB64 === "string" ? obj.daemonPublicKeyB64.trim() : "";
|
||||
typeof obj.daemonPublicKeyB64 === 'string' ? obj.daemonPublicKeyB64.trim() : ''
|
||||
|
||||
if (relayEndpointRaw && daemonPublicKeyB64) {
|
||||
try {
|
||||
const relayEndpoint = normalizeHostPort(relayEndpointRaw);
|
||||
const relayEndpoint = normalizeHostPort(relayEndpointRaw)
|
||||
connections.push({
|
||||
id: `relay:${relayEndpoint}`,
|
||||
type: "relay",
|
||||
type: 'relay',
|
||||
relayEndpoint,
|
||||
daemonPublicKeyB64,
|
||||
});
|
||||
})
|
||||
} catch {
|
||||
// ignore invalid relay endpoint
|
||||
}
|
||||
}
|
||||
|
||||
if (connections.length === 0) return null;
|
||||
if (connections.length === 0) return null
|
||||
|
||||
const preferredConnectionId: string | null = connections[0]?.id ?? null;
|
||||
const preferredConnectionId: string | null = connections[0]?.id ?? null
|
||||
|
||||
return {
|
||||
serverId: obj.id,
|
||||
@@ -382,18 +486,18 @@ async function loadDaemonRegistryFromStorage(): Promise<HostProfile[]> {
|
||||
preferredConnectionId,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
})
|
||||
.filter((entry): entry is HostProfile => entry !== null);
|
||||
.filter((entry): entry is HostProfile => entry !== null)
|
||||
|
||||
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(migrated));
|
||||
return migrated;
|
||||
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(migrated))
|
||||
return migrated
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
return []
|
||||
} catch (error) {
|
||||
console.error("[DaemonRegistry] Failed to load daemon registry", error);
|
||||
throw error;
|
||||
console.error('[DaemonRegistry] Failed to load daemon registry', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
applyStreamEvent,
|
||||
generateMessageId,
|
||||
hydrateStreamState,
|
||||
reduceStreamUpdate,
|
||||
type StreamItem,
|
||||
} from "@/types/stream";
|
||||
import type {
|
||||
@@ -17,6 +18,7 @@ import type {
|
||||
AgentStreamEventPayload,
|
||||
SessionOutboundMessage,
|
||||
} from "@server/shared/messages";
|
||||
import { parseServerInfoStatusPayload } from "@server/shared/messages";
|
||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||
import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types";
|
||||
import type { DaemonClient, ConnectionState } from "@server/client/daemon-client";
|
||||
@@ -303,6 +305,9 @@ export function SessionProvider({
|
||||
const clearAgentStreamHead = useSessionStore(
|
||||
(state) => state.clearAgentStreamHead
|
||||
);
|
||||
const setAgentTimelineCursor = useSessionStore(
|
||||
(state) => state.setAgentTimelineCursor
|
||||
);
|
||||
const setInitializingAgents = useSessionStore(
|
||||
(state) => state.setInitializingAgents
|
||||
);
|
||||
@@ -322,7 +327,6 @@ export function SessionProvider({
|
||||
const setPendingPermissions = useSessionStore(
|
||||
(state) => state.setPendingPermissions
|
||||
);
|
||||
const setGitDiffs = useSessionStore((state) => state.setGitDiffs);
|
||||
const setFileExplorer = useSessionStore((state) => state.setFileExplorer);
|
||||
const clearDraftInput = useDraftStore((state) => state.clearDraftInput);
|
||||
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
|
||||
@@ -372,7 +376,7 @@ export function SessionProvider({
|
||||
) => Promise<void>)
|
||||
| null
|
||||
>(null);
|
||||
const hasRequestedInitialSnapshotRef = useRef(false);
|
||||
const hasBootstrappedAgentUpdatesRef = useRef(false);
|
||||
const agentUpdatesSubscriptionIdRef = useRef<string | null>(null);
|
||||
const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null
|
||||
@@ -581,6 +585,15 @@ export function SessionProvider({
|
||||
return next;
|
||||
});
|
||||
|
||||
setAgentTimelineCursor(serverId, (prev) => {
|
||||
if (!prev.has(agentId)) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.delete(agentId);
|
||||
return next;
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -592,6 +605,10 @@ export function SessionProvider({
|
||||
console.log("[Session] Agent update:", agent.id, agent.status);
|
||||
|
||||
setAgents(serverId, (prev) => {
|
||||
const current = prev.get(agent.id);
|
||||
if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(agent.id, agent);
|
||||
return next;
|
||||
@@ -645,6 +662,7 @@ export function SessionProvider({
|
||||
setAgentLastActivity,
|
||||
setPendingPermissions,
|
||||
setQueuedMessages,
|
||||
setAgentTimelineCursor,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -666,34 +684,6 @@ export function SessionProvider({
|
||||
[serverId, setFileExplorer]
|
||||
);
|
||||
|
||||
const gitDiffMutation = useMutation({
|
||||
mutationFn: async ({ agentId }: { agentId: string }) => {
|
||||
if (!agentId) {
|
||||
throw new Error("Agent id is required");
|
||||
}
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.getGitDiff(agentId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return { agentId: payload.agentId, diff: payload.diff ?? "" };
|
||||
},
|
||||
});
|
||||
|
||||
const refreshAgentMutation = useMutation({
|
||||
mutationFn: async ({ agentId }: { agentId: string }) => {
|
||||
if (!agentId) {
|
||||
throw new Error("Agent id is required");
|
||||
}
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
return await client.refreshAgent(agentId);
|
||||
},
|
||||
});
|
||||
|
||||
const directoryListingMutation = useMutation({
|
||||
mutationFn: async ({ agentId, path }: { agentId: string; path: string }) => {
|
||||
if (!agentId) {
|
||||
@@ -757,9 +747,109 @@ export function SessionProvider({
|
||||
},
|
||||
});
|
||||
|
||||
const applyTimelineResponse = useCallback(
|
||||
(
|
||||
payload: Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "fetch_agent_timeline_response" }
|
||||
>["payload"]
|
||||
) => {
|
||||
const agentId = payload.agentId;
|
||||
const initKey = getInitKey(serverId, agentId);
|
||||
|
||||
if (payload.error) {
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
if (prev.get(agentId) !== true) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
rejectInitDeferred(initKey, new Error(payload.error));
|
||||
return;
|
||||
}
|
||||
|
||||
const hydratedEvents: Array<{
|
||||
event: AgentStreamEventPayload;
|
||||
timestamp: Date;
|
||||
}> = payload.entries.map((entry) => ({
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: entry.provider,
|
||||
item: entry.item,
|
||||
},
|
||||
timestamp: new Date(entry.timestamp),
|
||||
}));
|
||||
|
||||
const replace = payload.reset || payload.direction !== "after";
|
||||
if (replace) {
|
||||
const hydrated = hydrateStreamState(hydratedEvents);
|
||||
setAgentStreamTail(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, hydrated);
|
||||
return next;
|
||||
});
|
||||
clearAgentStreamHead(serverId, agentId);
|
||||
} else if (hydratedEvents.length > 0) {
|
||||
setAgentStreamTail(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
const current = next.get(agentId) ?? [];
|
||||
const updated = hydratedEvents.reduce<StreamItem[]>(
|
||||
(state, { event, timestamp }) => reduceStreamUpdate(state, event, timestamp),
|
||||
current
|
||||
);
|
||||
next.set(agentId, updated);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
setAgentTimelineCursor(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
if (payload.startCursor && payload.endCursor) {
|
||||
next.set(agentId, {
|
||||
epoch: payload.epoch,
|
||||
startSeq: payload.startCursor.seq,
|
||||
endSeq: payload.endCursor.seq,
|
||||
});
|
||||
} else if (payload.reset) {
|
||||
next.delete(agentId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
const deferredUpdate = pendingAgentUpdatesRef.current.get(agentId);
|
||||
pendingAgentUpdatesRef.current.delete(agentId);
|
||||
if (deferredUpdate) {
|
||||
applyAgentUpdatePayload(deferredUpdate);
|
||||
}
|
||||
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
if (prev.get(agentId) !== true) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
|
||||
resolveInitDeferred(initKey);
|
||||
markAgentHistorySynchronized(serverId, agentId);
|
||||
},
|
||||
[
|
||||
applyAgentUpdatePayload,
|
||||
clearAgentStreamHead,
|
||||
markAgentHistorySynchronized,
|
||||
serverId,
|
||||
setAgentStreamTail,
|
||||
setAgentTimelineCursor,
|
||||
setInitializingAgents,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectionSnapshot.isConnected) {
|
||||
hasRequestedInitialSnapshotRef.current = false;
|
||||
hasBootstrappedAgentUpdatesRef.current = false;
|
||||
const subscriptionId = agentUpdatesSubscriptionIdRef.current;
|
||||
if (subscriptionId && client) {
|
||||
try {
|
||||
@@ -771,10 +861,10 @@ export function SessionProvider({
|
||||
agentUpdatesSubscriptionIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (hasRequestedInitialSnapshotRef.current) {
|
||||
if (hasBootstrappedAgentUpdatesRef.current) {
|
||||
return;
|
||||
}
|
||||
hasRequestedInitialSnapshotRef.current = true;
|
||||
hasBootstrappedAgentUpdatesRef.current = true;
|
||||
|
||||
try {
|
||||
if (!agentUpdatesSubscriptionIdRef.current) {
|
||||
@@ -820,7 +910,7 @@ export function SessionProvider({
|
||||
|
||||
const unsubAgentStream = client.on("agent_stream", (message) => {
|
||||
if (message.type !== "agent_stream") return;
|
||||
const { agentId, event, timestamp } = message.payload;
|
||||
const { agentId, event, timestamp, seq, epoch } = message.payload;
|
||||
const parsedTimestamp = new Date(timestamp);
|
||||
|
||||
if (event.type === "attention_required") {
|
||||
@@ -863,74 +953,53 @@ export function SessionProvider({
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "timeline" &&
|
||||
typeof seq === "number" &&
|
||||
typeof epoch === "string"
|
||||
) {
|
||||
setAgentTimelineCursor(serverId, (prev) => {
|
||||
const current = prev.get(agentId);
|
||||
const next = new Map(prev);
|
||||
if (!current || current.epoch !== epoch) {
|
||||
next.set(agentId, { epoch, startSeq: seq, endSeq: seq });
|
||||
return next;
|
||||
}
|
||||
next.set(agentId, {
|
||||
epoch,
|
||||
startSeq: Math.min(current.startSeq, seq),
|
||||
endSeq: Math.max(current.endSeq, seq),
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// NOTE: We don't update lastActivityAt on every stream event to prevent
|
||||
// cascading rerenders. The agent_update handler updates agent.lastActivityAt
|
||||
// on status changes, which is sufficient for sorting and display purposes.
|
||||
});
|
||||
|
||||
const unsubAgentStreamSnapshot = client.on(
|
||||
"agent_stream_snapshot",
|
||||
const unsubAgentTimeline = client.on(
|
||||
"fetch_agent_timeline_response",
|
||||
(message) => {
|
||||
if (message.type !== "agent_stream_snapshot") return;
|
||||
const { agentId, events } = message.payload;
|
||||
|
||||
const hydrated = hydrateStreamState(
|
||||
events.map(({ event, timestamp }) => ({
|
||||
event: event as AgentStreamEventPayload,
|
||||
timestamp: new Date(timestamp),
|
||||
}))
|
||||
);
|
||||
const initKey = getInitKey(serverId, agentId);
|
||||
const hasInFlightHistorySync = Boolean(getInitDeferred(initKey));
|
||||
|
||||
setAgentStreamTail(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, hydrated);
|
||||
return next;
|
||||
});
|
||||
clearAgentStreamHead(serverId, agentId);
|
||||
|
||||
const deferredUpdate = pendingAgentUpdatesRef.current.get(agentId);
|
||||
pendingAgentUpdatesRef.current.delete(agentId);
|
||||
if (hasInFlightHistorySync && deferredUpdate) {
|
||||
applyAgentUpdatePayload(deferredUpdate);
|
||||
}
|
||||
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
if (prev.get(agentId) !== true) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Resolve the initialization promise (even for empty history)
|
||||
resolveInitDeferred(initKey);
|
||||
markAgentHistorySynchronized(serverId, agentId);
|
||||
if (message.type !== "fetch_agent_timeline_response") return;
|
||||
applyTimelineResponse(message.payload);
|
||||
}
|
||||
);
|
||||
|
||||
const unsubStatus = client.on("status", (message) => {
|
||||
if (message.type !== "status") return;
|
||||
const status = message.payload.status;
|
||||
if (status === "server_info") {
|
||||
const payload = message.payload as any;
|
||||
const rawServerId = typeof payload.serverId === "string" ? payload.serverId.trim() : "";
|
||||
if (!rawServerId) return;
|
||||
const rawHostname = typeof payload.hostname === "string" ? payload.hostname.trim() : "";
|
||||
const serverInfo = parseServerInfoStatusPayload(message.payload);
|
||||
if (serverInfo) {
|
||||
updateSessionServerInfo(serverId, {
|
||||
serverId: rawServerId,
|
||||
hostname: rawHostname.length > 0 ? rawHostname : null,
|
||||
serverId: serverInfo.serverId,
|
||||
hostname: serverInfo.hostname,
|
||||
...(serverInfo.capabilities
|
||||
? { capabilities: serverInfo.capabilities }
|
||||
: {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (status === "agent_initialized" && "agentId" in message.payload) {
|
||||
console.log("[Session] status agent_initialized", {
|
||||
agentId: (message.payload as any).agentId,
|
||||
requestId: (message.payload as any).requestId,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const unsubPermissionRequest = client.on(
|
||||
@@ -1250,6 +1319,14 @@ export function SessionProvider({
|
||||
return next;
|
||||
});
|
||||
clearAgentStreamHead(serverId, agentId);
|
||||
setAgentTimelineCursor(serverId, (prev) => {
|
||||
if (!prev.has(agentId)) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.delete(agentId);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Remove draft input
|
||||
clearDraftInput(agentId);
|
||||
@@ -1275,15 +1352,6 @@ export function SessionProvider({
|
||||
return next;
|
||||
});
|
||||
|
||||
setGitDiffs(serverId, (prev) => {
|
||||
if (!prev.has(agentId)) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.delete(agentId);
|
||||
return next;
|
||||
});
|
||||
|
||||
setFileExplorer(serverId, (prev) => {
|
||||
if (!prev.has(agentId)) {
|
||||
return prev;
|
||||
@@ -1318,7 +1386,7 @@ export function SessionProvider({
|
||||
return () => {
|
||||
unsubAgentUpdate();
|
||||
unsubAgentStream();
|
||||
unsubAgentStreamSnapshot();
|
||||
unsubAgentTimeline();
|
||||
unsubStatus();
|
||||
unsubPermissionRequest();
|
||||
unsubPermissionResolved();
|
||||
@@ -1339,74 +1407,20 @@ export function SessionProvider({
|
||||
setAgentStreamTail,
|
||||
setAgentStreamHead,
|
||||
clearAgentStreamHead,
|
||||
setAgentTimelineCursor,
|
||||
setInitializingAgents,
|
||||
setAgents,
|
||||
setAgentLastActivity,
|
||||
setPendingPermissions,
|
||||
setGitDiffs,
|
||||
setFileExplorer,
|
||||
setHasHydratedAgents,
|
||||
updateConnectionStatus,
|
||||
clearDraftInput,
|
||||
notifyAgentAttention,
|
||||
applyAgentUpdatePayload,
|
||||
markAgentHistorySynchronized,
|
||||
applyTimelineResponse,
|
||||
]);
|
||||
|
||||
const initializeAgent = useCallback(
|
||||
({ agentId, requestId }: { agentId: string; requestId?: string }) => {
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, true);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
console.warn("[Session] initializeAgent skipped: daemon unavailable");
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
client
|
||||
.initializeAgent(agentId, requestId)
|
||||
.catch((error) => {
|
||||
console.warn("[Session] initializeAgent failed", { agentId, error });
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
},
|
||||
[serverId, client, setInitializingAgents]
|
||||
);
|
||||
|
||||
const refreshAgent = useCallback(
|
||||
({ agentId }: { agentId: string }) => {
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, true);
|
||||
return next;
|
||||
});
|
||||
|
||||
refreshAgentMutation
|
||||
.mutateAsync({ agentId })
|
||||
.catch((error) => {
|
||||
console.warn("[Session] refreshAgent failed", { agentId, error });
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
},
|
||||
[serverId, refreshAgentMutation, setInitializingAgents]
|
||||
);
|
||||
|
||||
const sendAgentMessage = useCallback(
|
||||
async (
|
||||
agentId: string,
|
||||
@@ -1635,24 +1649,6 @@ export function SessionProvider({
|
||||
[]
|
||||
);
|
||||
|
||||
const requestGitDiff = useCallback(
|
||||
(agentId: string) => {
|
||||
gitDiffMutation
|
||||
.mutateAsync({ agentId })
|
||||
.then((result) => {
|
||||
setGitDiffs(serverId, (prev) =>
|
||||
new Map(prev).set(result.agentId, result.diff)
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
setGitDiffs(serverId, (prev) =>
|
||||
new Map(prev).set(agentId, `Error: ${error.message}`)
|
||||
);
|
||||
});
|
||||
},
|
||||
[serverId, gitDiffMutation, setGitDiffs]
|
||||
);
|
||||
|
||||
const requestDirectoryListing = useCallback(
|
||||
(agentId: string, path: string, options?: { recordHistory?: boolean }) => {
|
||||
const normalizedPath = path && path.length > 0 ? path : ".";
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createContext, useContext, useState, ReactNode, useCallback, useEffect,
|
||||
import { useSpeechmaticsAudio } from "@/hooks/use-speechmatics-audio";
|
||||
import type { SessionState } from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities";
|
||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||
import { REALTIME_VOICE_VAD_CONFIG } from "@/voice/realtime-voice-config";
|
||||
|
||||
@@ -64,38 +65,63 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
|
||||
const voiceTransportReadyRef = useRef(false);
|
||||
const voiceResyncInFlightRef = useRef(false);
|
||||
const silenceGraceStartMsRef = useRef<number | null>(null);
|
||||
const speechInterruptTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const speechInterruptStartMsRef = useRef<number | null>(null);
|
||||
const speechStartInterruptSentRef = useRef(false);
|
||||
const isVoiceModeRef = useRef(false);
|
||||
const vadStateRef = useRef<{ isDetecting: boolean; isSpeaking: boolean }>({
|
||||
isDetecting: false,
|
||||
isSpeaking: false,
|
||||
});
|
||||
|
||||
const clearSpeechStartInterruptTimer = useCallback((reason: string) => {
|
||||
const timer = speechInterruptTimerRef.current;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
speechInterruptTimerRef.current = null;
|
||||
const startedAt = speechInterruptStartMsRef.current;
|
||||
speechInterruptStartMsRef.current = null;
|
||||
if (startedAt !== null) {
|
||||
console.log("[Voice] Cleared speech-start interrupt timer", {
|
||||
reason,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
});
|
||||
} else {
|
||||
console.log("[Voice] Cleared speech-start interrupt timer", { reason });
|
||||
}
|
||||
return;
|
||||
}
|
||||
speechInterruptStartMsRef.current = null;
|
||||
}, []);
|
||||
|
||||
const interruptActiveVoiceTurn = useCallback((source: string) => {
|
||||
const session = realtimeSessionRef.current;
|
||||
const sessionAudioPlayer = session?.audioPlayer ?? null;
|
||||
const sessionClient = session?.client ?? null;
|
||||
const sessionIsPlayingAudio = session?.isPlayingAudio ?? false;
|
||||
|
||||
if (sessionIsPlayingAudio && sessionAudioPlayer) {
|
||||
if (bargeInPlaybackStopRef.current === null) {
|
||||
bargeInPlaybackStopRef.current = Date.now();
|
||||
}
|
||||
sessionAudioPlayer.stop();
|
||||
}
|
||||
|
||||
try {
|
||||
if (sessionClient) {
|
||||
void sessionClient.abortRequest().catch((error) => {
|
||||
console.error("[Voice] Failed to send abort_request:", error);
|
||||
});
|
||||
}
|
||||
console.log("[Voice] Sent abort_request before streaming audio", { source });
|
||||
} catch (error) {
|
||||
console.error("[Voice] Failed to send abort_request:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const realtimeAudio = useSpeechmaticsAudio({
|
||||
onSpeechStart: () => {
|
||||
console.log("[Voice] Segment started (speech confirmed)");
|
||||
// Stop audio playback if playing
|
||||
const session = realtimeSessionRef.current;
|
||||
const sessionAudioPlayer = session?.audioPlayer ?? null;
|
||||
const sessionClient = session?.client ?? null;
|
||||
const sessionIsPlayingAudio = session?.isPlayingAudio ?? false;
|
||||
|
||||
if (sessionIsPlayingAudio && sessionAudioPlayer) {
|
||||
if (bargeInPlaybackStopRef.current === null) {
|
||||
bargeInPlaybackStopRef.current = Date.now();
|
||||
}
|
||||
sessionAudioPlayer.stop();
|
||||
}
|
||||
|
||||
// Abort any in-flight orchestrator turn before the new speech segment streams
|
||||
try {
|
||||
if (sessionClient) {
|
||||
void sessionClient.abortRequest().catch((error) => {
|
||||
console.error("[Voice] Failed to send abort_request:", error);
|
||||
});
|
||||
}
|
||||
console.log("[Voice] Sent abort_request before streaming audio");
|
||||
} catch (error) {
|
||||
console.error("[Voice] Failed to send abort_request:", error);
|
||||
}
|
||||
},
|
||||
onSpeechEnd: () => {
|
||||
const silenceMs =
|
||||
@@ -108,6 +134,8 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
|
||||
console.log("[Voice] Segment finalized", { silenceMs });
|
||||
}
|
||||
silenceGraceStartMsRef.current = null;
|
||||
clearSpeechStartInterruptTimer("speech ended");
|
||||
speechStartInterruptSentRef.current = false;
|
||||
},
|
||||
onAudioSegment: ({ audioData, isLast }) => {
|
||||
if (!voiceTransportReadyRef.current) {
|
||||
@@ -158,6 +186,58 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
|
||||
realtimeSessionRef.current = activeSession;
|
||||
}, [activeSession]);
|
||||
|
||||
useEffect(() => {
|
||||
isVoiceModeRef.current = isVoiceMode;
|
||||
}, [isVoiceMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVoiceMode) {
|
||||
clearSpeechStartInterruptTimer("voice mode disabled");
|
||||
speechStartInterruptSentRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (realtimeAudio.isSpeaking) {
|
||||
if (
|
||||
speechStartInterruptSentRef.current ||
|
||||
speechInterruptTimerRef.current !== null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
speechInterruptStartMsRef.current = Date.now();
|
||||
speechInterruptTimerRef.current = setTimeout(() => {
|
||||
speechInterruptTimerRef.current = null;
|
||||
speechInterruptStartMsRef.current = null;
|
||||
if (
|
||||
!isVoiceModeRef.current ||
|
||||
!vadStateRef.current.isSpeaking ||
|
||||
speechStartInterruptSentRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
speechStartInterruptSentRef.current = true;
|
||||
console.log("[Voice] Speech persisted beyond grace; interrupting turn", {
|
||||
graceMs: REALTIME_VOICE_VAD_CONFIG.interruptGracePeriodMs,
|
||||
});
|
||||
interruptActiveVoiceTurn("speech_start_grace_elapsed");
|
||||
}, REALTIME_VOICE_VAD_CONFIG.interruptGracePeriodMs);
|
||||
return;
|
||||
}
|
||||
|
||||
clearSpeechStartInterruptTimer("speech stopped before grace");
|
||||
}, [
|
||||
clearSpeechStartInterruptTimer,
|
||||
interruptActiveVoiceTurn,
|
||||
isVoiceMode,
|
||||
realtimeAudio.isSpeaking,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearSpeechStartInterruptTimer("voice provider unmounted");
|
||||
};
|
||||
}, [clearSpeechStartInterruptTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
const next = {
|
||||
isDetecting: realtimeAudio.isDetecting,
|
||||
@@ -187,6 +267,7 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
|
||||
// Fully idle (neither detecting nor speaking).
|
||||
if (!next.isDetecting && !next.isSpeaking) {
|
||||
silenceGraceStartMsRef.current = null;
|
||||
speechStartInterruptSentRef.current = false;
|
||||
}
|
||||
|
||||
vadStateRef.current = next;
|
||||
@@ -254,6 +335,13 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
|
||||
if (!session) {
|
||||
throw new Error(`Host ${serverId} is not connected`);
|
||||
}
|
||||
const unavailableMessage = resolveVoiceUnavailableMessage({
|
||||
serverInfo: session.serverInfo,
|
||||
mode: "voice",
|
||||
});
|
||||
if (unavailableMessage) {
|
||||
throw new Error(unavailableMessage);
|
||||
}
|
||||
|
||||
setIsVoiceSwitching(true);
|
||||
voiceTransportReadyRef.current = false;
|
||||
|
||||
@@ -86,7 +86,11 @@ export class DictationStreamSender {
|
||||
}
|
||||
|
||||
if (!this.dictationId) {
|
||||
void this.restartStream("enqueue");
|
||||
if (!this.startPromise) {
|
||||
void this.restartStream("enqueue").catch((error) => {
|
||||
console.error("[DictationStreamSender] Failed to start stream from enqueue", error);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __private__ } from "./use-agent-form-state";
|
||||
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
type AgentProviderDefinition,
|
||||
} from "@server/server/agent/provider-manifest";
|
||||
import type {
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
describe("useAgentFormState", () => {
|
||||
describe("__private__.combineInitialValues", () => {
|
||||
@@ -146,5 +153,127 @@ describe("useAgentFormState", () => {
|
||||
|
||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||
});
|
||||
|
||||
it("normalizes legacy model id 'default' from initial values to auto", () => {
|
||||
const resolved = __private__.resolveFormState(
|
||||
{ model: "default" },
|
||||
{ provider: "codex" },
|
||||
codexModels,
|
||||
{
|
||||
serverId: false,
|
||||
provider: false,
|
||||
modeId: false,
|
||||
model: false,
|
||||
thinkingOptionId: false,
|
||||
workingDir: false,
|
||||
},
|
||||
{
|
||||
serverId: null,
|
||||
provider: "codex",
|
||||
modeId: "",
|
||||
model: "",
|
||||
thinkingOptionId: "",
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>()
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("");
|
||||
});
|
||||
|
||||
it("normalizes legacy model id 'default' from provider preferences to auto", () => {
|
||||
const resolved = __private__.resolveFormState(
|
||||
undefined,
|
||||
{
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "default",
|
||||
},
|
||||
},
|
||||
},
|
||||
codexModels,
|
||||
{
|
||||
serverId: false,
|
||||
provider: false,
|
||||
modeId: false,
|
||||
model: false,
|
||||
thinkingOptionId: false,
|
||||
workingDir: false,
|
||||
},
|
||||
{
|
||||
serverId: null,
|
||||
provider: "codex",
|
||||
modeId: "",
|
||||
model: "",
|
||||
thinkingOptionId: "",
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>()
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("");
|
||||
});
|
||||
|
||||
it("resolves provider only from allowed provider map", () => {
|
||||
const allowedProviderMap = new Map<AgentProvider, AgentProviderDefinition>(
|
||||
AGENT_PROVIDER_DEFINITIONS
|
||||
.filter((definition) => definition.id === "claude")
|
||||
.map((definition) => [definition.id as AgentProvider, definition])
|
||||
);
|
||||
const resolved = __private__.resolveFormState(
|
||||
undefined,
|
||||
{ provider: "codex" },
|
||||
null,
|
||||
{
|
||||
serverId: false,
|
||||
provider: false,
|
||||
modeId: false,
|
||||
model: false,
|
||||
thinkingOptionId: false,
|
||||
workingDir: false,
|
||||
},
|
||||
{
|
||||
serverId: null,
|
||||
provider: "codex",
|
||||
modeId: "",
|
||||
model: "",
|
||||
thinkingOptionId: "",
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
allowedProviderMap
|
||||
);
|
||||
|
||||
expect(resolved.provider).toBe("claude");
|
||||
});
|
||||
|
||||
it("does not force fallback provider when allowed provider map is empty", () => {
|
||||
const resolved = __private__.resolveFormState(
|
||||
undefined,
|
||||
{ provider: "codex" },
|
||||
null,
|
||||
{
|
||||
serverId: false,
|
||||
provider: false,
|
||||
modeId: false,
|
||||
model: false,
|
||||
thinkingOptionId: false,
|
||||
workingDir: false,
|
||||
},
|
||||
{
|
||||
serverId: null,
|
||||
provider: "codex",
|
||||
modeId: "",
|
||||
model: "",
|
||||
thinkingOptionId: "",
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
new Map<AgentProvider, AgentProviderDefinition>()
|
||||
);
|
||||
|
||||
expect(resolved.provider).toBe("codex");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,15 +89,25 @@ type UseAgentFormStateResult = {
|
||||
persistFormPreferences: () => Promise<void>;
|
||||
};
|
||||
|
||||
const providerDefinitions = AGENT_PROVIDER_DEFINITIONS;
|
||||
const providerDefinitionMap = new Map<AgentProvider, AgentProviderDefinition>(
|
||||
providerDefinitions.map((definition) => [definition.id, definition])
|
||||
const allProviderDefinitions = AGENT_PROVIDER_DEFINITIONS;
|
||||
const allProviderDefinitionMap = new Map<AgentProvider, AgentProviderDefinition>(
|
||||
allProviderDefinitions.map((definition) => [definition.id, definition])
|
||||
);
|
||||
const fallbackDefinition = providerDefinitions[0];
|
||||
const fallbackDefinition = allProviderDefinitions[0];
|
||||
const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude";
|
||||
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER =
|
||||
fallbackDefinition?.defaultModeId ?? "";
|
||||
|
||||
function normalizeSelectedModelId(
|
||||
modelId: string | null | undefined
|
||||
): string {
|
||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
||||
if (!normalized || normalized.toLowerCase() === "default") {
|
||||
return "";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveDefaultModel(
|
||||
availableModels: AgentModelDefinition[] | null
|
||||
): AgentModelDefinition | null {
|
||||
@@ -136,25 +146,33 @@ function resolveFormState(
|
||||
availableModels: AgentModelDefinition[] | null,
|
||||
userModified: UserModifiedFields,
|
||||
currentState: FormState,
|
||||
validServerIds: Set<string>
|
||||
validServerIds: Set<string>,
|
||||
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition> = allProviderDefinitionMap
|
||||
): FormState {
|
||||
// Start with current state - we only update non-user-modified fields
|
||||
const result = { ...currentState };
|
||||
const fallbackProvider = allowedProviderMap.keys().next().value as
|
||||
| AgentProvider
|
||||
| undefined;
|
||||
|
||||
// 1. Resolve provider first (other fields depend on it)
|
||||
if (!userModified.provider) {
|
||||
if (initialValues?.provider && providerDefinitionMap.has(initialValues.provider)) {
|
||||
if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) {
|
||||
result.provider = initialValues.provider;
|
||||
} else if (
|
||||
preferences?.provider &&
|
||||
providerDefinitionMap.has(preferences.provider as AgentProvider)
|
||||
allowedProviderMap.has(preferences.provider as AgentProvider)
|
||||
) {
|
||||
result.provider = preferences.provider as AgentProvider;
|
||||
} else if (!allowedProviderMap.has(result.provider) && fallbackProvider) {
|
||||
result.provider = fallbackProvider;
|
||||
}
|
||||
// else keep current (initialized to DEFAULT_PROVIDER)
|
||||
} else if (!allowedProviderMap.has(result.provider) && fallbackProvider) {
|
||||
result.provider = fallbackProvider;
|
||||
}
|
||||
|
||||
const providerDef = providerDefinitionMap.get(result.provider);
|
||||
const providerDef = allowedProviderMap.get(result.provider);
|
||||
const providerPrefs = preferences?.providerPreferences?.[result.provider];
|
||||
|
||||
// 2. Resolve modeId (depends on provider)
|
||||
@@ -181,25 +199,24 @@ function resolveFormState(
|
||||
if (!userModified.model) {
|
||||
const isValidModel = (m: string) =>
|
||||
availableModels?.some((am) => am.id === m) ?? false;
|
||||
const initialModel = normalizeSelectedModelId(initialValues?.model);
|
||||
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
||||
|
||||
if (
|
||||
typeof initialValues?.model === "string" &&
|
||||
initialValues.model.length > 0
|
||||
) {
|
||||
if (initialModel) {
|
||||
// If models aren't loaded yet, trust the initial value
|
||||
// It will be validated once models load
|
||||
if (!availableModels || isValidModel(initialValues.model)) {
|
||||
result.model = initialValues.model;
|
||||
} else if (providerPrefs?.model && isValidModel(providerPrefs.model)) {
|
||||
result.model = providerPrefs.model;
|
||||
if (!availableModels || isValidModel(initialModel)) {
|
||||
result.model = initialModel;
|
||||
} else if (preferredModel && isValidModel(preferredModel)) {
|
||||
result.model = preferredModel;
|
||||
} else {
|
||||
result.model = "";
|
||||
}
|
||||
} else if (typeof providerPrefs?.model === "string" && providerPrefs.model.length > 0) {
|
||||
} else if (preferredModel) {
|
||||
// If models haven't loaded yet, optimistically apply the stored preference.
|
||||
// We'll validate once models load and clear it if it isn't available.
|
||||
if (!availableModels || isValidModel(providerPrefs.model)) {
|
||||
result.model = providerPrefs.model;
|
||||
if (!availableModels || isValidModel(preferredModel)) {
|
||||
result.model = preferredModel;
|
||||
} else {
|
||||
result.model = "";
|
||||
}
|
||||
@@ -353,6 +370,45 @@ export function useAgentFormState(
|
||||
const client = sessionState?.client ?? null;
|
||||
const isConnected = sessionState?.connection?.isConnected ?? false;
|
||||
|
||||
const availableProvidersQuery = useQuery({
|
||||
queryKey: ["availableProviders", formState.serverId],
|
||||
enabled: Boolean(
|
||||
isVisible && isTargetDaemonReady && formState.serverId && client && isConnected
|
||||
),
|
||||
staleTime: 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.listAvailableProviders();
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.providers
|
||||
.filter((entry) => entry.available)
|
||||
.map((entry) => entry.provider);
|
||||
},
|
||||
});
|
||||
|
||||
const providerDefinitions = useMemo(() => {
|
||||
const availableProviders = availableProvidersQuery.data;
|
||||
if (!availableProviders) {
|
||||
return [];
|
||||
}
|
||||
const available = new Set(availableProviders);
|
||||
return allProviderDefinitions.filter((definition) =>
|
||||
available.has(definition.id as AgentProvider)
|
||||
);
|
||||
}, [availableProvidersQuery.data]);
|
||||
|
||||
const providerDefinitionMap = useMemo(
|
||||
() =>
|
||||
new Map<AgentProvider, AgentProviderDefinition>(
|
||||
providerDefinitions.map((definition) => [definition.id as AgentProvider, definition])
|
||||
),
|
||||
[providerDefinitions]
|
||||
);
|
||||
|
||||
const [debouncedCwd, setDebouncedCwd] = useState<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
const trimmed = formState.workingDir.trim();
|
||||
@@ -363,7 +419,14 @@ export function useAgentFormState(
|
||||
|
||||
const providerModelsQuery = useQuery({
|
||||
queryKey: ["providerModels", formState.serverId, formState.provider, debouncedCwd],
|
||||
enabled: Boolean(isVisible && isTargetDaemonReady && formState.serverId && client && isConnected),
|
||||
enabled: Boolean(
|
||||
isVisible &&
|
||||
isTargetDaemonReady &&
|
||||
formState.serverId &&
|
||||
client &&
|
||||
isConnected &&
|
||||
providerDefinitionMap.has(formState.provider)
|
||||
),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
@@ -403,7 +466,8 @@ export function useAgentFormState(
|
||||
availableModels,
|
||||
userModified,
|
||||
formStateRef.current,
|
||||
validServerIds
|
||||
validServerIds,
|
||||
providerDefinitionMap
|
||||
);
|
||||
|
||||
// Only update if something changed
|
||||
@@ -428,6 +492,7 @@ export function useAgentFormState(
|
||||
availableModels,
|
||||
userModified,
|
||||
validServerIds,
|
||||
providerDefinitionMap,
|
||||
]);
|
||||
|
||||
// Auto-select the first online host when:
|
||||
@@ -500,11 +565,11 @@ export function useAgentFormState(
|
||||
...prev,
|
||||
provider,
|
||||
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "",
|
||||
model: providerPrefs?.model ?? "",
|
||||
model: normalizeSelectedModelId(providerPrefs?.model),
|
||||
thinkingOptionId: providerPrefs?.thinkingOptionId ?? "",
|
||||
}));
|
||||
},
|
||||
[preferences?.providerPreferences, updatePreferences]
|
||||
[preferences?.providerPreferences, providerDefinitionMap, updatePreferences]
|
||||
);
|
||||
|
||||
const setModeFromUser = useCallback(
|
||||
@@ -518,9 +583,10 @@ export function useAgentFormState(
|
||||
|
||||
const setModelFromUser = useCallback(
|
||||
(modelId: string) => {
|
||||
setFormState((prev) => ({ ...prev, model: modelId }));
|
||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||
setFormState((prev) => ({ ...prev, model: normalizedModelId }));
|
||||
setUserModified((prev) => ({ ...prev, model: true }));
|
||||
void updateProviderPreferences(formState.provider, { model: modelId });
|
||||
void updateProviderPreferences(formState.provider, { model: normalizedModelId });
|
||||
},
|
||||
[formState.provider, updateProviderPreferences]
|
||||
);
|
||||
@@ -639,6 +705,8 @@ export function useAgentFormState(
|
||||
setThinkingOptionFromUser,
|
||||
setWorkingDir,
|
||||
setWorkingDirFromUser,
|
||||
providerDefinitions,
|
||||
providerDefinitionMap,
|
||||
agentDefinition,
|
||||
modeOptions,
|
||||
availableModels,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { FetchAgentTimelineOptions } from "@server/client/daemon-client";
|
||||
import {
|
||||
attachInitTimeout,
|
||||
createInitDeferred,
|
||||
@@ -9,10 +10,49 @@ import {
|
||||
} from "@/utils/agent-initialization";
|
||||
|
||||
const INIT_TIMEOUT_MS = 5 * 60_000;
|
||||
const DEFAULT_INITIAL_TIMELINE_LIMIT = 200;
|
||||
|
||||
type TimelineCursorState = {
|
||||
epoch: string;
|
||||
endSeq: number;
|
||||
};
|
||||
|
||||
function buildInitialTimelineRequest(
|
||||
cursor: TimelineCursorState | undefined
|
||||
): FetchAgentTimelineOptions {
|
||||
if (!cursor) {
|
||||
return {
|
||||
direction: "tail",
|
||||
limit: DEFAULT_INITIAL_TIMELINE_LIMIT,
|
||||
projection: "projected",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
direction: "after",
|
||||
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
|
||||
// Catch up all missing canonical rows by default.
|
||||
limit: 0,
|
||||
projection: "projected",
|
||||
};
|
||||
}
|
||||
|
||||
export function useAgentInitialization(serverId: string) {
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
|
||||
const setAgentInitializing = useCallback(
|
||||
(agentId: string, initializing: boolean) => {
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
if (prev.get(agentId) === initializing) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, initializing);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[serverId, setInitializingAgents]
|
||||
);
|
||||
|
||||
const ensureAgentIsInitialized = useCallback(
|
||||
(agentId: string): Promise<void> => {
|
||||
@@ -24,14 +64,7 @@ export function useAgentInitialization(serverId: string) {
|
||||
|
||||
const deferred = createInitDeferred(key);
|
||||
const timeoutId = setTimeout(() => {
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
if (prev.get(agentId) !== true) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
setAgentInitializing(agentId, false);
|
||||
rejectInitDeferred(
|
||||
key,
|
||||
new Error(
|
||||
@@ -41,34 +74,26 @@ export function useAgentInitialization(serverId: string) {
|
||||
}, INIT_TIMEOUT_MS);
|
||||
attachInitTimeout(key, timeoutId);
|
||||
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, true);
|
||||
return next;
|
||||
});
|
||||
setAgentInitializing(agentId, true);
|
||||
|
||||
if (!client) {
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
setAgentInitializing(agentId, false);
|
||||
rejectInitDeferred(key, new Error("Host is not connected"));
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const cursor = session?.agentTimelineCursor.get(agentId);
|
||||
const timelineRequest = buildInitialTimelineRequest(cursor);
|
||||
|
||||
client
|
||||
.initializeAgent(agentId)
|
||||
.fetchAgentTimeline(agentId, timelineRequest)
|
||||
.then(() => {
|
||||
// No-op: the actual "timeline hydrated" signal is the `agent_stream_snapshot`
|
||||
// message, handled in SessionContext.
|
||||
// No-op: hydration completion is handled by SessionContext
|
||||
// when it processes fetch_agent_timeline_response.
|
||||
})
|
||||
.catch((error) => {
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
});
|
||||
setAgentInitializing(agentId, false);
|
||||
rejectInitDeferred(
|
||||
key,
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
@@ -77,7 +102,7 @@ export function useAgentInitialization(serverId: string) {
|
||||
|
||||
return deferred.promise;
|
||||
},
|
||||
[client, serverId, setInitializingAgents]
|
||||
[client, serverId, setAgentInitializing]
|
||||
);
|
||||
|
||||
const refreshAgent = useCallback(
|
||||
@@ -85,24 +110,21 @@ export function useAgentInitialization(serverId: string) {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, true);
|
||||
return next;
|
||||
});
|
||||
setAgentInitializing(agentId, true);
|
||||
|
||||
try {
|
||||
await client.refreshAgent(agentId);
|
||||
} catch (error) {
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, false);
|
||||
return next;
|
||||
await client.fetchAgentTimeline(agentId, {
|
||||
direction: "tail",
|
||||
limit: DEFAULT_INITIAL_TIMELINE_LIMIT,
|
||||
projection: "projected",
|
||||
});
|
||||
} catch (error) {
|
||||
setAgentInitializing(agentId, false);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[client, serverId, setInitializingAgents]
|
||||
[client, setAgentInitializing]
|
||||
);
|
||||
|
||||
return { ensureAgentIsInitialized, refreshAgent };
|
||||
|
||||
@@ -57,7 +57,7 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
|
||||
const pendingPermissions = new Map();
|
||||
const agentLastActivity = new Map();
|
||||
|
||||
for (const snapshot of agentsList) {
|
||||
for (const { agent: snapshot } of agentsList.entries) {
|
||||
const agent = normalizeAgentSnapshot(snapshot, serverId);
|
||||
agents.set(agent.id, agent);
|
||||
agentLastActivity.set(agent.id, agent.lastActivityAt);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import type { AggregatedAgent, AggregatedAgentsResult } from "@/hooks/use-aggregated-agents";
|
||||
@@ -31,106 +30,72 @@ function toAggregatedAgent(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function useAllAgentsList(): AggregatedAgentsResult {
|
||||
export function useAllAgentsList(options?: {
|
||||
serverId?: string | null;
|
||||
}): AggregatedAgentsResult {
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const queryClient = useQueryClient();
|
||||
const serverId = useMemo(() => {
|
||||
const value = options?.serverId;
|
||||
return typeof value === "string" && value.trim().length > 0
|
||||
? value.trim()
|
||||
: null;
|
||||
}, [options?.serverId]);
|
||||
|
||||
const sessionClients = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const result: Record<
|
||||
string,
|
||||
NonNullable<typeof state.sessions[string]["client"]> | null
|
||||
> = {};
|
||||
for (const [serverId, session] of Object.entries(state.sessions)) {
|
||||
result[serverId] = session.client ?? null;
|
||||
const session = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId] : undefined
|
||||
);
|
||||
const client = session?.client ?? null;
|
||||
const isConnected = session?.connection.isConnected ?? false;
|
||||
const liveAgents = session?.agents ?? null;
|
||||
const canFetch = Boolean(serverId && client && isConnected);
|
||||
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: ["allAgents", serverId] as const,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
const sessionConnections = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const result: Record<string, boolean> = {};
|
||||
for (const [serverId, session] of Object.entries(state.sessions)) {
|
||||
result[serverId] = session.connection.isConnected;
|
||||
}
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
const liveAgents = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const result: Record<string, Map<string, Agent> | undefined> = {};
|
||||
for (const [serverId, session] of Object.entries(state.sessions)) {
|
||||
result[serverId] = session.agents;
|
||||
}
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
const serverEntries = useMemo(
|
||||
() =>
|
||||
Object.keys(sessionClients).map((serverId) => ({
|
||||
serverId,
|
||||
client: sessionClients[serverId] ?? null,
|
||||
isConnected: sessionConnections[serverId] ?? false,
|
||||
})),
|
||||
[sessionClients, sessionConnections]
|
||||
);
|
||||
|
||||
const queries = useQueries({
|
||||
queries: serverEntries.map(({ serverId, client, isConnected }) => ({
|
||||
queryKey: ["allAgents", serverId] as const,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
return await client.fetchAgents();
|
||||
},
|
||||
enabled: Boolean(client) && isConnected,
|
||||
staleTime: ALL_AGENTS_STALE_TIME,
|
||||
refetchOnMount: "always" as const,
|
||||
})),
|
||||
return await client.fetchAgents({
|
||||
filter: { labels: { ui: "true" } },
|
||||
});
|
||||
},
|
||||
enabled: canFetch,
|
||||
staleTime: ALL_AGENTS_STALE_TIME,
|
||||
refetchOnMount: "always" as const,
|
||||
});
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
for (const { serverId } of serverEntries) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["allAgents", serverId],
|
||||
});
|
||||
if (!serverId) {
|
||||
return;
|
||||
}
|
||||
}, [queryClient, serverEntries]);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["allAgents", serverId],
|
||||
});
|
||||
}, [queryClient, serverId]);
|
||||
|
||||
const agents = useMemo(() => {
|
||||
const all: AggregatedAgent[] = [];
|
||||
if (!serverId) {
|
||||
return [];
|
||||
}
|
||||
const data = agentsQuery.data?.entries ?? [];
|
||||
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
|
||||
const list: AggregatedAgent[] = [];
|
||||
|
||||
for (let idx = 0; idx < serverEntries.length; idx++) {
|
||||
const entry = serverEntries[idx];
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
const data = queries[idx]?.data;
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
const serverLabel =
|
||||
connectionStates.get(entry.serverId)?.daemon.label ?? entry.serverId;
|
||||
const liveById = liveAgents[entry.serverId];
|
||||
|
||||
for (const snapshot of data) {
|
||||
const normalized = normalizeAgentSnapshot(snapshot, entry.serverId);
|
||||
const live = liveById?.get(snapshot.id);
|
||||
all.push(
|
||||
toAggregatedAgent({
|
||||
source: live ?? normalized,
|
||||
serverId: entry.serverId,
|
||||
serverLabel,
|
||||
})
|
||||
);
|
||||
}
|
||||
for (const entry of data) {
|
||||
const snapshot = entry.agent;
|
||||
const normalized = normalizeAgentSnapshot(snapshot, serverId);
|
||||
const live = liveAgents?.get(snapshot.id);
|
||||
list.push(
|
||||
toAggregatedAgent({
|
||||
source: live ?? normalized,
|
||||
serverId,
|
||||
serverLabel,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
all.sort((left, right) => {
|
||||
list.sort((left, right) => {
|
||||
const leftRunning = left.status === "running";
|
||||
const rightRunning = right.status === "running";
|
||||
if (leftRunning && !rightRunning) {
|
||||
@@ -142,10 +107,11 @@ export function useAllAgentsList(): AggregatedAgentsResult {
|
||||
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
|
||||
});
|
||||
|
||||
return all;
|
||||
}, [serverEntries, queries, connectionStates, liveAgents]);
|
||||
return list;
|
||||
}, [agentsQuery.data, connectionStates, liveAgents, serverId]);
|
||||
|
||||
const isFetching = queries.some((query) => query.isPending || query.isFetching);
|
||||
const isFetching =
|
||||
canFetch && (agentsQuery.isPending || agentsQuery.isFetching);
|
||||
const isInitialLoad = isFetching && agents.length === 0;
|
||||
const isRevalidating = isFetching && agents.length > 0;
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ export function useCheckoutPrStatusQuery({
|
||||
|
||||
return {
|
||||
status: query.data?.status ?? null,
|
||||
githubFeaturesEnabled: query.data?.githubFeaturesEnabled ?? true,
|
||||
payloadError: query.data?.error ?? null,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { TextInput } from "react-native";
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
@@ -17,6 +17,12 @@ import {
|
||||
buildNewAgentRoute,
|
||||
resolveNewAgentWorkingDir,
|
||||
} from "@/utils/new-agent-routing";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostSettingsRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseServerIdFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
|
||||
@@ -46,19 +52,17 @@ function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
||||
}
|
||||
|
||||
function parseAgentKeyFromPathname(pathname: string): string | null {
|
||||
const match = pathname.match(/^\/agent\/([^/]+)\/([^/]+)/);
|
||||
if (!match) return null;
|
||||
return `${match[1]}:${match[2]}`;
|
||||
const match = parseHostAgentRouteFromPathname(pathname);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return `${match.serverId}:${match.agentId}`;
|
||||
}
|
||||
|
||||
function parseAgentRouteFromPathname(
|
||||
pathname: string
|
||||
): { serverId: string; agentId: string } | null {
|
||||
const match = pathname.match(/^\/agent\/([^/]+)\/([^/]+)/);
|
||||
if (!match) return null;
|
||||
const [, serverId, agentId] = match;
|
||||
if (!serverId || !agentId) return null;
|
||||
return { serverId, agentId };
|
||||
return parseHostAgentRouteFromPathname(pathname);
|
||||
}
|
||||
|
||||
type CommandCenterActionDefinition = {
|
||||
@@ -67,7 +71,7 @@ type CommandCenterActionDefinition = {
|
||||
icon?: "plus" | "settings";
|
||||
shortcutKeys?: ShortcutKey[];
|
||||
keywords: string[];
|
||||
buildRoute: (params: { newAgentRoute: string }) => string;
|
||||
buildRoute: (params: { newAgentRoute: string; settingsRoute: string }) => string;
|
||||
};
|
||||
|
||||
const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
|
||||
@@ -84,7 +88,7 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
|
||||
title: "Settings",
|
||||
icon: "settings",
|
||||
keywords: ["settings", "preferences", "config", "configuration"],
|
||||
buildRoute: () => "/settings",
|
||||
buildRoute: ({ settingsRoute }) => settingsRoute,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -122,9 +126,11 @@ export type CommandCenterItem =
|
||||
export function useCommandCenter() {
|
||||
const pathname = usePathname();
|
||||
const { agents } = useAggregatedAgents();
|
||||
const open = useKeyboardNavStore((s) => s.commandCenterOpen);
|
||||
const setOpen = useKeyboardNavStore((s) => s.setCommandCenterOpen);
|
||||
const requestFocusChatInput = useKeyboardNavStore((s) => s.requestFocusChatInput);
|
||||
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
|
||||
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
|
||||
const requestMessageInputAction = useKeyboardShortcutsStore(
|
||||
(s) => s.requestMessageInputAction
|
||||
);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const didNavigateRef = useRef(false);
|
||||
const prevOpenRef = useRef(open);
|
||||
@@ -137,22 +143,26 @@ export function useCommandCenter() {
|
||||
return filtered;
|
||||
}, [agents, query]);
|
||||
|
||||
const fallbackServerId = agents[0]?.serverId ?? null;
|
||||
|
||||
const agentKeyFromPathname = useMemo(
|
||||
() => parseAgentKeyFromPathname(pathname),
|
||||
[pathname]
|
||||
);
|
||||
|
||||
const newAgentRoute = useMemo(() => {
|
||||
const serverIdFromPath =
|
||||
parseServerIdFromPathname(pathname) ?? fallbackServerId;
|
||||
const routeAgent = parseAgentRouteFromPathname(pathname);
|
||||
if (!routeAgent) {
|
||||
return "/agent";
|
||||
return serverIdFromPath ? buildNewAgentRoute(serverIdFromPath) : "/";
|
||||
}
|
||||
|
||||
const { serverId, agentId } = routeAgent;
|
||||
const currentAgent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId);
|
||||
const cwd = currentAgent?.cwd?.trim();
|
||||
if (!cwd) {
|
||||
return "/agent";
|
||||
return buildNewAgentRoute(serverId);
|
||||
}
|
||||
|
||||
const checkout =
|
||||
@@ -160,8 +170,14 @@ export function useCommandCenter() {
|
||||
checkoutStatusQueryKey(serverId, cwd)
|
||||
) ?? null;
|
||||
const workingDir = resolveNewAgentWorkingDir(cwd, checkout);
|
||||
return buildNewAgentRoute(workingDir);
|
||||
}, [pathname]);
|
||||
return buildNewAgentRoute(serverId, workingDir);
|
||||
}, [fallbackServerId, pathname]);
|
||||
|
||||
const settingsRoute = useMemo(() => {
|
||||
const serverIdFromPath =
|
||||
parseServerIdFromPathname(pathname) ?? fallbackServerId;
|
||||
return serverIdFromPath ? buildHostSettingsRoute(serverIdFromPath) : "/";
|
||||
}, [fallbackServerId, pathname]);
|
||||
|
||||
const actionItems = useMemo(() => {
|
||||
return COMMAND_CENTER_ACTIONS.filter((action) =>
|
||||
@@ -171,10 +187,10 @@ export function useCommandCenter() {
|
||||
id: action.id,
|
||||
title: action.title,
|
||||
icon: action.icon,
|
||||
route: action.buildRoute({ newAgentRoute }),
|
||||
route: action.buildRoute({ newAgentRoute, settingsRoute }),
|
||||
shortcutKeys: action.shortcutKeys,
|
||||
}));
|
||||
}, [newAgentRoute, query]);
|
||||
}, [newAgentRoute, query, settingsRoute]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const next: CommandCenterItem[] = [];
|
||||
@@ -200,19 +216,19 @@ export function useCommandCenter() {
|
||||
const handleSelectAgent = useCallback(
|
||||
(agent: AggregatedAgent) => {
|
||||
didNavigateRef.current = true;
|
||||
const session = useSessionStore.getState().sessions[agent.serverId];
|
||||
session?.client?.clearAgentAttention(agent.id);
|
||||
|
||||
const shouldReplace = pathname.startsWith("/agent/");
|
||||
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
|
||||
const navigate = shouldReplace ? router.replace : router.push;
|
||||
|
||||
requestFocusChatInput(agentKey(agent));
|
||||
requestMessageInputAction({
|
||||
agentKey: agentKey(agent),
|
||||
kind: "focus",
|
||||
});
|
||||
// Don't restore focus back to the prior element after we navigate.
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
setOpen(false);
|
||||
navigate(`/agent/${agent.serverId}/${agent.id}` as any);
|
||||
navigate(buildHostAgentDetailRoute(agent.serverId, agent.id) as any);
|
||||
},
|
||||
[pathname, requestFocusChatInput, setOpen]
|
||||
[pathname, requestMessageInputAction, setOpen]
|
||||
);
|
||||
|
||||
const handleSelectAction = useCallback((action: CommandCenterActionItem) => {
|
||||
@@ -253,7 +269,10 @@ export function useCommandCenter() {
|
||||
isFocused,
|
||||
onTimeout: () => {
|
||||
if (agentKeyFromPathname) {
|
||||
requestFocusChatInput(agentKeyFromPathname);
|
||||
requestMessageInputAction({
|
||||
agentKey: agentKeyFromPathname,
|
||||
kind: "focus",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -269,7 +288,7 @@ export function useCommandCenter() {
|
||||
inputRef.current?.focus();
|
||||
}, 0);
|
||||
return () => clearTimeout(id);
|
||||
}, [agentKeyFromPathname, open, requestFocusChatInput]);
|
||||
}, [agentKeyFromPathname, open, requestMessageInputAction]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
@@ -159,9 +159,11 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
if (!isRecordingRef.current) {
|
||||
return;
|
||||
}
|
||||
void startNewStream("reconnect");
|
||||
void startNewStream("reconnect").catch((error) => {
|
||||
reportError(error, "Failed to restart dictation stream after reconnect");
|
||||
});
|
||||
});
|
||||
}, [client, startNewStream]);
|
||||
}, [client, reportError, startNewStream]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client) {
|
||||
@@ -260,7 +262,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
try {
|
||||
await audio.start();
|
||||
if (client?.isConnected) {
|
||||
void startNewStream("start");
|
||||
await startNewStream("start");
|
||||
}
|
||||
isRecordingRef.current = true;
|
||||
setIsRecording(true);
|
||||
@@ -268,9 +270,11 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
startDurationTracking();
|
||||
}
|
||||
} catch (err) {
|
||||
await audio.stop().catch(() => undefined);
|
||||
stopDurationTracking();
|
||||
isRecordingRef.current = false;
|
||||
setIsRecording(false);
|
||||
setStatus("idle");
|
||||
reportError(err, "Failed to start dictation");
|
||||
} finally {
|
||||
actionGateRef.current.starting = false;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { getIsTauriMac } from "@/constants/layout";
|
||||
import { useAggregatedAgents } from "./use-aggregated-agents";
|
||||
|
||||
type FaviconStatus = "none" | "running" | "attention";
|
||||
@@ -34,6 +35,21 @@ function deriveFaviconStatus(
|
||||
return "none";
|
||||
}
|
||||
|
||||
function deriveMacDockBadgeCount(
|
||||
agents: ReturnType<typeof useAggregatedAgents>["agents"]
|
||||
): number | undefined {
|
||||
const attentionCount = agents.filter(
|
||||
(agent) =>
|
||||
agent.requiresAttention &&
|
||||
(agent.attentionReason === "permission" || agent.attentionReason === "finished")
|
||||
).length;
|
||||
if (attentionCount > 0) {
|
||||
return attentionCount;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getFaviconUri(status: FaviconStatus, colorScheme: ColorScheme): string {
|
||||
const image = FAVICON_IMAGES[colorScheme][status];
|
||||
if (typeof image === "object" && "uri" in image) {
|
||||
@@ -73,9 +89,25 @@ function getSystemColorScheme(): ColorScheme {
|
||||
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() {
|
||||
const { agents } = useAggregatedAgents();
|
||||
const [colorScheme, setColorScheme] = useState<ColorScheme>(getSystemColorScheme);
|
||||
const lastDockBadgeCountRef = useRef<number | undefined>(undefined);
|
||||
|
||||
// Listen for system color scheme changes
|
||||
useEffect(() => {
|
||||
@@ -96,5 +128,11 @@ export function useFaviconStatus() {
|
||||
|
||||
const status = deriveFaviconStatus(agents);
|
||||
updateFavicon(status, colorScheme);
|
||||
|
||||
const dockBadgeCount = deriveMacDockBadgeCount(agents);
|
||||
if (dockBadgeCount !== lastDockBadgeCountRef.current) {
|
||||
lastDockBadgeCountRef.current = dockBadgeCount;
|
||||
void updateMacDockBadge(dockBadgeCount);
|
||||
}
|
||||
}, [agents, colorScheme]);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
|
||||
@@ -13,11 +13,75 @@ interface UseFileDropZoneReturn {
|
||||
}
|
||||
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".svg": "image/svg+xml",
|
||||
".heic": "image/heic",
|
||||
".heif": "image/heif",
|
||||
".avif": "image/avif",
|
||||
".tif": "image/tiff",
|
||||
".tiff": "image/tiff",
|
||||
};
|
||||
|
||||
type TauriDragDropPayload =
|
||||
| {
|
||||
type: "enter";
|
||||
paths: string[];
|
||||
}
|
||||
| {
|
||||
type: "over";
|
||||
}
|
||||
| {
|
||||
type: "drop";
|
||||
paths: string[];
|
||||
}
|
||||
| {
|
||||
type: "leave";
|
||||
};
|
||||
|
||||
type TauriDragDropEvent = {
|
||||
payload: TauriDragDropPayload;
|
||||
};
|
||||
|
||||
function isImageFile(file: File): boolean {
|
||||
return file.type.startsWith("image/");
|
||||
}
|
||||
|
||||
function 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> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -62,78 +126,167 @@ export function useFileDropZone({
|
||||
useEffect(() => {
|
||||
if (!IS_WEB) return;
|
||||
|
||||
const element = containerRef.current;
|
||||
if (!element) return;
|
||||
let disposed = false;
|
||||
let cleanup: (() => void) | undefined;
|
||||
let didCleanup = false;
|
||||
|
||||
function handleDragEnter(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
dragCounterRef.current++;
|
||||
if (e.dataTransfer?.types.includes("Files")) {
|
||||
setIsDragging(true);
|
||||
function runCleanup(unlisten?: () => void | Promise<void>) {
|
||||
if (didCleanup) return;
|
||||
const cleanupFn = unlisten ?? cleanup;
|
||||
if (!cleanupFn) return;
|
||||
didCleanup = true;
|
||||
try {
|
||||
void Promise.resolve(cleanupFn()).catch((error) => {
|
||||
console.warn("[useFileDropZone] Failed to remove Tauri drag-drop listener:", error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[useFileDropZone] Failed to remove Tauri drag-drop listener:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
async function setupTauriDragDrop(): Promise<boolean> {
|
||||
if (!isTauriEnvironment()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
dragCounterRef.current--;
|
||||
if (dragCounterRef.current === 0) {
|
||||
setIsDragging(false);
|
||||
const tauriWindow = (window as any).__TAURI__?.window?.getCurrentWindow?.();
|
||||
if (!tauriWindow || typeof tauriWindow.onDragDropEvent !== "function") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setIsDragging(false);
|
||||
dragCounterRef.current = 0;
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
const imageFiles = files.filter(isImageFile);
|
||||
|
||||
if (imageFiles.length === 0) return;
|
||||
|
||||
try {
|
||||
const attachments = await Promise.all(
|
||||
imageFiles.map(fileToImageAttachment)
|
||||
const unlisten = await tauriWindow.onDragDropEvent(
|
||||
(event: TauriDragDropEvent) => {
|
||||
const payload = event.payload;
|
||||
if (payload.type === "leave") {
|
||||
setIsDragging(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === "enter" || payload.type === "over") {
|
||||
if (!disabled) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Drop always ends the current drag operation.
|
||||
setIsDragging(false);
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const imagePaths = payload.paths.filter(isImagePath);
|
||||
if (imagePaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attachments = imagePaths.map(filePathToImageAttachment);
|
||||
onFilesDroppedRef.current(attachments);
|
||||
}
|
||||
);
|
||||
onFilesDroppedRef.current(attachments);
|
||||
|
||||
if (disposed) {
|
||||
runCleanup(unlisten);
|
||||
return true;
|
||||
}
|
||||
|
||||
cleanup = unlisten;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[useFileDropZone] Failed to process dropped files:", error);
|
||||
console.warn("[useFileDropZone] Failed to listen for Tauri drag-drop:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
element.addEventListener("dragenter", handleDragEnter);
|
||||
element.addEventListener("dragover", handleDragOver);
|
||||
element.addEventListener("dragleave", handleDragLeave);
|
||||
element.addEventListener("drop", handleDrop);
|
||||
function setupDomDragDrop() {
|
||||
const element = containerRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
function handleDragEnter(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
dragCounterRef.current++;
|
||||
if (e.dataTransfer?.types.includes("Files")) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
dragCounterRef.current--;
|
||||
if (dragCounterRef.current === 0) {
|
||||
setIsDragging(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setIsDragging(false);
|
||||
dragCounterRef.current = 0;
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
const imageFiles = files.filter(isImageFile);
|
||||
|
||||
if (imageFiles.length === 0) return;
|
||||
|
||||
try {
|
||||
const attachments = await Promise.all(
|
||||
imageFiles.map(fileToImageAttachment)
|
||||
);
|
||||
onFilesDroppedRef.current(attachments);
|
||||
} catch (error) {
|
||||
console.error("[useFileDropZone] Failed to process dropped files:", error);
|
||||
}
|
||||
}
|
||||
|
||||
element.addEventListener("dragenter", handleDragEnter);
|
||||
element.addEventListener("dragover", handleDragOver);
|
||||
element.addEventListener("dragleave", handleDragLeave);
|
||||
element.addEventListener("drop", handleDrop);
|
||||
|
||||
cleanup = () => {
|
||||
element.removeEventListener("dragenter", handleDragEnter);
|
||||
element.removeEventListener("dragover", handleDragOver);
|
||||
element.removeEventListener("dragleave", handleDragLeave);
|
||||
element.removeEventListener("drop", handleDrop);
|
||||
};
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const tauriListenersAttached = await setupTauriDragDrop();
|
||||
if (disposed || tauriListenersAttached) {
|
||||
return;
|
||||
}
|
||||
setupDomDragDrop();
|
||||
})();
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("dragenter", handleDragEnter);
|
||||
element.removeEventListener("dragover", handleDragOver);
|
||||
element.removeEventListener("dragleave", handleDragLeave);
|
||||
element.removeEventListener("drop", handleDrop);
|
||||
disposed = true;
|
||||
runCleanup();
|
||||
};
|
||||
}, [disabled]);
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export function useFileExplorerActions(serverId: string) {
|
||||
);
|
||||
|
||||
const requestDirectoryListing = useCallback(
|
||||
(
|
||||
async (
|
||||
agentId: string,
|
||||
path: string,
|
||||
options?: { recordHistory?: boolean; setCurrentPath?: boolean }
|
||||
@@ -77,42 +77,40 @@ export function useFileExplorerActions(serverId: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
void client
|
||||
.exploreFileSystem(agentId, normalizedPath, "list")
|
||||
.then((payload) => {
|
||||
updateExplorerState(agentId, (state) => {
|
||||
const nextState: AgentFileExplorerState = {
|
||||
...state,
|
||||
isLoading: false,
|
||||
lastError: payload.error ?? null,
|
||||
pendingRequest: null,
|
||||
directories: state.directories,
|
||||
files: state.files,
|
||||
};
|
||||
|
||||
if (!payload.error && payload.directory) {
|
||||
const directories = new Map(state.directories);
|
||||
directories.set(payload.directory.path, payload.directory);
|
||||
nextState.directories = directories;
|
||||
}
|
||||
|
||||
return nextState;
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
updateExplorerState(agentId, (state) => ({
|
||||
try {
|
||||
const payload = await client.exploreFileSystem(agentId, normalizedPath, "list");
|
||||
updateExplorerState(agentId, (state) => {
|
||||
const nextState: AgentFileExplorerState = {
|
||||
...state,
|
||||
isLoading: false,
|
||||
lastError: error instanceof Error ? error.message : "Failed to list directory",
|
||||
lastError: payload.error ?? null,
|
||||
pendingRequest: null,
|
||||
}));
|
||||
directories: state.directories,
|
||||
files: state.files,
|
||||
};
|
||||
|
||||
if (!payload.error && payload.directory) {
|
||||
const directories = new Map(state.directories);
|
||||
directories.set(payload.directory.path, payload.directory);
|
||||
nextState.directories = directories;
|
||||
}
|
||||
|
||||
return nextState;
|
||||
});
|
||||
} catch (error) {
|
||||
updateExplorerState(agentId, (state) => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
lastError: error instanceof Error ? error.message : "Failed to list directory",
|
||||
pendingRequest: null,
|
||||
}));
|
||||
}
|
||||
},
|
||||
[client, updateExplorerState]
|
||||
);
|
||||
|
||||
const requestFilePreview = useCallback(
|
||||
(agentId: string, path: string) => {
|
||||
async (agentId: string, path: string) => {
|
||||
const normalizedPath = path && path.length > 0 ? path : ".";
|
||||
updateExplorerState(agentId, (state) => ({
|
||||
...state,
|
||||
@@ -131,36 +129,34 @@ export function useFileExplorerActions(serverId: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
void client
|
||||
.exploreFileSystem(agentId, normalizedPath, "file")
|
||||
.then((payload) => {
|
||||
updateExplorerState(agentId, (state) => {
|
||||
const nextState: AgentFileExplorerState = {
|
||||
...state,
|
||||
isLoading: false,
|
||||
pendingRequest: null,
|
||||
directories: state.directories,
|
||||
files: state.files,
|
||||
};
|
||||
|
||||
if (!payload.error && payload.file) {
|
||||
const files = new Map(state.files);
|
||||
files.set(payload.file.path, payload.file);
|
||||
nextState.files = files;
|
||||
} else if (payload.error) {
|
||||
nextState.lastError = payload.error;
|
||||
}
|
||||
|
||||
return nextState;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
updateExplorerState(agentId, (state) => ({
|
||||
try {
|
||||
const payload = await client.exploreFileSystem(agentId, normalizedPath, "file");
|
||||
updateExplorerState(agentId, (state) => {
|
||||
const nextState: AgentFileExplorerState = {
|
||||
...state,
|
||||
isLoading: false,
|
||||
pendingRequest: null,
|
||||
}));
|
||||
directories: state.directories,
|
||||
files: state.files,
|
||||
};
|
||||
|
||||
if (!payload.error && payload.file) {
|
||||
const files = new Map(state.files);
|
||||
files.set(payload.file.path, payload.file);
|
||||
nextState.files = files;
|
||||
} else if (payload.error) {
|
||||
nextState.lastError = payload.error;
|
||||
}
|
||||
|
||||
return nextState;
|
||||
});
|
||||
} catch {
|
||||
updateExplorerState(agentId, (state) => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
pendingRequest: null,
|
||||
}));
|
||||
}
|
||||
},
|
||||
[client, updateExplorerState]
|
||||
);
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
|
||||
const GIT_DIFF_STALE_TIME = 30_000;
|
||||
|
||||
function gitDiffQueryKey(serverId: string, agentId: string) {
|
||||
return ["gitDiff", serverId, agentId] as const;
|
||||
}
|
||||
|
||||
interface UseGitDiffQueryOptions {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export function useGitDiffQuery({ serverId, agentId }: UseGitDiffQueryOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
const client = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.client ?? null
|
||||
);
|
||||
const isConnected = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
||||
);
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: gitDiffQueryKey(serverId, agentId),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
const response = await client.getGitDiff(agentId);
|
||||
return response.diff;
|
||||
},
|
||||
enabled: !!client && isConnected && !!agentId,
|
||||
staleTime: GIT_DIFF_STALE_TIME,
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
// Revalidate when sidebar opens with "changes" tab active
|
||||
useEffect(() => {
|
||||
if (!isOpen || explorerTab !== "changes" || !agentId) {
|
||||
return;
|
||||
}
|
||||
// Invalidate to trigger background refetch (shows stale data while fetching)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: gitDiffQueryKey(serverId, agentId),
|
||||
});
|
||||
}, [isOpen, explorerTab, serverId, agentId, queryClient]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
return query.refetch();
|
||||
}, [query]);
|
||||
|
||||
return {
|
||||
diff: query.data ?? null,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { usePathname, useRouter } from "expo-router";
|
||||
import { getIsTauri } from "@/constants/layout";
|
||||
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts";
|
||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import {
|
||||
checkoutStatusQueryKey,
|
||||
type CheckoutStatusPayload,
|
||||
} from "@/hooks/use-checkout-status-query";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
buildNewAgentRoute,
|
||||
resolveNewAgentWorkingDir,
|
||||
} from "@/utils/new-agent-routing";
|
||||
|
||||
export function useGlobalKeyboardNav({
|
||||
enabled,
|
||||
isMobile,
|
||||
toggleAgentList,
|
||||
selectedAgentId,
|
||||
toggleFileExplorer,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
isMobile: boolean;
|
||||
toggleAgentList: () => void;
|
||||
selectedAgentId?: string;
|
||||
toggleFileExplorer?: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const resetModifiers = useKeyboardNavStore((s) => s.resetModifiers);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (Platform.OS !== "web") return;
|
||||
if (isMobile) return;
|
||||
|
||||
const isTauri = getIsTauri();
|
||||
const shouldHandle = () => {
|
||||
if (typeof document === "undefined") return false;
|
||||
if (document.visibilityState !== "visible") return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const isEditableTarget = (event: KeyboardEvent): boolean => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return false;
|
||||
|
||||
if ((target as HTMLElement).isContentEditable) return true;
|
||||
const tag = target.tagName.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea") return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const parseShortcutDigit = (event: KeyboardEvent): number | null => {
|
||||
const code = event.code ?? "";
|
||||
if (code.startsWith("Digit")) {
|
||||
const n = Number(code.slice("Digit".length));
|
||||
return Number.isFinite(n) && n >= 1 && n <= 9 ? n : null;
|
||||
}
|
||||
if (code.startsWith("Numpad")) {
|
||||
const n = Number(code.slice("Numpad".length));
|
||||
return Number.isFinite(n) && n >= 1 && n <= 9 ? n : null;
|
||||
}
|
||||
const key = event.key ?? "";
|
||||
if (key >= "1" && key <= "9") {
|
||||
return Number(key);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const navigateToSidebarShortcut = (digit: number) => {
|
||||
const state = useKeyboardNavStore.getState();
|
||||
const targetKey = state.sidebarShortcutAgentKeys[digit - 1] ?? null;
|
||||
if (!targetKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseSidebarAgentKey(targetKey);
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
const { serverId, agentId } = parsed;
|
||||
|
||||
const shouldReplace = pathname.startsWith("/agent/");
|
||||
const navigate = shouldReplace ? router.replace : router.push;
|
||||
navigate(`/agent/${serverId}/${agentId}` as any);
|
||||
};
|
||||
|
||||
const navigateToNewAgent = () => {
|
||||
let target = "/agent";
|
||||
if (selectedAgentId) {
|
||||
const separatorIndex = selectedAgentId.indexOf(":");
|
||||
if (separatorIndex > 0) {
|
||||
const serverId = selectedAgentId.slice(0, separatorIndex);
|
||||
const agentId = selectedAgentId.slice(separatorIndex + 1);
|
||||
const agent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId);
|
||||
const cwd = agent?.cwd?.trim();
|
||||
if (cwd) {
|
||||
const checkout =
|
||||
queryClient.getQueryData<CheckoutStatusPayload>(
|
||||
checkoutStatusQueryKey(serverId, cwd)
|
||||
) ?? null;
|
||||
const workingDir = resolveNewAgentWorkingDir(cwd, checkout);
|
||||
target = buildNewAgentRoute(workingDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
router.push(target as any);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!shouldHandle()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key ?? "";
|
||||
const lowerKey = key.toLowerCase();
|
||||
|
||||
if (key === "Alt" && !event.shiftKey) {
|
||||
useKeyboardNavStore.getState().setAltDown(true);
|
||||
}
|
||||
if (isTauri && (key === "Meta" || key === "Control") && !event.shiftKey) {
|
||||
useKeyboardNavStore.getState().setCmdOrCtrlDown(true);
|
||||
}
|
||||
// If shift is pressed while a modifier is held, hide the badges
|
||||
if (key === "Shift") {
|
||||
const state = useKeyboardNavStore.getState();
|
||||
if (state.altDown || state.cmdOrCtrlDown) {
|
||||
state.resetModifiers();
|
||||
}
|
||||
}
|
||||
|
||||
const isMod = event.metaKey || event.ctrlKey;
|
||||
const isKeyN = event.code === "KeyN" || lowerKey === "n";
|
||||
|
||||
// Cmd/Ctrl+Alt+N: new agent (web + Tauri)
|
||||
// Note: intentionally works even when focus is inside an input/textarea.
|
||||
if (isMod && event.altKey && !event.shiftKey && isKeyN) {
|
||||
event.preventDefault();
|
||||
navigateToNewAgent();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+N: new agent (Tauri only)
|
||||
// Note: intentionally works even when focus is inside an input/textarea.
|
||||
if (isTauri && isMod && !event.altKey && !event.shiftKey && isKeyN) {
|
||||
event.preventDefault();
|
||||
navigateToNewAgent();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+B: toggle sidebar
|
||||
if (
|
||||
isMod &&
|
||||
(event.code === "KeyB" || lowerKey === "b")
|
||||
) {
|
||||
// The MessageInput already handles Cmd+B inside editable fields. If we also
|
||||
// handle it globally, it can double-toggle and look like it "doesn't work".
|
||||
if (isEditableTarget(event)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
toggleAgentList();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+.: toggle sidebar (VS Code quick-fix muscle memory)
|
||||
// Note: intentionally works even when focus is inside an input/textarea.
|
||||
if (
|
||||
isMod &&
|
||||
(event.code === "Period" || key === ".")
|
||||
) {
|
||||
// Ignore while command center is open.
|
||||
if (useKeyboardNavStore.getState().commandCenterOpen) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
toggleAgentList();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+E: toggle explorer sidebar (only when an agent is selected)
|
||||
if (
|
||||
selectedAgentId &&
|
||||
toggleFileExplorer &&
|
||||
isMod &&
|
||||
(event.code === "KeyE" || lowerKey === "e")
|
||||
) {
|
||||
// Same double-toggle issue as Cmd+B when focus is inside a text input.
|
||||
if (isEditableTarget(event)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
toggleFileExplorer();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+`: toggle explorer sidebar (VS Code muscle memory)
|
||||
// Note: intentionally works even when focus is inside an input/textarea.
|
||||
if (
|
||||
selectedAgentId &&
|
||||
toggleFileExplorer &&
|
||||
event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
(event.code === "Backquote" || key === "`")
|
||||
) {
|
||||
// Ignore while command center is open.
|
||||
if (useKeyboardNavStore.getState().commandCenterOpen) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
toggleFileExplorer();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+K: command center
|
||||
if (isMod && lowerKey === "k") {
|
||||
event.preventDefault();
|
||||
const s = useKeyboardNavStore.getState();
|
||||
if (!s.commandCenterOpen) {
|
||||
const target =
|
||||
event.target instanceof Element ? (event.target as Element) : null;
|
||||
const targetEl =
|
||||
target?.closest?.("textarea, input, [contenteditable='true']") ??
|
||||
(target instanceof HTMLElement ? target : null);
|
||||
const active = document.activeElement;
|
||||
const activeEl = active instanceof HTMLElement ? active : null;
|
||||
setCommandCenterFocusRestoreElement(
|
||||
(targetEl as HTMLElement | null) ?? activeEl ?? null
|
||||
);
|
||||
}
|
||||
s.setCommandCenterOpen(!s.commandCenterOpen);
|
||||
return;
|
||||
}
|
||||
|
||||
// Number switching: ignore while command center is open.
|
||||
if (useKeyboardNavStore.getState().commandCenterOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const digit = parseShortcutDigit(event);
|
||||
if (!digit) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Alt/Option+number: always (web + Tauri)
|
||||
if (event.altKey) {
|
||||
event.preventDefault();
|
||||
navigateToSidebarShortcut(digit);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+number: Tauri only (avoid browser tab switching)
|
||||
if (isTauri && isMod) {
|
||||
event.preventDefault();
|
||||
navigateToSidebarShortcut(digit);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
const key = event.key ?? "";
|
||||
if (key === "Alt") {
|
||||
useKeyboardNavStore.getState().setAltDown(false);
|
||||
}
|
||||
if (isTauri && (key === "Meta" || key === "Control")) {
|
||||
useKeyboardNavStore.getState().setCmdOrCtrlDown(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlurOrHide = () => {
|
||||
resetModifiers();
|
||||
};
|
||||
|
||||
// react-native-web can stop propagation on key events, so listen in capture phase.
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
window.addEventListener("keyup", handleKeyUp, true);
|
||||
window.addEventListener("blur", handleBlurOrHide);
|
||||
document.addEventListener("visibilitychange", handleBlurOrHide);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown, true);
|
||||
window.removeEventListener("keyup", handleKeyUp, true);
|
||||
window.removeEventListener("blur", handleBlurOrHide);
|
||||
document.removeEventListener("visibilitychange", handleBlurOrHide);
|
||||
};
|
||||
}, [
|
||||
enabled,
|
||||
isMobile,
|
||||
pathname,
|
||||
resetModifiers,
|
||||
router,
|
||||
selectedAgentId,
|
||||
toggleAgentList,
|
||||
toggleFileExplorer,
|
||||
]);
|
||||
}
|
||||
295
packages/app/src/hooks/use-keyboard-shortcuts.ts
Normal file
295
packages/app/src/hooks/use-keyboard-shortcuts.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { usePathname, useRouter } from "expo-router";
|
||||
import { getIsTauri } from "@/constants/layout";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts";
|
||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import {
|
||||
checkoutStatusQueryKey,
|
||||
type CheckoutStatusPayload,
|
||||
} from "@/hooks/use-checkout-status-query";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
buildNewAgentRoute,
|
||||
resolveSelectedAgentForNewAgent,
|
||||
resolveNewAgentWorkingDir,
|
||||
} from "@/utils/new-agent-routing";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
parseHostAgentDraftRouteFromPathname,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseServerIdFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
import {
|
||||
type MessageInputKeyboardActionKind,
|
||||
type KeyboardShortcutPayload,
|
||||
} from "@/keyboard/actions";
|
||||
import { resolveKeyboardShortcut } from "@/keyboard/keyboard-shortcuts";
|
||||
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
|
||||
function resolveSelectedOrRouteAgentKey(input: {
|
||||
selectedAgentId?: string;
|
||||
pathname: string;
|
||||
}): string | null {
|
||||
const DRAFT_AGENT_ID = "__new_agent__";
|
||||
if (input.selectedAgentId) {
|
||||
return input.selectedAgentId;
|
||||
}
|
||||
const route = parseHostAgentRouteFromPathname(input.pathname);
|
||||
if (!route) {
|
||||
const draftRoute = parseHostAgentDraftRouteFromPathname(input.pathname);
|
||||
if (!draftRoute) {
|
||||
return null;
|
||||
}
|
||||
return `${draftRoute.serverId}:${DRAFT_AGENT_ID}`;
|
||||
}
|
||||
return `${route.serverId}:${route.agentId}`;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts({
|
||||
enabled,
|
||||
isMobile,
|
||||
toggleAgentList,
|
||||
selectedAgentId,
|
||||
toggleFileExplorer,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
isMobile: boolean;
|
||||
toggleAgentList: () => void;
|
||||
selectedAgentId?: string;
|
||||
toggleFileExplorer?: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (Platform.OS !== "web") return;
|
||||
if (isMobile) return;
|
||||
|
||||
const isTauri = getIsTauri();
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
|
||||
const shouldHandle = () => {
|
||||
if (typeof document === "undefined") return false;
|
||||
if (document.visibilityState !== "visible") return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const navigateToSidebarShortcut = (digit: number): boolean => {
|
||||
const state = useKeyboardShortcutsStore.getState();
|
||||
const targetKey = state.sidebarShortcutAgentKeys[digit - 1] ?? null;
|
||||
if (!targetKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = parseSidebarAgentKey(targetKey);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
const { serverId, agentId } = parsed;
|
||||
|
||||
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
|
||||
const navigate = shouldReplace ? router.replace : router.push;
|
||||
navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
|
||||
return true;
|
||||
};
|
||||
|
||||
const navigateToNewAgent = (): boolean => {
|
||||
let targetServerId = parseServerIdFromPathname(pathname);
|
||||
let targetWorkingDir: string | null = null;
|
||||
const selectedAgent = resolveSelectedAgentForNewAgent({
|
||||
pathname,
|
||||
selectedAgentId,
|
||||
});
|
||||
if (selectedAgent) {
|
||||
targetServerId = selectedAgent.serverId;
|
||||
const agent = useSessionStore
|
||||
.getState()
|
||||
.sessions[selectedAgent.serverId]
|
||||
?.agents?.get(selectedAgent.agentId);
|
||||
const cwd = agent?.cwd?.trim();
|
||||
if (cwd) {
|
||||
const checkout =
|
||||
queryClient.getQueryData<CheckoutStatusPayload>(
|
||||
checkoutStatusQueryKey(selectedAgent.serverId, cwd)
|
||||
) ?? null;
|
||||
targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout);
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetServerId) {
|
||||
const sessionServerIds = Object.keys(useSessionStore.getState().sessions);
|
||||
targetServerId = sessionServerIds[0] ?? null;
|
||||
}
|
||||
|
||||
if (!targetServerId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any);
|
||||
return true;
|
||||
};
|
||||
|
||||
const requestMessageInputAction = (
|
||||
kind: MessageInputKeyboardActionKind
|
||||
): boolean => {
|
||||
const agentKey = resolveSelectedOrRouteAgentKey({ selectedAgentId, pathname });
|
||||
if (!agentKey) {
|
||||
return false;
|
||||
}
|
||||
useKeyboardShortcutsStore.getState().requestMessageInputAction({
|
||||
agentKey,
|
||||
kind,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleAction = (input: {
|
||||
action: string;
|
||||
payload: KeyboardShortcutPayload;
|
||||
event: KeyboardEvent;
|
||||
}): boolean => {
|
||||
switch (input.action) {
|
||||
case "agent.new":
|
||||
return navigateToNewAgent();
|
||||
case "sidebar.toggle.left":
|
||||
toggleAgentList();
|
||||
return true;
|
||||
case "sidebar.toggle.right":
|
||||
if (!selectedAgentId || !toggleFileExplorer) {
|
||||
return false;
|
||||
}
|
||||
toggleFileExplorer();
|
||||
return true;
|
||||
case "sidebar.navigate.shortcut":
|
||||
if (!input.payload || typeof input.payload !== "object" || !("digit" in input.payload)) {
|
||||
return false;
|
||||
}
|
||||
return navigateToSidebarShortcut(input.payload.digit);
|
||||
case "command-center.toggle": {
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
if (!store.commandCenterOpen) {
|
||||
const target =
|
||||
input.event.target instanceof Element ? (input.event.target as Element) : null;
|
||||
const targetEl =
|
||||
target?.closest?.("textarea, input, [contenteditable='true']") ??
|
||||
(target instanceof HTMLElement ? target : null);
|
||||
const active = document.activeElement;
|
||||
const activeEl = active instanceof HTMLElement ? active : null;
|
||||
setCommandCenterFocusRestoreElement(
|
||||
(targetEl as HTMLElement | null) ?? activeEl ?? null
|
||||
);
|
||||
}
|
||||
store.setCommandCenterOpen(!store.commandCenterOpen);
|
||||
return true;
|
||||
}
|
||||
case "shortcuts.dialog.toggle": {
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
store.setShortcutsDialogOpen(!store.shortcutsDialogOpen);
|
||||
return true;
|
||||
}
|
||||
case "message-input.action":
|
||||
if (!input.payload || typeof input.payload !== "object" || !("kind" in input.payload)) {
|
||||
return false;
|
||||
}
|
||||
return requestMessageInputAction(input.payload.kind);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!shouldHandle()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key ?? "";
|
||||
if (key === "Alt" && !event.shiftKey) {
|
||||
useKeyboardShortcutsStore.getState().setAltDown(true);
|
||||
}
|
||||
if (isTauri && (key === "Meta" || key === "Control") && !event.shiftKey) {
|
||||
useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(true);
|
||||
}
|
||||
if (key === "Shift") {
|
||||
const state = useKeyboardShortcutsStore.getState();
|
||||
if (state.altDown || state.cmdOrCtrlDown) {
|
||||
state.resetModifiers();
|
||||
}
|
||||
}
|
||||
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
const focusScope = resolveKeyboardFocusScope({
|
||||
target: event.target,
|
||||
commandCenterOpen: store.commandCenterOpen,
|
||||
});
|
||||
const match = resolveKeyboardShortcut({
|
||||
event,
|
||||
context: {
|
||||
isMac,
|
||||
isTauri,
|
||||
focusScope,
|
||||
commandCenterOpen: store.commandCenterOpen,
|
||||
hasSelectedAgent: Boolean(selectedAgentId && toggleFileExplorer),
|
||||
},
|
||||
});
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handled = handleAction({
|
||||
action: match.action,
|
||||
payload: match.payload,
|
||||
event,
|
||||
});
|
||||
if (!handled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (match.preventDefault) {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (match.stopPropagation) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
const key = event.key ?? "";
|
||||
if (key === "Alt") {
|
||||
useKeyboardShortcutsStore.getState().setAltDown(false);
|
||||
}
|
||||
if (isTauri && (key === "Meta" || key === "Control")) {
|
||||
useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlurOrHide = () => {
|
||||
resetModifiers();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
window.addEventListener("keyup", handleKeyUp, true);
|
||||
window.addEventListener("blur", handleBlurOrHide);
|
||||
document.addEventListener("visibilitychange", handleBlurOrHide);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown, true);
|
||||
window.removeEventListener("keyup", handleKeyUp, true);
|
||||
window.removeEventListener("blur", handleBlurOrHide);
|
||||
document.removeEventListener("visibilitychange", handleBlurOrHide);
|
||||
};
|
||||
}, [
|
||||
enabled,
|
||||
isMobile,
|
||||
pathname,
|
||||
resetModifiers,
|
||||
router,
|
||||
selectedAgentId,
|
||||
toggleAgentList,
|
||||
toggleFileExplorer,
|
||||
]);
|
||||
}
|
||||
@@ -1,53 +1,129 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import {
|
||||
useSectionOrderStore,
|
||||
sortProjectsByStoredOrder,
|
||||
} from "@/stores/section-order-store";
|
||||
import type { FetchAgentsGroupedByProjectResponseMessage } from "@server/shared/messages";
|
||||
deriveSidebarStateBucket,
|
||||
isSidebarActiveAgent,
|
||||
} from "@/utils/sidebar-agent-state";
|
||||
import type { ProjectPlacementPayload } from "@server/shared/messages";
|
||||
|
||||
const SIDEBAR_GROUPS_STALE_TIME = 15_000;
|
||||
const SIDEBAR_GROUPS_REFETCH_INTERVAL = 10_000;
|
||||
const MAX_AGENTS_PER_PROJECT = 5;
|
||||
const SIDEBAR_AGENTS_STALE_TIME = 15_000;
|
||||
const SIDEBAR_AGENTS_REFETCH_INTERVAL = 10_000;
|
||||
const SIDEBAR_DONE_FILL_TARGET = 50;
|
||||
|
||||
type SidebarGroupsPayload = FetchAgentsGroupedByProjectResponseMessage["payload"];
|
||||
export type SidebarCheckoutLite =
|
||||
SidebarGroupsPayload["groups"][number]["agents"][number]["checkout"];
|
||||
type MutableSidebarGroup = {
|
||||
export interface SidebarProjectOption {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
agents: AggregatedAgent[];
|
||||
};
|
||||
activeCount: number;
|
||||
totalCount: number;
|
||||
serverId: string;
|
||||
workingDir: string;
|
||||
}
|
||||
|
||||
export interface SidebarSectionData {
|
||||
key: string;
|
||||
projectKey: string;
|
||||
title: string;
|
||||
agents: AggregatedAgent[];
|
||||
firstAgentServerId?: string;
|
||||
firstAgentId?: string;
|
||||
workingDir?: string;
|
||||
export interface SidebarAgentListEntry {
|
||||
agent: AggregatedAgent & { createdAt: Date };
|
||||
project: ProjectPlacementPayload;
|
||||
}
|
||||
|
||||
export interface SidebarAgentsGroupedResult {
|
||||
sections: SidebarSectionData[];
|
||||
checkoutByAgentKey: Map<string, SidebarCheckoutLite>;
|
||||
entries: SidebarAgentListEntry[];
|
||||
projectOptions: SidebarProjectOption[];
|
||||
hasMoreEntries: boolean;
|
||||
isLoading: boolean;
|
||||
isInitialLoad: boolean;
|
||||
isRevalidating: boolean;
|
||||
refreshAll: () => void;
|
||||
}
|
||||
|
||||
function compareByLastActivityDesc(
|
||||
left: SidebarAgentListEntry,
|
||||
right: SidebarAgentListEntry
|
||||
): number {
|
||||
return right.agent.lastActivityAt.getTime() - left.agent.lastActivityAt.getTime();
|
||||
}
|
||||
|
||||
function compareByTitleAsc(
|
||||
left: SidebarAgentListEntry,
|
||||
right: SidebarAgentListEntry
|
||||
): number {
|
||||
const leftTitle = (left.agent.title?.trim() || "New agent").toLocaleLowerCase();
|
||||
const rightTitle = (right.agent.title?.trim() || "New agent").toLocaleLowerCase();
|
||||
const titleCmp = leftTitle.localeCompare(rightTitle, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
if (titleCmp !== 0) {
|
||||
return titleCmp;
|
||||
}
|
||||
|
||||
// Deterministic tie-breaker so running rows stay stable while status updates stream.
|
||||
return left.agent.id.localeCompare(right.agent.id, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
}
|
||||
|
||||
function applySidebarDefaultOrdering(
|
||||
entries: SidebarAgentListEntry[]
|
||||
): { entries: SidebarAgentListEntry[]; hasMore: boolean } {
|
||||
const needsInput: SidebarAgentListEntry[] = [];
|
||||
const failed: SidebarAgentListEntry[] = [];
|
||||
const running: SidebarAgentListEntry[] = [];
|
||||
const attention: SidebarAgentListEntry[] = [];
|
||||
const done: SidebarAgentListEntry[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const bucket = deriveSidebarStateBucket({
|
||||
status: entry.agent.status,
|
||||
requiresAttention: entry.agent.requiresAttention,
|
||||
attentionReason: entry.agent.attentionReason,
|
||||
});
|
||||
if (bucket === "needs_input") {
|
||||
needsInput.push(entry);
|
||||
continue;
|
||||
}
|
||||
if (bucket === "failed") {
|
||||
failed.push(entry);
|
||||
continue;
|
||||
}
|
||||
if (bucket === "running") {
|
||||
running.push(entry);
|
||||
continue;
|
||||
}
|
||||
if (bucket === "attention") {
|
||||
attention.push(entry);
|
||||
continue;
|
||||
}
|
||||
done.push(entry);
|
||||
}
|
||||
|
||||
needsInput.sort(compareByLastActivityDesc);
|
||||
failed.sort(compareByLastActivityDesc);
|
||||
running.sort(compareByTitleAsc);
|
||||
attention.sort(compareByLastActivityDesc);
|
||||
done.sort(compareByLastActivityDesc);
|
||||
|
||||
const active = [...needsInput, ...failed, ...running, ...attention];
|
||||
if (active.length >= SIDEBAR_DONE_FILL_TARGET) {
|
||||
return { entries: active, hasMore: done.length > 0 };
|
||||
}
|
||||
|
||||
const remainingDoneSlots = SIDEBAR_DONE_FILL_TARGET - active.length;
|
||||
const shownDone = done.slice(0, remainingDoneSlots);
|
||||
return {
|
||||
entries: [...active, ...shownDone],
|
||||
hasMore: done.length > shownDone.length,
|
||||
};
|
||||
}
|
||||
|
||||
function toAggregatedAgent(params: {
|
||||
source: Agent | ReturnType<typeof normalizeAgentSnapshot>;
|
||||
serverId: string;
|
||||
serverLabel: string;
|
||||
}): AggregatedAgent {
|
||||
}): AggregatedAgent & { createdAt: Date } {
|
||||
const source = params.source;
|
||||
return {
|
||||
id: source.id,
|
||||
@@ -55,6 +131,7 @@ function toAggregatedAgent(params: {
|
||||
serverLabel: params.serverLabel,
|
||||
title: source.title ?? null,
|
||||
status: source.status,
|
||||
createdAt: source.createdAt,
|
||||
lastActivityAt: source.lastActivityAt,
|
||||
cwd: source.cwd,
|
||||
provider: source.provider,
|
||||
@@ -68,236 +145,187 @@ function toAggregatedAgent(params: {
|
||||
|
||||
export function useSidebarAgentsGrouped(options?: {
|
||||
isOpen?: boolean;
|
||||
serverId?: string | null;
|
||||
selectedProjectKeys?: string[];
|
||||
}): SidebarAgentsGroupedResult {
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const queryClient = useQueryClient();
|
||||
const isOpen = options?.isOpen ?? true;
|
||||
|
||||
const sessionClients = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const result: Record<
|
||||
string,
|
||||
NonNullable<typeof state.sessions[string]["client"]> | null
|
||||
> = {};
|
||||
for (const [serverId, session] of Object.entries(state.sessions)) {
|
||||
result[serverId] = session.client ?? null;
|
||||
}
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
const sessionAgents = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const result: Record<string, Map<string, Agent> | undefined> = {};
|
||||
for (const [serverId, session] of Object.entries(state.sessions)) {
|
||||
result[serverId] = session.agents;
|
||||
}
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
const sessionConnections = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const result: Record<string, boolean> = {};
|
||||
for (const [serverId, session] of Object.entries(state.sessions)) {
|
||||
result[serverId] = session.connection.isConnected;
|
||||
}
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
const serverEntries = useMemo(
|
||||
const serverId = useMemo(() => {
|
||||
const value = options?.serverId;
|
||||
return typeof value === "string" && value.trim().length > 0
|
||||
? value.trim()
|
||||
: null;
|
||||
}, [options?.serverId]);
|
||||
const selectedProjectKeys = useMemo(
|
||||
() =>
|
||||
Object.keys(sessionClients).map((serverId) => ({
|
||||
serverId,
|
||||
client: sessionClients[serverId] ?? null,
|
||||
isConnected: sessionConnections[serverId] ?? false,
|
||||
})),
|
||||
[sessionClients, sessionConnections]
|
||||
new Set(
|
||||
(options?.selectedProjectKeys ?? [])
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
),
|
||||
[options?.selectedProjectKeys]
|
||||
);
|
||||
|
||||
const groupedQueries = useQueries({
|
||||
queries: serverEntries.map(({ serverId, client, isConnected }) => ({
|
||||
queryKey: ["sidebarAgentsGrouped", serverId] as const,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
return await client.fetchAgentsGroupedByProject({
|
||||
filter: { labels: { ui: "true" } },
|
||||
});
|
||||
},
|
||||
enabled: Boolean(client) && isConnected,
|
||||
staleTime: SIDEBAR_GROUPS_STALE_TIME,
|
||||
refetchInterval: isOpen ? SIDEBAR_GROUPS_REFETCH_INTERVAL : false,
|
||||
refetchIntervalInBackground: isOpen,
|
||||
refetchOnMount: "always" as const,
|
||||
})),
|
||||
const session = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId] : undefined
|
||||
);
|
||||
const client = session?.client ?? null;
|
||||
const liveAgents = session?.agents ?? null;
|
||||
const isConnected = session?.connection.isConnected ?? false;
|
||||
const canFetch = Boolean(serverId && client && isConnected);
|
||||
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: ["sidebarAgentsList", serverId] as const,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
return await client.fetchAgents({
|
||||
filter: { labels: { ui: "true" } },
|
||||
sort: [
|
||||
{ key: "status_priority", direction: "asc" },
|
||||
{ key: "updated_at", direction: "desc" },
|
||||
],
|
||||
});
|
||||
},
|
||||
enabled: canFetch,
|
||||
staleTime: SIDEBAR_AGENTS_STALE_TIME,
|
||||
refetchInterval: isOpen ? SIDEBAR_AGENTS_REFETCH_INTERVAL : false,
|
||||
refetchIntervalInBackground: isOpen,
|
||||
refetchOnMount: "always" as const,
|
||||
});
|
||||
|
||||
const projectOrder = useSectionOrderStore((state) => state.projectOrder);
|
||||
const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder);
|
||||
|
||||
const { sections, checkoutByAgentKey, hasAnyData } = useMemo(() => {
|
||||
const groupsByKey = new Map<string, MutableSidebarGroup>();
|
||||
const checkoutLookup = new Map<string, SidebarCheckoutLite>();
|
||||
const seenAgentKeys = new Set<string>();
|
||||
const groupedFetchReadyByServer = new Map<string, boolean>();
|
||||
|
||||
for (let idx = 0; idx < serverEntries.length; idx++) {
|
||||
const { serverId } = serverEntries[idx] ?? {};
|
||||
if (!serverId) {
|
||||
continue;
|
||||
}
|
||||
groupedFetchReadyByServer.set(serverId, groupedQueries[idx]?.isFetched ?? false);
|
||||
const payload = groupedQueries[idx]?.data as SidebarGroupsPayload | undefined;
|
||||
if (!payload) {
|
||||
continue;
|
||||
}
|
||||
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
|
||||
const liveAgents = sessionAgents[serverId];
|
||||
|
||||
for (const group of payload.groups) {
|
||||
const existing: MutableSidebarGroup =
|
||||
groupsByKey.get(group.projectKey) ??
|
||||
{
|
||||
projectKey: group.projectKey,
|
||||
projectName: group.projectName,
|
||||
agents: [],
|
||||
};
|
||||
|
||||
for (const entry of group.agents) {
|
||||
const normalized = normalizeAgentSnapshot(entry.agent, serverId);
|
||||
const live = liveAgents?.get(entry.agent.id);
|
||||
const nextAgent = toAggregatedAgent({
|
||||
source: live ?? normalized,
|
||||
serverId,
|
||||
serverLabel,
|
||||
});
|
||||
if (nextAgent.archivedAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const agentKey = `${serverId}:${entry.agent.id}`;
|
||||
seenAgentKeys.add(agentKey);
|
||||
checkoutLookup.set(agentKey, live?.projectPlacement?.checkout ?? entry.checkout);
|
||||
existing.agents.push(nextAgent);
|
||||
}
|
||||
|
||||
groupsByKey.set(group.projectKey, existing);
|
||||
}
|
||||
const { entries, projectOptions, hasAnyData, hasMoreEntries } = useMemo(() => {
|
||||
if (!serverId) {
|
||||
return {
|
||||
entries: [] as SidebarAgentListEntry[],
|
||||
projectOptions: [] as SidebarProjectOption[],
|
||||
hasAnyData: false,
|
||||
hasMoreEntries: false,
|
||||
};
|
||||
}
|
||||
|
||||
for (const { serverId } of serverEntries) {
|
||||
if (!groupedFetchReadyByServer.get(serverId)) {
|
||||
continue;
|
||||
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
|
||||
const seenAgentIds = new Set<string>();
|
||||
const byProject = new Map<string, SidebarProjectOption>();
|
||||
const mergedEntries: SidebarAgentListEntry[] = [];
|
||||
|
||||
const pushEntry = (entry: SidebarAgentListEntry): void => {
|
||||
if (entry.agent.archivedAt) {
|
||||
return;
|
||||
}
|
||||
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
|
||||
const liveAgents = sessionAgents[serverId];
|
||||
if (!liveAgents) {
|
||||
continue;
|
||||
const dedupeKey = `${entry.agent.serverId}:${entry.agent.id}`;
|
||||
if (seenAgentIds.has(dedupeKey)) {
|
||||
return;
|
||||
}
|
||||
seenAgentIds.add(dedupeKey);
|
||||
mergedEntries.push(entry);
|
||||
|
||||
const existing = byProject.get(entry.project.projectKey);
|
||||
const isActive = isSidebarActiveAgent({
|
||||
status: entry.agent.status,
|
||||
requiresAttention: entry.agent.requiresAttention,
|
||||
attentionReason: entry.agent.attentionReason,
|
||||
});
|
||||
if (existing) {
|
||||
existing.totalCount += 1;
|
||||
if (isActive) {
|
||||
existing.activeCount += 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
byProject.set(entry.project.projectKey, {
|
||||
projectKey: entry.project.projectKey,
|
||||
projectName: entry.project.projectName,
|
||||
activeCount: isActive ? 1 : 0,
|
||||
totalCount: 1,
|
||||
serverId,
|
||||
workingDir: entry.project.checkout.cwd,
|
||||
});
|
||||
};
|
||||
|
||||
const fetchedEntries = agentsQuery.data?.entries ?? [];
|
||||
for (const fetchedEntry of fetchedEntries) {
|
||||
const normalized = normalizeAgentSnapshot(fetchedEntry.agent, serverId);
|
||||
const live = liveAgents?.get(fetchedEntry.agent.id);
|
||||
const project = live?.projectPlacement ?? fetchedEntry.project;
|
||||
if (!project) {
|
||||
continue;
|
||||
}
|
||||
const agent = toAggregatedAgent({
|
||||
source: live ?? normalized,
|
||||
serverId,
|
||||
serverLabel,
|
||||
});
|
||||
pushEntry({ agent, project });
|
||||
}
|
||||
|
||||
if (liveAgents) {
|
||||
for (const live of liveAgents.values()) {
|
||||
if (live.archivedAt || live.labels.ui !== "true") {
|
||||
continue;
|
||||
}
|
||||
if (!live.projectPlacement) {
|
||||
// Ignore fetchAgents-hydrated snapshots for sidebar placement.
|
||||
// Sidebar should derive placement from grouped RPC or project-enriched agent_update.
|
||||
continue;
|
||||
}
|
||||
const agentKey = `${serverId}:${live.id}`;
|
||||
if (seenAgentKeys.has(agentKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const livePlacement = live.projectPlacement;
|
||||
const projectKey = livePlacement.projectKey;
|
||||
const existing: MutableSidebarGroup =
|
||||
groupsByKey.get(projectKey) ??
|
||||
{
|
||||
projectKey,
|
||||
projectName: livePlacement.projectName,
|
||||
agents: [],
|
||||
};
|
||||
existing.agents.push(
|
||||
toAggregatedAgent({
|
||||
source: live,
|
||||
serverId,
|
||||
serverLabel,
|
||||
})
|
||||
);
|
||||
checkoutLookup.set(agentKey, livePlacement.checkout);
|
||||
groupsByKey.set(projectKey, existing);
|
||||
const agent = toAggregatedAgent({
|
||||
source: live,
|
||||
serverId,
|
||||
serverLabel,
|
||||
});
|
||||
pushEntry({ agent, project: live.projectPlacement });
|
||||
}
|
||||
}
|
||||
|
||||
const sortedGroups = Array.from(groupsByKey.values())
|
||||
.map((group) => {
|
||||
const agents = [...group.agents].sort(
|
||||
(left, right) =>
|
||||
right.lastActivityAt.getTime() - left.lastActivityAt.getTime()
|
||||
);
|
||||
return {
|
||||
...group,
|
||||
agents: agents.slice(0, MAX_AGENTS_PER_PROJECT),
|
||||
};
|
||||
})
|
||||
.filter((group) => group.agents.length > 0)
|
||||
.sort((left, right) => {
|
||||
const leftRecent = left.agents[0]?.lastActivityAt.getTime() ?? 0;
|
||||
const rightRecent = right.agents[0]?.lastActivityAt.getTime() ?? 0;
|
||||
return rightRecent - leftRecent;
|
||||
});
|
||||
const filteredEntries =
|
||||
selectedProjectKeys.size > 0
|
||||
? mergedEntries.filter((entry) =>
|
||||
selectedProjectKeys.has(entry.project.projectKey)
|
||||
)
|
||||
: mergedEntries;
|
||||
|
||||
const orderedGroups = sortProjectsByStoredOrder(sortedGroups, projectOrder);
|
||||
const nextSections = orderedGroups.map((group) => {
|
||||
const firstAgent = group.agents[0];
|
||||
return {
|
||||
key: `project:${group.projectKey}`,
|
||||
projectKey: group.projectKey,
|
||||
title: group.projectName,
|
||||
agents: group.agents,
|
||||
firstAgentServerId: firstAgent?.serverId,
|
||||
firstAgentId: firstAgent?.id,
|
||||
workingDir: firstAgent?.cwd,
|
||||
};
|
||||
const ordered = applySidebarDefaultOrdering(filteredEntries);
|
||||
const options = Array.from(byProject.values()).sort((left, right) => {
|
||||
if (left.activeCount !== right.activeCount) {
|
||||
return right.activeCount - left.activeCount;
|
||||
}
|
||||
return left.projectName.localeCompare(right.projectName);
|
||||
});
|
||||
|
||||
return {
|
||||
sections: nextSections,
|
||||
checkoutByAgentKey: checkoutLookup,
|
||||
hasAnyData: nextSections.length > 0,
|
||||
entries: ordered.entries,
|
||||
projectOptions: options,
|
||||
hasAnyData: ordered.entries.length > 0,
|
||||
hasMoreEntries: ordered.hasMore,
|
||||
};
|
||||
}, [serverEntries, groupedQueries, connectionStates, sessionAgents, projectOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentKeys = sections.map((section) => section.projectKey);
|
||||
const storedKeys = new Set(projectOrder);
|
||||
const newKeys = currentKeys.filter((key) => !storedKeys.has(key));
|
||||
if (newKeys.length > 0) {
|
||||
setProjectOrder([...projectOrder, ...newKeys]);
|
||||
}
|
||||
}, [sections, projectOrder, setProjectOrder]);
|
||||
}, [
|
||||
agentsQuery.data?.entries,
|
||||
connectionStates,
|
||||
liveAgents,
|
||||
selectedProjectKeys,
|
||||
serverId,
|
||||
]);
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
for (const { serverId } of serverEntries) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["sidebarAgentsGrouped", serverId],
|
||||
});
|
||||
if (!serverId) {
|
||||
return;
|
||||
}
|
||||
}, [queryClient, serverEntries]);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["sidebarAgentsList", serverId],
|
||||
});
|
||||
}, [queryClient, serverId]);
|
||||
|
||||
const isFetching = groupedQueries.some(
|
||||
(query) => query.isPending || query.isFetching
|
||||
);
|
||||
const isFetching =
|
||||
canFetch && (agentsQuery.isPending || agentsQuery.isFetching);
|
||||
const isInitialLoad = isFetching && !hasAnyData;
|
||||
const isRevalidating = isFetching && hasAnyData;
|
||||
|
||||
return {
|
||||
sections,
|
||||
checkoutByAgentKey,
|
||||
entries,
|
||||
projectOptions,
|
||||
hasMoreEntries,
|
||||
isLoading: isFetching,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
|
||||
27
packages/app/src/keyboard/actions.ts
Normal file
27
packages/app/src/keyboard/actions.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export type KeyboardFocusScope =
|
||||
| "terminal"
|
||||
| "message-input"
|
||||
| "command-center"
|
||||
| "editable"
|
||||
| "other";
|
||||
|
||||
export type MessageInputKeyboardActionKind =
|
||||
| "focus"
|
||||
| "dictation-toggle"
|
||||
| "dictation-cancel"
|
||||
| "voice-toggle"
|
||||
| "voice-mute-toggle";
|
||||
|
||||
export type KeyboardActionId =
|
||||
| "agent.new"
|
||||
| "sidebar.toggle.left"
|
||||
| "sidebar.toggle.right"
|
||||
| "sidebar.navigate.shortcut"
|
||||
| "command-center.toggle"
|
||||
| "shortcuts.dialog.toggle"
|
||||
| "message-input.action";
|
||||
|
||||
export type KeyboardShortcutPayload =
|
||||
| { digit: number }
|
||||
| { kind: MessageInputKeyboardActionKind }
|
||||
| null;
|
||||
42
packages/app/src/keyboard/focus-scope.ts
Normal file
42
packages/app/src/keyboard/focus-scope.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { KeyboardFocusScope } from "@/keyboard/actions";
|
||||
|
||||
export function resolveKeyboardFocusScope(input: {
|
||||
target: EventTarget | null;
|
||||
commandCenterOpen: boolean;
|
||||
}): KeyboardFocusScope {
|
||||
const { target, commandCenterOpen } = input;
|
||||
if (!(target instanceof Element)) {
|
||||
return commandCenterOpen ? "command-center" : "other";
|
||||
}
|
||||
|
||||
if (
|
||||
target.closest("[data-testid='terminal-surface']") ||
|
||||
target.closest(".xterm")
|
||||
) {
|
||||
return "terminal";
|
||||
}
|
||||
|
||||
if (
|
||||
commandCenterOpen &&
|
||||
(target.closest("[data-testid='command-center-panel']") ||
|
||||
target.closest("[data-testid='command-center-input']"))
|
||||
) {
|
||||
return "command-center";
|
||||
}
|
||||
|
||||
if (target.closest("[data-testid='message-input-root']")) {
|
||||
return "message-input";
|
||||
}
|
||||
|
||||
const editable = target as HTMLElement;
|
||||
if (editable.isContentEditable) {
|
||||
return commandCenterOpen ? "command-center" : "editable";
|
||||
}
|
||||
|
||||
const tag = target.tagName.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "select") {
|
||||
return commandCenterOpen ? "command-center" : "editable";
|
||||
}
|
||||
|
||||
return commandCenterOpen ? "command-center" : "other";
|
||||
}
|
||||
233
packages/app/src/keyboard/keyboard-shortcuts.test.ts
Normal file
233
packages/app/src/keyboard/keyboard-shortcuts.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildKeyboardShortcutHelpSections,
|
||||
resolveKeyboardShortcut,
|
||||
type KeyboardShortcutContext,
|
||||
} from "./keyboard-shortcuts";
|
||||
|
||||
function keyboardEvent(overrides: Partial<KeyboardEvent>): KeyboardEvent {
|
||||
return {
|
||||
key: "",
|
||||
code: "",
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
repeat: false,
|
||||
...overrides,
|
||||
} as KeyboardEvent;
|
||||
}
|
||||
|
||||
function shortcutContext(
|
||||
overrides: Partial<KeyboardShortcutContext> = {}
|
||||
): KeyboardShortcutContext {
|
||||
return {
|
||||
isMac: false,
|
||||
isTauri: false,
|
||||
focusScope: "other",
|
||||
commandCenterOpen: false,
|
||||
hasSelectedAgent: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("keyboard-shortcuts", () => {
|
||||
it("matches question-mark shortcut to toggle the shortcuts dialog", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "?",
|
||||
code: "Slash",
|
||||
shiftKey: true,
|
||||
}),
|
||||
context: shortcutContext({ focusScope: "other" }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("shortcuts.dialog.toggle");
|
||||
});
|
||||
|
||||
it("does not match question-mark shortcut inside editable scopes", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "?",
|
||||
code: "Slash",
|
||||
shiftKey: true,
|
||||
}),
|
||||
context: shortcutContext({ focusScope: "message-input" }),
|
||||
});
|
||||
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it("matches Cmd+B sidebar toggle on macOS", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "b",
|
||||
code: "KeyB",
|
||||
metaKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isMac: true }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("sidebar.toggle.left");
|
||||
});
|
||||
|
||||
it("does not bind Ctrl+B on non-mac", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "b",
|
||||
code: "KeyB",
|
||||
ctrlKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isMac: false }),
|
||||
});
|
||||
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps Mod+. as sidebar toggle fallback", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: ".",
|
||||
code: "Period",
|
||||
ctrlKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isMac: false }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("sidebar.toggle.left");
|
||||
});
|
||||
|
||||
it("routes Mod+D to message-input action outside terminal", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "d",
|
||||
code: "KeyD",
|
||||
metaKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isMac: true, focusScope: "message-input" }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("message-input.action");
|
||||
expect(match?.payload).toEqual({ kind: "dictation-toggle" });
|
||||
});
|
||||
|
||||
it("does not route message-input actions when terminal is focused", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "d",
|
||||
code: "KeyD",
|
||||
metaKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isMac: true, focusScope: "terminal" }),
|
||||
});
|
||||
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps space typing available in message input", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: " ",
|
||||
code: "Space",
|
||||
}),
|
||||
context: shortcutContext({ focusScope: "message-input" }),
|
||||
});
|
||||
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it("routes space to voice mute toggle outside editable scopes", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: " ",
|
||||
code: "Space",
|
||||
}),
|
||||
context: shortcutContext({ focusScope: "other" }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("message-input.action");
|
||||
expect(match?.payload).toEqual({ kind: "voice-mute-toggle" });
|
||||
});
|
||||
|
||||
it("lets Escape continue to local handlers while routing dictation cancel", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "Escape",
|
||||
code: "Escape",
|
||||
}),
|
||||
context: shortcutContext({ focusScope: "message-input" }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("message-input.action");
|
||||
expect(match?.payload).toEqual({ kind: "dictation-cancel" });
|
||||
expect(match?.preventDefault).toBe(false);
|
||||
expect(match?.stopPropagation).toBe(false);
|
||||
});
|
||||
|
||||
it("parses Alt+digit sidebar shortcut payload", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "2",
|
||||
code: "Digit2",
|
||||
altKey: true,
|
||||
}),
|
||||
context: shortcutContext(),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("sidebar.navigate.shortcut");
|
||||
expect(match?.payload).toEqual({ digit: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyboard-shortcut help sections", () => {
|
||||
function findRow(
|
||||
sections: ReturnType<typeof buildKeyboardShortcutHelpSections>,
|
||||
id: string
|
||||
) {
|
||||
for (const section of sections) {
|
||||
const row = section.rows.find((candidate) => candidate.id === id);
|
||||
if (row) {
|
||||
return row;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
it("uses non-tauri defaults for new-agent and quick-open", () => {
|
||||
const sections = buildKeyboardShortcutHelpSections({
|
||||
isMac: true,
|
||||
isTauri: false,
|
||||
});
|
||||
|
||||
expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "alt", "N"]);
|
||||
expect(findRow(sections, "quick-open-agent")?.keys).toEqual([
|
||||
"alt",
|
||||
"1-9",
|
||||
]);
|
||||
});
|
||||
|
||||
it("switches to tauri bindings in help rows", () => {
|
||||
const sections = buildKeyboardShortcutHelpSections({
|
||||
isMac: true,
|
||||
isTauri: true,
|
||||
});
|
||||
|
||||
expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "N"]);
|
||||
expect(findRow(sections, "quick-open-agent")?.keys).toEqual([
|
||||
"mod",
|
||||
"1-9",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses mod+period as non-mac left sidebar shortcut", () => {
|
||||
const sections = buildKeyboardShortcutHelpSections({
|
||||
isMac: false,
|
||||
isTauri: false,
|
||||
});
|
||||
|
||||
expect(findRow(sections, "toggle-left-sidebar")?.keys).toEqual([
|
||||
"mod",
|
||||
".",
|
||||
]);
|
||||
});
|
||||
});
|
||||
423
packages/app/src/keyboard/keyboard-shortcuts.ts
Normal file
423
packages/app/src/keyboard/keyboard-shortcuts.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import type {
|
||||
KeyboardActionId,
|
||||
KeyboardFocusScope,
|
||||
KeyboardShortcutPayload,
|
||||
MessageInputKeyboardActionKind,
|
||||
} from "@/keyboard/actions";
|
||||
|
||||
export type KeyboardShortcutContext = {
|
||||
isMac: boolean;
|
||||
isTauri: boolean;
|
||||
focusScope: KeyboardFocusScope;
|
||||
commandCenterOpen: boolean;
|
||||
hasSelectedAgent: boolean;
|
||||
};
|
||||
|
||||
export type KeyboardShortcutMatch = {
|
||||
action: KeyboardActionId;
|
||||
payload: KeyboardShortcutPayload;
|
||||
preventDefault: boolean;
|
||||
stopPropagation: boolean;
|
||||
};
|
||||
|
||||
export type KeyboardShortcutHelpRow = {
|
||||
id: string;
|
||||
label: string;
|
||||
keys: ShortcutKey[];
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type KeyboardShortcutHelpSection = {
|
||||
id: "global" | "agent-input";
|
||||
title: string;
|
||||
rows: KeyboardShortcutHelpRow[];
|
||||
};
|
||||
|
||||
type KeyboardShortcutPlatformContext = {
|
||||
isMac: boolean;
|
||||
isTauri: boolean;
|
||||
};
|
||||
|
||||
type KeyboardShortcutHelpEntry = {
|
||||
id: string;
|
||||
section: KeyboardShortcutHelpSection["id"];
|
||||
label: string;
|
||||
keys: ShortcutKey[];
|
||||
note?: string;
|
||||
when?: (context: KeyboardShortcutPlatformContext) => boolean;
|
||||
};
|
||||
|
||||
type KeyboardShortcutBinding = {
|
||||
id: string;
|
||||
action: KeyboardActionId;
|
||||
matches: (event: KeyboardEvent) => boolean;
|
||||
when: (context: KeyboardShortcutContext) => boolean;
|
||||
payload?: (event: KeyboardEvent) => KeyboardShortcutPayload;
|
||||
preventDefault?: boolean;
|
||||
stopPropagation?: boolean;
|
||||
help?: KeyboardShortcutHelpEntry;
|
||||
};
|
||||
|
||||
const SHORTCUT_HELP_SECTION_TITLES: Record<
|
||||
KeyboardShortcutHelpSection["id"],
|
||||
string
|
||||
> = {
|
||||
global: "Global",
|
||||
"agent-input": "Agent Input",
|
||||
};
|
||||
|
||||
function isMod(event: KeyboardEvent): boolean {
|
||||
return event.metaKey || event.ctrlKey;
|
||||
}
|
||||
|
||||
function parseDigit(event: KeyboardEvent): number | null {
|
||||
const code = event.code ?? "";
|
||||
if (code.startsWith("Digit")) {
|
||||
const value = Number(code.slice("Digit".length));
|
||||
return Number.isFinite(value) && value >= 1 && value <= 9 ? value : null;
|
||||
}
|
||||
if (code.startsWith("Numpad")) {
|
||||
const value = Number(code.slice("Numpad".length));
|
||||
return Number.isFinite(value) && value >= 1 && value <= 9 ? value : null;
|
||||
}
|
||||
const key = event.key ?? "";
|
||||
if (key >= "1" && key <= "9") {
|
||||
return Number(key);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasDigit(event: KeyboardEvent): boolean {
|
||||
return parseDigit(event) !== null;
|
||||
}
|
||||
|
||||
function isQuestionMarkShortcut(event: KeyboardEvent): boolean {
|
||||
return (
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
event.shiftKey &&
|
||||
!event.repeat &&
|
||||
(event.key === "?" || event.code === "Slash")
|
||||
);
|
||||
}
|
||||
|
||||
function withMessageInputAction(
|
||||
kind: MessageInputKeyboardActionKind
|
||||
): (event: KeyboardEvent) => KeyboardShortcutPayload {
|
||||
return () => ({ kind });
|
||||
}
|
||||
|
||||
const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
{
|
||||
id: "agent-new-mod-alt-n",
|
||||
action: "agent.new",
|
||||
matches: (event) =>
|
||||
isMod(event) &&
|
||||
event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.code === "KeyN" || event.key.toLowerCase() === "n"),
|
||||
when: () => true,
|
||||
help: {
|
||||
id: "new-agent",
|
||||
section: "global",
|
||||
label: "Create new agent",
|
||||
keys: ["mod", "alt", "N"],
|
||||
when: (context) => !context.isTauri,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agent-new-tauri-mod-n",
|
||||
action: "agent.new",
|
||||
matches: (event) =>
|
||||
isMod(event) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.code === "KeyN" || event.key.toLowerCase() === "n"),
|
||||
when: (context) => context.isTauri,
|
||||
help: {
|
||||
id: "new-agent",
|
||||
section: "global",
|
||||
label: "Create new agent",
|
||||
keys: ["mod", "N"],
|
||||
when: (context) => context.isTauri,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "command-center-toggle",
|
||||
action: "command-center.toggle",
|
||||
matches: (event) =>
|
||||
isMod(event) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.code === "KeyK" || event.key.toLowerCase() === "k"),
|
||||
when: () => true,
|
||||
help: {
|
||||
id: "toggle-command-center",
|
||||
section: "global",
|
||||
label: "Toggle command center",
|
||||
keys: ["mod", "K"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "shortcuts-dialog-toggle-question-mark",
|
||||
action: "shortcuts.dialog.toggle",
|
||||
matches: isQuestionMarkShortcut,
|
||||
when: (context) => context.focusScope === "other",
|
||||
help: {
|
||||
id: "show-shortcuts",
|
||||
section: "global",
|
||||
label: "Show keyboard shortcuts",
|
||||
keys: ["?"],
|
||||
note: "Available when focus is not in a text field or terminal.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sidebar-toggle-left-mac-cmd-b",
|
||||
action: "sidebar.toggle.left",
|
||||
matches: (event) =>
|
||||
event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.code === "KeyB" || event.key.toLowerCase() === "b"),
|
||||
when: (context) => context.isMac,
|
||||
help: {
|
||||
id: "toggle-left-sidebar",
|
||||
section: "global",
|
||||
label: "Toggle left sidebar",
|
||||
keys: ["mod", "B"],
|
||||
when: (context) => context.isMac,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sidebar-toggle-left-mod-period",
|
||||
action: "sidebar.toggle.left",
|
||||
matches: (event) =>
|
||||
isMod(event) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.code === "Period" || event.key === "."),
|
||||
when: (context) => !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "toggle-left-sidebar",
|
||||
section: "global",
|
||||
label: "Toggle left sidebar",
|
||||
keys: ["mod", "."],
|
||||
when: (context) => !context.isMac,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sidebar-toggle-right-mod-e",
|
||||
action: "sidebar.toggle.right",
|
||||
matches: (event) =>
|
||||
isMod(event) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.code === "KeyE" || event.key.toLowerCase() === "e"),
|
||||
when: (context) => context.hasSelectedAgent && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "toggle-right-sidebar",
|
||||
section: "global",
|
||||
label: "Toggle right sidebar",
|
||||
keys: ["mod", "E"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sidebar-toggle-right-ctrl-backquote",
|
||||
action: "sidebar.toggle.right",
|
||||
matches: (event) =>
|
||||
event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.code === "Backquote" || event.key === "`"),
|
||||
when: (context) => context.hasSelectedAgent && !context.commandCenterOpen,
|
||||
},
|
||||
{
|
||||
id: "message-input-voice-toggle",
|
||||
action: "message-input.action",
|
||||
matches: (event) =>
|
||||
isMod(event) &&
|
||||
event.shiftKey &&
|
||||
!event.altKey &&
|
||||
(event.code === "KeyD" || event.key.toLowerCase() === "d") &&
|
||||
!event.repeat,
|
||||
payload: withMessageInputAction("voice-toggle"),
|
||||
when: (context) =>
|
||||
!context.commandCenterOpen && context.focusScope !== "terminal",
|
||||
help: {
|
||||
id: "voice-toggle",
|
||||
section: "agent-input",
|
||||
label: "Toggle voice mode",
|
||||
keys: ["mod", "shift", "D"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "message-input-dictation-toggle",
|
||||
action: "message-input.action",
|
||||
matches: (event) =>
|
||||
isMod(event) &&
|
||||
!event.shiftKey &&
|
||||
!event.altKey &&
|
||||
(event.code === "KeyD" || event.key.toLowerCase() === "d"),
|
||||
payload: withMessageInputAction("dictation-toggle"),
|
||||
when: (context) =>
|
||||
!context.commandCenterOpen && context.focusScope !== "terminal",
|
||||
help: {
|
||||
id: "dictation-toggle",
|
||||
section: "agent-input",
|
||||
label: "Start/stop dictation",
|
||||
keys: ["mod", "D"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "message-input-dictation-cancel",
|
||||
action: "message-input.action",
|
||||
matches: (event) => event.key === "Escape",
|
||||
payload: withMessageInputAction("dictation-cancel"),
|
||||
when: (context) =>
|
||||
!context.commandCenterOpen && context.focusScope !== "terminal",
|
||||
preventDefault: false,
|
||||
stopPropagation: false,
|
||||
help: {
|
||||
id: "dictation-cancel",
|
||||
section: "agent-input",
|
||||
label: "Cancel dictation",
|
||||
keys: ["Esc"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "message-input-voice-mute-toggle",
|
||||
action: "message-input.action",
|
||||
matches: (event) =>
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.code === "Space" || event.key === " ") &&
|
||||
!event.repeat,
|
||||
payload: withMessageInputAction("voice-mute-toggle"),
|
||||
when: (context) =>
|
||||
!context.commandCenterOpen && context.focusScope === "other",
|
||||
help: {
|
||||
id: "voice-mute-toggle",
|
||||
section: "agent-input",
|
||||
label: "Mute/unmute voice mode",
|
||||
keys: ["Space"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sidebar-shortcut-alt-digit",
|
||||
action: "sidebar.navigate.shortcut",
|
||||
matches: (event) => event.altKey && hasDigit(event),
|
||||
payload: (event) => {
|
||||
const digit = parseDigit(event);
|
||||
return digit ? { digit } : null;
|
||||
},
|
||||
when: (context) => !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "quick-open-agent",
|
||||
section: "global",
|
||||
label: "Open sidebar agent shortcut",
|
||||
keys: ["alt", "1-9"],
|
||||
when: (context) => !context.isTauri,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sidebar-shortcut-tauri-mod-digit",
|
||||
action: "sidebar.navigate.shortcut",
|
||||
matches: (event) => isMod(event) && hasDigit(event),
|
||||
payload: (event) => {
|
||||
const digit = parseDigit(event);
|
||||
return digit ? { digit } : null;
|
||||
},
|
||||
when: (context) => context.isTauri && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "quick-open-agent",
|
||||
section: "global",
|
||||
label: "Open sidebar agent shortcut",
|
||||
keys: ["mod", "1-9"],
|
||||
when: (context) => context.isTauri,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function resolveKeyboardShortcut(input: {
|
||||
event: KeyboardEvent;
|
||||
context: KeyboardShortcutContext;
|
||||
}): KeyboardShortcutMatch | null {
|
||||
const { event, context } = input;
|
||||
for (const binding of SHORTCUT_BINDINGS) {
|
||||
if (!binding.matches(event)) {
|
||||
continue;
|
||||
}
|
||||
if (!binding.when(context)) {
|
||||
continue;
|
||||
}
|
||||
const payload = binding.payload?.(event) ?? null;
|
||||
return {
|
||||
action: binding.action,
|
||||
payload,
|
||||
preventDefault: binding.preventDefault ?? true,
|
||||
stopPropagation: binding.stopPropagation ?? true,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildKeyboardShortcutHelpSections(
|
||||
input: KeyboardShortcutPlatformContext
|
||||
): KeyboardShortcutHelpSection[] {
|
||||
const seenRows = new Set<string>();
|
||||
const rowsBySection = new Map<KeyboardShortcutHelpSection["id"], KeyboardShortcutHelpRow[]>([
|
||||
["global", []],
|
||||
["agent-input", []],
|
||||
]);
|
||||
|
||||
for (const binding of SHORTCUT_BINDINGS) {
|
||||
const help = binding.help;
|
||||
if (!help) {
|
||||
continue;
|
||||
}
|
||||
if (help.when && !help.when(input)) {
|
||||
continue;
|
||||
}
|
||||
const rowKey = `${help.section}:${help.id}`;
|
||||
if (seenRows.has(rowKey)) {
|
||||
continue;
|
||||
}
|
||||
seenRows.add(rowKey);
|
||||
|
||||
const rows = rowsBySection.get(help.section);
|
||||
if (!rows) {
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
id: help.id,
|
||||
label: help.label,
|
||||
keys: help.keys,
|
||||
...(help.note ? { note: help.note } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const sectionOrder: KeyboardShortcutHelpSection["id"][] = [
|
||||
"global",
|
||||
"agent-input",
|
||||
];
|
||||
|
||||
return sectionOrder.flatMap((sectionId) => {
|
||||
const rows = rowsBySection.get(sectionId) ?? [];
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: sectionId,
|
||||
title: SHORTCUT_HELP_SECTION_TITLES[sectionId],
|
||||
rows,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import * as Clipboard from "expo-clipboard";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import ReanimatedAnimated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
@@ -45,7 +46,6 @@ import {
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import type { Agent } from "@/contexts/session-context";
|
||||
@@ -60,10 +60,20 @@ import { extractAgentModel } from "@/utils/extract-agent-model";
|
||||
import { startPerfMonitor } from "@/utils/perf-monitor";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { deriveBranchLabel, deriveProjectPath } from "@/utils/agent-display-info";
|
||||
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||
import {
|
||||
checkoutStatusQueryKey,
|
||||
type CheckoutStatusPayload,
|
||||
useCheckoutStatusQuery,
|
||||
} from "@/hooks/use-checkout-status-query";
|
||||
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
||||
import {
|
||||
derivePendingPermissionKey,
|
||||
normalizeAgentSnapshot,
|
||||
} from "@/utils/agent-snapshots";
|
||||
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
|
||||
import type { FetchAgentsEntry } from "@server/client/daemon-client";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -71,9 +81,13 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
||||
import type { ExplorerCheckoutContext } from "@/stores/panel-store";
|
||||
|
||||
const DROPDOWN_WIDTH = 220;
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
const RECONNECT_NOTICE_DELAY_MS = 10_000;
|
||||
const CONNECTED_NOTICE_DURATION_MS = 2_500;
|
||||
|
||||
export function AgentReadyScreen({
|
||||
serverId,
|
||||
@@ -101,7 +115,6 @@ export function AgentReadyScreen({
|
||||
const isUnknownDaemon = Boolean(connectionServerId && !connection);
|
||||
const connectionStatus =
|
||||
connection?.status ?? (isUnknownDaemon ? "offline" : "idle");
|
||||
const connectionStatusLabel = formatConnectionStatus(connectionStatus);
|
||||
const lastConnectionError = connection?.lastError ?? null;
|
||||
|
||||
const handleBackToHome = useCallback(() => {
|
||||
@@ -124,7 +137,11 @@ export function AgentReadyScreen({
|
||||
targetMs: 300,
|
||||
});
|
||||
}
|
||||
router.replace("/agent" as any);
|
||||
if (targetServerId) {
|
||||
router.replace(buildHostAgentDraftRoute(targetServerId) as any);
|
||||
return;
|
||||
}
|
||||
router.replace("/" as any);
|
||||
}, [resolvedAgentId, resolvedServerId, router]);
|
||||
|
||||
const focusServerId = resolvedServerId;
|
||||
@@ -152,7 +169,6 @@ export function AgentReadyScreen({
|
||||
onBack={handleBackToHome}
|
||||
serverLabel={serverLabel}
|
||||
connectionStatus={connectionStatus}
|
||||
connectionStatusLabel={connectionStatusLabel}
|
||||
lastError={lastConnectionError}
|
||||
isUnknownDaemon={isUnknownDaemon}
|
||||
/>
|
||||
@@ -164,6 +180,7 @@ export function AgentReadyScreen({
|
||||
<AgentScreenContent
|
||||
serverId={resolvedServerId}
|
||||
agentId={resolvedAgentId}
|
||||
connectionStatus={connectionStatus}
|
||||
/>
|
||||
</ExplorerSidebarAnimationProvider>
|
||||
);
|
||||
@@ -172,6 +189,7 @@ export function AgentReadyScreen({
|
||||
type AgentScreenContentProps = {
|
||||
serverId: string;
|
||||
agentId?: string;
|
||||
connectionStatus: ConnectionStatus;
|
||||
};
|
||||
|
||||
type MissingAgentState =
|
||||
@@ -194,11 +212,14 @@ function isNotFoundErrorMessage(message: string): boolean {
|
||||
function AgentScreenContent({
|
||||
serverId,
|
||||
agentId,
|
||||
connectionStatus,
|
||||
}: AgentScreenContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const resolvedAgentId = agentId;
|
||||
|
||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
||||
|
||||
@@ -218,22 +239,100 @@ function AgentScreenContent({
|
||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
|
||||
const setActiveExplorerCheckout = usePanelStore((state) => state.setActiveExplorerCheckout);
|
||||
const activateExplorerTabForCheckout = usePanelStore(
|
||||
(state) => state.activateExplorerTabForCheckout
|
||||
);
|
||||
|
||||
// Derive isExplorerOpen from the unified panel state
|
||||
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
const openExplorerWithDefaultTab = useCallback(() => {
|
||||
// Generic explorer toggles should land on Changes by default.
|
||||
setExplorerTab("changes");
|
||||
// Select only the specific agent
|
||||
const agent = useSessionStore((state) =>
|
||||
resolvedAgentId
|
||||
? state.sessions[serverId]?.agents?.get(resolvedAgentId)
|
||||
: undefined
|
||||
);
|
||||
// Checkout status for header subtitle + git fallback when cached project placement is absent
|
||||
const checkoutStatusQuery = useCheckoutStatusQuery({
|
||||
serverId,
|
||||
cwd: agent?.cwd ?? "",
|
||||
});
|
||||
const checkout = checkoutStatusQuery.status;
|
||||
const resolveCachedCheckoutIsGit = useCallback(
|
||||
(params: {
|
||||
agentId?: string | null;
|
||||
cwd?: string | null;
|
||||
projectPlacementIsGit?: boolean;
|
||||
checkoutStatusIsGit?: boolean;
|
||||
}): boolean | null => {
|
||||
if (typeof params.projectPlacementIsGit === "boolean") {
|
||||
return params.projectPlacementIsGit;
|
||||
}
|
||||
|
||||
const agentId = params.agentId?.trim();
|
||||
if (agentId) {
|
||||
const sidebarAgents = queryClient.getQueryData<{
|
||||
entries: FetchAgentsEntry[];
|
||||
}>(["sidebarAgentsList", serverId]);
|
||||
const sidebarIsGit = sidebarAgents?.entries.find(
|
||||
(entry) => entry.agent.id === agentId
|
||||
)?.project?.checkout?.isGit;
|
||||
if (typeof sidebarIsGit === "boolean") {
|
||||
return sidebarIsGit;
|
||||
}
|
||||
}
|
||||
|
||||
const cwd = params.cwd?.trim();
|
||||
if (!cwd) {
|
||||
return null;
|
||||
}
|
||||
const cachedCheckout = queryClient.getQueryData<CheckoutStatusPayload>(
|
||||
checkoutStatusQueryKey(serverId, cwd)
|
||||
);
|
||||
if (typeof cachedCheckout?.isGit === "boolean") {
|
||||
return cachedCheckout.isGit;
|
||||
}
|
||||
if (typeof params.checkoutStatusIsGit === "boolean") {
|
||||
return params.checkoutStatusIsGit;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[queryClient, serverId]
|
||||
);
|
||||
const resolveCurrentExplorerCheckout = useCallback((): ExplorerCheckoutContext | null => {
|
||||
if (!resolvedAgentId) {
|
||||
return null;
|
||||
}
|
||||
const currentAgent = useSessionStore
|
||||
.getState()
|
||||
.sessions[serverId]
|
||||
?.agents?.get(resolvedAgentId);
|
||||
const cwd = currentAgent?.cwd?.trim();
|
||||
const isGit = resolveCachedCheckoutIsGit({
|
||||
agentId: resolvedAgentId,
|
||||
cwd,
|
||||
projectPlacementIsGit: currentAgent?.projectPlacement?.checkout?.isGit,
|
||||
checkoutStatusIsGit: checkout?.isGit,
|
||||
});
|
||||
if (!cwd || typeof isGit !== "boolean") {
|
||||
return null;
|
||||
}
|
||||
return { serverId, cwd, isGit };
|
||||
}, [resolveCachedCheckoutIsGit, resolvedAgentId, checkout?.isGit, serverId]);
|
||||
const openExplorerForActiveCheckout = useCallback(() => {
|
||||
const checkoutContext = resolveCurrentExplorerCheckout();
|
||||
if (checkoutContext) {
|
||||
activateExplorerTabForCheckout(checkoutContext);
|
||||
}
|
||||
openFileExplorer();
|
||||
}, [openFileExplorer, setExplorerTab]);
|
||||
}, [activateExplorerTabForCheckout, openFileExplorer, resolveCurrentExplorerCheckout]);
|
||||
const handleToggleExplorer = useCallback(() => {
|
||||
if (isExplorerOpen) {
|
||||
toggleFileExplorer();
|
||||
return;
|
||||
}
|
||||
openExplorerWithDefaultTab();
|
||||
}, [isExplorerOpen, openExplorerWithDefaultTab, toggleFileExplorer]);
|
||||
openExplorerForActiveCheckout();
|
||||
}, [isExplorerOpen, openExplorerForActiveCheckout, toggleFileExplorer]);
|
||||
|
||||
const {
|
||||
translateX: explorerTranslateX,
|
||||
@@ -243,6 +342,10 @@ function AgentScreenContent({
|
||||
animateToClose: animateExplorerToClose,
|
||||
isGesturing: isExplorerGesturing,
|
||||
} = useExplorerSidebarAnimation();
|
||||
const handleOpenExplorerFromGesture = useCallback(() => {
|
||||
openExplorerForActiveCheckout();
|
||||
animateExplorerToOpen();
|
||||
}, [animateExplorerToOpen, openExplorerForActiveCheckout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") {
|
||||
@@ -282,8 +385,7 @@ function AgentScreenContent({
|
||||
// Open if dragged more than 1/3 of window or fast swipe left
|
||||
const shouldOpen = event.translationX < -explorerWindowWidth / 3 || event.velocityX < -500;
|
||||
if (shouldOpen) {
|
||||
animateExplorerToOpen();
|
||||
runOnJS(openExplorerWithDefaultTab)();
|
||||
runOnJS(handleOpenExplorerFromGesture)();
|
||||
} else {
|
||||
animateExplorerToClose();
|
||||
}
|
||||
@@ -297,9 +399,8 @@ function AgentScreenContent({
|
||||
explorerWindowWidth,
|
||||
explorerTranslateX,
|
||||
explorerBackdropOpacity,
|
||||
animateExplorerToOpen,
|
||||
animateExplorerToClose,
|
||||
openExplorerWithDefaultTab,
|
||||
handleOpenExplorerFromGesture,
|
||||
isExplorerGesturing,
|
||||
]
|
||||
);
|
||||
@@ -319,14 +420,43 @@ function AgentScreenContent({
|
||||
return () => handler.remove();
|
||||
}, [isExplorerOpen, closeToAgent]);
|
||||
|
||||
const resolvedAgentId = agentId;
|
||||
const activeExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => {
|
||||
const cwd = agent?.cwd?.trim();
|
||||
const isGit = resolveCachedCheckoutIsGit({
|
||||
agentId: resolvedAgentId,
|
||||
cwd,
|
||||
projectPlacementIsGit: agent?.projectPlacement?.checkout?.isGit,
|
||||
checkoutStatusIsGit: checkout?.isGit,
|
||||
});
|
||||
if (!cwd || typeof isGit !== "boolean") {
|
||||
return null;
|
||||
}
|
||||
return { serverId, cwd, isGit };
|
||||
}, [
|
||||
agent?.cwd,
|
||||
agent?.projectPlacement?.checkout?.isGit,
|
||||
resolveCachedCheckoutIsGit,
|
||||
resolvedAgentId,
|
||||
checkout?.isGit,
|
||||
serverId,
|
||||
]);
|
||||
|
||||
// Select only the specific agent
|
||||
const agent = useSessionStore((state) =>
|
||||
resolvedAgentId
|
||||
? state.sessions[serverId]?.agents?.get(resolvedAgentId)
|
||||
: undefined
|
||||
);
|
||||
useEffect(() => {
|
||||
setActiveExplorerCheckout(activeExplorerCheckout);
|
||||
}, [activeExplorerCheckout, setActiveExplorerCheckout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeExplorerCheckout) {
|
||||
return;
|
||||
}
|
||||
activateExplorerTabForCheckout(activeExplorerCheckout);
|
||||
}, [activateExplorerTabForCheckout, activeExplorerCheckout]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setActiveExplorerCheckout(null);
|
||||
};
|
||||
}, [setActiveExplorerCheckout]);
|
||||
|
||||
// Select only the specific stream tail - use stable empty array to avoid infinite loop
|
||||
const streamItemsRaw = useSessionStore((state) =>
|
||||
@@ -362,6 +492,10 @@ function AgentScreenContent({
|
||||
const allPendingPermissions = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.pendingPermissions
|
||||
);
|
||||
const setAgents = useSessionStore((state) => state.setAgents);
|
||||
const setPendingPermissions = useSessionStore(
|
||||
(state) => state.setPendingPermissions
|
||||
);
|
||||
const pendingPermissions = useMemo(() => {
|
||||
if (!allPendingPermissions || !resolvedAgentId) return new Map();
|
||||
const filtered = new Map();
|
||||
@@ -379,10 +513,18 @@ function AgentScreenContent({
|
||||
const isConnected = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
||||
);
|
||||
const focusedAgentId = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.focusedAgentId ?? null
|
||||
);
|
||||
const { ensureAgentIsInitialized, refreshAgent } = useAgentInitialization(serverId);
|
||||
const [missingAgentState, setMissingAgentState] = useState<MissingAgentState>({
|
||||
kind: "idle",
|
||||
});
|
||||
const [showReconnectNotice, setShowReconnectNotice] = useState(false);
|
||||
const [dismissedReconnectNotice, setDismissedReconnectNotice] = useState(false);
|
||||
const [showConnectedNotice, setShowConnectedNotice] = useState(false);
|
||||
const reconnectNoticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const connectedNoticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const initAttemptTokenRef = useRef(0);
|
||||
const setFocusedAgentId = useCallback(
|
||||
(agentId: string | null) => {
|
||||
@@ -410,12 +552,57 @@ function AgentScreenContent({
|
||||
const agentModel = extractAgentModel(agent);
|
||||
const modelDisplayValue = agentModel ?? "Unknown";
|
||||
|
||||
// Checkout status for header subtitle
|
||||
const checkoutStatusQuery = useCheckoutStatusQuery({
|
||||
serverId,
|
||||
cwd: agent?.cwd ?? "",
|
||||
});
|
||||
const checkout = checkoutStatusQuery.status;
|
||||
useEffect(() => {
|
||||
if (reconnectNoticeTimeoutRef.current) {
|
||||
clearTimeout(reconnectNoticeTimeoutRef.current);
|
||||
reconnectNoticeTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (connectionStatus === "online") {
|
||||
if (showReconnectNotice || dismissedReconnectNotice) {
|
||||
setShowConnectedNotice(true);
|
||||
}
|
||||
setShowReconnectNotice(false);
|
||||
setDismissedReconnectNotice(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowConnectedNotice(false);
|
||||
if (!showReconnectNotice && !dismissedReconnectNotice) {
|
||||
reconnectNoticeTimeoutRef.current = setTimeout(() => {
|
||||
setShowReconnectNotice(true);
|
||||
}, RECONNECT_NOTICE_DELAY_MS);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (reconnectNoticeTimeoutRef.current) {
|
||||
clearTimeout(reconnectNoticeTimeoutRef.current);
|
||||
reconnectNoticeTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [connectionStatus, dismissedReconnectNotice, showReconnectNotice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showConnectedNotice) {
|
||||
if (connectedNoticeTimeoutRef.current) {
|
||||
clearTimeout(connectedNoticeTimeoutRef.current);
|
||||
connectedNoticeTimeoutRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
connectedNoticeTimeoutRef.current = setTimeout(() => {
|
||||
setShowConnectedNotice(false);
|
||||
connectedNoticeTimeoutRef.current = null;
|
||||
}, CONNECTED_NOTICE_DURATION_MS);
|
||||
return () => {
|
||||
if (connectedNoticeTimeoutRef.current) {
|
||||
clearTimeout(connectedNoticeTimeoutRef.current);
|
||||
connectedNoticeTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [showConnectedNotice]);
|
||||
|
||||
const isGitCheckout = activeExplorerCheckout?.isGit ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedAgentId) {
|
||||
@@ -554,7 +741,7 @@ function AgentScreenContent({
|
||||
return;
|
||||
}
|
||||
// On native clients, daemon stream forwarding is focused-agent only, so switching
|
||||
// agents can leave timeline gaps unless we explicitly request a snapshot.
|
||||
// agents can leave timeline gaps unless we explicitly pull timeline catch-up.
|
||||
const shouldSyncOnEntry = needsAuthoritativeSync || Platform.OS !== "web";
|
||||
if (!shouldSyncOnEntry) {
|
||||
return;
|
||||
@@ -600,7 +787,46 @@ function AgentScreenContent({
|
||||
const attemptToken = ++initAttemptTokenRef.current;
|
||||
|
||||
ensureAgentIsInitialized(resolvedAgentId)
|
||||
.then(() => {
|
||||
.then(async () => {
|
||||
if (attemptToken !== initAttemptTokenRef.current) {
|
||||
return;
|
||||
}
|
||||
const currentAgent = useSessionStore
|
||||
.getState()
|
||||
.sessions[serverId]
|
||||
?.agents.get(resolvedAgentId);
|
||||
if (!currentAgent && client) {
|
||||
const snapshot = await client.fetchAgent(resolvedAgentId);
|
||||
if (attemptToken !== initAttemptTokenRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!snapshot) {
|
||||
setMissingAgentState({
|
||||
kind: "not_found",
|
||||
message: `Agent not found: ${resolvedAgentId}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeAgentSnapshot(snapshot, serverId);
|
||||
setAgents(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(normalized.id, normalized);
|
||||
return next;
|
||||
});
|
||||
setPendingPermissions(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const [key, pending] of next.entries()) {
|
||||
if (pending.agentId === normalized.id) {
|
||||
next.delete(key);
|
||||
}
|
||||
}
|
||||
for (const request of normalized.pendingPermissions) {
|
||||
const key = derivePendingPermissionKey(normalized.id, request);
|
||||
next.set(key, { key, agentId: normalized.id, request });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
if (attemptToken !== initAttemptTokenRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -619,10 +845,14 @@ function AgentScreenContent({
|
||||
});
|
||||
}, [
|
||||
agent,
|
||||
client,
|
||||
ensureAgentIsInitialized,
|
||||
isConnected,
|
||||
missingAgentState.kind,
|
||||
resolvedAgentId,
|
||||
serverId,
|
||||
setAgents,
|
||||
setPendingPermissions,
|
||||
shouldUseOptimisticStream,
|
||||
]);
|
||||
|
||||
@@ -634,25 +864,30 @@ function AgentScreenContent({
|
||||
document.title = title;
|
||||
}, [agent?.title]);
|
||||
|
||||
// Track previous agent status to detect completion while viewing
|
||||
const previousStatusRef = useRef<string | null>(null);
|
||||
|
||||
// Clear attention when agent finishes while user is viewing this screen
|
||||
// Clear attention as soon as the user is focused on this agent screen.
|
||||
useEffect(() => {
|
||||
if (!resolvedAgentId || !agent || !client) {
|
||||
const clearAgentId = resolvedAgentId?.trim();
|
||||
if (!clearAgentId || !client) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousStatus = previousStatusRef.current;
|
||||
const currentStatus = agent.status;
|
||||
previousStatusRef.current = currentStatus;
|
||||
|
||||
// If agent transitioned from running to idle while we're viewing,
|
||||
// immediately clear attention since user witnessed the completion
|
||||
if (previousStatus === "running" && currentStatus === "idle") {
|
||||
client.clearAgentAttention(resolvedAgentId);
|
||||
if (
|
||||
!shouldClearAgentAttentionOnView({
|
||||
agentId: clearAgentId,
|
||||
focusedAgentId,
|
||||
isConnected,
|
||||
requiresAttention: agent?.requiresAttention,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}, [resolvedAgentId, agent?.status, client]);
|
||||
client.clearAgentAttention(clearAgentId);
|
||||
}, [
|
||||
agent?.requiresAttention,
|
||||
client,
|
||||
focusedAgentId,
|
||||
isConnected,
|
||||
resolvedAgentId,
|
||||
]);
|
||||
|
||||
const handleRefreshAgent = useCallback(() => {
|
||||
if (!resolvedAgentId) {
|
||||
@@ -692,6 +927,17 @@ function AgentScreenContent({
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (missingAgentState.kind === "error") {
|
||||
return (
|
||||
<View style={styles.container} testID="agent-load-error">
|
||||
<MenuHeader title="Agent" />
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>Failed to load agent</Text>
|
||||
<Text style={styles.statusText}>{missingAgentState.message}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="agent-loading">
|
||||
@@ -902,6 +1148,47 @@ function AgentScreenContent({
|
||||
}
|
||||
/>
|
||||
|
||||
{(showReconnectNotice || showConnectedNotice) && (
|
||||
<View
|
||||
style={[
|
||||
styles.connectionNotice,
|
||||
showReconnectNotice
|
||||
? styles.connectionNoticeReconnecting
|
||||
: styles.connectionNoticeConnected,
|
||||
]}
|
||||
>
|
||||
{showConnectedNotice ? (
|
||||
<CheckCircle2
|
||||
size={14}
|
||||
color={theme.colors.palette.green[600]}
|
||||
/>
|
||||
) : null}
|
||||
<Text
|
||||
style={[
|
||||
styles.connectionNoticeText,
|
||||
showReconnectNotice
|
||||
? styles.connectionNoticeTextReconnecting
|
||||
: styles.connectionNoticeTextConnected,
|
||||
]}
|
||||
>
|
||||
{showReconnectNotice ? "Reconnecting..." : "Connected"}
|
||||
</Text>
|
||||
{showReconnectNotice ? (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setShowReconnectNotice(false);
|
||||
setDismissedReconnectNotice(true);
|
||||
}}
|
||||
style={styles.connectionNoticeDismiss}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss reconnecting notice"
|
||||
>
|
||||
<Text style={styles.connectionNoticeDismissText}>Dismiss</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Content Area with Keyboard Animation */}
|
||||
<View style={styles.contentContainer}>
|
||||
{shouldBlockForHistorySync ? (
|
||||
@@ -940,7 +1227,12 @@ function AgentScreenContent({
|
||||
|
||||
{/* Explorer Sidebar - Desktop: inline, Mobile: overlay */}
|
||||
{!isMobile && isExplorerOpen && resolvedAgentId && (
|
||||
<ExplorerSidebar serverId={serverId} agentId={resolvedAgentId} cwd={effectiveAgent.cwd} />
|
||||
<ExplorerSidebar
|
||||
serverId={serverId}
|
||||
agentId={resolvedAgentId}
|
||||
cwd={effectiveAgent.cwd}
|
||||
isGit={isGitCheckout}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
@@ -957,7 +1249,12 @@ function AgentScreenContent({
|
||||
|
||||
{/* Mobile Explorer Sidebar Overlay */}
|
||||
{isMobile && resolvedAgentId && (
|
||||
<ExplorerSidebar serverId={serverId} agentId={resolvedAgentId} cwd={effectiveAgent.cwd} />
|
||||
<ExplorerSidebar
|
||||
serverId={serverId}
|
||||
agentId={resolvedAgentId}
|
||||
cwd={effectiveAgent.cwd}
|
||||
isGit={isGitCheckout}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -967,14 +1264,12 @@ function AgentSessionUnavailableState({
|
||||
onBack,
|
||||
serverLabel,
|
||||
connectionStatus,
|
||||
connectionStatusLabel,
|
||||
lastError,
|
||||
isUnknownDaemon = false,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
serverLabel: string;
|
||||
connectionStatus: ConnectionStatus;
|
||||
connectionStatusLabel: string;
|
||||
lastError: string | null;
|
||||
isUnknownDaemon?: boolean;
|
||||
}) {
|
||||
@@ -1015,11 +1310,10 @@ function AgentSessionUnavailableState({
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.offlineTitle}>
|
||||
{serverLabel} is currently {connectionStatusLabel.toLowerCase()}.
|
||||
Reconnecting to {serverLabel}...
|
||||
</Text>
|
||||
<Text style={styles.offlineDescription}>
|
||||
We'll reconnect automatically and show this agent as soon as the
|
||||
host comes back online.
|
||||
We will show this agent again as soon as the host is reachable.
|
||||
</Text>
|
||||
{lastError ? (
|
||||
<Text style={styles.offlineDetails}>{lastError}</Text>
|
||||
@@ -1049,6 +1343,49 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
},
|
||||
connectionNotice: {
|
||||
marginHorizontal: theme.spacing[4],
|
||||
marginTop: theme.spacing[1],
|
||||
marginBottom: theme.spacing[2],
|
||||
minHeight: 32,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
connectionNoticeReconnecting: {
|
||||
backgroundColor: `${theme.colors.palette.yellow[400]}22`,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.palette.yellow[400],
|
||||
},
|
||||
connectionNoticeConnected: {
|
||||
backgroundColor: theme.colors.palette.green[100],
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.palette.green[400],
|
||||
},
|
||||
connectionNoticeText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
connectionNoticeTextReconnecting: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
connectionNoticeTextConnected: {
|
||||
color: theme.colors.palette.green[800],
|
||||
},
|
||||
connectionNoticeDismiss: {
|
||||
marginLeft: "auto",
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
connectionNoticeDismissText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -14,8 +14,8 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import { Folder, GitBranch, Menu, Monitor, PanelLeft } from "lucide-react-native";
|
||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||
import { Folder, GitBranch } from "lucide-react-native";
|
||||
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { AgentInputArea } from "@/components/agent-input-area";
|
||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||
import { AgentConfigRow, FormSelectTrigger } from "@/components/agent-form/agent-form-dropdowns";
|
||||
@@ -27,11 +27,13 @@ import {
|
||||
CHECKOUT_STATUS_STALE_TIME,
|
||||
checkoutStatusQueryKey,
|
||||
} from "@/hooks/use-checkout-status-query";
|
||||
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
|
||||
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 { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
@@ -46,6 +48,7 @@ import type {
|
||||
AgentSessionConfig,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
|
||||
const DRAFT_AGENT_ID = "__new_agent__";
|
||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||
@@ -111,20 +114,19 @@ type DraftAgentParams = {
|
||||
type DraftAgentScreenProps = {
|
||||
isVisible?: boolean;
|
||||
onCreateFlowActiveChange?: (active: boolean) => void;
|
||||
forcedServerId?: string;
|
||||
};
|
||||
|
||||
export function DraftAgentScreen({
|
||||
isVisible = true,
|
||||
onCreateFlowActiveChange,
|
||||
forcedServerId,
|
||||
}: DraftAgentScreenProps = {}) {
|
||||
const { theme } = useUnistyles();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
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 { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||
@@ -143,7 +145,11 @@ export function DraftAgentScreen({
|
||||
};
|
||||
});
|
||||
|
||||
const resolvedServerId = getParamValue(params.serverId);
|
||||
const forcedServerIdParam = forcedServerId?.trim();
|
||||
const resolvedServerId =
|
||||
forcedServerIdParam && forcedServerIdParam.length > 0
|
||||
? forcedServerIdParam
|
||||
: getParamValue(params.serverId);
|
||||
const resolvedProvider = getValidProvider(getParamValue(params.provider));
|
||||
const resolvedMode = getValidMode(resolvedProvider, getParamValue(params.modeId));
|
||||
const resolvedModel = getParamValue(params.model);
|
||||
@@ -214,34 +220,12 @@ export function DraftAgentScreen({
|
||||
isCreateFlow: true,
|
||||
onlineServerIds,
|
||||
});
|
||||
const daemonLabelByServerId = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const daemon of daemons) {
|
||||
const label = daemon.label?.trim();
|
||||
map.set(daemon.serverId, label && label.length > 0 ? label : daemon.serverId);
|
||||
}
|
||||
return map;
|
||||
}, [daemons]);
|
||||
const hostEntry = selectedServerId
|
||||
? connectionStates.get(selectedServerId)
|
||||
: undefined;
|
||||
const selectedDaemonLabel = selectedServerId
|
||||
? daemonLabelByServerId.get(selectedServerId)
|
||||
: null;
|
||||
const hostLabel =
|
||||
selectedDaemonLabel ??
|
||||
hostEntry?.daemon.label ??
|
||||
selectedServerId ??
|
||||
"Select host";
|
||||
const isMobile =
|
||||
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 [isHostOpen, setIsHostOpen] = useState(false);
|
||||
const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">("none");
|
||||
const [baseBranch, setBaseBranch] = useState("");
|
||||
const [worktreeSlug, setWorktreeSlug] = useState("");
|
||||
@@ -249,7 +233,10 @@ export function DraftAgentScreen({
|
||||
const [isWorkingDirOpen, setIsWorkingDirOpen] = useState(false);
|
||||
const [isWorktreePickerOpen, setIsWorktreePickerOpen] = useState(false);
|
||||
const [isBranchOpen, setIsBranchOpen] = useState(false);
|
||||
const hostAnchorRef = useRef<View>(null);
|
||||
const [branchSearchQuery, setBranchSearchQuery] = useState("");
|
||||
const [debouncedBranchSearchQuery, setDebouncedBranchSearchQuery] = useState("");
|
||||
const [workingDirSearchQuery, setWorkingDirSearchQuery] = useState("");
|
||||
const [debouncedWorkingDirSearchQuery, setDebouncedWorkingDirSearchQuery] = useState("");
|
||||
const workingDirAnchorRef = useRef<View>(null);
|
||||
const worktreeAnchorRef = useRef<View>(null);
|
||||
const branchAnchorRef = useRef<View>(null);
|
||||
@@ -258,6 +245,18 @@ export function DraftAgentScreen({
|
||||
const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId);
|
||||
const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear);
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = branchSearchQuery.trim();
|
||||
const timer = setTimeout(() => setDebouncedBranchSearchQuery(trimmed), 180);
|
||||
return () => clearTimeout(timer);
|
||||
}, [branchSearchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = workingDirSearchQuery.trim();
|
||||
const timer = setTimeout(() => setDebouncedWorkingDirSearchQuery(trimmed), 180);
|
||||
return () => clearTimeout(timer);
|
||||
}, [workingDirSearchQuery]);
|
||||
|
||||
type CreateAttempt = {
|
||||
messageId: string;
|
||||
text: string;
|
||||
@@ -319,6 +318,7 @@ export function DraftAgentScreen({
|
||||
const sessionAgents = useSessionStore((state) =>
|
||||
selectedServerId ? state.sessions[selectedServerId]?.agents : undefined
|
||||
);
|
||||
const { agents: allAgents } = useAllAgentsList({ serverId: selectedServerId });
|
||||
const worktreePathLastCreatedAt = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
if (!sessionAgents) {
|
||||
@@ -337,24 +337,23 @@ export function DraftAgentScreen({
|
||||
return map;
|
||||
}, [sessionAgents]);
|
||||
const agentWorkingDirSuggestions = useMemo(() => {
|
||||
if (!selectedServerId || !sessionAgents) {
|
||||
return [];
|
||||
}
|
||||
const pathLastCreated = new Map<string, Date>();
|
||||
sessionAgents.forEach((agent) => {
|
||||
if (agent.cwd && !agent.cwd.includes(".paseo/worktrees")) {
|
||||
const existing = pathLastCreated.get(agent.cwd);
|
||||
if (!existing || agent.createdAt > existing) {
|
||||
pathLastCreated.set(agent.cwd, agent.createdAt);
|
||||
}
|
||||
}
|
||||
});
|
||||
return Array.from(pathLastCreated.keys()).sort((a, b) => {
|
||||
const aTime = pathLastCreated.get(a)!.getTime();
|
||||
const bTime = pathLastCreated.get(b)!.getTime();
|
||||
return bTime - aTime;
|
||||
});
|
||||
}, [selectedServerId, sessionAgents]);
|
||||
const liveSources = sessionAgents
|
||||
? Array.from(sessionAgents.values()).map((agent) => ({
|
||||
cwd: agent.cwd,
|
||||
createdAt: agent.createdAt,
|
||||
lastActivityAt: agent.lastActivityAt,
|
||||
}))
|
||||
: [];
|
||||
const fetchedSources = allAgents.map((agent) => ({
|
||||
cwd: agent.cwd,
|
||||
lastActivityAt: agent.lastActivityAt,
|
||||
}));
|
||||
|
||||
return collectAgentWorkingDirectorySuggestions([
|
||||
...liveSources,
|
||||
...fetchedSources,
|
||||
]);
|
||||
}, [allAgents, sessionAgents]);
|
||||
|
||||
const sessionClient = useSessionStore((state) =>
|
||||
selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null
|
||||
@@ -489,6 +488,70 @@ export function DraftAgentScreen({
|
||||
? "Select a worktree to attach"
|
||||
: null;
|
||||
|
||||
const branchSuggestionsQuery = useQuery({
|
||||
queryKey: [
|
||||
"branchSuggestions",
|
||||
selectedServerId,
|
||||
trimmedWorkingDir,
|
||||
debouncedBranchSearchQuery,
|
||||
],
|
||||
queryFn: async () => {
|
||||
const client = sessionClient;
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.getBranchSuggestions({
|
||||
cwd: trimmedWorkingDir || ".",
|
||||
query: debouncedBranchSearchQuery || undefined,
|
||||
limit: 50,
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.branches ?? [];
|
||||
},
|
||||
enabled:
|
||||
isCreateWorktree &&
|
||||
isGitDirectory &&
|
||||
!isNonGitDirectory &&
|
||||
Boolean(trimmedWorkingDir) &&
|
||||
!repoAvailabilityError &&
|
||||
Boolean(sessionClient) &&
|
||||
isConnected,
|
||||
retry: false,
|
||||
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(
|
||||
(name: string): { valid: boolean; error?: string } => {
|
||||
if (!name) {
|
||||
@@ -622,22 +685,55 @@ export function DraftAgentScreen({
|
||||
|
||||
const selectedWorktreeLabel =
|
||||
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 worktreeTriggerValue =
|
||||
worktreeMode === "create"
|
||||
? "Create new worktree"
|
||||
: selectedWorktreeLabel || "Select worktree";
|
||||
const hostOptions = useMemo(
|
||||
() =>
|
||||
daemons.map((daemon) => ({
|
||||
id: daemon.serverId,
|
||||
label: daemonLabelByServerId.get(daemon.serverId) ?? daemon.serverId,
|
||||
description: formatConnectionStatus(
|
||||
connectionStates.get(daemon.serverId)?.status ?? "idle"
|
||||
),
|
||||
})),
|
||||
[connectionStates, daemonLabelByServerId, daemons]
|
||||
);
|
||||
const worktreeComboOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -658,21 +754,36 @@ export function DraftAgentScreen({
|
||||
);
|
||||
|
||||
const branchComboOptions = useMemo(() => {
|
||||
const branchSet = new Set<string>();
|
||||
const currentBranch = checkout?.isGit ? checkout.currentBranch?.trim() : null;
|
||||
if (currentBranch && currentBranch !== "HEAD") {
|
||||
branchSet.add(currentBranch);
|
||||
const options = buildBranchComboOptions({
|
||||
suggestedBranches: branchSuggestionsQuery.data ?? [],
|
||||
currentBranch: checkout?.isGit ? checkout.currentBranch : null,
|
||||
baseRef: checkout?.isGit ? checkout.baseRef : null,
|
||||
typedBaseBranch: baseBranch,
|
||||
worktreeBranchLabels: worktreeOptions.map((option) => option.label),
|
||||
});
|
||||
|
||||
const normalizedQuery = normalizeBranchOptionName(branchSearchQuery)?.toLowerCase() ?? "";
|
||||
if (!normalizedQuery) {
|
||||
return options;
|
||||
}
|
||||
if (baseBranch.trim()) {
|
||||
branchSet.add(baseBranch.trim());
|
||||
}
|
||||
for (const option of worktreeOptions) {
|
||||
if (option.label) {
|
||||
branchSet.add(option.label);
|
||||
|
||||
return options.sort((a, b) => {
|
||||
const aLower = a.label.toLowerCase();
|
||||
const bLower = b.label.toLowerCase();
|
||||
const aPrefix = aLower.startsWith(normalizedQuery);
|
||||
const bPrefix = bLower.startsWith(normalizedQuery);
|
||||
if (aPrefix !== bPrefix) {
|
||||
return aPrefix ? -1 : 1;
|
||||
}
|
||||
}
|
||||
return Array.from(branchSet).map((name) => ({ id: name, label: name }));
|
||||
}, [baseBranch, checkout, worktreeOptions]);
|
||||
return aLower.localeCompare(bLower);
|
||||
});
|
||||
}, [
|
||||
baseBranch,
|
||||
branchSearchQuery,
|
||||
branchSuggestionsQuery.data,
|
||||
checkout,
|
||||
worktreeOptions,
|
||||
]);
|
||||
|
||||
const createAgentClient = useSessionStore((state) =>
|
||||
selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null
|
||||
@@ -785,6 +896,13 @@ export function DraftAgentScreen({
|
||||
dispatch({ type: "DRAFT_SET_ERROR", message: "No host selected" });
|
||||
throw new Error("No host selected");
|
||||
}
|
||||
if (providerDefinitions.length === 0) {
|
||||
dispatch({
|
||||
type: "DRAFT_SET_ERROR",
|
||||
message: "No available providers on the selected host",
|
||||
});
|
||||
throw new Error("No available providers on the selected host");
|
||||
}
|
||||
if (gitBlockingError) {
|
||||
dispatch({ type: "DRAFT_SET_ERROR", message: gitBlockingError });
|
||||
throw new Error(gitBlockingError);
|
||||
@@ -865,7 +983,9 @@ export function DraftAgentScreen({
|
||||
const agentId = (result as { id?: string })?.id;
|
||||
if (agentId && selectedServerId) {
|
||||
updatePendingAgentId(agentId);
|
||||
router.replace(`/agent/${selectedServerId}/${agentId}` as any);
|
||||
router.replace(
|
||||
buildHostAgentDetailRoute(selectedServerId, agentId) as any
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -894,6 +1014,7 @@ export function DraftAgentScreen({
|
||||
isDirectoryNotExists,
|
||||
isNonGitDirectory,
|
||||
modeOptions,
|
||||
providerDefinitions,
|
||||
persistFormPreferences,
|
||||
router,
|
||||
selectedMode,
|
||||
@@ -932,20 +1053,7 @@ export function DraftAgentScreen({
|
||||
isMobile ? { paddingTop: insets.top + theme.spacing[2] } : null,
|
||||
]}
|
||||
>
|
||||
<HeaderToggleButton
|
||||
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>
|
||||
<SidebarMenuToggle />
|
||||
</View>
|
||||
|
||||
<Animated.View style={[styles.contentContainer, animatedKeyboardStyle]}>
|
||||
@@ -965,7 +1073,7 @@ export function DraftAgentScreen({
|
||||
<View style={isMobile ? styles.stackedSelectorGroup : styles.topSelectorRow}>
|
||||
<FormSelectTrigger
|
||||
controlRef={workingDirAnchorRef}
|
||||
containerStyle={isMobile ? styles.fullSelector : styles.topSelectorPrimary}
|
||||
containerStyle={styles.fullSelector}
|
||||
label="Working directory"
|
||||
value={displayWorkingDir}
|
||||
placeholder="/path/to/project"
|
||||
@@ -973,16 +1081,7 @@ export function DraftAgentScreen({
|
||||
icon={<Folder size={16} color={theme.colors.foregroundMuted} />}
|
||||
showLabel={false}
|
||||
valueEllipsizeMode="middle"
|
||||
/>
|
||||
<FormSelectTrigger
|
||||
controlRef={hostAnchorRef}
|
||||
containerStyle={isMobile ? styles.fullSelector : styles.topSelectorSecondary}
|
||||
label="Host"
|
||||
value={hostLabel}
|
||||
placeholder="Select host"
|
||||
onPress={() => setIsHostOpen(true)}
|
||||
icon={<Monitor size={16} color={theme.colors.foregroundMuted} />}
|
||||
showLabel={false}
|
||||
testID="working-directory-select"
|
||||
/>
|
||||
</View>
|
||||
{isDirectoryNotExists && (
|
||||
@@ -1051,17 +1150,6 @@ export function DraftAgentScreen({
|
||||
{attachWorktreeError ? <Text style={styles.errorInlineText}>{attachWorktreeError}</Text> : null}
|
||||
{worktreeOptionsError ? <Text style={styles.errorInlineText}>{worktreeOptionsError}</Text> : null}
|
||||
</View>
|
||||
<Combobox
|
||||
options={hostOptions}
|
||||
value={selectedServerId ?? ""}
|
||||
onSelect={(serverId) => setSelectedServerIdFromUser(serverId)}
|
||||
title="Host"
|
||||
searchPlaceholder="Search hosts..."
|
||||
open={isHostOpen}
|
||||
onOpenChange={setIsHostOpen}
|
||||
anchorRef={hostAnchorRef}
|
||||
/>
|
||||
|
||||
<Combobox
|
||||
options={worktreeComboOptions}
|
||||
value={
|
||||
@@ -1097,21 +1185,16 @@ export function DraftAgentScreen({
|
||||
/>
|
||||
|
||||
<Combobox
|
||||
options={agentWorkingDirSuggestions.map((path) => ({
|
||||
id: path,
|
||||
label: shortenPath(path),
|
||||
}))}
|
||||
options={workingDirComboOptions}
|
||||
value={workingDir}
|
||||
onSelect={setWorkingDirFromUser}
|
||||
searchPlaceholder="/path/to/project"
|
||||
emptyText={
|
||||
agentWorkingDirSuggestions.length > 0
|
||||
? "No agent directories match your search."
|
||||
: "We'll suggest directories from agents on this host once they exist."
|
||||
}
|
||||
onSearchQueryChange={setWorkingDirSearchQuery}
|
||||
searchPlaceholder="Search directories..."
|
||||
emptyText={workingDirEmptyText}
|
||||
allowCustomValue
|
||||
customValuePrefix="Use"
|
||||
customValueDescription="Launch the agent in this directory"
|
||||
customValuePrefix=""
|
||||
customValueKind="directory"
|
||||
optionsPosition="above-search"
|
||||
title="Working directory"
|
||||
open={isWorkingDirOpen}
|
||||
onOpenChange={setIsWorkingDirOpen}
|
||||
@@ -1122,13 +1205,19 @@ export function DraftAgentScreen({
|
||||
options={branchComboOptions}
|
||||
value={baseBranch}
|
||||
onSelect={handleBaseBranchChange}
|
||||
onSearchQueryChange={setBranchSearchQuery}
|
||||
searchPlaceholder="Choose a base branch..."
|
||||
allowCustomValue
|
||||
customValuePrefix="Use"
|
||||
customValueDescription="Use this branch name"
|
||||
title="Select base branch"
|
||||
open={isBranchOpen}
|
||||
onOpenChange={setIsBranchOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setIsBranchOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setBranchSearchQuery("");
|
||||
}
|
||||
}}
|
||||
anchorRef={branchAnchorRef}
|
||||
/>
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ import { BackHeader } from "@/components/headers/back-header";
|
||||
import { useSessionDirectory } from "@/hooks/use-session-directory";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import type { Agent } from "@/contexts/session-context";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostAgentDraftRoute,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
type AgentMatch = {
|
||||
serverId: string;
|
||||
@@ -50,16 +54,21 @@ export function LegacyAgentIdScreen({ agentId }: { agentId: string }) {
|
||||
return;
|
||||
}
|
||||
const match = matches[0];
|
||||
router.replace(`/agent/${match.serverId}/${match.agent.id}` as any);
|
||||
router.replace(buildHostAgentDetailRoute(match.serverId, match.agent.id) as any);
|
||||
}, [isRedirecting, matches, router]);
|
||||
|
||||
const handleGoDraft = useCallback(() => {
|
||||
router.replace("/agent" as any);
|
||||
}, [router]);
|
||||
const firstMatchServerId = matches[0]?.serverId ?? null;
|
||||
if (firstMatchServerId) {
|
||||
router.replace(buildHostAgentDraftRoute(firstMatchServerId) as any);
|
||||
return;
|
||||
}
|
||||
router.replace("/" as any);
|
||||
}, [matches, router]);
|
||||
|
||||
const handleSelectMatch = useCallback(
|
||||
(match: AgentMatch) => {
|
||||
router.replace(`/agent/${match.serverId}/${match.agent.id}` as any);
|
||||
router.replace(buildHostAgentDetailRoute(match.serverId, match.agent.id) as any);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
@@ -4,9 +4,13 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { AgentList } from "@/components/agent-list";
|
||||
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
||||
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
||||
import { router } from "expo-router";
|
||||
|
||||
export default function AgentsScreen() {
|
||||
const { agents, isRevalidating, refreshAll } = useAllAgentsList();
|
||||
export function AgentsScreen({ serverId }: { serverId: string }) {
|
||||
const { agents, isRevalidating, refreshAll } = useAllAgentsList({
|
||||
serverId,
|
||||
});
|
||||
|
||||
// Track user-initiated refresh to avoid showing spinner on background revalidation
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
@@ -33,7 +37,10 @@ export default function AgentsScreen() {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title="All agents" />
|
||||
<BackHeader
|
||||
title="All agents"
|
||||
onBack={() => router.replace(buildHostAgentDraftRoute(serverId) as any)}
|
||||
/>
|
||||
<AgentList
|
||||
agents={sortedAgents}
|
||||
showCheckoutInfo={false}
|
||||
@@ -19,6 +19,7 @@ import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/cont
|
||||
import { useDaemonConnections, type ActiveConnection, type ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
||||
import { measureConnectionLatency } from "@/utils/test-daemon-connection";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { theme as defaultTheme } from "@/styles/theme";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -76,7 +77,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
letterSpacing: 0.6,
|
||||
textTransform: "uppercase",
|
||||
marginBottom: theme.spacing[3],
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
@@ -85,7 +85,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
letterSpacing: 0.4,
|
||||
textTransform: "uppercase",
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
input: {
|
||||
@@ -180,8 +179,19 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 1,
|
||||
},
|
||||
hostCardPressed: {
|
||||
opacity: 0.85,
|
||||
hostSettingsButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: "transparent",
|
||||
backgroundColor: "transparent",
|
||||
marginLeft: theme.spacing[2],
|
||||
},
|
||||
hostSettingsButtonActive: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
advancedTrigger: {
|
||||
flexDirection: "row",
|
||||
@@ -405,7 +415,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
export default function SettingsScreen() {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const params = useLocalSearchParams<{ editHost?: string }>();
|
||||
const params = useLocalSearchParams<{ editHost?: string; serverId?: string }>();
|
||||
const routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : "";
|
||||
const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings();
|
||||
const {
|
||||
daemons,
|
||||
@@ -429,8 +440,9 @@ export default function SettingsScreen() {
|
||||
const isMountedRef = useRef(true);
|
||||
const lastHandledEditHostRef = useRef<string | null>(null);
|
||||
const appVersion = Constants.expoConfig?.version ?? (Constants as any).manifest?.version ?? "0.1.0";
|
||||
const editingDaemonLive = editingDaemon
|
||||
? daemons.find((daemon) => daemon.serverId === editingDaemon.serverId) ?? null
|
||||
const editingServerId = editingDaemon?.serverId ?? null;
|
||||
const editingDaemonLive = editingServerId
|
||||
? daemons.find((daemon) => daemon.serverId === editingServerId) ?? null
|
||||
: null;
|
||||
const pendingNameHostname = useSessionStore(
|
||||
useCallback(
|
||||
@@ -448,6 +460,13 @@ export default function SettingsScreen() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Keep the edit modal bound to live registry state.
|
||||
useEffect(() => {
|
||||
if (!editingServerId) return;
|
||||
if (editingDaemonLive) return;
|
||||
setEditingDaemon(null);
|
||||
}, [editingDaemonLive, editingServerId]);
|
||||
|
||||
const waitForCondition = useCallback(
|
||||
async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
@@ -516,7 +535,7 @@ export default function SettingsScreen() {
|
||||
]);
|
||||
|
||||
const handleSaveEditDaemon = useCallback(async (nextLabelRaw: string) => {
|
||||
if (!editingDaemon) return;
|
||||
if (!editingServerId) return;
|
||||
if (isSavingEdit) return;
|
||||
|
||||
const nextLabel = nextLabelRaw.trim();
|
||||
@@ -527,7 +546,7 @@ export default function SettingsScreen() {
|
||||
|
||||
try {
|
||||
setIsSavingEdit(true);
|
||||
await updateHost(editingDaemon.serverId, { label: nextLabel });
|
||||
await updateHost(editingServerId, { label: nextLabel });
|
||||
handleCloseEditDaemon();
|
||||
} catch (error) {
|
||||
console.error("[Settings] Failed to rename host", error);
|
||||
@@ -535,7 +554,7 @@ export default function SettingsScreen() {
|
||||
} finally {
|
||||
setIsSavingEdit(false);
|
||||
}
|
||||
}, [editingDaemon, handleCloseEditDaemon, isSavingEdit, updateHost]);
|
||||
}, [editingServerId, handleCloseEditDaemon, isSavingEdit, updateHost]);
|
||||
|
||||
const handleRemoveConnection = useCallback(
|
||||
async (serverId: string, connectionId: string) => {
|
||||
@@ -550,13 +569,13 @@ export default function SettingsScreen() {
|
||||
}, []);
|
||||
|
||||
const handleAddConnectionFromModal = useCallback(() => {
|
||||
if (!editingDaemon) return;
|
||||
const serverId = editingDaemon.serverId;
|
||||
if (!editingServerId) return;
|
||||
const serverId = editingServerId;
|
||||
setEditingDaemon(null);
|
||||
setAddConnectionTargetServerId(serverId);
|
||||
setPendingEditReopenServerId(serverId);
|
||||
setIsAddHostMethodVisible(true);
|
||||
}, [editingDaemon]);
|
||||
}, [editingServerId]);
|
||||
|
||||
const handleThemeChange = useCallback(
|
||||
(newTheme: AppSettings["theme"]) => {
|
||||
@@ -637,7 +656,7 @@ export default function SettingsScreen() {
|
||||
connectionStatus={connectionStatus}
|
||||
activeConnection={activeConnection}
|
||||
lastError={lastConnectionError}
|
||||
onPress={handleEditDaemon}
|
||||
onOpenSettings={handleEditDaemon}
|
||||
/>
|
||||
);
|
||||
})
|
||||
@@ -666,16 +685,19 @@ export default function SettingsScreen() {
|
||||
setIsAddHostMethodVisible(false);
|
||||
setIsPasteLinkVisible(true);
|
||||
}}
|
||||
onScanQr={() => {
|
||||
const targetServerId = addConnectionTargetServerId;
|
||||
const source = targetServerId ? "editHost" : "settings";
|
||||
closeAddConnectionFlow();
|
||||
router.push({
|
||||
pathname: "/pair-scan",
|
||||
params: targetServerId ? { source, targetServerId } : { source },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
onScanQr={() => {
|
||||
const targetServerId = addConnectionTargetServerId;
|
||||
const source = targetServerId ? "editHost" : "settings";
|
||||
const sourceServerId = routeServerId || targetServerId || undefined;
|
||||
closeAddConnectionFlow();
|
||||
router.push({
|
||||
pathname: "/pair-scan",
|
||||
params: targetServerId
|
||||
? { source, targetServerId, sourceServerId }
|
||||
: { source, sourceServerId },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<AddHostModal
|
||||
visible={isDirectHostVisible}
|
||||
@@ -763,11 +785,11 @@ export default function SettingsScreen() {
|
||||
) : null}
|
||||
|
||||
<HostDetailModal
|
||||
visible={Boolean(editingDaemon)}
|
||||
host={editingDaemonLive ?? editingDaemon}
|
||||
connectionStatus={editingDaemon ? (connectionStates.get(editingDaemon.serverId)?.status ?? "idle") : "idle"}
|
||||
activeConnection={editingDaemon ? (connectionStates.get(editingDaemon.serverId)?.activeConnection ?? null) : null}
|
||||
lastError={editingDaemon ? (connectionStates.get(editingDaemon.serverId)?.lastError ?? null) : null}
|
||||
visible={Boolean(editingDaemonLive)}
|
||||
host={editingDaemonLive}
|
||||
connectionStatus={editingServerId ? (connectionStates.get(editingServerId)?.status ?? "idle") : "idle"}
|
||||
activeConnection={editingServerId ? (connectionStates.get(editingServerId)?.activeConnection ?? null) : null}
|
||||
lastError={editingServerId ? (connectionStates.get(editingServerId)?.lastError ?? null) : null}
|
||||
isSaving={isSavingEdit}
|
||||
onClose={handleCloseEditDaemon}
|
||||
onSave={(label) => void handleSaveEditDaemon(label)}
|
||||
@@ -873,7 +895,6 @@ function HostDetailModal({
|
||||
}: HostDetailModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [draftLabel, setDraftLabel] = useState("");
|
||||
const activeServerIdRef = useRef<string | null>(null);
|
||||
const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{ serverId: string; connectionId: string; title: string } | null>(null);
|
||||
const [isRemovingConnection, setIsRemovingConnection] = useState(false);
|
||||
|
||||
@@ -962,29 +983,21 @@ function HostDetailModal({
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.OS === "web") {
|
||||
const hasBrowserConfirm =
|
||||
typeof globalThis !== "undefined" &&
|
||||
typeof (globalThis as any).confirm === "function";
|
||||
|
||||
const confirmed = hasBrowserConfirm
|
||||
? (globalThis as any).confirm(`Restart ${host.label}? ${restartConfirmationMessage}`)
|
||||
: true;
|
||||
|
||||
if (confirmed) {
|
||||
beginServerRestart();
|
||||
void confirmDialog({
|
||||
title: `Restart ${host.label}`,
|
||||
message: restartConfirmationMessage,
|
||||
confirmLabel: "Restart",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
}).then((confirmed) => {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(`Restart ${host.label}`, restartConfirmationMessage, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Restart",
|
||||
style: "destructive",
|
||||
onPress: beginServerRestart,
|
||||
},
|
||||
]);
|
||||
beginServerRestart();
|
||||
}).catch((error) => {
|
||||
console.error(`[Settings] Failed to open restart confirmation for ${host.label}`, error);
|
||||
Alert.alert("Error", "Unable to open the restart confirmation dialog.");
|
||||
});
|
||||
}, [beginServerRestart, daemonClient, host, restartConfirmationMessage]);
|
||||
|
||||
// Status display
|
||||
@@ -1018,22 +1031,20 @@ function HostDetailModal({
|
||||
})();
|
||||
const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
|
||||
|
||||
const handleDraftLabelChange = useCallback((nextValue: string) => {
|
||||
setDraftLabel(nextValue);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !host) return;
|
||||
if (activeServerIdRef.current !== host.serverId) {
|
||||
setDraftLabel(host.label ?? "");
|
||||
activeServerIdRef.current = host.serverId;
|
||||
return;
|
||||
}
|
||||
if (!draftLabel.trim()) {
|
||||
setDraftLabel(host.label ?? "");
|
||||
}
|
||||
}, [visible, host, draftLabel]);
|
||||
// Initialize once per modal open / host switch; keep user edits fully local while typing.
|
||||
setDraftLabel(host.label ?? "");
|
||||
}, [visible, host?.serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
activeServerIdRef.current = null;
|
||||
setIsRestarting(false);
|
||||
setDraftLabel("");
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
@@ -1072,7 +1083,7 @@ function HostDetailModal({
|
||||
<AdaptiveTextInput
|
||||
style={styles.input}
|
||||
value={draftLabel}
|
||||
onChangeText={setDraftLabel}
|
||||
onChangeText={handleDraftLabelChange}
|
||||
placeholder="My Host"
|
||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||
/>
|
||||
@@ -1283,7 +1294,7 @@ interface DaemonCardProps {
|
||||
connectionStatus: ConnectionStatus;
|
||||
activeConnection: ActiveConnection | null;
|
||||
lastError: string | null;
|
||||
onPress: (daemon: HostProfile) => void;
|
||||
onOpenSettings: (daemon: HostProfile) => void;
|
||||
}
|
||||
|
||||
function DaemonCard({
|
||||
@@ -1291,7 +1302,7 @@ function DaemonCard({
|
||||
connectionStatus,
|
||||
activeConnection,
|
||||
lastError,
|
||||
onPress,
|
||||
onOpenSettings,
|
||||
}: DaemonCardProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const statusLabel = formatConnectionStatus(connectionStatus);
|
||||
@@ -1326,12 +1337,9 @@ function DaemonCard({
|
||||
})();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.hostCard, pressed && styles.hostCardPressed]}
|
||||
onPress={() => onPress(daemon)}
|
||||
<View
|
||||
style={styles.hostCard}
|
||||
testID={`daemon-card-${daemon.serverId}`}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${daemon.label}, ${statusLabel}`}
|
||||
>
|
||||
<View style={styles.hostCardContent}>
|
||||
<View style={styles.hostHeaderRow}>
|
||||
@@ -1354,10 +1362,28 @@ function DaemonCard({
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.hostSettingsButton,
|
||||
(pressed || hovered) && styles.hostSettingsButtonActive,
|
||||
]}
|
||||
onPress={() => onOpenSettings(daemon)}
|
||||
testID={`daemon-card-settings-${daemon.serverId}`}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open settings for ${daemon.label}`}
|
||||
>
|
||||
{({ pressed, hovered }) => (
|
||||
<Settings
|
||||
size={16}
|
||||
color={pressed || hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
{connectionError ? <Text style={styles.hostError}>{connectionError}</Text> : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import * as LegacyFileSystem from "expo-file-system/legacy";
|
||||
import * as Sharing from "expo-sharing";
|
||||
import type { HostProfile } from "@/contexts/daemon-registry-context";
|
||||
import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
interface DownloadProgress {
|
||||
percent: number;
|
||||
@@ -303,7 +304,7 @@ function buildDownloadUrl(
|
||||
function triggerBrowserDownload(url: string, fileName: string) {
|
||||
if (typeof document === "undefined") {
|
||||
if (typeof window !== "undefined") {
|
||||
window.open(url, "_blank", "noopener");
|
||||
void openExternalUrl(url);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
34
packages/app/src/stores/explorer-tab-memory.ts
Normal file
34
packages/app/src/stores/explorer-tab-memory.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export type ExplorerTab = "changes" | "files" | "terminals";
|
||||
|
||||
export function isExplorerTab(value: unknown): value is ExplorerTab {
|
||||
return value === "changes" || value === "files" || value === "terminals";
|
||||
}
|
||||
|
||||
export function buildExplorerCheckoutKey(serverId: string, cwd: string): string | null {
|
||||
const trimmedServerId = serverId.trim();
|
||||
const trimmedCwd = cwd.trim();
|
||||
if (!trimmedServerId || !trimmedCwd) {
|
||||
return null;
|
||||
}
|
||||
return `${trimmedServerId}::${trimmedCwd}`;
|
||||
}
|
||||
|
||||
export function coerceExplorerTabForCheckout(tab: ExplorerTab, isGit: boolean): ExplorerTab {
|
||||
if (!isGit && tab === "changes") {
|
||||
return "files";
|
||||
}
|
||||
return tab;
|
||||
}
|
||||
|
||||
export function resolveExplorerTabForCheckout(params: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
isGit: boolean;
|
||||
explorerTabByCheckout: Record<string, ExplorerTab>;
|
||||
}): ExplorerTab {
|
||||
const key = buildExplorerCheckoutKey(params.serverId, params.cwd);
|
||||
const stored = key ? params.explorerTabByCheckout[key] : null;
|
||||
const defaultTab: ExplorerTab = params.isGit ? "changes" : "files";
|
||||
const nextTab = stored && isExplorerTab(stored) ? stored : defaultTab;
|
||||
return coerceExplorerTabForCheckout(nextTab, params.isGit);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
type FocusChatInputRequest = {
|
||||
id: number;
|
||||
agentKey: string;
|
||||
};
|
||||
|
||||
interface KeyboardNavState {
|
||||
commandCenterOpen: boolean;
|
||||
altDown: boolean;
|
||||
cmdOrCtrlDown: boolean;
|
||||
/** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */
|
||||
sidebarShortcutAgentKeys: string[];
|
||||
|
||||
/** Web-only request to focus the MessageInput for the selected agent. */
|
||||
focusChatInputRequest: FocusChatInputRequest | null;
|
||||
requestFocusChatInput: (agentKey: string) => void;
|
||||
clearFocusChatInputRequest: () => void;
|
||||
|
||||
setCommandCenterOpen: (open: boolean) => void;
|
||||
setAltDown: (down: boolean) => void;
|
||||
setCmdOrCtrlDown: (down: boolean) => void;
|
||||
setSidebarShortcutAgentKeys: (keys: string[]) => void;
|
||||
resetModifiers: () => void;
|
||||
}
|
||||
|
||||
export const useKeyboardNavStore = create<KeyboardNavState>((set, get) => ({
|
||||
commandCenterOpen: false,
|
||||
altDown: false,
|
||||
cmdOrCtrlDown: false,
|
||||
sidebarShortcutAgentKeys: [],
|
||||
|
||||
focusChatInputRequest: null,
|
||||
requestFocusChatInput: (agentKey) => {
|
||||
const prev = get().focusChatInputRequest;
|
||||
const id = (prev?.id ?? 0) + 1;
|
||||
set({ focusChatInputRequest: { id, agentKey } });
|
||||
},
|
||||
clearFocusChatInputRequest: () => set({ focusChatInputRequest: null }),
|
||||
|
||||
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
|
||||
setAltDown: (down) => set({ altDown: down }),
|
||||
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
|
||||
setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }),
|
||||
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
|
||||
}));
|
||||
|
||||
62
packages/app/src/stores/keyboard-shortcuts-store.ts
Normal file
62
packages/app/src/stores/keyboard-shortcuts-store.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { create } from "zustand";
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
|
||||
export type MessageInputActionRequest = {
|
||||
id: number;
|
||||
agentKey: string;
|
||||
kind: MessageInputKeyboardActionKind;
|
||||
};
|
||||
|
||||
interface KeyboardShortcutsState {
|
||||
commandCenterOpen: boolean;
|
||||
shortcutsDialogOpen: boolean;
|
||||
altDown: boolean;
|
||||
cmdOrCtrlDown: boolean;
|
||||
/** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */
|
||||
sidebarShortcutAgentKeys: string[];
|
||||
messageInputActionRequest: MessageInputActionRequest | null;
|
||||
|
||||
setCommandCenterOpen: (open: boolean) => void;
|
||||
setShortcutsDialogOpen: (open: boolean) => void;
|
||||
setAltDown: (down: boolean) => void;
|
||||
setCmdOrCtrlDown: (down: boolean) => void;
|
||||
setSidebarShortcutAgentKeys: (keys: string[]) => void;
|
||||
resetModifiers: () => void;
|
||||
|
||||
requestMessageInputAction: (input: {
|
||||
agentKey: string;
|
||||
kind: MessageInputKeyboardActionKind;
|
||||
}) => void;
|
||||
clearMessageInputActionRequest: (id: number) => void;
|
||||
}
|
||||
|
||||
export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
|
||||
(set, get) => ({
|
||||
commandCenterOpen: false,
|
||||
shortcutsDialogOpen: false,
|
||||
altDown: false,
|
||||
cmdOrCtrlDown: false,
|
||||
sidebarShortcutAgentKeys: [],
|
||||
messageInputActionRequest: null,
|
||||
|
||||
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
|
||||
setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }),
|
||||
setAltDown: (down) => set({ altDown: down }),
|
||||
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
|
||||
setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }),
|
||||
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
|
||||
|
||||
requestMessageInputAction: ({ agentKey, kind }) => {
|
||||
const previous = get().messageInputActionRequest;
|
||||
const id = (previous?.id ?? 0) + 1;
|
||||
set({ messageInputActionRequest: { id, agentKey, kind } });
|
||||
},
|
||||
clearMessageInputActionRequest: (id) => {
|
||||
const current = get().messageInputActionRequest;
|
||||
if (!current || current.id !== id) {
|
||||
return;
|
||||
}
|
||||
set({ messageInputActionRequest: null });
|
||||
},
|
||||
})
|
||||
);
|
||||
74
packages/app/src/stores/panel-store.test.ts
Normal file
74
packages/app/src/stores/panel-store.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildExplorerCheckoutKey,
|
||||
resolveExplorerTabForCheckout,
|
||||
} from "@/stores/explorer-tab-memory";
|
||||
|
||||
describe("panel-store explorer tab resolution", () => {
|
||||
const serverId = "server-1";
|
||||
const cwd = "/tmp/repo";
|
||||
|
||||
it("defaults to changes for git checkouts", () => {
|
||||
expect(
|
||||
resolveExplorerTabForCheckout({
|
||||
serverId,
|
||||
cwd,
|
||||
isGit: true,
|
||||
explorerTabByCheckout: {},
|
||||
})
|
||||
).toBe("changes");
|
||||
});
|
||||
|
||||
it("defaults to files for non-git checkouts", () => {
|
||||
expect(
|
||||
resolveExplorerTabForCheckout({
|
||||
serverId,
|
||||
cwd,
|
||||
isGit: false,
|
||||
explorerTabByCheckout: {},
|
||||
})
|
||||
).toBe("files");
|
||||
});
|
||||
|
||||
it("restores a stored files tab for git checkouts", () => {
|
||||
const key = buildExplorerCheckoutKey(serverId, cwd)!;
|
||||
expect(
|
||||
resolveExplorerTabForCheckout({
|
||||
serverId,
|
||||
cwd,
|
||||
isGit: true,
|
||||
explorerTabByCheckout: {
|
||||
[key]: "files",
|
||||
},
|
||||
})
|
||||
).toBe("files");
|
||||
});
|
||||
|
||||
it("restores a stored terminals tab for git checkouts", () => {
|
||||
const key = buildExplorerCheckoutKey(serverId, cwd)!;
|
||||
expect(
|
||||
resolveExplorerTabForCheckout({
|
||||
serverId,
|
||||
cwd,
|
||||
isGit: true,
|
||||
explorerTabByCheckout: {
|
||||
[key]: "terminals",
|
||||
},
|
||||
})
|
||||
).toBe("terminals");
|
||||
});
|
||||
|
||||
it("coerces stored changes to files for non-git checkouts", () => {
|
||||
const key = buildExplorerCheckoutKey(serverId, cwd)!;
|
||||
expect(
|
||||
resolveExplorerTabForCheckout({
|
||||
serverId,
|
||||
cwd,
|
||||
isGit: false,
|
||||
explorerTabByCheckout: {
|
||||
[key]: "changes",
|
||||
},
|
||||
})
|
||||
).toBe("files");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,14 @@ import { create } from "zustand";
|
||||
import { persist, createJSONStorage } from "zustand/middleware";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { Platform } from "react-native";
|
||||
import {
|
||||
buildExplorerCheckoutKey,
|
||||
coerceExplorerTabForCheckout,
|
||||
isExplorerTab,
|
||||
resolveExplorerTabForCheckout,
|
||||
type ExplorerTab,
|
||||
} from "./explorer-tab-memory";
|
||||
export type { ExplorerTab } from "./explorer-tab-memory";
|
||||
|
||||
/**
|
||||
* Mobile panel state machine.
|
||||
@@ -27,8 +35,12 @@ interface DesktopSidebarState {
|
||||
fileExplorerOpen: boolean;
|
||||
}
|
||||
|
||||
export type ExplorerTab = "changes" | "files";
|
||||
export type SortOption = "name" | "modified" | "size";
|
||||
export interface ExplorerCheckoutContext {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
isGit: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = Platform.OS === "web" ? 640 : 400;
|
||||
export const MIN_EXPLORER_SIDEBAR_WIDTH = 280;
|
||||
@@ -48,6 +60,8 @@ interface PanelState {
|
||||
|
||||
// File explorer settings (shared between mobile/desktop)
|
||||
explorerTab: ExplorerTab;
|
||||
explorerTabByCheckout: Record<string, ExplorerTab>;
|
||||
activeExplorerCheckout: ExplorerCheckoutContext | null;
|
||||
explorerWidth: number;
|
||||
explorerSortOption: SortOption;
|
||||
explorerFilesSplitRatio: number;
|
||||
@@ -61,6 +75,9 @@ interface PanelState {
|
||||
|
||||
// File explorer settings actions
|
||||
setExplorerTab: (tab: ExplorerTab) => void;
|
||||
setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void;
|
||||
activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void;
|
||||
setActiveExplorerCheckout: (checkout: ExplorerCheckoutContext | null) => void;
|
||||
setExplorerWidth: (width: number) => void;
|
||||
setExplorerSortOption: (option: SortOption) => void;
|
||||
setExplorerFilesSplitRatio: (ratio: number) => void;
|
||||
@@ -81,6 +98,18 @@ function clampExplorerFilesSplitRatio(ratio: number): number {
|
||||
return clampNumber(ratio, MIN_EXPLORER_FILES_SPLIT_RATIO, MAX_EXPLORER_FILES_SPLIT_RATIO);
|
||||
}
|
||||
|
||||
function resolveExplorerTabFromActiveCheckout(state: PanelState): ExplorerTab | null {
|
||||
if (!state.activeExplorerCheckout) {
|
||||
return null;
|
||||
}
|
||||
return resolveExplorerTabForCheckout({
|
||||
serverId: state.activeExplorerCheckout.serverId,
|
||||
cwd: state.activeExplorerCheckout.cwd,
|
||||
isGit: state.activeExplorerCheckout.isGit,
|
||||
explorerTabByCheckout: state.explorerTabByCheckout,
|
||||
});
|
||||
}
|
||||
|
||||
const DEFAULT_DESKTOP_OPEN = Platform.OS === "web";
|
||||
|
||||
export const usePanelStore = create<PanelState>()(
|
||||
@@ -97,6 +126,8 @@ export const usePanelStore = create<PanelState>()(
|
||||
|
||||
// File explorer defaults
|
||||
explorerTab: "changes",
|
||||
explorerTabByCheckout: {},
|
||||
activeExplorerCheckout: null,
|
||||
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
|
||||
explorerSortOption: "name",
|
||||
explorerFilesSplitRatio: DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
|
||||
@@ -108,10 +139,14 @@ export const usePanelStore = create<PanelState>()(
|
||||
})),
|
||||
|
||||
openFileExplorer: () =>
|
||||
set((state) => ({
|
||||
mobileView: "file-explorer",
|
||||
desktop: { ...state.desktop, fileExplorerOpen: true },
|
||||
})),
|
||||
set((state) => {
|
||||
const resolvedTab = resolveExplorerTabFromActiveCheckout(state);
|
||||
return {
|
||||
mobileView: "file-explorer",
|
||||
desktop: { ...state.desktop, fileExplorerOpen: true },
|
||||
...(resolvedTab ? { explorerTab: resolvedTab } : {}),
|
||||
};
|
||||
}),
|
||||
|
||||
closeToAgent: () =>
|
||||
set((state) => ({
|
||||
@@ -142,17 +177,63 @@ export const usePanelStore = create<PanelState>()(
|
||||
toggleFileExplorer: () =>
|
||||
set((state) => {
|
||||
// Mobile: toggle between agent and file-explorer
|
||||
const newMobileView = state.mobileView === "file-explorer" ? "agent" : "file-explorer";
|
||||
return {
|
||||
mobileView: newMobileView,
|
||||
const willOpenMobile = state.mobileView !== "file-explorer";
|
||||
const willOpenDesktop = !state.desktop.fileExplorerOpen;
|
||||
const nextState: Partial<PanelState> = {
|
||||
mobileView: willOpenMobile ? "file-explorer" : "agent",
|
||||
desktop: {
|
||||
...state.desktop,
|
||||
fileExplorerOpen: !state.desktop.fileExplorerOpen,
|
||||
fileExplorerOpen: willOpenDesktop,
|
||||
},
|
||||
};
|
||||
if (willOpenMobile || willOpenDesktop) {
|
||||
const resolvedTab = resolveExplorerTabFromActiveCheckout(state);
|
||||
if (resolvedTab) {
|
||||
nextState.explorerTab = resolvedTab;
|
||||
}
|
||||
}
|
||||
return nextState;
|
||||
}),
|
||||
|
||||
setExplorerTab: (tab) => set({ explorerTab: tab }),
|
||||
setExplorerTabForCheckout: ({ serverId, cwd, isGit, tab }) =>
|
||||
set((state) => {
|
||||
const resolvedTab = coerceExplorerTabForCheckout(tab, isGit);
|
||||
const key = buildExplorerCheckoutKey(serverId, cwd);
|
||||
const nextState: Partial<PanelState> = { explorerTab: resolvedTab };
|
||||
if (key) {
|
||||
const current = state.explorerTabByCheckout[key];
|
||||
if (current !== resolvedTab) {
|
||||
nextState.explorerTabByCheckout = {
|
||||
...state.explorerTabByCheckout,
|
||||
[key]: resolvedTab,
|
||||
};
|
||||
}
|
||||
}
|
||||
return nextState;
|
||||
}),
|
||||
activateExplorerTabForCheckout: (checkout) =>
|
||||
set((state) => ({
|
||||
activeExplorerCheckout: checkout,
|
||||
explorerTab: resolveExplorerTabForCheckout({
|
||||
serverId: checkout.serverId,
|
||||
cwd: checkout.cwd,
|
||||
isGit: checkout.isGit,
|
||||
explorerTabByCheckout: state.explorerTabByCheckout,
|
||||
}),
|
||||
})),
|
||||
setActiveExplorerCheckout: (checkout) =>
|
||||
set((state) => {
|
||||
const current = state.activeExplorerCheckout;
|
||||
if (
|
||||
current?.serverId === checkout?.serverId &&
|
||||
current?.cwd === checkout?.cwd &&
|
||||
current?.isGit === checkout?.isGit
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
return { activeExplorerCheckout: checkout };
|
||||
}),
|
||||
setExplorerWidth: (width) => set({ explorerWidth: clampWidth(width) }),
|
||||
setExplorerSortOption: (option) => set({ explorerSortOption: option }),
|
||||
setExplorerFilesSplitRatio: (ratio) =>
|
||||
@@ -164,7 +245,7 @@ export const usePanelStore = create<PanelState>()(
|
||||
}),
|
||||
{
|
||||
name: "panel-state",
|
||||
version: 3,
|
||||
version: 4,
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
migrate: (persistedState, version) => {
|
||||
const state = persistedState as Partial<PanelState> & Record<string, unknown>;
|
||||
@@ -197,12 +278,29 @@ export const usePanelStore = create<PanelState>()(
|
||||
}
|
||||
}
|
||||
|
||||
if (version < 4 || typeof state.explorerTabByCheckout !== "object" || !state.explorerTabByCheckout) {
|
||||
state.explorerTabByCheckout = {};
|
||||
} else {
|
||||
const entries = Object.entries(state.explorerTabByCheckout as Record<string, unknown>);
|
||||
const next: Record<string, ExplorerTab> = {};
|
||||
for (const [key, value] of entries) {
|
||||
if (!isExplorerTab(value)) {
|
||||
continue;
|
||||
}
|
||||
next[key] = value;
|
||||
}
|
||||
state.explorerTabByCheckout = next;
|
||||
}
|
||||
|
||||
state.activeExplorerCheckout = null;
|
||||
|
||||
return state as PanelState;
|
||||
},
|
||||
partialize: (state) => ({
|
||||
mobileView: state.mobileView,
|
||||
desktop: state.desktop,
|
||||
explorerTab: state.explorerTab,
|
||||
explorerTabByCheckout: state.explorerTabByCheckout,
|
||||
explorerWidth: state.explorerWidth,
|
||||
explorerSortOption: state.explorerSortOption,
|
||||
explorerFilesSplitRatio: state.explorerFilesSplitRatio,
|
||||
@@ -234,10 +332,14 @@ export function usePanelState(isMobile: boolean) {
|
||||
toggleFileExplorer: store.toggleFileExplorer,
|
||||
// Explorer settings
|
||||
explorerTab: store.explorerTab,
|
||||
explorerTabByCheckout: store.explorerTabByCheckout,
|
||||
explorerWidth: store.explorerWidth,
|
||||
explorerSortOption: store.explorerSortOption,
|
||||
explorerFilesSplitRatio: store.explorerFilesSplitRatio,
|
||||
setExplorerTab: store.setExplorerTab,
|
||||
setExplorerTabForCheckout: store.setExplorerTabForCheckout,
|
||||
activateExplorerTabForCheckout: store.activateExplorerTabForCheckout,
|
||||
setActiveExplorerCheckout: store.setActiveExplorerCheckout,
|
||||
setExplorerWidth: store.setExplorerWidth,
|
||||
setExplorerSortOption: store.setExplorerSortOption,
|
||||
setExplorerFilesSplitRatio: store.setExplorerFilesSplitRatio,
|
||||
@@ -262,10 +364,14 @@ export function usePanelState(isMobile: boolean) {
|
||||
toggleFileExplorer: store.toggleFileExplorer,
|
||||
// Explorer settings
|
||||
explorerTab: store.explorerTab,
|
||||
explorerTabByCheckout: store.explorerTabByCheckout,
|
||||
explorerWidth: store.explorerWidth,
|
||||
explorerSortOption: store.explorerSortOption,
|
||||
explorerFilesSplitRatio: store.explorerFilesSplitRatio,
|
||||
setExplorerTab: store.setExplorerTab,
|
||||
setExplorerTabForCheckout: store.setExplorerTabForCheckout,
|
||||
activateExplorerTabForCheckout: store.activateExplorerTabForCheckout,
|
||||
setActiveExplorerCheckout: store.setActiveExplorerCheckout,
|
||||
setExplorerWidth: store.setExplorerWidth,
|
||||
setExplorerSortOption: store.setExplorerSortOption,
|
||||
setExplorerFilesSplitRatio: store.setExplorerFilesSplitRatio,
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
FileDownloadTokenResponse,
|
||||
GitSetupOptions,
|
||||
ProjectPlacementPayload,
|
||||
ServerCapabilities,
|
||||
} from "@server/shared/messages";
|
||||
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
||||
|
||||
@@ -155,8 +156,15 @@ export interface DaemonConnectionSnapshot {
|
||||
export type DaemonServerInfo = {
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
capabilities?: ServerCapabilities;
|
||||
};
|
||||
|
||||
export interface AgentTimelineCursorState {
|
||||
epoch: string;
|
||||
startSeq: number;
|
||||
endSeq: number;
|
||||
}
|
||||
|
||||
// Per-session state
|
||||
export interface SessionState {
|
||||
serverId: string;
|
||||
@@ -189,6 +197,7 @@ export interface SessionState {
|
||||
// Stream state (head/tail model)
|
||||
agentStreamTail: Map<string, StreamItem[]>;
|
||||
agentStreamHead: Map<string, StreamItem[]>;
|
||||
agentTimelineCursor: Map<string, AgentTimelineCursorState>;
|
||||
historySyncGeneration: number;
|
||||
agentHistorySyncGeneration: Map<string, number>;
|
||||
|
||||
@@ -201,9 +210,6 @@ export interface SessionState {
|
||||
// Permissions
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
|
||||
// Git diffs
|
||||
gitDiffs: Map<string, string>;
|
||||
|
||||
// File explorer
|
||||
fileExplorer: Map<string, AgentFileExplorerState>;
|
||||
|
||||
@@ -243,6 +249,12 @@ interface SessionStoreActions {
|
||||
setAgentStreamTail: (serverId: string, state: Map<string, StreamItem[]> | ((prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>)) => void;
|
||||
setAgentStreamHead: (serverId: string, state: Map<string, StreamItem[]> | ((prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>)) => void;
|
||||
clearAgentStreamHead: (serverId: string, agentId: string) => void;
|
||||
setAgentTimelineCursor: (
|
||||
serverId: string,
|
||||
state:
|
||||
| Map<string, AgentTimelineCursorState>
|
||||
| ((prev: Map<string, AgentTimelineCursorState>) => Map<string, AgentTimelineCursorState>)
|
||||
) => void;
|
||||
bumpHistorySyncGeneration: (serverId: string) => void;
|
||||
markAgentHistorySynchronized: (serverId: string, agentId: string) => void;
|
||||
|
||||
@@ -258,9 +270,6 @@ interface SessionStoreActions {
|
||||
// Permissions
|
||||
setPendingPermissions: (serverId: string, perms: Map<string, PendingPermission> | ((prev: Map<string, PendingPermission>) => Map<string, PendingPermission>)) => void;
|
||||
|
||||
// Git diffs
|
||||
setGitDiffs: (serverId: string, diffs: Map<string, string> | ((prev: Map<string, string>) => Map<string, string>)) => void;
|
||||
|
||||
// File explorer
|
||||
setFileExplorer: (serverId: string, state: Map<string, AgentFileExplorerState> | ((prev: Map<string, AgentFileExplorerState>) => Map<string, AgentFileExplorerState>)) => void;
|
||||
|
||||
@@ -327,17 +336,24 @@ function createInitialSessionState(serverId: string, client: DaemonClient, audio
|
||||
currentAssistantMessage: "",
|
||||
agentStreamTail: new Map(),
|
||||
agentStreamHead: new Map(),
|
||||
agentTimelineCursor: new Map(),
|
||||
historySyncGeneration: 0,
|
||||
agentHistorySyncGeneration: new Map(),
|
||||
initializingAgents: new Map(),
|
||||
agents: new Map(),
|
||||
pendingPermissions: new Map(),
|
||||
gitDiffs: new Map(),
|
||||
fileExplorer: new Map(),
|
||||
queuedMessages: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
function areServerCapabilitiesEqual(
|
||||
current: ServerCapabilities | undefined,
|
||||
next: ServerCapabilities | undefined
|
||||
): boolean {
|
||||
return JSON.stringify(current ?? null) === JSON.stringify(next ?? null);
|
||||
}
|
||||
|
||||
export const useSessionStore = create<SessionStore>()(
|
||||
subscribeWithSelector((set, get) => ({
|
||||
sessions: {},
|
||||
@@ -439,8 +455,14 @@ export const useSessionStore = create<SessionStore>()(
|
||||
|
||||
const nextHostname = info.hostname?.trim() || null;
|
||||
const prevHostname = session.serverInfo?.hostname?.trim() || null;
|
||||
const nextCapabilities = info.capabilities;
|
||||
const prevCapabilities = session.serverInfo?.capabilities;
|
||||
|
||||
if (session.serverInfo?.serverId === info.serverId && prevHostname === nextHostname) {
|
||||
if (
|
||||
session.serverInfo?.serverId === info.serverId &&
|
||||
prevHostname === nextHostname &&
|
||||
areServerCapabilitiesEqual(prevCapabilities, nextCapabilities)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
@@ -455,7 +477,11 @@ export const useSessionStore = create<SessionStore>()(
|
||||
...prev.sessions,
|
||||
[serverId]: {
|
||||
...session,
|
||||
serverInfo: { serverId: info.serverId, hostname: nextHostname },
|
||||
serverInfo: {
|
||||
serverId: info.serverId,
|
||||
hostname: nextHostname,
|
||||
...(nextCapabilities ? { capabilities: nextCapabilities } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -610,6 +636,30 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setAgentTimelineCursor: (serverId, state) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
const nextState =
|
||||
typeof state === "function" ? state(session.agentTimelineCursor) : state;
|
||||
if (session.agentTimelineCursor === nextState) {
|
||||
return prev;
|
||||
}
|
||||
logSessionStoreUpdate("setAgentTimelineCursor", serverId, {
|
||||
agentCount: nextState.size,
|
||||
});
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, agentTimelineCursor: nextState },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
bumpHistorySyncGeneration: (serverId) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
@@ -745,28 +795,6 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
// Git diffs
|
||||
setGitDiffs: (serverId, diffs) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
const nextDiffs = typeof diffs === "function" ? diffs(session.gitDiffs) : diffs;
|
||||
if (session.gitDiffs === nextDiffs) {
|
||||
return prev;
|
||||
}
|
||||
logSessionStoreUpdate("setGitDiffs", serverId, { count: nextDiffs.size });
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, gitDiffs: nextDiffs },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// File explorer
|
||||
setFileExplorer: (serverId, state) => {
|
||||
set((prev) => {
|
||||
|
||||
77
packages/app/src/terminal/runtime/terminal-debug.ts
Normal file
77
packages/app/src/terminal/runtime/terminal-debug.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
type TerminalDebugGlobal = {
|
||||
__PASEO_TERMINAL_DEBUG?: boolean;
|
||||
};
|
||||
|
||||
type TerminalDebugLogInput = {
|
||||
scope: string;
|
||||
event: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function resolveGlobalDebugFlag(): boolean | null {
|
||||
if (typeof globalThis === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = (globalThis as TerminalDebugGlobal).__PASEO_TERMINAL_DEBUG;
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isTerminalDebugEnabled(): boolean {
|
||||
const globalFlag = resolveGlobalDebugFlag();
|
||||
if (globalFlag !== null) {
|
||||
return globalFlag;
|
||||
}
|
||||
return process.env.NODE_ENV === "development";
|
||||
}
|
||||
|
||||
export function terminalDebugLog(input: TerminalDebugLogInput): void {
|
||||
if (!isTerminalDebugEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = input.details
|
||||
? { ...input.details, ts: Date.now() }
|
||||
: { ts: Date.now() };
|
||||
console.log(`[terminal][${input.scope}] ${input.event}`, payload);
|
||||
}
|
||||
|
||||
function escapeControlBytes(input: { text: string }): string {
|
||||
let output = "";
|
||||
for (const char of input.text) {
|
||||
const code = char.charCodeAt(0);
|
||||
if (code === 10) {
|
||||
output += "\\n";
|
||||
continue;
|
||||
}
|
||||
if (code === 13) {
|
||||
output += "\\r";
|
||||
continue;
|
||||
}
|
||||
if (code === 9) {
|
||||
output += "\\t";
|
||||
continue;
|
||||
}
|
||||
if (code < 32 || code === 127) {
|
||||
output += `\\x${code.toString(16).padStart(2, "0")}`;
|
||||
continue;
|
||||
}
|
||||
output += char;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function summarizeTerminalText(input: {
|
||||
text: string;
|
||||
maxChars?: number;
|
||||
}): string {
|
||||
const maxChars = input.maxChars ?? 80;
|
||||
const escaped = escapeControlBytes({ text: input.text });
|
||||
if (escaped.length <= maxChars) {
|
||||
return escaped;
|
||||
}
|
||||
return `${escaped.slice(0, maxChars)}…`;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
695
packages/app/src/terminal/runtime/terminal-emulator-runtime.ts
Normal file
695
packages/app/src/terminal/runtime/terminal-emulator-runtime.ts
Normal file
@@ -0,0 +1,695 @@
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import {
|
||||
type PendingTerminalModifiers,
|
||||
isTerminalModifierDomKey,
|
||||
mergeTerminalModifiers,
|
||||
normalizeDomTerminalKey,
|
||||
normalizeTerminalTransportKey,
|
||||
shouldInterceptDomTerminalKey,
|
||||
} from "@/utils/terminal-keys";
|
||||
import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
|
||||
|
||||
export type TerminalEmulatorRuntimeTheme = {
|
||||
backgroundColor: string;
|
||||
foregroundColor: string;
|
||||
cursorColor: string;
|
||||
};
|
||||
|
||||
export type TerminalEmulatorRuntimeMountInput = {
|
||||
root: HTMLDivElement;
|
||||
host: HTMLDivElement;
|
||||
initialOutputText: string;
|
||||
theme: TerminalEmulatorRuntimeTheme;
|
||||
};
|
||||
|
||||
export type TerminalEmulatorRuntimeCallbacks = {
|
||||
onInput?: (data: string) => Promise<void> | void;
|
||||
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
|
||||
onTerminalKey?: (input: {
|
||||
key: string;
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
meta: boolean;
|
||||
}) => Promise<void> | void;
|
||||
onPendingModifiersConsumed?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
type TerminalEmulatorRuntimeDisposables = {
|
||||
disposeInput: () => void;
|
||||
disconnectResizeObserver: () => void;
|
||||
removeWindowResize: () => void;
|
||||
removeVisualViewportResize: () => void;
|
||||
clearFitInterval: () => void;
|
||||
clearFitTimeouts: () => void;
|
||||
removeFontListeners: () => void;
|
||||
removeTouchListeners: () => void;
|
||||
restoreDocumentStyles: () => void;
|
||||
restoreViewportStyles: () => void;
|
||||
disposeFitAddon: () => void;
|
||||
disposeTerminal: () => void;
|
||||
};
|
||||
|
||||
type TerminalOutputOperation = {
|
||||
type: "write" | "clear";
|
||||
text: string;
|
||||
onCommitted?: () => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__paseoTerminal?: Terminal;
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
|
||||
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 {
|
||||
private callbacks: TerminalEmulatorRuntimeCallbacks = {};
|
||||
private pendingModifiers: PendingTerminalModifiers = {
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
alt: false,
|
||||
};
|
||||
private terminal: Terminal | null = null;
|
||||
private fitAddon: FitAddon | null = null;
|
||||
private lastSize: { rows: number; cols: number } | null = null;
|
||||
private cleanup: (() => void) | null = null;
|
||||
private outputOperations: TerminalOutputOperation[] = [];
|
||||
private inFlightOutputOperation: TerminalOutputOperation | null = null;
|
||||
private inFlightOutputOperationTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
setCallbacks(input: { callbacks: TerminalEmulatorRuntimeCallbacks }): void {
|
||||
this.callbacks = input.callbacks;
|
||||
}
|
||||
|
||||
setPendingModifiers(input: { pendingModifiers: PendingTerminalModifiers }): void {
|
||||
this.pendingModifiers = input.pendingModifiers;
|
||||
}
|
||||
|
||||
mount(input: TerminalEmulatorRuntimeMountInput): void {
|
||||
terminalDebugLog({
|
||||
scope: "emulator-runtime",
|
||||
event: "mount:start",
|
||||
details: {
|
||||
initialOutputLength: input.initialOutputText.length,
|
||||
},
|
||||
});
|
||||
this.unmount();
|
||||
|
||||
input.host.innerHTML = "";
|
||||
this.lastSize = null;
|
||||
|
||||
const terminal = new Terminal({
|
||||
allowProposedApi: true,
|
||||
convertEol: false,
|
||||
cursorBlink: true,
|
||||
cursorStyle: "bar",
|
||||
fontFamily: "'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', monospace",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.25,
|
||||
scrollback: 10_000,
|
||||
theme: {
|
||||
background: input.theme.backgroundColor,
|
||||
foreground: input.theme.foregroundColor,
|
||||
cursor: input.theme.cursorColor,
|
||||
},
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(input.host);
|
||||
|
||||
const restoreDocumentStyles = this.applyDocumentBoundsStyles({
|
||||
root: input.root,
|
||||
});
|
||||
const restoreViewportStyles = this.applyViewportTouchStyles({
|
||||
host: input.host,
|
||||
});
|
||||
|
||||
this.terminal = terminal;
|
||||
this.fitAddon = fitAddon;
|
||||
window.__paseoTerminal = terminal;
|
||||
|
||||
const fitAndEmitResize = (force: boolean): void => {
|
||||
const currentTerminal = this.terminal;
|
||||
const currentFitAddon = this.fitAddon;
|
||||
if (!currentTerminal || !currentFitAddon) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
currentFitAddon.fit();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRows = currentTerminal.rows;
|
||||
const nextCols = currentTerminal.cols;
|
||||
const previous = this.lastSize;
|
||||
if (!force && previous && previous.rows === nextRows && previous.cols === nextCols) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastSize = { rows: nextRows, cols: nextCols };
|
||||
terminalDebugLog({
|
||||
scope: "emulator-runtime",
|
||||
event: "resize:emit",
|
||||
details: {
|
||||
rows: nextRows,
|
||||
cols: nextCols,
|
||||
force,
|
||||
},
|
||||
});
|
||||
this.callbacks.onResize?.({
|
||||
rows: nextRows,
|
||||
cols: nextCols,
|
||||
});
|
||||
};
|
||||
|
||||
fitAndEmitResize(true);
|
||||
|
||||
const inputDisposable = terminal.onData((data) => {
|
||||
terminalDebugLog({
|
||||
scope: "emulator-runtime",
|
||||
event: "input:onData",
|
||||
details: {
|
||||
length: data.length,
|
||||
preview: summarizeTerminalText({ text: data, maxChars: 64 }),
|
||||
},
|
||||
});
|
||||
this.callbacks.onInput?.(data);
|
||||
});
|
||||
|
||||
terminal.attachCustomKeyEventHandler((event) => {
|
||||
if (event.type !== "keydown" || event.isComposing) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedKey = normalizeDomTerminalKey(event.key);
|
||||
if (!normalizedKey || isTerminalModifierDomKey(event.key)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
!shouldInterceptDomTerminalKey({
|
||||
key: normalizedKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
altKey: event.altKey,
|
||||
pendingModifiers: this.pendingModifiers,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const modifiers = mergeTerminalModifiers({
|
||||
pendingModifiers: this.pendingModifiers,
|
||||
ctrlKey: event.ctrlKey,
|
||||
shiftKey: event.shiftKey,
|
||||
altKey: event.altKey,
|
||||
metaKey: event.metaKey,
|
||||
});
|
||||
this.callbacks.onTerminalKey?.({
|
||||
key: normalizeTerminalTransportKey(normalizedKey),
|
||||
...modifiers,
|
||||
});
|
||||
terminalDebugLog({
|
||||
scope: "emulator-runtime",
|
||||
event: "key:intercepted",
|
||||
details: {
|
||||
key: normalizedKey,
|
||||
ctrl: modifiers.ctrl,
|
||||
shift: modifiers.shift,
|
||||
alt: modifiers.alt,
|
||||
meta: modifiers.meta,
|
||||
},
|
||||
});
|
||||
|
||||
if (this.pendingModifiers.ctrl || this.pendingModifiers.shift || this.pendingModifiers.alt) {
|
||||
this.callbacks.onPendingModifiersConsumed?.();
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
});
|
||||
|
||||
const removeTouchListeners = this.setupTouchScrollHandlers({
|
||||
root: input.root,
|
||||
host: input.host,
|
||||
terminal,
|
||||
});
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAndEmitResize(false);
|
||||
});
|
||||
resizeObserver.observe(input.root);
|
||||
resizeObserver.observe(input.host);
|
||||
|
||||
const windowResizeHandler = () => fitAndEmitResize(false);
|
||||
window.addEventListener("resize", windowResizeHandler);
|
||||
|
||||
const visualViewport = window.visualViewport;
|
||||
const visualViewportResizeHandler = () => fitAndEmitResize(false);
|
||||
visualViewport?.addEventListener("resize", visualViewportResizeHandler);
|
||||
|
||||
const fitInterval = window.setInterval(() => {
|
||||
fitAndEmitResize(false);
|
||||
}, 250);
|
||||
const fitTimeouts = FIT_TIMEOUT_DELAYS_MS.map((delayMs) =>
|
||||
window.setTimeout(() => {
|
||||
fitAndEmitResize(true);
|
||||
}, delayMs)
|
||||
);
|
||||
|
||||
const fontSet = document.fonts;
|
||||
const fontReadyHandler = () => {
|
||||
fitAndEmitResize(true);
|
||||
};
|
||||
fontSet?.addEventListener?.("loadingdone", fontReadyHandler);
|
||||
void fontSet?.ready
|
||||
.then(() => {
|
||||
fitAndEmitResize(true);
|
||||
})
|
||||
.catch(() => {
|
||||
// no-op
|
||||
});
|
||||
|
||||
window.setTimeout(() => {
|
||||
fitAndEmitResize(true);
|
||||
}, 0);
|
||||
|
||||
if (input.initialOutputText.length > 0) {
|
||||
terminal.write(input.initialOutputText);
|
||||
terminalDebugLog({
|
||||
scope: "emulator-runtime",
|
||||
event: "output:initial-write",
|
||||
details: {
|
||||
length: input.initialOutputText.length,
|
||||
preview: summarizeTerminalText({
|
||||
text: input.initialOutputText,
|
||||
maxChars: 96,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.processOutputQueue();
|
||||
|
||||
const disposables: TerminalEmulatorRuntimeDisposables = {
|
||||
disposeInput: () => {
|
||||
inputDisposable.dispose();
|
||||
},
|
||||
disconnectResizeObserver: () => {
|
||||
resizeObserver.disconnect();
|
||||
},
|
||||
removeWindowResize: () => {
|
||||
window.removeEventListener("resize", windowResizeHandler);
|
||||
},
|
||||
removeVisualViewportResize: () => {
|
||||
visualViewport?.removeEventListener("resize", visualViewportResizeHandler);
|
||||
},
|
||||
clearFitInterval: () => {
|
||||
window.clearInterval(fitInterval);
|
||||
},
|
||||
clearFitTimeouts: () => {
|
||||
for (const handle of fitTimeouts) {
|
||||
window.clearTimeout(handle);
|
||||
}
|
||||
},
|
||||
removeFontListeners: () => {
|
||||
fontSet?.removeEventListener?.("loadingdone", fontReadyHandler);
|
||||
},
|
||||
removeTouchListeners,
|
||||
restoreDocumentStyles,
|
||||
restoreViewportStyles,
|
||||
disposeFitAddon: () => {
|
||||
fitAddon.dispose();
|
||||
},
|
||||
disposeTerminal: () => {
|
||||
terminal.dispose();
|
||||
},
|
||||
};
|
||||
|
||||
this.cleanup = () => {
|
||||
disposables.disposeInput();
|
||||
disposables.disconnectResizeObserver();
|
||||
disposables.removeWindowResize();
|
||||
disposables.removeVisualViewportResize();
|
||||
disposables.clearFitInterval();
|
||||
disposables.clearFitTimeouts();
|
||||
disposables.removeFontListeners();
|
||||
disposables.removeTouchListeners();
|
||||
disposables.disposeFitAddon();
|
||||
disposables.disposeTerminal();
|
||||
disposables.restoreDocumentStyles();
|
||||
disposables.restoreViewportStyles();
|
||||
};
|
||||
}
|
||||
|
||||
write(input: { text: string; onCommitted?: () => void }): void {
|
||||
if (input.text.length === 0) {
|
||||
input.onCommitted?.();
|
||||
return;
|
||||
}
|
||||
this.outputOperations.push({
|
||||
type: "write",
|
||||
text: input.text,
|
||||
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
|
||||
});
|
||||
terminalDebugLog({
|
||||
scope: "emulator-runtime",
|
||||
event: "output:enqueue",
|
||||
details: {
|
||||
chunkLength: input.text.length,
|
||||
queueLength: this.outputOperations.length,
|
||||
preview: summarizeTerminalText({ text: input.text, maxChars: 64 }),
|
||||
},
|
||||
});
|
||||
this.processOutputQueue();
|
||||
}
|
||||
|
||||
clear(input?: { onCommitted?: () => void }): void {
|
||||
this.outputOperations.push({
|
||||
type: "clear",
|
||||
text: "",
|
||||
...(input?.onCommitted ? { onCommitted: input.onCommitted } : {}),
|
||||
});
|
||||
terminalDebugLog({
|
||||
scope: "emulator-runtime",
|
||||
event: "output:clear",
|
||||
});
|
||||
this.processOutputQueue();
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this.terminal?.focus();
|
||||
}
|
||||
|
||||
unmount(): void {
|
||||
terminalDebugLog({
|
||||
scope: "emulator-runtime",
|
||||
event: "mount:unmount",
|
||||
details: {
|
||||
pendingWriteOperations: this.outputOperations.length,
|
||||
},
|
||||
});
|
||||
this.clearInFlightOutputTimeout();
|
||||
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 = null;
|
||||
if (window.__paseoTerminal === this.terminal) {
|
||||
window.__paseoTerminal = undefined;
|
||||
}
|
||||
this.terminal = null;
|
||||
this.fitAddon = null;
|
||||
this.lastSize = null;
|
||||
}
|
||||
|
||||
private processOutputQueue(): void {
|
||||
if (this.inFlightOutputOperation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const terminal = this.terminal;
|
||||
if (!terminal) {
|
||||
return;
|
||||
}
|
||||
|
||||
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({
|
||||
scope: "emulator-runtime",
|
||||
event: "output:flush",
|
||||
details: {
|
||||
length: text.length,
|
||||
preview: summarizeTerminalText({ text, maxChars: 96 }),
|
||||
},
|
||||
});
|
||||
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 {
|
||||
const documentElement = document.documentElement;
|
||||
const body = document.body;
|
||||
const rootContainer = input.root.parentElement;
|
||||
|
||||
const previousDocumentElementOverflow = documentElement.style.overflow;
|
||||
const previousDocumentElementWidth = documentElement.style.width;
|
||||
const previousDocumentElementHeight = documentElement.style.height;
|
||||
|
||||
const previousBodyOverflow = body.style.overflow;
|
||||
const previousBodyWidth = body.style.width;
|
||||
const previousBodyHeight = body.style.height;
|
||||
const previousBodyMargin = body.style.margin;
|
||||
const previousBodyPadding = body.style.padding;
|
||||
|
||||
const previousRootOverflow = rootContainer?.style.overflow ?? "";
|
||||
const previousRootWidth = rootContainer?.style.width ?? "";
|
||||
const previousRootHeight = rootContainer?.style.height ?? "";
|
||||
|
||||
documentElement.style.overflow = "hidden";
|
||||
documentElement.style.width = "100%";
|
||||
documentElement.style.height = "100%";
|
||||
|
||||
body.style.overflow = "hidden";
|
||||
body.style.width = "100%";
|
||||
body.style.height = "100%";
|
||||
body.style.margin = "0";
|
||||
body.style.padding = "0";
|
||||
|
||||
if (rootContainer) {
|
||||
rootContainer.style.overflow = "hidden";
|
||||
rootContainer.style.width = "100%";
|
||||
rootContainer.style.height = "100%";
|
||||
}
|
||||
|
||||
return () => {
|
||||
documentElement.style.overflow = previousDocumentElementOverflow;
|
||||
documentElement.style.width = previousDocumentElementWidth;
|
||||
documentElement.style.height = previousDocumentElementHeight;
|
||||
|
||||
body.style.overflow = previousBodyOverflow;
|
||||
body.style.width = previousBodyWidth;
|
||||
body.style.height = previousBodyHeight;
|
||||
body.style.margin = previousBodyMargin;
|
||||
body.style.padding = previousBodyPadding;
|
||||
|
||||
if (rootContainer) {
|
||||
rootContainer.style.overflow = previousRootOverflow;
|
||||
rootContainer.style.width = previousRootWidth;
|
||||
rootContainer.style.height = previousRootHeight;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private applyViewportTouchStyles(input: { host: HTMLDivElement }): () => void {
|
||||
const viewportElement = input.host.querySelector<HTMLElement>(".xterm-viewport");
|
||||
const screenElement = input.host.querySelector<HTMLElement>(".xterm-screen");
|
||||
|
||||
const previousViewportOverscroll = viewportElement?.style.overscrollBehavior ?? "";
|
||||
const previousViewportTouchAction = viewportElement?.style.touchAction ?? "";
|
||||
const previousViewportOverflowY = viewportElement?.style.overflowY ?? "";
|
||||
const previousViewportOverflowX = viewportElement?.style.overflowX ?? "";
|
||||
const previousViewportPointerEvents = viewportElement?.style.pointerEvents ?? "";
|
||||
const previousViewportWebkitOverflowScrolling =
|
||||
viewportElement?.style.getPropertyValue("-webkit-overflow-scrolling") ?? "";
|
||||
const previousScreenPointerEvents = screenElement?.style.pointerEvents ?? "";
|
||||
|
||||
if (viewportElement) {
|
||||
viewportElement.style.overscrollBehavior = "none";
|
||||
viewportElement.style.touchAction = "pan-y";
|
||||
viewportElement.style.overflowY = "auto";
|
||||
viewportElement.style.overflowX = "hidden";
|
||||
viewportElement.style.pointerEvents = "auto";
|
||||
viewportElement.style.setProperty("-webkit-overflow-scrolling", "touch");
|
||||
}
|
||||
if (screenElement) {
|
||||
screenElement.style.pointerEvents = "none";
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (viewportElement) {
|
||||
viewportElement.style.overscrollBehavior = previousViewportOverscroll;
|
||||
viewportElement.style.touchAction = previousViewportTouchAction;
|
||||
viewportElement.style.overflowY = previousViewportOverflowY;
|
||||
viewportElement.style.overflowX = previousViewportOverflowX;
|
||||
viewportElement.style.pointerEvents = previousViewportPointerEvents;
|
||||
viewportElement.style.setProperty(
|
||||
"-webkit-overflow-scrolling",
|
||||
previousViewportWebkitOverflowScrolling
|
||||
);
|
||||
}
|
||||
if (screenElement) {
|
||||
screenElement.style.pointerEvents = previousScreenPointerEvents;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private setupTouchScrollHandlers(input: {
|
||||
root: HTMLDivElement;
|
||||
host: HTMLDivElement;
|
||||
terminal: Terminal;
|
||||
}): () => void {
|
||||
let touchScrollRemainderPx = 0;
|
||||
const measuredLineHeight =
|
||||
input.host.querySelector<HTMLElement>(".xterm-rows > div")?.getBoundingClientRect()
|
||||
.height ?? 0;
|
||||
const touchScrollLineHeightPx =
|
||||
measuredLineHeight > 0
|
||||
? measuredLineHeight
|
||||
: DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX;
|
||||
|
||||
const activeTouch = {
|
||||
identifier: -1,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
lastX: 0,
|
||||
lastY: 0,
|
||||
mode: null as "vertical" | "horizontal" | null,
|
||||
};
|
||||
|
||||
const touchStartHandler = (event: TouchEvent) => {
|
||||
if (event.touches.length !== 1) {
|
||||
touchScrollRemainderPx = 0;
|
||||
activeTouch.identifier = -1;
|
||||
activeTouch.mode = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const touch = event.touches[0];
|
||||
if (!touch) {
|
||||
touchScrollRemainderPx = 0;
|
||||
activeTouch.identifier = -1;
|
||||
activeTouch.mode = null;
|
||||
return;
|
||||
}
|
||||
|
||||
activeTouch.identifier = touch.identifier;
|
||||
activeTouch.startX = touch.clientX;
|
||||
activeTouch.startY = touch.clientY;
|
||||
activeTouch.lastX = touch.clientX;
|
||||
activeTouch.lastY = touch.clientY;
|
||||
activeTouch.mode = null;
|
||||
touchScrollRemainderPx = 0;
|
||||
};
|
||||
|
||||
const touchMoveHandler = (event: TouchEvent) => {
|
||||
if (event.touches.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const touch = Array.from(event.touches).find(
|
||||
(candidate) => candidate.identifier === activeTouch.identifier
|
||||
);
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const totalDeltaX = touch.clientX - activeTouch.startX;
|
||||
const totalDeltaY = touch.clientY - activeTouch.startY;
|
||||
if (activeTouch.mode === null) {
|
||||
const absX = Math.abs(totalDeltaX);
|
||||
const absY = Math.abs(totalDeltaY);
|
||||
if (absX > 8 || absY > 8) {
|
||||
activeTouch.mode = absY >= absX ? "vertical" : "horizontal";
|
||||
}
|
||||
}
|
||||
|
||||
const deltaY = touch.clientY - activeTouch.lastY;
|
||||
activeTouch.lastX = touch.clientX;
|
||||
activeTouch.lastY = touch.clientY;
|
||||
|
||||
if (activeTouch.mode !== "vertical") {
|
||||
return;
|
||||
}
|
||||
|
||||
touchScrollRemainderPx += deltaY;
|
||||
const lineDelta = Math.trunc(touchScrollRemainderPx / touchScrollLineHeightPx);
|
||||
if (lineDelta !== 0) {
|
||||
input.terminal.scrollLines(-lineDelta);
|
||||
touchScrollRemainderPx -= lineDelta * touchScrollLineHeightPx;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const touchEndHandler = (event: TouchEvent) => {
|
||||
const activeTouchEnded = Array.from(event.changedTouches).some(
|
||||
(touch) => touch.identifier === activeTouch.identifier
|
||||
);
|
||||
if (activeTouchEnded || event.touches.length === 0) {
|
||||
touchScrollRemainderPx = 0;
|
||||
activeTouch.identifier = -1;
|
||||
activeTouch.mode = null;
|
||||
}
|
||||
};
|
||||
|
||||
const touchCancelHandler = () => {
|
||||
touchScrollRemainderPx = 0;
|
||||
activeTouch.identifier = -1;
|
||||
activeTouch.mode = null;
|
||||
};
|
||||
|
||||
input.root.addEventListener("touchstart", touchStartHandler, { passive: true });
|
||||
input.root.addEventListener("touchmove", touchMoveHandler, { passive: false });
|
||||
input.root.addEventListener("touchend", touchEndHandler, { passive: true });
|
||||
input.root.addEventListener("touchcancel", touchCancelHandler, { passive: true });
|
||||
|
||||
return () => {
|
||||
input.root.removeEventListener("touchstart", touchStartHandler);
|
||||
input.root.removeEventListener("touchmove", touchMoveHandler);
|
||||
input.root.removeEventListener("touchend", touchEndHandler);
|
||||
input.root.removeEventListener("touchcancel", touchCancelHandler);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
101
packages/app/src/terminal/runtime/terminal-output-pump.test.ts
Normal file
101
packages/app/src/terminal/runtime/terminal-output-pump.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { TerminalOutputPump } from "./terminal-output-pump";
|
||||
|
||||
describe("terminal-output-pump", () => {
|
||||
it("batches selected-terminal chunk bursts into ordered flushes", () => {
|
||||
vi.useFakeTimers();
|
||||
const chunks: Array<{ sequence: number; text: string }> = [];
|
||||
const pump = new TerminalOutputPump({
|
||||
maxOutputChars: 100,
|
||||
onSelectedOutputChunk: (chunk) => {
|
||||
chunks.push(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
pump.setSelectedTerminal({ terminalId: "term-1" });
|
||||
pump.append({ terminalId: "term-1", text: "a" });
|
||||
pump.append({ terminalId: "term-1", text: "b" });
|
||||
pump.append({ terminalId: "term-1", text: "c" });
|
||||
|
||||
expect(chunks).toEqual([]);
|
||||
|
||||
vi.runOnlyPendingTimers();
|
||||
|
||||
expect(chunks).toEqual([
|
||||
{ sequence: 1, text: "abc" },
|
||||
]);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps per-terminal snapshots and switches selected stream deterministically", () => {
|
||||
vi.useFakeTimers();
|
||||
const chunks: Array<{ sequence: number; text: string }> = [];
|
||||
const pump = new TerminalOutputPump({
|
||||
maxOutputChars: 10,
|
||||
onSelectedOutputChunk: (chunk) => {
|
||||
chunks.push(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
pump.setSelectedTerminal({ terminalId: "term-1" });
|
||||
pump.append({ terminalId: "term-1", text: "hello" });
|
||||
vi.runOnlyPendingTimers();
|
||||
expect(pump.readSnapshot({ terminalId: "term-1" })).toBe("hello");
|
||||
|
||||
pump.append({ terminalId: "term-2", text: "world" });
|
||||
vi.runOnlyPendingTimers();
|
||||
expect(pump.readSnapshot({ terminalId: "term-2" })).toBe("world");
|
||||
|
||||
pump.setSelectedTerminal({ terminalId: "term-2" });
|
||||
pump.append({ terminalId: "term-2", text: "!" });
|
||||
vi.runOnlyPendingTimers();
|
||||
|
||||
expect(chunks).toEqual([
|
||||
{ sequence: 1, text: "hello" },
|
||||
{ sequence: 2, text: "!" },
|
||||
]);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("resets selected output when clearing selected terminal", () => {
|
||||
vi.useFakeTimers();
|
||||
const chunks: Array<{ sequence: number; text: string }> = [];
|
||||
const pump = new TerminalOutputPump({
|
||||
maxOutputChars: 10,
|
||||
onSelectedOutputChunk: (chunk) => {
|
||||
chunks.push(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
pump.setSelectedTerminal({ terminalId: "term-1" });
|
||||
pump.append({ terminalId: "term-1", text: "abc" });
|
||||
vi.runOnlyPendingTimers();
|
||||
|
||||
pump.clearTerminal({ terminalId: "term-1" });
|
||||
|
||||
expect(pump.readSnapshot({ terminalId: "term-1" })).toBe("");
|
||||
expect(chunks).toEqual([
|
||||
{ sequence: 1, text: "abc" },
|
||||
{ sequence: 2, text: "" },
|
||||
]);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("prunes orphaned terminal buffers", () => {
|
||||
const pump = new TerminalOutputPump({
|
||||
maxOutputChars: 100,
|
||||
onSelectedOutputChunk: () => {},
|
||||
});
|
||||
|
||||
pump.append({ terminalId: "a", text: "one" });
|
||||
pump.append({ terminalId: "b", text: "two" });
|
||||
pump.prune({ terminalIds: ["b"] });
|
||||
|
||||
expect(pump.readSnapshot({ terminalId: "a" })).toBe("");
|
||||
expect(pump.readSnapshot({ terminalId: "b" })).toBe("two");
|
||||
});
|
||||
});
|
||||
183
packages/app/src/terminal/runtime/terminal-output-pump.ts
Normal file
183
packages/app/src/terminal/runtime/terminal-output-pump.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
appendTerminalOutputBuffer,
|
||||
createTerminalOutputBuffer,
|
||||
readTerminalOutputBuffer,
|
||||
type TerminalOutputBuffer,
|
||||
} from "@/utils/terminal-output-buffer";
|
||||
import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
|
||||
|
||||
export type TerminalOutputChunk = {
|
||||
sequence: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type TerminalOutputPumpOptions = {
|
||||
maxOutputChars: number;
|
||||
onSelectedOutputChunk: (chunk: TerminalOutputChunk) => void;
|
||||
};
|
||||
|
||||
export type TerminalOutputPumpSetSelectedInput = {
|
||||
terminalId: string | null;
|
||||
};
|
||||
|
||||
export type TerminalOutputPumpAppendInput = {
|
||||
terminalId: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type TerminalOutputPumpReadInput = {
|
||||
terminalId: string | null;
|
||||
};
|
||||
|
||||
export type TerminalOutputPumpClearInput = {
|
||||
terminalId: string;
|
||||
};
|
||||
|
||||
export type TerminalOutputPumpPruneInput = {
|
||||
terminalIds: string[];
|
||||
};
|
||||
|
||||
export class TerminalOutputPump {
|
||||
private readonly buffersByTerminalId = new Map<string, TerminalOutputBuffer>();
|
||||
private selectedTerminalId: string | null = null;
|
||||
private selectedChunkSequence = 0;
|
||||
private selectedChunkAccumulator = "";
|
||||
private selectedChunkFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(private readonly options: TerminalOutputPumpOptions) {}
|
||||
|
||||
setSelectedTerminal(input: TerminalOutputPumpSetSelectedInput): void {
|
||||
if (this.selectedTerminalId === input.terminalId) {
|
||||
return;
|
||||
}
|
||||
|
||||
terminalDebugLog({
|
||||
scope: "output-pump",
|
||||
event: "selected-terminal:set",
|
||||
details: {
|
||||
previousTerminalId: this.selectedTerminalId,
|
||||
nextTerminalId: input.terminalId,
|
||||
},
|
||||
});
|
||||
this.clearSelectedChunkFlushTimer();
|
||||
this.selectedChunkAccumulator = "";
|
||||
this.selectedTerminalId = input.terminalId;
|
||||
}
|
||||
|
||||
append(input: TerminalOutputPumpAppendInput): void {
|
||||
if (input.text.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let buffer = this.buffersByTerminalId.get(input.terminalId);
|
||||
if (!buffer) {
|
||||
buffer = createTerminalOutputBuffer();
|
||||
this.buffersByTerminalId.set(input.terminalId, buffer);
|
||||
}
|
||||
|
||||
appendTerminalOutputBuffer({
|
||||
buffer,
|
||||
text: input.text,
|
||||
maxChars: this.options.maxOutputChars,
|
||||
});
|
||||
|
||||
if (this.selectedTerminalId !== input.terminalId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedChunkAccumulator += input.text;
|
||||
terminalDebugLog({
|
||||
scope: "output-pump",
|
||||
event: "selected-terminal:accumulate",
|
||||
details: {
|
||||
terminalId: input.terminalId,
|
||||
appendedLength: input.text.length,
|
||||
accumulatorLength: this.selectedChunkAccumulator.length,
|
||||
preview: summarizeTerminalText({ text: input.text, maxChars: 80 }),
|
||||
},
|
||||
});
|
||||
this.scheduleSelectedChunkFlush();
|
||||
}
|
||||
|
||||
clearTerminal(input: TerminalOutputPumpClearInput): void {
|
||||
this.buffersByTerminalId.delete(input.terminalId);
|
||||
if (this.selectedTerminalId === input.terminalId) {
|
||||
this.clearSelectedChunkFlushTimer();
|
||||
this.selectedChunkAccumulator = "";
|
||||
this.emitSelectedChunk({ text: "" });
|
||||
}
|
||||
}
|
||||
|
||||
prune(input: TerminalOutputPumpPruneInput): void {
|
||||
const terminalIdSet = new Set(input.terminalIds);
|
||||
for (const terminalId of Array.from(this.buffersByTerminalId.keys())) {
|
||||
if (!terminalIdSet.has(terminalId)) {
|
||||
this.buffersByTerminalId.delete(terminalId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readSnapshot(input: TerminalOutputPumpReadInput): string {
|
||||
if (!input.terminalId) {
|
||||
return "";
|
||||
}
|
||||
const buffer = this.buffersByTerminalId.get(input.terminalId);
|
||||
if (!buffer) {
|
||||
return "";
|
||||
}
|
||||
return readTerminalOutputBuffer({ buffer });
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.clearSelectedChunkFlushTimer();
|
||||
this.selectedChunkAccumulator = "";
|
||||
this.selectedTerminalId = null;
|
||||
this.buffersByTerminalId.clear();
|
||||
}
|
||||
|
||||
private scheduleSelectedChunkFlush(): void {
|
||||
if (this.selectedChunkFlushTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedChunkFlushTimer = setTimeout(() => {
|
||||
this.selectedChunkFlushTimer = null;
|
||||
this.flushSelectedChunkAccumulator();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private flushSelectedChunkAccumulator(): void {
|
||||
if (this.selectedChunkAccumulator.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = this.selectedChunkAccumulator;
|
||||
this.selectedChunkAccumulator = "";
|
||||
terminalDebugLog({
|
||||
scope: "output-pump",
|
||||
event: "selected-terminal:flush",
|
||||
details: {
|
||||
terminalId: this.selectedTerminalId,
|
||||
textLength: text.length,
|
||||
preview: summarizeTerminalText({ text, maxChars: 96 }),
|
||||
},
|
||||
});
|
||||
this.emitSelectedChunk({ text });
|
||||
}
|
||||
|
||||
private emitSelectedChunk(input: { text: string }): void {
|
||||
this.selectedChunkSequence += 1;
|
||||
this.options.onSelectedOutputChunk({
|
||||
sequence: this.selectedChunkSequence,
|
||||
text: input.text,
|
||||
});
|
||||
}
|
||||
|
||||
private clearSelectedChunkFlushTimer(): void {
|
||||
if (!this.selectedChunkFlushTimer) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(this.selectedChunkFlushTimer);
|
||||
this.selectedChunkFlushTimer = null;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user