mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
47 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 |
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.
|
||||
37
CLAUDE.md
37
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`.
|
||||
@@ -160,14 +182,21 @@ npm run release:patch
|
||||
npm run version:all:patch # npm version across all workspaces (creates commit + local tag)
|
||||
npm run release:check
|
||||
npm run release:publish
|
||||
npm run release:push # pushes HEAD and current version tag (triggers desktop release)
|
||||
npm run release:push # pushes HEAD and current version tag (triggers desktop + EAS mobile workflows)
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `version:all:*` uses `npm version` with workspace support and runs the root `version` lifecycle script to sync internal `@getpaseo/*` dependency versions before the release commit/tag is created.
|
||||
- `version:all:*` bumps the root package version and runs the root `version` lifecycle script to sync workspace versions and internal `@getpaseo/*` dependency versions before the release commit/tag is created.
|
||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale local package types during release checks.
|
||||
- If `release:publish` fails after a successful publish of one workspace, re-run `npm run release:publish`; npm will skip already-published versions and continue where possible.
|
||||
- After each release, update the website Mac download CTA URL to the new version tag in `packages/website/src/routes/index.tsx`.
|
||||
- If a user asks to "release paseo" (without specifying major/minor), treat it as a patch release and run `npm run release:patch`.
|
||||
- All workspaces share one version by design. Keep versions synchronized and release together.
|
||||
- The website Mac download CTA URL is derived from `packages/website/package.json` version at build time, so no manual update is required after release.
|
||||
|
||||
Release completion checklist:
|
||||
- `npm run release:patch` completes successfully.
|
||||
- GitHub `Desktop Release` workflow for the new `v*` tag is green.
|
||||
- EAS `release-mobile.yml` workflow for the same tag is green (Expo queues can take longer on the free plan).
|
||||
|
||||
## Orchestrator Mode
|
||||
|
||||
|
||||
85
README.md
85
README.md
@@ -17,77 +17,44 @@
|
||||
|
||||
Paseo is a self-hosted daemon for Claude Code, Codex, and OpenCode. Agents run on your machine with your full dev environment. Connect from phone, desktop, or web.
|
||||
|
||||
## 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
|
||||
npm run version:all:patch
|
||||
npm run release:push
|
||||
```
|
||||
|
||||
For the full package release flow, use:
|
||||
|
||||
```bash
|
||||
npm run release:patch
|
||||
```
|
||||
|
||||
This triggers the `Desktop Release` workflow (`.github/workflows/desktop-release.yml`).
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
35
package-lock.json
generated
35
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.12",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -13681,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",
|
||||
@@ -20335,7 +20344,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.12",
|
||||
"dependencies": {
|
||||
"@boudra/expo-two-way-audio": "^0.1.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -20343,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.4",
|
||||
"@getpaseo/server": "0.1.12",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
@@ -20390,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",
|
||||
@@ -20446,11 +20456,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.12",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.4",
|
||||
"@getpaseo/server": "0.1.4",
|
||||
"@getpaseo/relay": "0.1.12",
|
||||
"@getpaseo/server": "0.1.12",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -20500,14 +20510,14 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.12",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.6"
|
||||
}
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.12",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -20523,12 +20533,12 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.4",
|
||||
"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.4",
|
||||
"@getpaseo/relay": "0.1.12",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/css": "^1.3.0",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
@@ -20546,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",
|
||||
@@ -20879,7 +20890,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.12",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.4",
|
||||
"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",
|
||||
@@ -35,9 +35,9 @@
|
||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||
"release:prepare": "npm install --workspaces --include-workspace-root",
|
||||
"version:all:patch": "npm version patch --workspaces --include-workspace-root --message \"chore(release): cut %s\"",
|
||||
"version:all:minor": "npm version minor --workspaces --include-workspace-root --message \"chore(release): cut %s\"",
|
||||
"version:all:major": "npm version major --workspaces --include-workspace-root --message \"chore(release): cut %s\"",
|
||||
"version:all: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",
|
||||
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -156,7 +156,6 @@ export default async function globalSetup() {
|
||||
// Keep e2e bootstrap fast and deterministic; terminal/sidebar tests do not need speech.
|
||||
PASEO_DICTATION_ENABLED: "0",
|
||||
PASEO_VOICE_MODE_ENABLED: "0",
|
||||
PASEO_LOCAL_AUTO_DOWNLOAD: "0",
|
||||
NODE_ENV: 'development',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
|
||||
@@ -173,7 +173,8 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
.first();
|
||||
await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const input = page.getByRole('textbox', { name: '/path/to/project' });
|
||||
const legacyInput = page.getByRole('textbox', { name: '/path/to/project' }).first();
|
||||
const directorySearchInput = page.getByRole('textbox', { name: /search directories/i }).first();
|
||||
const worktreePicker = page.getByTestId('worktree-attach-picker');
|
||||
const worktreeSheetTitle = page.getByText('Select worktree', { exact: true });
|
||||
const closeBottomSheet = async () => {
|
||||
@@ -218,29 +219,54 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
|
||||
await closeWorktreeSheetIfOpen();
|
||||
|
||||
if (!(await input.isVisible())) {
|
||||
const pickerInputVisible = async () =>
|
||||
(await directorySearchInput.isVisible().catch(() => false)) ||
|
||||
(await legacyInput.isVisible().catch(() => false));
|
||||
|
||||
if (!(await pickerInputVisible())) {
|
||||
await closeBottomSheet();
|
||||
await workingDirectorySelect.click({ force: true });
|
||||
if (!(await input.isVisible())) {
|
||||
if (!(await pickerInputVisible())) {
|
||||
await closeBottomSheet();
|
||||
await workingDirectorySelect.click({ force: true });
|
||||
}
|
||||
await expect(input).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => pickerInputVisible(), { timeout: 10000 })
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
const trimmedDirectory = directory.replace(/\/+$/, '');
|
||||
await input.fill(trimmedDirectory);
|
||||
await input.press('Enter');
|
||||
const activeInput =
|
||||
(await directorySearchInput.isVisible().catch(() => false))
|
||||
? directorySearchInput
|
||||
: legacyInput;
|
||||
|
||||
// Desktop web supports selecting via Enter; mobile may require clicking the explicit option.
|
||||
const useOption = page.getByTestId('working-directory-custom-option').first();
|
||||
if (await useOption.isVisible().catch(() => false)) {
|
||||
await expect(useOption).toContainText(`Use "${trimmedDirectory}"`);
|
||||
await useOption.click({ force: true });
|
||||
await activeInput.fill(trimmedDirectory);
|
||||
|
||||
if (activeInput === directorySearchInput) {
|
||||
// Combobox custom rows can be either plain path labels or prefixed labels.
|
||||
const plainOption = page
|
||||
.getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, 'i'))
|
||||
.first();
|
||||
const prefixedUseOption = page
|
||||
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, 'i'))
|
||||
.first();
|
||||
|
||||
if (await plainOption.isVisible().catch(() => false)) {
|
||||
await plainOption.click({ force: true });
|
||||
} else if (await prefixedUseOption.isVisible().catch(() => false)) {
|
||||
await prefixedUseOption.click({ force: true });
|
||||
} else {
|
||||
// Fallback: accept highlighted option (directory suggestion).
|
||||
await activeInput.press('Enter');
|
||||
}
|
||||
} else {
|
||||
// Legacy path picker fallback.
|
||||
await activeInput.press('Enter');
|
||||
}
|
||||
|
||||
// Wait for the sheet to close after clicking "Use"
|
||||
await expect(input).not.toBeVisible({ timeout: 10000 });
|
||||
// Wait for picker to close.
|
||||
await expect(activeInput).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
const directoryCandidates = new Set<string>([trimmedDirectory]);
|
||||
if (trimmedDirectory.startsWith('/var/')) {
|
||||
|
||||
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();
|
||||
|
||||
|
||||
@@ -95,6 +95,33 @@ async function openTerminalsPanel(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
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-"]');
|
||||
@@ -105,6 +132,16 @@ async function selectNewestTerminalTab(page: Page): Promise<void> {
|
||||
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 });
|
||||
@@ -213,6 +250,151 @@ test("Terminals tab creates multiple terminals and streams command output", asyn
|
||||
}
|
||||
});
|
||||
|
||||
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-");
|
||||
@@ -256,6 +438,75 @@ test("terminals are shared by agents on the same cwd", async ({ page }) => {
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
158
packages/app/e2e/working-directory-combobox-positioning.spec.ts
Normal file
158
packages/app/e2e/working-directory-combobox-positioning.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoHome } from "./helpers/app";
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
test("working directory combobox stays visually stable while typing search", async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
|
||||
const workingDirectorySelect = page
|
||||
.locator('[data-testid="working-directory-select"]:visible')
|
||||
.first();
|
||||
await expect(workingDirectorySelect).toBeVisible();
|
||||
await workingDirectorySelect.click({ force: true });
|
||||
|
||||
const searchInput = page.getByRole("textbox", { name: /search directories/i }).first();
|
||||
await expect(searchInput).toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
const trigger = document.querySelector('[data-testid="working-directory-select"]');
|
||||
const searchInput = document.querySelector('input[placeholder="Search directories..."]');
|
||||
const container = document.querySelector('[data-testid="combobox-desktop-container"]');
|
||||
if (!(trigger instanceof HTMLElement)) {
|
||||
throw new Error("Missing working-directory-select trigger.");
|
||||
}
|
||||
if (!(searchInput instanceof HTMLInputElement)) {
|
||||
throw new Error("Missing working directory search input.");
|
||||
}
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
throw new Error("Missing combobox desktop container.");
|
||||
}
|
||||
|
||||
const state = {
|
||||
samples: 0,
|
||||
underTriggerSamples: 0,
|
||||
emptyWhileSearchingSamples: 0,
|
||||
minSearchDelta: Number.POSITIVE_INFINITY,
|
||||
maxSearchDelta: Number.NEGATIVE_INFINITY,
|
||||
minContainerDelta: Number.POSITIVE_INFINITY,
|
||||
maxContainerDelta: Number.NEGATIVE_INFINITY,
|
||||
logs: [] as Array<{
|
||||
reason: string;
|
||||
query: string;
|
||||
searchDelta: number;
|
||||
containerDelta: number;
|
||||
hasEmpty: boolean;
|
||||
containerTop: number;
|
||||
containerBottom: number;
|
||||
triggerTop: number;
|
||||
searchBottom: number;
|
||||
}>,
|
||||
};
|
||||
|
||||
const sample = (reason: string) => {
|
||||
if (!document.body.contains(trigger) || !document.body.contains(searchInput) || !document.body.contains(container)) {
|
||||
return;
|
||||
}
|
||||
const query = searchInput.value.trim();
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
const triggerRect = trigger.getBoundingClientRect();
|
||||
const searchRect = searchInput.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const hasEmpty = Boolean(container.querySelector('[data-testid="combobox-empty-text"]'));
|
||||
const searchDelta = searchRect.bottom - triggerRect.top;
|
||||
const containerDelta = containerRect.bottom - triggerRect.top;
|
||||
|
||||
state.samples += 1;
|
||||
state.minSearchDelta = Math.min(state.minSearchDelta, searchDelta);
|
||||
state.maxSearchDelta = Math.max(state.maxSearchDelta, searchDelta);
|
||||
state.minContainerDelta = Math.min(state.minContainerDelta, containerDelta);
|
||||
state.maxContainerDelta = Math.max(state.maxContainerDelta, containerDelta);
|
||||
if (searchDelta > 2 || containerDelta > 2) {
|
||||
state.underTriggerSamples += 1;
|
||||
}
|
||||
if (hasEmpty) {
|
||||
state.emptyWhileSearchingSamples += 1;
|
||||
}
|
||||
if (state.logs.length < 80) {
|
||||
state.logs.push({
|
||||
reason,
|
||||
query,
|
||||
searchDelta,
|
||||
containerDelta,
|
||||
hasEmpty,
|
||||
containerTop: containerRect.top,
|
||||
containerBottom: containerRect.bottom,
|
||||
triggerTop: triggerRect.top,
|
||||
searchBottom: searchRect.bottom,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const mutationObserver = new MutationObserver(() => sample("mutation"));
|
||||
mutationObserver.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => sample("resize"));
|
||||
resizeObserver.observe(container);
|
||||
resizeObserver.observe(searchInput);
|
||||
|
||||
let rafId = 0;
|
||||
const loop = () => {
|
||||
sample("raf");
|
||||
rafId = requestAnimationFrame(loop);
|
||||
};
|
||||
rafId = requestAnimationFrame(loop);
|
||||
|
||||
(window as any).__paseoComboboxObserver = {
|
||||
stop: () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
mutationObserver.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
sample("stop");
|
||||
return {
|
||||
...state,
|
||||
minSearchDelta: state.samples > 0 ? state.minSearchDelta : 0,
|
||||
maxSearchDelta: state.samples > 0 ? state.maxSearchDelta : 0,
|
||||
minContainerDelta: state.samples > 0 ? state.minContainerDelta : 0,
|
||||
maxContainerDelta: state.samples > 0 ? state.maxContainerDelta : 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const queries = [
|
||||
"/tmp/paseo-stability-a",
|
||||
"/tmp/paseo-stability-ab",
|
||||
"/tmp/paseo-stability-abc",
|
||||
"/tmp/paseo-stability-longer-branch",
|
||||
"/tmp/paseo-stability-z",
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
await searchInput.fill("");
|
||||
await searchInput.type(query, { delay: 20 });
|
||||
const customOption = page.getByText(new RegExp(`^${escapeRegex(query)}$`)).first();
|
||||
await expect(customOption).toBeVisible();
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
const stats = await page.evaluate(() => (window as any).__paseoComboboxObserver.stop());
|
||||
const debug = JSON.stringify(stats.logs.slice(-10));
|
||||
|
||||
expect(stats.samples, debug).toBeGreaterThan(20);
|
||||
expect(stats.underTriggerSamples, debug).toBe(0);
|
||||
expect(stats.emptyWhileSearchingSamples, debug).toBe(0);
|
||||
expect(stats.maxSearchDelta - stats.minSearchDelta, debug).toBeLessThanOrEqual(3);
|
||||
expect(stats.maxContainerDelta - stats.minContainerDelta, debug).toBeLessThanOrEqual(3);
|
||||
expect(stats.maxContainerDelta, debug).toBeLessThanOrEqual(2);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 14.0.0"
|
||||
"version": ">= 14.0.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
@@ -18,6 +19,9 @@
|
||||
"channel": "production",
|
||||
"env": {
|
||||
"APP_VARIANT": "production"
|
||||
},
|
||||
"android": {
|
||||
"autoIncrement": "versionCode"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
@@ -30,6 +34,13 @@
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
"production": {
|
||||
"ios": {
|
||||
"ascAppId": "6758887924"
|
||||
},
|
||||
"android": {
|
||||
"releaseStatus": "draft"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.4",
|
||||
"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.4",
|
||||
"@getpaseo/server": "0.1.12",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
@@ -77,6 +77,7 @@
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo-updates": "~29.0.12",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"lezer-elixir": "^1.1.2",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"react": "19.1.0",
|
||||
|
||||
@@ -36,7 +36,8 @@ 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,
|
||||
@@ -144,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,
|
||||
@@ -169,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])
|
||||
@@ -225,9 +228,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
isGesturing.value = false;
|
||||
}),
|
||||
[
|
||||
chromeEnabled,
|
||||
isMobile,
|
||||
isOpen,
|
||||
openGestureEnabled,
|
||||
windowWidth,
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
@@ -255,6 +256,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
{isMobile && chromeEnabled && <SlidingSidebar selectedAgentId={selectedAgentId} />}
|
||||
<DownloadToast />
|
||||
<CommandCenter />
|
||||
<KeyboardShortcutsDialog />
|
||||
</View>
|
||||
);
|
||||
|
||||
|
||||
@@ -883,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,7 +28,7 @@ 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";
|
||||
@@ -74,8 +74,12 @@ 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
|
||||
@@ -108,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(" ");
|
||||
@@ -229,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[]) => {
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -590,7 +634,7 @@ export function AgentInputArea({
|
||||
]
|
||||
);
|
||||
|
||||
const cancelButton = isAgentRunning && !hasSendableContent ? (
|
||||
const cancelButton = isAgentRunning && !hasSendableContent && !isProcessing ? (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleCancelAgent}
|
||||
@@ -734,6 +778,7 @@ export function AgentInputArea({
|
||||
voiceAgentId={agentId}
|
||||
isAgentRunning={isAgentRunning}
|
||||
onQueue={handleQueue}
|
||||
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
|
||||
onKeyPress={handleCommandKeyPress}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -105,12 +105,6 @@ export function AgentList({
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear attention flag when opening agent
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
if (session?.client) {
|
||||
session.client.clearAgentAttention(agentId);
|
||||
}
|
||||
|
||||
const navigationKey = buildAgentNavigationKey(serverId, agentId);
|
||||
startNavigationTiming(navigationKey, {
|
||||
from: "home",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import Animated, {
|
||||
useAnimatedReaction,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
runOnJS,
|
||||
@@ -19,44 +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 isTerminalDebugEnabled(): boolean {
|
||||
const explicit = (
|
||||
globalThis as {
|
||||
__PASEO_TERMINAL_DEBUG?: unknown;
|
||||
}
|
||||
).__PASEO_TERMINAL_DEBUG;
|
||||
if (typeof explicit === "boolean") {
|
||||
return explicit;
|
||||
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;
|
||||
}
|
||||
const devFlag = (globalThis as { __DEV__?: unknown }).__DEV__;
|
||||
return devFlag === true;
|
||||
}
|
||||
|
||||
function logTerminalDebug(message: string, payload?: Record<string, unknown>): void {
|
||||
if (!isTerminalDebugEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (payload) {
|
||||
console.log("[TerminalDebug][ExplorerSidebar] " + message, payload);
|
||||
return;
|
||||
}
|
||||
console.log("[TerminalDebug][ExplorerSidebar] " + message);
|
||||
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 =
|
||||
@@ -66,13 +52,13 @@ 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 terminalDebugEnabled = isTerminalDebugEnabled();
|
||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||
const bottomInset = useSharedValue(insets.bottom);
|
||||
const closeGestureLastLogX = useSharedValue(0);
|
||||
const closeTouchStartX = useSharedValue(0);
|
||||
const closeTouchStartY = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
bottomInset.value = insets.bottom;
|
||||
@@ -112,68 +98,13 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
||||
closeToAgent();
|
||||
}, [closeToAgent]);
|
||||
|
||||
const logCloseGesture = useCallback(
|
||||
(phase: string, payload?: Record<string, unknown>) => {
|
||||
logTerminalDebug("close gesture " + phase, {
|
||||
isMobile,
|
||||
isOpen,
|
||||
explorerTab,
|
||||
...(payload ?? {}),
|
||||
});
|
||||
},
|
||||
[explorerTab, isMobile, isOpen]
|
||||
);
|
||||
|
||||
const logKeyboardInset = useCallback(
|
||||
(rawHeight: number, shift: number, inset: number) => {
|
||||
logTerminalDebug("keyboard inset", {
|
||||
rawHeight: Math.round(rawHeight),
|
||||
shift: Math.round(shift),
|
||||
inset: Math.round(inset),
|
||||
isMobile,
|
||||
isOpen,
|
||||
explorerTab,
|
||||
});
|
||||
},
|
||||
[explorerTab, isMobile, isOpen]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminalDebugEnabled) {
|
||||
return;
|
||||
}
|
||||
logTerminalDebug("close gesture config", { isMobile, isOpen, explorerTab });
|
||||
}, [explorerTab, isMobile, isOpen, terminalDebugEnabled]);
|
||||
const enableSidebarCloseGesture = isMobile && isOpen;
|
||||
|
||||
const handleTabPress = useCallback(
|
||||
(tab: ExplorerTab) => {
|
||||
setExplorerTab(tab);
|
||||
setExplorerTabForCheckout({ serverId, cwd, isGit, tab });
|
||||
},
|
||||
[setExplorerTab]
|
||||
);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => {
|
||||
const rawHeight = Math.abs(keyboardHeight.value);
|
||||
return {
|
||||
rawHeight,
|
||||
shift: Math.max(0, rawHeight - bottomInset.value),
|
||||
inset: bottomInset.value,
|
||||
};
|
||||
},
|
||||
(next, previous) => {
|
||||
if (!terminalDebugEnabled) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
previous &&
|
||||
Math.abs(previous.shift - next.shift) < 4 &&
|
||||
Math.abs(previous.rawHeight - next.rawHeight) < 4
|
||||
) {
|
||||
return;
|
||||
}
|
||||
runOnJS(logKeyboardInset)(next.rawHeight, next.shift, next.inset);
|
||||
}
|
||||
[cwd, isGit, serverId, setExplorerTabForCheckout]
|
||||
);
|
||||
|
||||
// Swipe gesture to close (swipe right on mobile)
|
||||
@@ -181,18 +112,47 @@ 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;
|
||||
if (terminalDebugEnabled) {
|
||||
closeGestureLastLogX.value = 0;
|
||||
runOnJS(logCloseGesture)("start", { windowWidth: Math.round(windowWidth) });
|
||||
}
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
// Right sidebar: swipe right to close (positive translationX)
|
||||
@@ -200,30 +160,11 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
||||
translateX.value = newTranslateX;
|
||||
const progress = 1 - newTranslateX / windowWidth;
|
||||
backdropOpacity.value = Math.max(0, Math.min(1, progress));
|
||||
|
||||
if (
|
||||
terminalDebugEnabled &&
|
||||
Math.abs(newTranslateX - closeGestureLastLogX.value) >= 80
|
||||
) {
|
||||
closeGestureLastLogX.value = newTranslateX;
|
||||
runOnJS(logCloseGesture)("update", {
|
||||
translationX: Math.round(event.translationX),
|
||||
appliedTranslateX: Math.round(newTranslateX),
|
||||
velocityX: Math.round(event.velocityX),
|
||||
});
|
||||
}
|
||||
})
|
||||
.onEnd((event) => {
|
||||
isGesturing.value = false;
|
||||
const shouldClose =
|
||||
event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||
if (terminalDebugEnabled) {
|
||||
runOnJS(logCloseGesture)("end", {
|
||||
translationX: Math.round(event.translationX),
|
||||
velocityX: Math.round(event.velocityX),
|
||||
shouldClose,
|
||||
});
|
||||
}
|
||||
if (shouldClose) {
|
||||
animateToClose();
|
||||
runOnJS(handleClose)();
|
||||
@@ -233,25 +174,19 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
||||
})
|
||||
.onFinalize(() => {
|
||||
isGesturing.value = false;
|
||||
if (terminalDebugEnabled) {
|
||||
runOnJS(logCloseGesture)("finalize");
|
||||
}
|
||||
}),
|
||||
[
|
||||
isMobile,
|
||||
isOpen,
|
||||
explorerTab,
|
||||
enableSidebarCloseGesture,
|
||||
windowWidth,
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
handleClose,
|
||||
logCloseGesture,
|
||||
isGesturing,
|
||||
closeGestureRef,
|
||||
closeGestureLastLogX,
|
||||
terminalDebugEnabled,
|
||||
closeTouchStartX,
|
||||
closeTouchStartY,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -295,7 +230,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
||||
|
||||
const mobileKeyboardInsetStyle = useAnimatedStyle(() => {
|
||||
const absoluteHeight = Math.abs(keyboardHeight.value);
|
||||
const shift = Math.max(0, absoluteHeight - bottomInset.value);
|
||||
const shift = resolveKeyboardShift(absoluteHeight, bottomInset.value);
|
||||
return {
|
||||
paddingBottom: bottomInset.value + shift,
|
||||
};
|
||||
@@ -333,6 +268,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
cwd={cwd}
|
||||
isGit={isGit}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</Animated.View>
|
||||
@@ -365,6 +301,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
cwd={cwd}
|
||||
isGit={isGit}
|
||||
isMobile={false}
|
||||
/>
|
||||
</Animated.View>
|
||||
@@ -378,6 +315,7 @@ interface SidebarContentProps {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
isGit: boolean;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
@@ -388,19 +326,12 @@ function SidebarContent({
|
||||
serverId,
|
||||
agentId,
|
||||
cwd,
|
||||
isGit,
|
||||
isMobile,
|
||||
}: SidebarContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { status } = useCheckoutStatusQuery({ serverId, cwd });
|
||||
const isGit = status?.isGit ?? false;
|
||||
const hasResolvedCheckoutStatus = status !== null;
|
||||
|
||||
// Switch to Files tab if Changes tab is hidden and user was on it
|
||||
useEffect(() => {
|
||||
if (hasResolvedCheckoutStatus && !isGit && activeTab === "changes") {
|
||||
onTabPress("files");
|
||||
}
|
||||
}, [hasResolvedCheckoutStatus, isGit, activeTab, onTabPress]);
|
||||
const resolvedTab: ExplorerTab =
|
||||
!isGit && activeTab === "changes" ? "files" : activeTab;
|
||||
|
||||
return (
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
@@ -410,13 +341,13 @@ function SidebarContent({
|
||||
{isGit && (
|
||||
<Pressable
|
||||
testID="explorer-tab-changes"
|
||||
style={[styles.tab, activeTab === "changes" && styles.tabActive]}
|
||||
style={[styles.tab, resolvedTab === "changes" && styles.tabActive]}
|
||||
onPress={() => onTabPress("changes")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "changes" && styles.tabTextActive,
|
||||
resolvedTab === "changes" && styles.tabTextActive,
|
||||
]}
|
||||
>
|
||||
Changes
|
||||
@@ -425,13 +356,13 @@ function SidebarContent({
|
||||
)}
|
||||
<Pressable
|
||||
testID="explorer-tab-files"
|
||||
style={[styles.tab, activeTab === "files" && styles.tabActive]}
|
||||
style={[styles.tab, resolvedTab === "files" && styles.tabActive]}
|
||||
onPress={() => onTabPress("files")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "files" && styles.tabTextActive,
|
||||
resolvedTab === "files" && styles.tabTextActive,
|
||||
]}
|
||||
>
|
||||
Files
|
||||
@@ -439,13 +370,13 @@ function SidebarContent({
|
||||
</Pressable>
|
||||
<Pressable
|
||||
testID="explorer-tab-terminals"
|
||||
style={[styles.tab, activeTab === "terminals" && styles.tabActive]}
|
||||
style={[styles.tab, resolvedTab === "terminals" && styles.tabActive]}
|
||||
onPress={() => onTabPress("terminals")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "terminals" && styles.tabTextActive,
|
||||
resolvedTab === "terminals" && styles.tabTextActive,
|
||||
]}
|
||||
>
|
||||
Terminals
|
||||
@@ -463,13 +394,13 @@ 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} />
|
||||
)}
|
||||
{activeTab === "terminals" && (
|
||||
{resolvedTab === "terminals" && (
|
||||
<TerminalPane serverId={serverId} cwd={cwd} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
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,
|
||||
@@ -52,6 +51,7 @@ import {
|
||||
} 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
|
||||
@@ -85,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]";
|
||||
|
||||
@@ -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,13 +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;
|
||||
@@ -75,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;
|
||||
}
|
||||
@@ -82,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.
|
||||
@@ -131,6 +134,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
voiceAgentId,
|
||||
isAgentRunning = false,
|
||||
onQueue,
|
||||
onSubmitLoadingPress,
|
||||
onKeyPress: onKeyPressCallback,
|
||||
},
|
||||
ref
|
||||
@@ -138,8 +142,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
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
|
||||
@@ -153,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
|
||||
@@ -295,121 +331,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
await startDictation();
|
||||
}, [dictationUnavailableMessage, startDictation, toast]);
|
||||
|
||||
// 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 message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: null;
|
||||
if (message && message.trim().length > 0) {
|
||||
toast.error(message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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 {
|
||||
void startDictationIfAvailable();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Escape: cancel dictation
|
||||
if (event.key === "Escape" && dictating) {
|
||||
event.preventDefault();
|
||||
cancelDictation();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [
|
||||
cancelDictation,
|
||||
client,
|
||||
confirmDictation,
|
||||
disabled,
|
||||
isAgentRunning,
|
||||
isConnected,
|
||||
isRealtimeVoiceForCurrentAgent,
|
||||
isScreenFocused,
|
||||
startDictationIfAvailable,
|
||||
toast,
|
||||
voiceAgentId,
|
||||
voiceServerId,
|
||||
voice,
|
||||
]);
|
||||
|
||||
// Animate overlay
|
||||
useEffect(() => {
|
||||
overlayTransition.value = withTiming(showOverlay ? 1 : 0, {
|
||||
@@ -636,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 {
|
||||
void startDictationIfAvailable();
|
||||
}
|
||||
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)
|
||||
@@ -712,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 */}
|
||||
@@ -860,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}
|
||||
@@ -884,22 +761,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
)}
|
||||
{shouldShowSendButton && (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleSendMessage}
|
||||
disabled={
|
||||
isSubmitDisabled ||
|
||||
isSubmitLoading ||
|
||||
disabled
|
||||
<TooltipTrigger
|
||||
onPress={
|
||||
canPressLoadingButton ? onSubmitLoadingPress : handleSendMessage
|
||||
}
|
||||
accessibilityLabel={isAgentRunning ? "Send and interrupt" : "Send message"}
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
styles.sendButton,
|
||||
(isSubmitDisabled ||
|
||||
isSubmitLoading ||
|
||||
disabled) &&
|
||||
styles.buttonDisabled,
|
||||
]}
|
||||
disabled={isSendButtonDisabled}
|
||||
accessibilityLabel={submitAccessibilityLabel}
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
styles.sendButton,
|
||||
isSendButtonDisabled && styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
{isSubmitLoading ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
|
||||
@@ -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",
|
||||
@@ -540,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(() => {
|
||||
@@ -1123,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)}`
|
||||
@@ -1242,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 }],
|
||||
}));
|
||||
@@ -1478,6 +1608,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
</Pressable>
|
||||
{detailContent ? (
|
||||
<Pressable
|
||||
ref={detailWrapperRef}
|
||||
style={expandableBadgeStylesheet.detailWrapper}
|
||||
onHoverIn={() => onDetailHoverChange?.(true)}
|
||||
onHoverOut={() => onDetailHoverChange?.(false)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,15 +17,24 @@ 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 {
|
||||
buildHostAgentDraftRoute,
|
||||
buildHostAgentsRoute,
|
||||
buildHostSettingsRoute,
|
||||
mapPathnameToServer,
|
||||
@@ -85,14 +94,23 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
|
||||
// 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, serverId: activeServerId });
|
||||
} = useSidebarAgentsGrouped({
|
||||
isOpen,
|
||||
serverId: activeServerId,
|
||||
selectedProjectKeys,
|
||||
});
|
||||
useEffect(() => {
|
||||
setSelectedProjectKeys([]);
|
||||
}, [activeServerId]);
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
@@ -102,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);
|
||||
@@ -120,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);
|
||||
@@ -135,11 +154,34 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
}, [closeToAgent]);
|
||||
|
||||
const handleCreateAgentClean = useCallback(() => {
|
||||
if (!activeServerId) {
|
||||
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(buildHostAgentDraftRoute(activeServerId) as any);
|
||||
}, [activeServerId]);
|
||||
router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any);
|
||||
}, [activeServerId, pathname, selectedAgentId]);
|
||||
|
||||
// Mobile: close sidebar and navigate
|
||||
const handleCreateAgentCleanMobile = useCallback(() => {
|
||||
@@ -196,6 +238,27 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
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) {
|
||||
@@ -297,9 +360,36 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Middle: scrollable agent list */}
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarAgentList
|
||||
entries={entries}
|
||||
projectOptions={projectOptions}
|
||||
selectedProjectKeys={selectedProjectKeys}
|
||||
onSelectedProjectKeysChange={setSelectedProjectKeys}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
listFooterComponent={listFooterComponent}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentSelect={handleAgentSelectMobile}
|
||||
parentGestureRef={closeGestureRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.sidebarFooter}>
|
||||
<View style={styles.footerHostSlot}>
|
||||
<Pressable
|
||||
ref={hostTriggerRef}
|
||||
style={styles.hostTrigger}
|
||||
style={({ hovered = false }) => [
|
||||
styles.hostTrigger,
|
||||
hovered && styles.hostTriggerHovered,
|
||||
]}
|
||||
onPress={() => setIsHostPickerOpen(true)}
|
||||
disabled={hostOptions.length === 0}
|
||||
>
|
||||
@@ -314,10 +404,47 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
</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}
|
||||
@@ -325,54 +452,6 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
anchorRef={hostTriggerRef}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Middle: scrollable agent list */}
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarAgentList
|
||||
sections={sections}
|
||||
checkoutByAgentKey={checkoutByAgentKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentSelect={handleAgentSelectMobile}
|
||||
parentGestureRef={closeGestureRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
@@ -387,9 +466,8 @@ 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}
|
||||
>
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
@@ -401,13 +479,38 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<Plus size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
|
||||
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
|
||||
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Middle: scrollable agent list */}
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarAgentList
|
||||
entries={entries}
|
||||
projectOptions={projectOptions}
|
||||
selectedProjectKeys={selectedProjectKeys}
|
||||
onSelectedProjectKeysChange={setSelectedProjectKeys}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
listFooterComponent={listFooterComponent}
|
||||
selectedAgentId={selectedAgentId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.sidebarFooter}>
|
||||
<View style={styles.footerHostSlot}>
|
||||
<Pressable
|
||||
ref={hostTriggerRef}
|
||||
style={styles.hostTrigger}
|
||||
style={({ hovered = false }) => [
|
||||
styles.hostTrigger,
|
||||
hovered && styles.hostTriggerHovered,
|
||||
]}
|
||||
onPress={() => setIsHostPickerOpen(true)}
|
||||
disabled={hostOptions.length === 0}
|
||||
>
|
||||
@@ -422,47 +525,24 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Combobox
|
||||
options={hostOptions}
|
||||
value={activeServerId ?? ""}
|
||||
onSelect={handleHostSelect}
|
||||
title="Switch host"
|
||||
searchPlaceholder="Search hosts..."
|
||||
open={isHostPickerOpen}
|
||||
onOpenChange={setIsHostPickerOpen}
|
||||
anchorRef={hostTriggerRef}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Middle: scrollable agent list */}
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarAgentList
|
||||
sections={sections}
|
||||
checkoutByAgentKey={checkoutByAgentKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
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.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"
|
||||
@@ -478,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>
|
||||
);
|
||||
@@ -510,9 +601,12 @@ 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",
|
||||
@@ -520,7 +614,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
sidebarHeaderRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
justifyContent: "flex-start",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
newAgentButton: {
|
||||
@@ -543,12 +637,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
hostTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
justifyContent: "flex-start",
|
||||
gap: theme.spacing[2],
|
||||
minWidth: 0,
|
||||
maxWidth: "55%",
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: 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,
|
||||
@@ -558,6 +658,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
hostTriggerText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
sidebarFooter: {
|
||||
flexDirection: "row",
|
||||
@@ -568,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: {
|
||||
|
||||
@@ -1,78 +1,60 @@
|
||||
"use dom";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
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;
|
||||
outputText: string;
|
||||
initialOutputText: string;
|
||||
outputChunkText: string;
|
||||
outputChunkSequence: number;
|
||||
testId?: string;
|
||||
backgroundColor?: string;
|
||||
foregroundColor?: string;
|
||||
cursorColor?: string;
|
||||
onInput?: (data: string) => Promise<void> | void;
|
||||
onResize?: (rows: number, cols: number) => 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 {
|
||||
__paseoTerminal?: Terminal;
|
||||
}
|
||||
}
|
||||
|
||||
function isTerminalDebugEnabled(): boolean {
|
||||
const explicit = (
|
||||
globalThis as {
|
||||
__PASEO_TERMINAL_DEBUG?: unknown;
|
||||
}
|
||||
).__PASEO_TERMINAL_DEBUG;
|
||||
if (typeof explicit === "boolean") {
|
||||
return explicit;
|
||||
}
|
||||
const devFlag = (globalThis as { __DEV__?: unknown }).__DEV__;
|
||||
return devFlag === true;
|
||||
}
|
||||
|
||||
function logTerminalDebug(message: string, payload?: Record<string, unknown>): void {
|
||||
if (!isTerminalDebugEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (payload) {
|
||||
console.log(`[TerminalDebug][DOM] ${message}`, payload);
|
||||
return;
|
||||
}
|
||||
console.log(`[TerminalDebug][DOM] ${message}`);
|
||||
interface Window {}
|
||||
}
|
||||
|
||||
export default function TerminalEmulator({
|
||||
streamKey,
|
||||
outputText,
|
||||
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 terminalRef = useRef<Terminal | null>(null);
|
||||
const renderedOutputRef = useRef("");
|
||||
const lastSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||
const onInputRef = useRef<TerminalEmulatorProps["onInput"]>(onInput);
|
||||
const onResizeRef = useRef<TerminalEmulatorProps["onResize"]>(onResize);
|
||||
|
||||
useEffect(() => {
|
||||
onInputRef.current = onInput;
|
||||
}, [onInput]);
|
||||
|
||||
useEffect(() => {
|
||||
onResizeRef.current = onResize;
|
||||
}, [onResize]);
|
||||
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
@@ -81,302 +63,94 @@ export default function TerminalEmulator({
|
||||
return;
|
||||
}
|
||||
|
||||
logTerminalDebug("mount", {
|
||||
streamKey,
|
||||
hasOnInput: Boolean(onInputRef.current),
|
||||
hasOnResize: Boolean(onResizeRef.current),
|
||||
});
|
||||
|
||||
renderedOutputRef.current = "";
|
||||
lastSizeRef.current = null;
|
||||
host.innerHTML = "";
|
||||
|
||||
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: backgroundColor,
|
||||
foreground: foregroundColor,
|
||||
cursor: cursorColor,
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
runtimeRef.current = runtime;
|
||||
runtime.setCallbacks({
|
||||
callbacks: {
|
||||
onInput,
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
},
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(host);
|
||||
|
||||
const documentElement = document.documentElement;
|
||||
const body = document.body;
|
||||
const rootContainer = 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 ?? "";
|
||||
|
||||
// Force document to follow WebView bounds; xterm viewport owns scrollback.
|
||||
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%";
|
||||
}
|
||||
|
||||
const viewportElement = host.querySelector<HTMLElement>(".xterm-viewport");
|
||||
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") ?? "";
|
||||
if (viewportElement) {
|
||||
viewportElement.style.overscrollBehavior = "contain";
|
||||
viewportElement.style.touchAction = "pan-y";
|
||||
viewportElement.style.overflowY = "auto";
|
||||
viewportElement.style.overflowX = "hidden";
|
||||
viewportElement.style.pointerEvents = "auto";
|
||||
viewportElement.style.setProperty("-webkit-overflow-scrolling", "touch");
|
||||
}
|
||||
|
||||
terminalRef.current = terminal;
|
||||
window.__paseoTerminal = terminal;
|
||||
|
||||
const fitAndEmitResize = (force = false) => {
|
||||
const handler = onResizeRef.current;
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
fitAddon.fit();
|
||||
} catch {
|
||||
logTerminalDebug("fit failed");
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = terminal.rows;
|
||||
const cols = terminal.cols;
|
||||
const previous = lastSizeRef.current;
|
||||
if (!force && previous && previous.rows === rows && previous.cols === cols) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastSizeRef.current = { rows, cols };
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
logTerminalDebug("fit+resize", {
|
||||
force,
|
||||
rows,
|
||||
cols,
|
||||
rootWidth: Math.round(rootRect.width),
|
||||
rootHeight: Math.round(rootRect.height),
|
||||
});
|
||||
void handler(rows, cols);
|
||||
};
|
||||
|
||||
fitAndEmitResize(true);
|
||||
|
||||
const inputDisposable = terminal.onData((data) => {
|
||||
const handler = onInputRef.current;
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
logTerminalDebug("input", {
|
||||
length: data.length,
|
||||
preview: data.slice(0, 20),
|
||||
});
|
||||
void handler(data);
|
||||
runtime.setPendingModifiers({ pendingModifiers });
|
||||
runtime.mount({
|
||||
root,
|
||||
host,
|
||||
initialOutputText,
|
||||
theme: {
|
||||
backgroundColor,
|
||||
foregroundColor,
|
||||
cursorColor,
|
||||
},
|
||||
});
|
||||
|
||||
let lastScrollLogTs = 0;
|
||||
let lastWheelLogTs = 0;
|
||||
let lastTouchMoveLogTs = 0;
|
||||
|
||||
const viewportScrollHandler = () => {
|
||||
const now = Date.now();
|
||||
if (now - lastScrollLogTs < 120) {
|
||||
return;
|
||||
}
|
||||
lastScrollLogTs = now;
|
||||
logTerminalDebug("viewport scroll", {
|
||||
baseY: terminal.buffer.active.baseY,
|
||||
viewportY: terminal.buffer.active.viewportY,
|
||||
});
|
||||
};
|
||||
const viewportWheelHandler = (event: WheelEvent) => {
|
||||
const now = Date.now();
|
||||
if (now - lastWheelLogTs < 120) {
|
||||
return;
|
||||
}
|
||||
lastWheelLogTs = now;
|
||||
logTerminalDebug("viewport wheel", {
|
||||
deltaY: event.deltaY,
|
||||
deltaX: event.deltaX,
|
||||
});
|
||||
};
|
||||
const viewportTouchStartHandler = (event: TouchEvent) => {
|
||||
logTerminalDebug("viewport touchstart", {
|
||||
touches: event.touches.length,
|
||||
});
|
||||
};
|
||||
const viewportTouchMoveHandler = (event: TouchEvent) => {
|
||||
const now = Date.now();
|
||||
if (now - lastTouchMoveLogTs < 120) {
|
||||
return;
|
||||
}
|
||||
lastTouchMoveLogTs = now;
|
||||
logTerminalDebug("viewport touchmove", {
|
||||
touches: event.touches.length,
|
||||
});
|
||||
};
|
||||
|
||||
viewportElement?.addEventListener("scroll", viewportScrollHandler, { passive: true });
|
||||
viewportElement?.addEventListener("wheel", viewportWheelHandler, { passive: true });
|
||||
viewportElement?.addEventListener("touchstart", viewportTouchStartHandler, {
|
||||
passive: true,
|
||||
});
|
||||
viewportElement?.addEventListener("touchmove", viewportTouchMoveHandler, {
|
||||
passive: true,
|
||||
});
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAndEmitResize();
|
||||
});
|
||||
resizeObserver.observe(root);
|
||||
|
||||
const windowResizeHandler = () => fitAndEmitResize();
|
||||
window.addEventListener("resize", windowResizeHandler);
|
||||
|
||||
const visualViewport = window.visualViewport;
|
||||
const visualViewportResizeHandler = () => fitAndEmitResize();
|
||||
visualViewport?.addEventListener("resize", visualViewportResizeHandler);
|
||||
|
||||
// Safety net for keyboard/layout transitions that can skip callbacks.
|
||||
const fitInterval = window.setInterval(() => {
|
||||
fitAndEmitResize();
|
||||
}, 250);
|
||||
|
||||
window.setTimeout(() => fitAndEmitResize(true), 0);
|
||||
|
||||
if (outputText.length > 0) {
|
||||
terminal.write(outputText);
|
||||
renderedOutputRef.current = outputText;
|
||||
}
|
||||
terminal.focus();
|
||||
|
||||
return () => {
|
||||
inputDisposable.dispose();
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener("resize", windowResizeHandler);
|
||||
visualViewport?.removeEventListener("resize", visualViewportResizeHandler);
|
||||
window.clearInterval(fitInterval);
|
||||
viewportElement?.removeEventListener("scroll", viewportScrollHandler);
|
||||
viewportElement?.removeEventListener("wheel", viewportWheelHandler);
|
||||
viewportElement?.removeEventListener("touchstart", viewportTouchStartHandler);
|
||||
viewportElement?.removeEventListener("touchmove", viewportTouchMoveHandler);
|
||||
|
||||
fitAddon.dispose();
|
||||
terminal.dispose();
|
||||
|
||||
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;
|
||||
runtime.unmount();
|
||||
if (runtimeRef.current === runtime) {
|
||||
runtimeRef.current = null;
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
terminalRef.current = null;
|
||||
if (window.__paseoTerminal === terminal) {
|
||||
window.__paseoTerminal = undefined;
|
||||
}
|
||||
logTerminalDebug("unmount", { streamKey });
|
||||
renderedOutputRef.current = "";
|
||||
lastSizeRef.current = null;
|
||||
};
|
||||
}, [backgroundColor, cursorColor, foregroundColor, streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) {
|
||||
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;
|
||||
}
|
||||
|
||||
const previous = renderedOutputRef.current;
|
||||
if (outputText === previous) {
|
||||
if (!runtime) {
|
||||
onOutputChunkConsumed?.(outputChunkSequence);
|
||||
return;
|
||||
}
|
||||
|
||||
if (previous.length > 0 && outputText.startsWith(previous)) {
|
||||
const suffix = outputText.slice(previous.length);
|
||||
if (suffix.length > 0) {
|
||||
terminal.write(suffix);
|
||||
}
|
||||
} else {
|
||||
terminal.reset();
|
||||
terminal.clear();
|
||||
if (outputText.length > 0) {
|
||||
terminal.write(outputText);
|
||||
}
|
||||
if (outputChunkText.length === 0) {
|
||||
runtime.clear({
|
||||
onCommitted: () => {
|
||||
onOutputChunkConsumed?.(outputChunkSequence);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
runtime.write({
|
||||
text: outputChunkText,
|
||||
onCommitted: () => {
|
||||
onOutputChunkConsumed?.(outputChunkSequence);
|
||||
},
|
||||
});
|
||||
}, [onOutputChunkConsumed, outputChunkSequence, outputChunkText]);
|
||||
|
||||
renderedOutputRef.current = outputText;
|
||||
}, [outputText]);
|
||||
useEffect(() => {
|
||||
if (focusRequestToken <= 0) {
|
||||
return;
|
||||
}
|
||||
runtimeRef.current?.focus();
|
||||
}, [focusRequestToken]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-testid={testId}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
backgroundColor,
|
||||
@@ -384,8 +158,7 @@ export default function TerminalEmulator({
|
||||
overscrollBehavior: "none",
|
||||
}}
|
||||
onPointerDown={() => {
|
||||
logTerminalDebug("root pointerdown", { streamKey });
|
||||
terminalRef.current?.focus();
|
||||
runtimeRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
||||
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
|
||||
@@ -356,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,36 +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
|
||||
@@ -108,6 +135,7 @@ function SearchInput({
|
||||
export interface ComboboxItemProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
kind?: "directory";
|
||||
selected?: boolean;
|
||||
active?: boolean;
|
||||
onPress: () => void;
|
||||
@@ -117,6 +145,7 @@ export interface ComboboxItemProps {
|
||||
export function ComboboxItem({
|
||||
label,
|
||||
description,
|
||||
kind,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
@@ -134,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 ? (
|
||||
@@ -150,7 +184,7 @@ 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({
|
||||
@@ -158,26 +192,39 @@ export function Combobox({
|
||||
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);
|
||||
@@ -223,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 }) {
|
||||
@@ -241,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: {
|
||||
@@ -259,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;
|
||||
@@ -293,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) => {
|
||||
@@ -390,7 +512,7 @@ export function Combobox({
|
||||
setActiveIndex((currentIndex) =>
|
||||
getNextActiveIndex({
|
||||
currentIndex,
|
||||
itemCount: visibleOptions.length,
|
||||
itemCount: orderedVisibleOptions.length,
|
||||
key,
|
||||
})
|
||||
);
|
||||
@@ -398,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;
|
||||
}
|
||||
|
||||
@@ -411,7 +533,7 @@ export function Combobox({
|
||||
handleClose();
|
||||
}
|
||||
},
|
||||
[activeIndex, handleClose, handleSelect, isMobile, isOpen, visibleOptions]
|
||||
[activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -444,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)}
|
||||
@@ -461,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
|
||||
@@ -509,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,
|
||||
{
|
||||
@@ -518,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}
|
||||
@@ -537,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>
|
||||
@@ -581,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
|
||||
? {}
|
||||
: {
|
||||
@@ -608,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,
|
||||
@@ -670,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",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -605,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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -56,7 +56,9 @@ export function useAllAgentsList(options?: {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
return await client.fetchAgents();
|
||||
return await client.fetchAgents({
|
||||
filter: { labels: { ui: "true" } },
|
||||
});
|
||||
},
|
||||
enabled: canFetch,
|
||||
staleTime: ALL_AGENTS_STALE_TIME,
|
||||
@@ -76,11 +78,12 @@ export function useAllAgentsList(options?: {
|
||||
if (!serverId) {
|
||||
return [];
|
||||
}
|
||||
const data = agentsQuery.data ?? [];
|
||||
const data = agentsQuery.data?.entries ?? [];
|
||||
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
|
||||
const list: AggregatedAgent[] = [];
|
||||
|
||||
for (const snapshot of data) {
|
||||
for (const entry of data) {
|
||||
const snapshot = entry.agent;
|
||||
const normalized = normalizeAgentSnapshot(snapshot, serverId);
|
||||
const live = liveAgents?.get(snapshot.id);
|
||||
list.push(
|
||||
|
||||
@@ -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 {
|
||||
@@ -126,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);
|
||||
@@ -214,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 = 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(buildHostAgentDetailRoute(agent.serverId, agent.id) as any);
|
||||
},
|
||||
[pathname, requestFocusChatInput, setOpen]
|
||||
[pathname, requestMessageInputAction, setOpen]
|
||||
);
|
||||
|
||||
const handleSelectAction = useCallback((action: CommandCenterActionItem) => {
|
||||
@@ -267,7 +269,10 @@ export function useCommandCenter() {
|
||||
isFocused,
|
||||
onTimeout: () => {
|
||||
if (agentKeyFromPathname) {
|
||||
requestFocusChatInput(agentKeyFromPathname);
|
||||
requestMessageInputAction({
|
||||
agentKey: agentKeyFromPathname,
|
||||
kind: "focus",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -283,7 +288,7 @@ export function useCommandCenter() {
|
||||
inputRef.current?.focus();
|
||||
}, 0);
|
||||
return () => clearTimeout(id);
|
||||
}, [agentKeyFromPathname, open, requestFocusChatInput]);
|
||||
}, [agentKeyFromPathname, open, requestMessageInputAction]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -1,316 +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";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseServerIdFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
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 = Boolean(parseHostAgentRouteFromPathname(pathname));
|
||||
const navigate = shouldReplace ? router.replace : router.push;
|
||||
navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
|
||||
};
|
||||
|
||||
const navigateToNewAgent = () => {
|
||||
let targetServerId = parseServerIdFromPathname(pathname);
|
||||
let targetWorkingDir: string | null = null;
|
||||
if (selectedAgentId) {
|
||||
const separatorIndex = selectedAgentId.indexOf(":");
|
||||
if (separatorIndex > 0) {
|
||||
const serverId = selectedAgentId.slice(0, separatorIndex);
|
||||
const agentId = selectedAgentId.slice(separatorIndex + 1);
|
||||
targetServerId = serverId;
|
||||
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;
|
||||
targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetServerId) {
|
||||
const sessionServerIds = Object.keys(useSessionStore.getState().sessions);
|
||||
targetServerId = sessionServerIds[0] ?? null;
|
||||
}
|
||||
|
||||
if (!targetServerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) 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 { 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,
|
||||
@@ -69,6 +146,7 @@ function toAggregatedAgent(params: {
|
||||
export function useSidebarAgentsGrouped(options?: {
|
||||
isOpen?: boolean;
|
||||
serverId?: string | null;
|
||||
selectedProjectKeys?: string[];
|
||||
}): SidebarAgentsGroupedResult {
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -79,6 +157,15 @@ export function useSidebarAgentsGrouped(options?: {
|
||||
? value.trim()
|
||||
: null;
|
||||
}, [options?.serverId]);
|
||||
const selectedProjectKeys = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
(options?.selectedProjectKeys ?? [])
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
),
|
||||
[options?.selectedProjectKeys]
|
||||
);
|
||||
|
||||
const session = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId] : undefined
|
||||
@@ -88,189 +175,160 @@ export function useSidebarAgentsGrouped(options?: {
|
||||
const isConnected = session?.connection.isConnected ?? false;
|
||||
const canFetch = Boolean(serverId && client && isConnected);
|
||||
|
||||
const groupedQuery = useQuery({
|
||||
queryKey: ["sidebarAgentsGrouped", serverId] as const,
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: ["sidebarAgentsList", serverId] as const,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
return await client.fetchAgentsGroupedByProject({
|
||||
return await client.fetchAgents({
|
||||
filter: { labels: { ui: "true" } },
|
||||
sort: [
|
||||
{ key: "status_priority", direction: "asc" },
|
||||
{ key: "updated_at", direction: "desc" },
|
||||
],
|
||||
});
|
||||
},
|
||||
enabled: canFetch,
|
||||
staleTime: SIDEBAR_GROUPS_STALE_TIME,
|
||||
refetchInterval: isOpen ? SIDEBAR_GROUPS_REFETCH_INTERVAL : false,
|
||||
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 { entries, projectOptions, hasAnyData, hasMoreEntries } = useMemo(() => {
|
||||
if (!serverId) {
|
||||
return {
|
||||
sections: [] as SidebarSectionData[],
|
||||
checkoutByAgentKey: new Map<string, SidebarCheckoutLite>(),
|
||||
entries: [] as SidebarAgentListEntry[],
|
||||
projectOptions: [] as SidebarProjectOption[],
|
||||
hasAnyData: false,
|
||||
hasMoreEntries: false,
|
||||
};
|
||||
}
|
||||
|
||||
const groupsByKey = new Map<string, MutableSidebarGroup>();
|
||||
const checkoutLookup = new Map<string, SidebarCheckoutLite>();
|
||||
const seenAgentKeys = new Set<string>();
|
||||
const payload = groupedQuery.data as SidebarGroupsPayload | undefined;
|
||||
const groupedFetchReady = groupedQuery.isFetched;
|
||||
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
|
||||
const seenAgentIds = new Set<string>();
|
||||
const byProject = new Map<string, SidebarProjectOption>();
|
||||
const mergedEntries: SidebarAgentListEntry[] = [];
|
||||
|
||||
if (payload) {
|
||||
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 pushEntry = (entry: SidebarAgentListEntry): void => {
|
||||
if (entry.agent.archivedAt) {
|
||||
return;
|
||||
}
|
||||
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 (groupedFetchReady && liveAgents) {
|
||||
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,
|
||||
};
|
||||
}, [
|
||||
agentsQuery.data?.entries,
|
||||
connectionStates,
|
||||
groupedQuery.data,
|
||||
groupedQuery.isFetched,
|
||||
liveAgents,
|
||||
projectOrder,
|
||||
selectedProjectKeys,
|
||||
serverId,
|
||||
]);
|
||||
|
||||
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]);
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
if (!serverId) {
|
||||
return;
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["sidebarAgentsGrouped", serverId],
|
||||
queryKey: ["sidebarAgentsList", serverId],
|
||||
});
|
||||
}, [queryClient, serverId]);
|
||||
|
||||
const isFetching =
|
||||
canFetch && (groupedQuery.isPending || groupedQuery.isFetching);
|
||||
canFetch && (agentsQuery.isPending || agentsQuery.isFetching);
|
||||
const isInitialLoad = isFetching && !hasAnyData;
|
||||
const isRevalidating = isFetching && hasAnyData;
|
||||
|
||||
return {
|
||||
sections,
|
||||
checkoutByAgentKey,
|
||||
entries,
|
||||
projectOptions,
|
||||
hasMoreEntries,
|
||||
isLoading: isFetching,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
refreshAll,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
@@ -59,7 +60,11 @@ 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";
|
||||
@@ -67,6 +72,8 @@ import {
|
||||
derivePendingPermissionKey,
|
||||
normalizeAgentSnapshot,
|
||||
} from "@/utils/agent-snapshots";
|
||||
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
|
||||
import type { FetchAgentsEntry } from "@server/client/daemon-client";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -75,6 +82,7 @@ import {
|
||||
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[] = [];
|
||||
@@ -210,6 +218,8 @@ function AgentScreenContent({
|
||||
const toast = useToast();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const resolvedAgentId = agentId;
|
||||
|
||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
||||
|
||||
@@ -229,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,
|
||||
@@ -254,6 +342,10 @@ function AgentScreenContent({
|
||||
animateToClose: animateExplorerToClose,
|
||||
isGesturing: isExplorerGesturing,
|
||||
} = useExplorerSidebarAnimation();
|
||||
const handleOpenExplorerFromGesture = useCallback(() => {
|
||||
openExplorerForActiveCheckout();
|
||||
animateExplorerToOpen();
|
||||
}, [animateExplorerToOpen, openExplorerForActiveCheckout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") {
|
||||
@@ -293,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();
|
||||
}
|
||||
@@ -308,9 +399,8 @@ function AgentScreenContent({
|
||||
explorerWindowWidth,
|
||||
explorerTranslateX,
|
||||
explorerBackdropOpacity,
|
||||
animateExplorerToOpen,
|
||||
animateExplorerToClose,
|
||||
openExplorerWithDefaultTab,
|
||||
handleOpenExplorerFromGesture,
|
||||
isExplorerGesturing,
|
||||
]
|
||||
);
|
||||
@@ -330,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) =>
|
||||
@@ -394,6 +513,9 @@ function AgentScreenContent({
|
||||
const isConnected = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
||||
);
|
||||
const focusedAgentId = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.focusedAgentId ?? null
|
||||
);
|
||||
const { ensureAgentIsInitialized, refreshAgent } = useAgentInitialization(serverId);
|
||||
const [missingAgentState, setMissingAgentState] = useState<MissingAgentState>({
|
||||
kind: "idle",
|
||||
@@ -480,12 +602,7 @@ function AgentScreenContent({
|
||||
};
|
||||
}, [showConnectedNotice]);
|
||||
|
||||
// Checkout status for header subtitle
|
||||
const checkoutStatusQuery = useCheckoutStatusQuery({
|
||||
serverId,
|
||||
cwd: agent?.cwd ?? "",
|
||||
});
|
||||
const checkout = checkoutStatusQuery.status;
|
||||
const isGitCheckout = activeExplorerCheckout?.isGit ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedAgentId) {
|
||||
@@ -747,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) {
|
||||
@@ -1105,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>
|
||||
);
|
||||
@@ -1122,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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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, 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 { 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";
|
||||
@@ -125,9 +127,6 @@ export function DraftAgentScreen({
|
||||
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();
|
||||
@@ -226,11 +225,6 @@ export function DraftAgentScreen({
|
||||
: undefined;
|
||||
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 [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">("none");
|
||||
const [baseBranch, setBaseBranch] = useState("");
|
||||
@@ -241,6 +235,8 @@ export function DraftAgentScreen({
|
||||
const [isBranchOpen, setIsBranchOpen] = useState(false);
|
||||
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);
|
||||
@@ -255,6 +251,12 @@ export function DraftAgentScreen({
|
||||
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;
|
||||
@@ -316,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) {
|
||||
@@ -334,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
|
||||
@@ -520,6 +522,36 @@ export function DraftAgentScreen({
|
||||
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) {
|
||||
@@ -653,6 +685,50 @@ 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"
|
||||
@@ -977,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]}>
|
||||
@@ -1122,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}
|
||||
|
||||
@@ -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",
|
||||
@@ -646,7 +656,7 @@ export default function SettingsScreen() {
|
||||
connectionStatus={connectionStatus}
|
||||
activeConnection={activeConnection}
|
||||
lastError={lastConnectionError}
|
||||
onPress={handleEditDaemon}
|
||||
onOpenSettings={handleEditDaemon}
|
||||
/>
|
||||
);
|
||||
})
|
||||
@@ -885,8 +895,6 @@ function HostDetailModal({
|
||||
}: HostDetailModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [draftLabel, setDraftLabel] = useState("");
|
||||
const [isDraftLabelDirty, setIsDraftLabelDirty] = useState(false);
|
||||
const activeServerIdRef = useRef<string | null>(null);
|
||||
const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{ serverId: string; connectionId: string; title: string } | null>(null);
|
||||
const [isRemovingConnection, setIsRemovingConnection] = useState(false);
|
||||
|
||||
@@ -975,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
|
||||
@@ -1033,28 +1033,18 @@ function HostDetailModal({
|
||||
|
||||
const handleDraftLabelChange = useCallback((nextValue: string) => {
|
||||
setDraftLabel(nextValue);
|
||||
setIsDraftLabelDirty(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !host) return;
|
||||
const hostChanged = activeServerIdRef.current !== host.serverId;
|
||||
if (hostChanged) {
|
||||
setDraftLabel(host.label ?? "");
|
||||
setIsDraftLabelDirty(false);
|
||||
activeServerIdRef.current = host.serverId;
|
||||
return;
|
||||
}
|
||||
if (!isDraftLabelDirty) {
|
||||
setDraftLabel(host.label ?? "");
|
||||
}
|
||||
}, [visible, host?.serverId, host?.label, isDraftLabelDirty]);
|
||||
// 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);
|
||||
setIsDraftLabelDirty(false);
|
||||
setDraftLabel("");
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
@@ -1304,7 +1294,7 @@ interface DaemonCardProps {
|
||||
connectionStatus: ConnectionStatus;
|
||||
activeConnection: ActiveConnection | null;
|
||||
lastError: string | null;
|
||||
onPress: (daemon: HostProfile) => void;
|
||||
onOpenSettings: (daemon: HostProfile) => void;
|
||||
}
|
||||
|
||||
function DaemonCard({
|
||||
@@ -1312,7 +1302,7 @@ function DaemonCard({
|
||||
connectionStatus,
|
||||
activeConnection,
|
||||
lastError,
|
||||
onPress,
|
||||
onOpenSettings,
|
||||
}: DaemonCardProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const statusLabel = formatConnectionStatus(connectionStatus);
|
||||
@@ -1347,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}>
|
||||
@@ -1375,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" | "terminals";
|
||||
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,
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
TerminalStreamController,
|
||||
type TerminalStreamControllerAttachPayload,
|
||||
type TerminalStreamControllerChunk,
|
||||
type TerminalStreamControllerClient,
|
||||
type TerminalStreamControllerStatus,
|
||||
} from "./terminal-stream-controller";
|
||||
|
||||
type FakeStreamSubscriber = (chunk: TerminalStreamControllerChunk) => void;
|
||||
|
||||
class FakeTerminalStreamClient implements TerminalStreamControllerClient {
|
||||
private readonly streamSubscribers = new Map<number, Set<FakeStreamSubscriber>>();
|
||||
private readonly pendingChunksByStreamId = new Map<number, TerminalStreamControllerChunk[]>();
|
||||
public attachCalls: Array<{
|
||||
terminalId: string;
|
||||
options?: {
|
||||
resumeOffset?: number;
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
};
|
||||
}> = [];
|
||||
public detachCalls: number[] = [];
|
||||
public nextAttachResponses: TerminalStreamControllerAttachPayload[] = [];
|
||||
|
||||
async attachTerminalStream(
|
||||
terminalId: string,
|
||||
options?: {
|
||||
resumeOffset?: number;
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
}
|
||||
): Promise<TerminalStreamControllerAttachPayload> {
|
||||
this.attachCalls.push({ terminalId, options });
|
||||
const response = this.nextAttachResponses.shift();
|
||||
if (!response) {
|
||||
throw new Error("Missing fake attach response");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async detachTerminalStream(streamId: number): Promise<void> {
|
||||
this.detachCalls.push(streamId);
|
||||
}
|
||||
|
||||
onTerminalStreamData(
|
||||
streamId: number,
|
||||
handler: (chunk: TerminalStreamControllerChunk) => void
|
||||
): () => void {
|
||||
const pendingChunks = this.pendingChunksByStreamId.get(streamId);
|
||||
if (pendingChunks && pendingChunks.length > 0) {
|
||||
for (const chunk of pendingChunks) {
|
||||
handler(chunk);
|
||||
}
|
||||
this.pendingChunksByStreamId.delete(streamId);
|
||||
}
|
||||
|
||||
const subscribers = this.streamSubscribers.get(streamId) ?? new Set();
|
||||
subscribers.add(handler);
|
||||
this.streamSubscribers.set(streamId, subscribers);
|
||||
return () => {
|
||||
const current = this.streamSubscribers.get(streamId);
|
||||
current?.delete(handler);
|
||||
if (current && current.size === 0) {
|
||||
this.streamSubscribers.delete(streamId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
emitChunk(input: {
|
||||
streamId: number;
|
||||
offset?: number;
|
||||
endOffset: number;
|
||||
replay?: boolean;
|
||||
data: string;
|
||||
}): void {
|
||||
const subscribers = this.streamSubscribers.get(input.streamId);
|
||||
if (!subscribers || subscribers.size === 0) {
|
||||
return;
|
||||
}
|
||||
const bytes = new TextEncoder().encode(input.data);
|
||||
const chunk: TerminalStreamControllerChunk = {
|
||||
offset: input.offset ?? input.endOffset - bytes.byteLength,
|
||||
endOffset: input.endOffset,
|
||||
replay: input.replay,
|
||||
data: bytes,
|
||||
};
|
||||
for (const subscriber of subscribers) {
|
||||
subscriber(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
bufferChunk(input: {
|
||||
streamId: number;
|
||||
offset?: number;
|
||||
endOffset: number;
|
||||
replay?: boolean;
|
||||
data: string;
|
||||
}): void {
|
||||
const chunks = this.pendingChunksByStreamId.get(input.streamId) ?? [];
|
||||
const bytes = new TextEncoder().encode(input.data);
|
||||
chunks.push({
|
||||
offset: input.offset ?? input.endOffset - bytes.byteLength,
|
||||
endOffset: input.endOffset,
|
||||
replay: input.replay,
|
||||
data: bytes,
|
||||
});
|
||||
this.pendingChunksByStreamId.set(input.streamId, chunks);
|
||||
}
|
||||
}
|
||||
|
||||
function createControllerHarness(input?: {
|
||||
client?: FakeTerminalStreamClient;
|
||||
}): {
|
||||
client: FakeTerminalStreamClient;
|
||||
chunks: Array<{ terminalId: string; text: string }>;
|
||||
statuses: TerminalStreamControllerStatus[];
|
||||
resets: string[];
|
||||
controller: TerminalStreamController;
|
||||
} {
|
||||
const client = input?.client ?? new FakeTerminalStreamClient();
|
||||
const chunks: Array<{ terminalId: string; text: string }> = [];
|
||||
const statuses: TerminalStreamControllerStatus[] = [];
|
||||
const resets: string[] = [];
|
||||
|
||||
const controller = new TerminalStreamController({
|
||||
client,
|
||||
getPreferredSize: () => ({ rows: 24, cols: 80 }),
|
||||
onChunk: (chunk) => {
|
||||
chunks.push(chunk);
|
||||
},
|
||||
onStatusChange: (status) => {
|
||||
statuses.push(status);
|
||||
},
|
||||
onReset: ({ terminalId }) => {
|
||||
resets.push(terminalId);
|
||||
},
|
||||
waitForDelay: async () => {},
|
||||
});
|
||||
|
||||
return {
|
||||
client,
|
||||
chunks,
|
||||
statuses,
|
||||
resets,
|
||||
controller,
|
||||
};
|
||||
}
|
||||
|
||||
async function flushAsyncWork(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => resolve(), 0);
|
||||
});
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("terminal-stream-controller", () => {
|
||||
it("streams burst chunks in order without dropping intermediate chunks", async () => {
|
||||
const harness = createControllerHarness();
|
||||
harness.client.nextAttachResponses.push({
|
||||
streamId: 7,
|
||||
currentOffset: 0,
|
||||
reset: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
harness.controller.setTerminal({ terminalId: "term-1" });
|
||||
await flushAsyncWork();
|
||||
|
||||
harness.client.emitChunk({
|
||||
streamId: 7,
|
||||
endOffset: 1,
|
||||
data: "a",
|
||||
});
|
||||
harness.client.emitChunk({
|
||||
streamId: 7,
|
||||
endOffset: 2,
|
||||
data: "b",
|
||||
});
|
||||
harness.client.emitChunk({
|
||||
streamId: 7,
|
||||
endOffset: 3,
|
||||
data: "c",
|
||||
});
|
||||
|
||||
expect(harness.chunks).toEqual([
|
||||
{ terminalId: "term-1", text: "a" },
|
||||
{ terminalId: "term-1", text: "b" },
|
||||
{ terminalId: "term-1", text: "c" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("retries retryable attach failures and then attaches", async () => {
|
||||
const harness = createControllerHarness();
|
||||
harness.client.nextAttachResponses.push({
|
||||
streamId: null,
|
||||
currentOffset: 0,
|
||||
reset: false,
|
||||
error: "network disconnected",
|
||||
});
|
||||
harness.client.nextAttachResponses.push({
|
||||
streamId: 9,
|
||||
currentOffset: 5,
|
||||
reset: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
harness.controller.setTerminal({ terminalId: "term-1" });
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(harness.client.attachCalls.length).toBe(2);
|
||||
expect(harness.client.attachCalls[1]?.options).toEqual({
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
});
|
||||
expect(harness.controller.getActiveStreamId()).toBe(9);
|
||||
expect(harness.statuses.at(-1)).toEqual({
|
||||
terminalId: "term-1",
|
||||
streamId: 9,
|
||||
isAttaching: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles stream exit by reconnecting on the same terminal", async () => {
|
||||
const harness = createControllerHarness();
|
||||
harness.client.nextAttachResponses.push({
|
||||
streamId: 3,
|
||||
currentOffset: 0,
|
||||
reset: false,
|
||||
error: null,
|
||||
});
|
||||
harness.client.nextAttachResponses.push({
|
||||
streamId: 4,
|
||||
currentOffset: 2,
|
||||
reset: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
harness.controller.setTerminal({ terminalId: "term-1" });
|
||||
await flushAsyncWork();
|
||||
|
||||
harness.client.emitChunk({
|
||||
streamId: 3,
|
||||
endOffset: 2,
|
||||
data: "hi",
|
||||
});
|
||||
harness.controller.handleStreamExit({
|
||||
terminalId: "term-1",
|
||||
streamId: 3,
|
||||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(harness.client.attachCalls.length).toBe(2);
|
||||
expect(harness.client.attachCalls[1]?.options).toEqual({
|
||||
resumeOffset: 2,
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
});
|
||||
expect(harness.controller.getActiveStreamId()).toBe(4);
|
||||
expect(harness.statuses.at(-1)).toEqual({
|
||||
terminalId: "term-1",
|
||||
streamId: 4,
|
||||
isAttaching: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("emits reset callback when attach indicates output reset", async () => {
|
||||
const harness = createControllerHarness();
|
||||
harness.client.nextAttachResponses.push({
|
||||
streamId: 12,
|
||||
currentOffset: 0,
|
||||
reset: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
harness.controller.setTerminal({ terminalId: "term-reset" });
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(harness.resets).toEqual(["term-reset"]);
|
||||
expect(harness.controller.getActiveStreamId()).toBe(12);
|
||||
});
|
||||
|
||||
it("delivers buffered replay chunks flushed synchronously during subscribe", async () => {
|
||||
const harness = createControllerHarness();
|
||||
harness.client.nextAttachResponses.push({
|
||||
streamId: 17,
|
||||
replayedFrom: 0,
|
||||
currentOffset: 10,
|
||||
reset: false,
|
||||
error: null,
|
||||
});
|
||||
harness.client.bufferChunk({
|
||||
streamId: 17,
|
||||
offset: 0,
|
||||
endOffset: 10,
|
||||
replay: true,
|
||||
data: "buffered-replay",
|
||||
});
|
||||
|
||||
harness.controller.setTerminal({ terminalId: "term-buffered" });
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(harness.chunks).toEqual([
|
||||
{ terminalId: "term-buffered", text: "buffered-replay" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears stale selected output when bootstrap replay starts before current offset", async () => {
|
||||
const harness = createControllerHarness();
|
||||
harness.client.nextAttachResponses.push({
|
||||
streamId: 61,
|
||||
replayedFrom: 0,
|
||||
currentOffset: 20,
|
||||
reset: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
harness.controller.setTerminal({ terminalId: "term-bootstrap-reset" });
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(harness.resets).toEqual(["term-bootstrap-reset"]);
|
||||
});
|
||||
|
||||
it("reattaches and replays from last contiguous offset when chunk offsets gap", async () => {
|
||||
const harness = createControllerHarness();
|
||||
harness.client.nextAttachResponses.push({
|
||||
streamId: 31,
|
||||
currentOffset: 0,
|
||||
reset: false,
|
||||
error: null,
|
||||
});
|
||||
harness.client.nextAttachResponses.push({
|
||||
streamId: 32,
|
||||
currentOffset: 8,
|
||||
reset: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
harness.controller.setTerminal({ terminalId: "term-gap" });
|
||||
await flushAsyncWork();
|
||||
|
||||
harness.client.emitChunk({
|
||||
streamId: 31,
|
||||
offset: 0,
|
||||
endOffset: 2,
|
||||
data: "ok",
|
||||
});
|
||||
harness.client.emitChunk({
|
||||
streamId: 31,
|
||||
offset: 4,
|
||||
endOffset: 8,
|
||||
data: "miss",
|
||||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(harness.chunks).toEqual([{ terminalId: "term-gap", text: "ok" }]);
|
||||
expect(harness.client.detachCalls).toContain(31);
|
||||
expect(harness.client.attachCalls.length).toBe(2);
|
||||
expect(harness.client.attachCalls[1]?.options).toEqual({
|
||||
resumeOffset: 2,
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
});
|
||||
expect(harness.controller.getActiveStreamId()).toBe(32);
|
||||
});
|
||||
|
||||
it("does not treat replay range before currentOffset as a live gap", async () => {
|
||||
const harness = createControllerHarness();
|
||||
harness.client.nextAttachResponses.push({
|
||||
streamId: 41,
|
||||
replayedFrom: 50,
|
||||
currentOffset: 100,
|
||||
reset: false,
|
||||
error: null,
|
||||
});
|
||||
harness.client.bufferChunk({
|
||||
streamId: 41,
|
||||
offset: 80,
|
||||
endOffset: 100,
|
||||
data: "replay-tail",
|
||||
});
|
||||
|
||||
harness.controller.setTerminal({ terminalId: "term-replay" });
|
||||
await flushAsyncWork();
|
||||
|
||||
harness.client.emitChunk({
|
||||
streamId: 41,
|
||||
offset: 100,
|
||||
endOffset: 102,
|
||||
data: "ok",
|
||||
});
|
||||
|
||||
expect(harness.client.detachCalls).toEqual([]);
|
||||
expect(harness.client.attachCalls).toHaveLength(1);
|
||||
expect(harness.controller.getActiveStreamId()).toBe(41);
|
||||
expect(harness.chunks).toEqual([
|
||||
{ terminalId: "term-replay", text: "replay-tail" },
|
||||
{ terminalId: "term-replay", text: "ok" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts clamped replay start after reconnect without entering a reconnect loop", async () => {
|
||||
const harness = createControllerHarness();
|
||||
harness.client.nextAttachResponses.push({
|
||||
streamId: 71,
|
||||
replayedFrom: 205,
|
||||
currentOffset: 205,
|
||||
reset: false,
|
||||
error: null,
|
||||
});
|
||||
harness.client.nextAttachResponses.push({
|
||||
streamId: 72,
|
||||
replayedFrom: 396,
|
||||
currentOffset: 978,
|
||||
reset: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
harness.controller.setTerminal({ terminalId: "term-clamped-replay" });
|
||||
await flushAsyncWork();
|
||||
|
||||
harness.client.emitChunk({
|
||||
streamId: 71,
|
||||
offset: 205,
|
||||
endOffset: 299,
|
||||
data: "first",
|
||||
});
|
||||
harness.client.emitChunk({
|
||||
streamId: 71,
|
||||
offset: 396,
|
||||
endOffset: 493,
|
||||
data: "gap",
|
||||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(harness.client.attachCalls).toHaveLength(2);
|
||||
expect(harness.client.attachCalls[1]?.options).toEqual({
|
||||
resumeOffset: 299,
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
});
|
||||
expect(harness.controller.getActiveStreamId()).toBe(72);
|
||||
|
||||
harness.client.emitChunk({
|
||||
streamId: 72,
|
||||
replay: true,
|
||||
offset: 396,
|
||||
endOffset: 493,
|
||||
data: "replay-1",
|
||||
});
|
||||
harness.client.emitChunk({
|
||||
streamId: 72,
|
||||
replay: true,
|
||||
offset: 493,
|
||||
endOffset: 590,
|
||||
data: "replay-2",
|
||||
});
|
||||
harness.client.emitChunk({
|
||||
streamId: 72,
|
||||
replay: true,
|
||||
offset: 687,
|
||||
endOffset: 784,
|
||||
data: "replay-3",
|
||||
});
|
||||
harness.client.emitChunk({
|
||||
streamId: 72,
|
||||
replay: false,
|
||||
offset: 978,
|
||||
endOffset: 1075,
|
||||
data: "live",
|
||||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(harness.client.attachCalls).toHaveLength(2);
|
||||
expect(harness.client.detachCalls.filter((streamId) => streamId === 71)).toHaveLength(1);
|
||||
expect(harness.chunks.at(-1)).toEqual({
|
||||
terminalId: "term-clamped-replay",
|
||||
text: "live",
|
||||
});
|
||||
});
|
||||
});
|
||||
618
packages/app/src/terminal/runtime/terminal-stream-controller.ts
Normal file
618
packages/app/src/terminal/runtime/terminal-stream-controller.ts
Normal file
@@ -0,0 +1,618 @@
|
||||
import {
|
||||
getTerminalAttachRetryDelayMs,
|
||||
getTerminalResumeOffset,
|
||||
isTerminalAttachRetryableError,
|
||||
updateTerminalResumeOffset,
|
||||
waitForDuration,
|
||||
withPromiseTimeout,
|
||||
} from "@/utils/terminal-attach";
|
||||
import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
|
||||
|
||||
export type TerminalStreamControllerAttachPayload = {
|
||||
streamId: number | null;
|
||||
replayedFrom?: number;
|
||||
currentOffset: number;
|
||||
reset: boolean;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
export type TerminalStreamControllerChunk = {
|
||||
offset: number;
|
||||
endOffset: number;
|
||||
replay?: boolean;
|
||||
data: Uint8Array;
|
||||
};
|
||||
|
||||
export type TerminalStreamControllerClient = {
|
||||
attachTerminalStream: (
|
||||
terminalId: string,
|
||||
options?: {
|
||||
resumeOffset?: number;
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
}
|
||||
) => Promise<TerminalStreamControllerAttachPayload>;
|
||||
detachTerminalStream: (streamId: number) => Promise<unknown>;
|
||||
onTerminalStreamData: (
|
||||
streamId: number,
|
||||
handler: (chunk: TerminalStreamControllerChunk) => void
|
||||
) => () => void;
|
||||
};
|
||||
|
||||
export type TerminalStreamControllerSize = {
|
||||
rows: number;
|
||||
cols: number;
|
||||
};
|
||||
|
||||
export type TerminalStreamControllerStatus = {
|
||||
terminalId: string | null;
|
||||
streamId: number | null;
|
||||
isAttaching: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export type TerminalStreamControllerOptions = {
|
||||
client: TerminalStreamControllerClient;
|
||||
getPreferredSize: () => TerminalStreamControllerSize | null;
|
||||
onChunk: (input: { terminalId: string; text: string }) => void;
|
||||
onReset?: (input: { terminalId: string }) => void;
|
||||
onStatusChange?: (status: TerminalStreamControllerStatus) => void;
|
||||
maxAttachAttempts?: number;
|
||||
attachTimeoutMs?: number;
|
||||
reconnectErrorMessage?: string;
|
||||
withTimeout?: <T>(input: {
|
||||
promise: Promise<T>;
|
||||
timeoutMs: number;
|
||||
timeoutMessage: string;
|
||||
}) => Promise<T>;
|
||||
waitForDelay?: (input: { durationMs: number }) => Promise<void>;
|
||||
isRetryableError?: (input: { message: string }) => boolean;
|
||||
getRetryDelayMs?: (input: { attempt: number }) => number;
|
||||
};
|
||||
|
||||
type TerminalStreamControllerActiveStream = {
|
||||
terminalId: string;
|
||||
streamId: number;
|
||||
decoder: TextDecoder;
|
||||
nextExpectedOffset: number | null;
|
||||
catchUpEndOffset: number | null;
|
||||
unsubscribe: () => void;
|
||||
};
|
||||
|
||||
const DEFAULT_ATTACH_MAX_ATTEMPTS = 4;
|
||||
const DEFAULT_ATTACH_TIMEOUT_MS = 12_000;
|
||||
const DEFAULT_RECONNECT_ERROR_MESSAGE = "Terminal stream ended. Reconnecting…";
|
||||
|
||||
export class TerminalStreamController {
|
||||
private readonly resumeOffsetByTerminalId = new Map<string, number>();
|
||||
private selectedTerminalId: string | null = null;
|
||||
private activeStream: TerminalStreamControllerActiveStream | null = null;
|
||||
private attachGeneration = 0;
|
||||
private isDisposed = false;
|
||||
private status: TerminalStreamControllerStatus = {
|
||||
terminalId: null,
|
||||
streamId: null,
|
||||
isAttaching: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
constructor(private readonly options: TerminalStreamControllerOptions) {}
|
||||
|
||||
getActiveStreamId(): number | null {
|
||||
return this.activeStream?.streamId ?? null;
|
||||
}
|
||||
|
||||
setTerminal(input: { terminalId: string | null }): void {
|
||||
if (this.isDisposed) {
|
||||
return;
|
||||
}
|
||||
terminalDebugLog({
|
||||
scope: "stream-controller",
|
||||
event: "terminal:set",
|
||||
details: {
|
||||
previousTerminalId: this.selectedTerminalId,
|
||||
nextTerminalId: input.terminalId,
|
||||
},
|
||||
});
|
||||
|
||||
const nextTerminalId = input.terminalId;
|
||||
const previousTerminalId = this.selectedTerminalId;
|
||||
const isSameTerminal = previousTerminalId === nextTerminalId;
|
||||
const hasActiveStreamForSelection =
|
||||
isSameTerminal &&
|
||||
this.activeStream?.terminalId === nextTerminalId &&
|
||||
typeof this.activeStream.streamId === "number";
|
||||
if (hasActiveStreamForSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedTerminalId = nextTerminalId;
|
||||
this.attachGeneration += 1;
|
||||
const generation = this.attachGeneration;
|
||||
|
||||
void this.detachActiveStream({ shouldDetach: true });
|
||||
|
||||
if (!nextTerminalId) {
|
||||
this.updateStatus({
|
||||
terminalId: null,
|
||||
streamId: null,
|
||||
isAttaching: false,
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateStatus({
|
||||
terminalId: nextTerminalId,
|
||||
streamId: null,
|
||||
isAttaching: true,
|
||||
error: null,
|
||||
});
|
||||
void this.attachTerminal({
|
||||
terminalId: nextTerminalId,
|
||||
generation,
|
||||
});
|
||||
}
|
||||
|
||||
handleStreamExit(input: { terminalId: string; streamId: number }): void {
|
||||
if (this.isDisposed) {
|
||||
return;
|
||||
}
|
||||
terminalDebugLog({
|
||||
scope: "stream-controller",
|
||||
event: "stream:exit",
|
||||
details: {
|
||||
terminalId: input.terminalId,
|
||||
streamId: input.streamId,
|
||||
},
|
||||
});
|
||||
|
||||
const activeStream = this.activeStream;
|
||||
if (!activeStream) {
|
||||
return;
|
||||
}
|
||||
if (activeStream.terminalId !== input.terminalId || activeStream.streamId !== input.streamId) {
|
||||
return;
|
||||
}
|
||||
if (this.selectedTerminalId !== input.terminalId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.attachGeneration += 1;
|
||||
const generation = this.attachGeneration;
|
||||
void this.detachActiveStream({ shouldDetach: false });
|
||||
this.updateStatus({
|
||||
terminalId: input.terminalId,
|
||||
streamId: null,
|
||||
isAttaching: true,
|
||||
error:
|
||||
this.options.reconnectErrorMessage ?? DEFAULT_RECONNECT_ERROR_MESSAGE,
|
||||
});
|
||||
void this.attachTerminal({
|
||||
terminalId: input.terminalId,
|
||||
generation,
|
||||
});
|
||||
}
|
||||
|
||||
pruneResumeOffsets(input: { terminalIds: string[] }): void {
|
||||
const terminalIdSet = new Set(input.terminalIds);
|
||||
for (const terminalId of Array.from(this.resumeOffsetByTerminalId.keys())) {
|
||||
if (!terminalIdSet.has(terminalId)) {
|
||||
this.resumeOffsetByTerminalId.delete(terminalId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.isDisposed) {
|
||||
return;
|
||||
}
|
||||
this.isDisposed = true;
|
||||
this.attachGeneration += 1;
|
||||
this.selectedTerminalId = null;
|
||||
void this.detachActiveStream({ shouldDetach: true });
|
||||
this.resumeOffsetByTerminalId.clear();
|
||||
this.updateStatus({
|
||||
terminalId: null,
|
||||
streamId: null,
|
||||
isAttaching: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
private async attachTerminal(input: {
|
||||
terminalId: string;
|
||||
generation: number;
|
||||
}): Promise<void> {
|
||||
const {
|
||||
maxAttachAttempts = DEFAULT_ATTACH_MAX_ATTEMPTS,
|
||||
attachTimeoutMs = DEFAULT_ATTACH_TIMEOUT_MS,
|
||||
withTimeout = withPromiseTimeout,
|
||||
waitForDelay = waitForDuration,
|
||||
isRetryableError = isTerminalAttachRetryableError,
|
||||
getRetryDelayMs = getTerminalAttachRetryDelayMs,
|
||||
} = this.options;
|
||||
|
||||
let lastErrorMessage = "Unable to attach terminal stream";
|
||||
|
||||
for (let attempt = 0; attempt < maxAttachAttempts; attempt += 1) {
|
||||
if (!this.isAttachGenerationCurrent({ generation: input.generation, terminalId: input.terminalId })) {
|
||||
return;
|
||||
}
|
||||
terminalDebugLog({
|
||||
scope: "stream-controller",
|
||||
event: "attach:attempt",
|
||||
details: {
|
||||
terminalId: input.terminalId,
|
||||
generation: input.generation,
|
||||
attempt,
|
||||
maxAttachAttempts,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const preferredSize = this.options.getPreferredSize();
|
||||
const resumeOffset = getTerminalResumeOffset({
|
||||
terminalId: input.terminalId,
|
||||
resumeOffsetByTerminalId: this.resumeOffsetByTerminalId,
|
||||
});
|
||||
const attachPayload = await withTimeout({
|
||||
promise: this.options.client.attachTerminalStream(input.terminalId, {
|
||||
...(resumeOffset !== undefined ? { resumeOffset } : {}),
|
||||
...(preferredSize
|
||||
? { rows: preferredSize.rows, cols: preferredSize.cols }
|
||||
: {}),
|
||||
}),
|
||||
timeoutMs: attachTimeoutMs,
|
||||
timeoutMessage: "Timed out attaching terminal stream",
|
||||
});
|
||||
|
||||
if (!this.isAttachGenerationCurrent({ generation: input.generation, terminalId: input.terminalId })) {
|
||||
if (typeof attachPayload.streamId === "number") {
|
||||
void this.options.client.detachTerminalStream(attachPayload.streamId).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachPayload.error || typeof attachPayload.streamId !== "number") {
|
||||
lastErrorMessage = attachPayload.error ?? "Unable to attach terminal stream";
|
||||
terminalDebugLog({
|
||||
scope: "stream-controller",
|
||||
event: "attach:response-error",
|
||||
details: {
|
||||
terminalId: input.terminalId,
|
||||
attempt,
|
||||
error: lastErrorMessage,
|
||||
},
|
||||
});
|
||||
const hasRemainingAttempts = attempt < maxAttachAttempts - 1;
|
||||
if (hasRemainingAttempts && isRetryableError({ message: lastErrorMessage })) {
|
||||
await waitForDelay({ durationMs: getRetryDelayMs({ attempt }) });
|
||||
continue;
|
||||
}
|
||||
|
||||
this.updateStatus({
|
||||
terminalId: input.terminalId,
|
||||
streamId: null,
|
||||
isAttaching: false,
|
||||
error: lastErrorMessage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachPayload.reset) {
|
||||
this.resumeOffsetByTerminalId.delete(input.terminalId);
|
||||
this.options.onReset?.({ terminalId: input.terminalId });
|
||||
}
|
||||
|
||||
updateTerminalResumeOffset({
|
||||
terminalId: input.terminalId,
|
||||
offset: attachPayload.currentOffset,
|
||||
resumeOffsetByTerminalId: this.resumeOffsetByTerminalId,
|
||||
});
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const streamId = attachPayload.streamId;
|
||||
const replayedFromOffset =
|
||||
typeof attachPayload.replayedFrom === "number"
|
||||
? Math.max(0, Math.floor(attachPayload.replayedFrom))
|
||||
: null;
|
||||
const currentOffset = Math.max(
|
||||
0,
|
||||
Math.floor(attachPayload.currentOffset)
|
||||
);
|
||||
const shouldResetForReplayBootstrap =
|
||||
typeof resumeOffset !== "number" &&
|
||||
typeof replayedFromOffset === "number" &&
|
||||
replayedFromOffset < currentOffset;
|
||||
if (shouldResetForReplayBootstrap) {
|
||||
this.options.onReset?.({ terminalId: input.terminalId });
|
||||
}
|
||||
const startExpectedOffset =
|
||||
replayedFromOffset ??
|
||||
(typeof resumeOffset === "number"
|
||||
? Math.max(0, Math.floor(resumeOffset))
|
||||
: currentOffset);
|
||||
const catchUpEndOffset = currentOffset;
|
||||
|
||||
const activeStream: TerminalStreamControllerActiveStream = {
|
||||
terminalId: input.terminalId,
|
||||
streamId,
|
||||
decoder,
|
||||
nextExpectedOffset: startExpectedOffset,
|
||||
catchUpEndOffset,
|
||||
unsubscribe: () => {},
|
||||
};
|
||||
this.activeStream = activeStream;
|
||||
const unsubscribe = this.options.client.onTerminalStreamData(streamId, (chunk) => {
|
||||
this.handleChunk({
|
||||
terminalId: input.terminalId,
|
||||
streamId,
|
||||
chunk,
|
||||
decoder,
|
||||
});
|
||||
});
|
||||
if (this.activeStream === activeStream) {
|
||||
activeStream.unsubscribe = unsubscribe;
|
||||
} else {
|
||||
unsubscribe();
|
||||
}
|
||||
terminalDebugLog({
|
||||
scope: "stream-controller",
|
||||
event: "attach:success",
|
||||
details: {
|
||||
terminalId: input.terminalId,
|
||||
streamId,
|
||||
replayedFrom: attachPayload.replayedFrom ?? null,
|
||||
requestedResumeOffset: resumeOffset ?? null,
|
||||
currentOffset: attachPayload.currentOffset,
|
||||
reset: attachPayload.reset,
|
||||
bootstrapReset: shouldResetForReplayBootstrap,
|
||||
},
|
||||
});
|
||||
this.updateStatus({
|
||||
terminalId: input.terminalId,
|
||||
streamId,
|
||||
isAttaching: false,
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
lastErrorMessage =
|
||||
error instanceof Error ? error.message : "Unable to attach terminal stream";
|
||||
terminalDebugLog({
|
||||
scope: "stream-controller",
|
||||
event: "attach:exception",
|
||||
details: {
|
||||
terminalId: input.terminalId,
|
||||
attempt,
|
||||
error: lastErrorMessage,
|
||||
},
|
||||
});
|
||||
const hasRemainingAttempts = attempt < maxAttachAttempts - 1;
|
||||
if (hasRemainingAttempts && isRetryableError({ message: lastErrorMessage })) {
|
||||
await waitForDelay({ durationMs: getRetryDelayMs({ attempt }) });
|
||||
continue;
|
||||
}
|
||||
|
||||
this.updateStatus({
|
||||
terminalId: input.terminalId,
|
||||
streamId: null,
|
||||
isAttaching: false,
|
||||
error: lastErrorMessage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.updateStatus({
|
||||
terminalId: input.terminalId,
|
||||
streamId: null,
|
||||
isAttaching: false,
|
||||
error: lastErrorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
private handleChunk(input: {
|
||||
terminalId: string;
|
||||
streamId: number;
|
||||
chunk: TerminalStreamControllerChunk;
|
||||
decoder: TextDecoder;
|
||||
}): void {
|
||||
const activeStream = this.activeStream;
|
||||
if (!activeStream) {
|
||||
return;
|
||||
}
|
||||
if (activeStream.streamId !== input.streamId || activeStream.terminalId !== input.terminalId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chunkOffset = Number.isFinite(input.chunk.offset)
|
||||
? Math.max(0, Math.floor(input.chunk.offset))
|
||||
: 0;
|
||||
const chunkEndOffset = Number.isFinite(input.chunk.endOffset)
|
||||
? Math.max(0, Math.floor(input.chunk.endOffset))
|
||||
: chunkOffset;
|
||||
if (chunkEndOffset < chunkOffset) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedOffset = activeStream.nextExpectedOffset;
|
||||
if (typeof expectedOffset === "number") {
|
||||
if (chunkEndOffset <= expectedOffset) {
|
||||
return;
|
||||
}
|
||||
if (chunkOffset !== expectedOffset) {
|
||||
const catchUpEndOffset = activeStream.catchUpEndOffset;
|
||||
const canSkipReplayGap =
|
||||
chunkOffset > expectedOffset &&
|
||||
typeof catchUpEndOffset === "number" &&
|
||||
expectedOffset < catchUpEndOffset &&
|
||||
chunkOffset <= catchUpEndOffset;
|
||||
|
||||
if (canSkipReplayGap) {
|
||||
activeStream.nextExpectedOffset = chunkOffset;
|
||||
} else {
|
||||
this.recoverFromStreamGap({
|
||||
terminalId: input.terminalId,
|
||||
streamId: input.streamId,
|
||||
expectedOffset,
|
||||
observedOffset: chunkOffset,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
typeof activeStream.catchUpEndOffset === "number" &&
|
||||
chunkEndOffset >= activeStream.catchUpEndOffset
|
||||
) {
|
||||
activeStream.catchUpEndOffset = null;
|
||||
}
|
||||
|
||||
activeStream.nextExpectedOffset = chunkEndOffset;
|
||||
updateTerminalResumeOffset({
|
||||
terminalId: input.terminalId,
|
||||
offset: chunkEndOffset,
|
||||
resumeOffsetByTerminalId: this.resumeOffsetByTerminalId,
|
||||
});
|
||||
|
||||
const text = input.decoder.decode(input.chunk.data, { stream: true });
|
||||
if (text.length === 0) {
|
||||
return;
|
||||
}
|
||||
terminalDebugLog({
|
||||
scope: "stream-controller",
|
||||
event: "stream:chunk",
|
||||
details: {
|
||||
terminalId: input.terminalId,
|
||||
streamId: input.streamId,
|
||||
offset: chunkOffset,
|
||||
endOffset: chunkEndOffset,
|
||||
replay: Boolean(input.chunk.replay),
|
||||
byteLength: input.chunk.data.byteLength,
|
||||
textLength: text.length,
|
||||
preview: summarizeTerminalText({ text, maxChars: 96 }),
|
||||
},
|
||||
});
|
||||
|
||||
this.options.onChunk({
|
||||
terminalId: input.terminalId,
|
||||
text,
|
||||
});
|
||||
}
|
||||
|
||||
private recoverFromStreamGap(input: {
|
||||
terminalId: string;
|
||||
streamId: number;
|
||||
expectedOffset: number;
|
||||
observedOffset: number;
|
||||
}): void {
|
||||
const activeStream = this.activeStream;
|
||||
if (!activeStream) {
|
||||
return;
|
||||
}
|
||||
if (activeStream.streamId !== input.streamId || activeStream.terminalId !== input.terminalId) {
|
||||
return;
|
||||
}
|
||||
if (this.selectedTerminalId !== input.terminalId) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateTerminalResumeOffset({
|
||||
terminalId: input.terminalId,
|
||||
offset: input.expectedOffset,
|
||||
resumeOffsetByTerminalId: this.resumeOffsetByTerminalId,
|
||||
});
|
||||
|
||||
this.attachGeneration += 1;
|
||||
const generation = this.attachGeneration;
|
||||
|
||||
void this.detachActiveStream({ shouldDetach: true });
|
||||
this.updateStatus({
|
||||
terminalId: input.terminalId,
|
||||
streamId: null,
|
||||
isAttaching: true,
|
||||
error:
|
||||
this.options.reconnectErrorMessage ?? DEFAULT_RECONNECT_ERROR_MESSAGE,
|
||||
});
|
||||
void this.attachTerminal({
|
||||
terminalId: input.terminalId,
|
||||
generation,
|
||||
});
|
||||
}
|
||||
|
||||
private async detachActiveStream(input: { shouldDetach: boolean }): Promise<void> {
|
||||
const activeStream = this.activeStream;
|
||||
if (!activeStream) {
|
||||
return;
|
||||
}
|
||||
terminalDebugLog({
|
||||
scope: "stream-controller",
|
||||
event: "stream:detach",
|
||||
details: {
|
||||
terminalId: activeStream.terminalId,
|
||||
streamId: activeStream.streamId,
|
||||
shouldDetach: input.shouldDetach,
|
||||
},
|
||||
});
|
||||
this.activeStream = null;
|
||||
|
||||
try {
|
||||
const tail = activeStream.decoder.decode();
|
||||
if (tail.length > 0) {
|
||||
this.options.onChunk({
|
||||
terminalId: activeStream.terminalId,
|
||||
text: tail,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
|
||||
try {
|
||||
activeStream.unsubscribe();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
|
||||
if (!input.shouldDetach) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.options.client.detachTerminalStream(activeStream.streamId);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
private isAttachGenerationCurrent(input: {
|
||||
generation: number;
|
||||
terminalId: string;
|
||||
}): boolean {
|
||||
if (this.isDisposed) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
this.attachGeneration === input.generation &&
|
||||
this.selectedTerminalId === input.terminalId
|
||||
);
|
||||
}
|
||||
|
||||
private updateStatus(status: TerminalStreamControllerStatus): void {
|
||||
this.status = status;
|
||||
terminalDebugLog({
|
||||
scope: "stream-controller",
|
||||
event: "status:update",
|
||||
details: {
|
||||
terminalId: status.terminalId,
|
||||
streamId: status.streamId,
|
||||
isAttaching: status.isAttaching,
|
||||
error: status.error,
|
||||
},
|
||||
});
|
||||
this.options.onStatusChange?.(status);
|
||||
}
|
||||
}
|
||||
60
packages/app/src/utils/agent-attention.test.ts
Normal file
60
packages/app/src/utils/agent-attention.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { shouldClearAgentAttentionOnView } from "./agent-attention";
|
||||
|
||||
describe("shouldClearAgentAttentionOnView", () => {
|
||||
it("returns true only when the viewed agent is focused, connected, and requires attention", () => {
|
||||
expect(
|
||||
shouldClearAgentAttentionOnView({
|
||||
agentId: "agent-1",
|
||||
focusedAgentId: "agent-1",
|
||||
isConnected: true,
|
||||
requiresAttention: true,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the app is disconnected", () => {
|
||||
expect(
|
||||
shouldClearAgentAttentionOnView({
|
||||
agentId: "agent-1",
|
||||
focusedAgentId: "agent-1",
|
||||
isConnected: false,
|
||||
requiresAttention: true,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the agent is not focused", () => {
|
||||
expect(
|
||||
shouldClearAgentAttentionOnView({
|
||||
agentId: "agent-1",
|
||||
focusedAgentId: "agent-2",
|
||||
isConnected: true,
|
||||
requiresAttention: true,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when attention is already clear", () => {
|
||||
expect(
|
||||
shouldClearAgentAttentionOnView({
|
||||
agentId: "agent-1",
|
||||
focusedAgentId: "agent-1",
|
||||
isConnected: true,
|
||||
requiresAttention: false,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty agent ids", () => {
|
||||
expect(
|
||||
shouldClearAgentAttentionOnView({
|
||||
agentId: "",
|
||||
focusedAgentId: "agent-1",
|
||||
isConnected: true,
|
||||
requiresAttention: true,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
22
packages/app/src/utils/agent-attention.ts
Normal file
22
packages/app/src/utils/agent-attention.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
interface ShouldClearAgentAttentionOnViewInput {
|
||||
agentId: string | null | undefined;
|
||||
focusedAgentId: string | null | undefined;
|
||||
isConnected: boolean;
|
||||
requiresAttention: boolean | null | undefined;
|
||||
}
|
||||
|
||||
export function shouldClearAgentAttentionOnView(
|
||||
input: ShouldClearAgentAttentionOnViewInput
|
||||
): boolean {
|
||||
const agentId = input.agentId?.trim();
|
||||
if (!agentId) {
|
||||
return false;
|
||||
}
|
||||
if (!input.isConnected) {
|
||||
return false;
|
||||
}
|
||||
if (!input.requiresAttention) {
|
||||
return false;
|
||||
}
|
||||
return input.focusedAgentId === agentId;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collectAgentWorkingDirectorySuggestions } from "@/utils/agent-working-directory-suggestions";
|
||||
|
||||
describe("collectAgentWorkingDirectorySuggestions", () => {
|
||||
it("deduplicates by cwd and sorts by most recent timestamp", () => {
|
||||
const results = collectAgentWorkingDirectorySuggestions([
|
||||
{
|
||||
cwd: "/Users/me/project-alpha",
|
||||
createdAt: new Date("2026-02-10T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
cwd: "/Users/me/project-beta",
|
||||
createdAt: new Date("2026-02-11T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
cwd: "/Users/me/project-alpha",
|
||||
lastActivityAt: new Date("2026-02-12T10:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(results).toEqual([
|
||||
"/Users/me/project-alpha",
|
||||
"/Users/me/project-beta",
|
||||
]);
|
||||
});
|
||||
|
||||
it("excludes Paseo-owned worktree paths", () => {
|
||||
const results = collectAgentWorkingDirectorySuggestions([
|
||||
{
|
||||
cwd: "/Users/me/repo/.paseo/worktrees/feature-a",
|
||||
createdAt: new Date("2026-02-12T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
cwd: "/Users/me/repo",
|
||||
createdAt: new Date("2026-02-10T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
cwd: "C:\\Users\\me\\repo\\.paseo\\worktrees\\feature-b",
|
||||
createdAt: new Date("2026-02-11T10:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(results).toEqual(["/Users/me/repo"]);
|
||||
});
|
||||
|
||||
it("ignores empty cwd values", () => {
|
||||
const results = collectAgentWorkingDirectorySuggestions([
|
||||
{ cwd: " ", createdAt: new Date("2026-02-10T10:00:00.000Z") },
|
||||
{ cwd: null, createdAt: new Date("2026-02-11T10:00:00.000Z") },
|
||||
{ cwd: undefined, lastActivityAt: new Date("2026-02-12T10:00:00.000Z") },
|
||||
{
|
||||
cwd: "/Users/me/project",
|
||||
createdAt: new Date("2026-02-09T10:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(results).toEqual(["/Users/me/project"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
export interface AgentWorkingDirectorySource {
|
||||
cwd?: string | null;
|
||||
createdAt?: Date | null;
|
||||
lastActivityAt?: Date | null;
|
||||
}
|
||||
|
||||
const PASEO_WORKTREE_PATH_PATTERN = /(^|\/)\.paseo\/worktrees(\/|$)/;
|
||||
|
||||
export function collectAgentWorkingDirectorySuggestions(
|
||||
sources: Iterable<AgentWorkingDirectorySource>
|
||||
): string[] {
|
||||
const lastSeenByPath = new Map<string, number>();
|
||||
|
||||
for (const source of sources) {
|
||||
const cwd = source.cwd?.trim();
|
||||
if (!cwd) {
|
||||
continue;
|
||||
}
|
||||
if (isPaseoOwnedWorktreePath(cwd)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timestamp = toEpochMs(source.lastActivityAt ?? source.createdAt);
|
||||
const previous = lastSeenByPath.get(cwd);
|
||||
if (previous === undefined || timestamp > previous) {
|
||||
lastSeenByPath.set(cwd, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(lastSeenByPath.entries())
|
||||
.sort((left, right) => {
|
||||
const timeDiff = right[1] - left[1];
|
||||
if (timeDiff !== 0) {
|
||||
return timeDiff;
|
||||
}
|
||||
return left[0].localeCompare(right[0]);
|
||||
})
|
||||
.map(([cwd]) => cwd);
|
||||
}
|
||||
|
||||
function isPaseoOwnedWorktreePath(cwd: string): boolean {
|
||||
return PASEO_WORKTREE_PATH_PATTERN.test(cwd.replace(/\\/g, "/"));
|
||||
}
|
||||
|
||||
function toEpochMs(date: Date | null | undefined): number {
|
||||
if (!(date instanceof Date)) {
|
||||
return 0;
|
||||
}
|
||||
const value = date.getTime();
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
116
packages/app/src/utils/confirm-dialog.test.ts
Normal file
116
packages/app/src/utils/confirm-dialog.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type MockPlatform = "web" | "ios" | "android";
|
||||
|
||||
type AlertButton = {
|
||||
onPress?: () => void;
|
||||
};
|
||||
|
||||
async function loadModuleForPlatform(platform: MockPlatform): Promise<{
|
||||
confirmDialog: typeof import("./confirm-dialog").confirmDialog;
|
||||
alertMock: ReturnType<typeof vi.fn>;
|
||||
}> {
|
||||
vi.resetModules();
|
||||
|
||||
const alertMock = vi.fn();
|
||||
vi.doMock("react-native", () => ({
|
||||
Alert: {
|
||||
alert: alertMock,
|
||||
},
|
||||
Platform: { OS: platform },
|
||||
}));
|
||||
|
||||
const module = await import("./confirm-dialog");
|
||||
return { confirmDialog: module.confirmDialog, alertMock };
|
||||
}
|
||||
|
||||
function clearDialogGlobals(): void {
|
||||
delete (globalThis as { __TAURI__?: unknown }).__TAURI__;
|
||||
delete (globalThis as { confirm?: unknown }).confirm;
|
||||
}
|
||||
|
||||
describe("confirmDialog", () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock("react-native");
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
clearDialogGlobals();
|
||||
});
|
||||
|
||||
it("uses Tauri dialog.ask on web when available", async () => {
|
||||
const askMock = vi.fn(async () => true);
|
||||
(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
|
||||
dialog: { ask: askMock },
|
||||
};
|
||||
|
||||
const { confirmDialog, alertMock } = await loadModuleForPlatform("web");
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Restart host",
|
||||
message: "This will restart the daemon.",
|
||||
confirmLabel: "Restart",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
});
|
||||
|
||||
expect(confirmed).toBe(true);
|
||||
expect(alertMock).not.toHaveBeenCalled();
|
||||
expect(askMock).toHaveBeenCalledWith("This will restart the daemon.", {
|
||||
title: "Restart host",
|
||||
okLabel: "Restart",
|
||||
cancelLabel: "Cancel",
|
||||
kind: "warning",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to browser confirm on web when Tauri APIs are unavailable", async () => {
|
||||
const browserConfirm = vi.fn(() => true);
|
||||
(globalThis as { confirm?: unknown }).confirm = browserConfirm;
|
||||
|
||||
const { confirmDialog } = await loadModuleForPlatform("web");
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Restart host",
|
||||
message: "This will restart the daemon.",
|
||||
});
|
||||
|
||||
expect(confirmed).toBe(true);
|
||||
expect(browserConfirm).toHaveBeenCalledWith(
|
||||
"Restart host\n\nThis will restart the daemon."
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on web when no confirm backend exists", async () => {
|
||||
const { confirmDialog } = await loadModuleForPlatform("web");
|
||||
|
||||
await expect(
|
||||
confirmDialog({
|
||||
title: "Restart host",
|
||||
message: "This will restart the daemon.",
|
||||
})
|
||||
).rejects.toThrow("[ConfirmDialog] No web confirmation backend is available.");
|
||||
});
|
||||
|
||||
it("uses native Alert on iOS/Android", async () => {
|
||||
const { confirmDialog, alertMock } = await loadModuleForPlatform("ios");
|
||||
alertMock.mockImplementation(
|
||||
(
|
||||
_title: string,
|
||||
_message: string,
|
||||
buttons?: AlertButton[]
|
||||
) => {
|
||||
const confirmButton = buttons?.[1];
|
||||
confirmButton?.onPress?.();
|
||||
}
|
||||
);
|
||||
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Restart host",
|
||||
message: "This will restart the daemon.",
|
||||
confirmLabel: "Restart",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
});
|
||||
|
||||
expect(confirmed).toBe(true);
|
||||
expect(alertMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
144
packages/app/src/utils/confirm-dialog.ts
Normal file
144
packages/app/src/utils/confirm-dialog.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { Alert, Platform } from "react-native";
|
||||
|
||||
export interface ConfirmDialogInput {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
destructive?: boolean;
|
||||
}
|
||||
|
||||
interface TauriDialogAskOptions {
|
||||
title?: string;
|
||||
okLabel?: string;
|
||||
cancelLabel?: string;
|
||||
kind?: "info" | "warning" | "error";
|
||||
}
|
||||
|
||||
interface TauriDialogApi {
|
||||
ask?: (message: string, options?: TauriDialogAskOptions) => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface TauriCoreApi {
|
||||
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface TauriGlobalApi {
|
||||
dialog?: TauriDialogApi;
|
||||
core?: TauriCoreApi;
|
||||
}
|
||||
|
||||
interface ConfirmButtonConfig {
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
}
|
||||
|
||||
function resolveButtonLabels(input: ConfirmDialogInput): ConfirmButtonConfig {
|
||||
return {
|
||||
confirmLabel: input.confirmLabel ?? "Confirm",
|
||||
cancelLabel: input.cancelLabel ?? "Cancel",
|
||||
};
|
||||
}
|
||||
|
||||
async function showNativeConfirmDialog(input: ConfirmDialogInput): Promise<boolean> {
|
||||
const labels = resolveButtonLabels(input);
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
Alert.alert(
|
||||
input.title,
|
||||
input.message,
|
||||
[
|
||||
{
|
||||
text: labels.cancelLabel,
|
||||
style: "cancel",
|
||||
onPress: () => resolve(false),
|
||||
},
|
||||
{
|
||||
text: labels.confirmLabel,
|
||||
style: input.destructive ? "destructive" : "default",
|
||||
onPress: () => resolve(true),
|
||||
},
|
||||
],
|
||||
{
|
||||
cancelable: true,
|
||||
onDismiss: () => resolve(false),
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getTauriApi(): TauriGlobalApi | null {
|
||||
if (Platform.OS !== "web") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tauriApi = (globalThis as { __TAURI__?: TauriGlobalApi }).__TAURI__;
|
||||
return tauriApi ?? null;
|
||||
}
|
||||
|
||||
function buildTauriAskOptions(input: ConfirmDialogInput): TauriDialogAskOptions {
|
||||
const labels = resolveButtonLabels(input);
|
||||
|
||||
return {
|
||||
title: input.title,
|
||||
okLabel: labels.confirmLabel,
|
||||
cancelLabel: labels.cancelLabel,
|
||||
kind: input.destructive ? "warning" : "info",
|
||||
};
|
||||
}
|
||||
|
||||
async function showTauriConfirmDialog(input: ConfirmDialogInput): Promise<boolean | null> {
|
||||
const tauriApi = getTauriApi();
|
||||
if (!tauriApi) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const options = buildTauriAskOptions(input);
|
||||
const tauriAsk = tauriApi.dialog?.ask;
|
||||
|
||||
if (typeof tauriAsk === "function") {
|
||||
try {
|
||||
return Boolean(await tauriAsk(input.message, options));
|
||||
} catch (error) {
|
||||
console.warn("[ConfirmDialog] Tauri dialog.ask failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
const tauriInvoke = tauriApi.core?.invoke;
|
||||
if (typeof tauriInvoke === "function") {
|
||||
try {
|
||||
const result = await tauriInvoke("plugin:dialog|ask", {
|
||||
message: input.message,
|
||||
...options,
|
||||
});
|
||||
return result === true;
|
||||
} catch (error) {
|
||||
console.warn("[ConfirmDialog] Tauri plugin:dialog|ask failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function showWebConfirmDialog(input: ConfirmDialogInput): boolean {
|
||||
const browserConfirm = (globalThis as { confirm?: (message?: string) => boolean }).confirm;
|
||||
if (typeof browserConfirm !== "function") {
|
||||
throw new Error("[ConfirmDialog] No web confirmation backend is available.");
|
||||
}
|
||||
|
||||
const promptMessage = `${input.title}\n\n${input.message}`;
|
||||
return browserConfirm(promptMessage);
|
||||
}
|
||||
|
||||
export async function confirmDialog(input: ConfirmDialogInput): Promise<boolean> {
|
||||
if (Platform.OS !== "web") {
|
||||
return showNativeConfirmDialog(input);
|
||||
}
|
||||
|
||||
const tauriResult = await showTauriConfirmDialog(input);
|
||||
if (tauriResult !== null) {
|
||||
return tauriResult;
|
||||
}
|
||||
|
||||
return showWebConfirmDialog(input);
|
||||
}
|
||||
34
packages/app/src/utils/host-routes.test.ts
Normal file
34
packages/app/src/utils/host-routes.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseHostAgentDraftRouteFromPathname,
|
||||
parseHostAgentRouteFromPathname,
|
||||
} from "./host-routes";
|
||||
|
||||
describe("parseHostAgentDraftRouteFromPathname", () => {
|
||||
it("parses draft route server id", () => {
|
||||
expect(parseHostAgentDraftRouteFromPathname("/h/local/agent")).toEqual({
|
||||
serverId: "local",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses encoded server id", () => {
|
||||
expect(
|
||||
parseHostAgentDraftRouteFromPathname("/h/team%20host/agent")
|
||||
).toEqual({
|
||||
serverId: "team host",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not match agent detail routes", () => {
|
||||
expect(parseHostAgentDraftRouteFromPathname("/h/local/agent/abc123")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseHostAgentRouteFromPathname", () => {
|
||||
it("continues parsing detail routes", () => {
|
||||
expect(parseHostAgentRouteFromPathname("/h/local/agent/abc123")).toEqual({
|
||||
serverId: "local",
|
||||
agentId: "abc123",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,14 @@ function encodeSegment(value: string): string {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
|
||||
function decodeSegment(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseServerIdFromPathname(pathname: string): string | null {
|
||||
const match = pathname.match(/^\/h\/([^/]+)(?:\/|$)/);
|
||||
if (!match) {
|
||||
@@ -21,11 +29,7 @@ export function parseServerIdFromPathname(pathname: string): string | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return trimNonEmpty(decodeURIComponent(raw));
|
||||
} catch {
|
||||
return trimNonEmpty(raw);
|
||||
}
|
||||
return trimNonEmpty(decodeSegment(raw));
|
||||
}
|
||||
|
||||
export function parseHostAgentRouteFromPathname(
|
||||
@@ -41,16 +45,8 @@ export function parseHostAgentRouteFromPathname(
|
||||
return null;
|
||||
}
|
||||
|
||||
const decode = (value: string) => {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const serverId = trimNonEmpty(decode(encodedServerId));
|
||||
const agentId = trimNonEmpty(decode(encodedAgentId));
|
||||
const serverId = trimNonEmpty(decodeSegment(encodedServerId));
|
||||
const agentId = trimNonEmpty(decodeSegment(encodedAgentId));
|
||||
if (!serverId || !agentId) {
|
||||
return null;
|
||||
}
|
||||
@@ -58,6 +54,24 @@ export function parseHostAgentRouteFromPathname(
|
||||
return { serverId, agentId };
|
||||
}
|
||||
|
||||
export function parseHostAgentDraftRouteFromPathname(
|
||||
pathname: string
|
||||
): { serverId: string } | null {
|
||||
const match = pathname.match(/^\/h\/([^/]+)\/agent\/?$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const encodedServerId = match[1];
|
||||
if (!encodedServerId) {
|
||||
return null;
|
||||
}
|
||||
const serverId = trimNonEmpty(decodeSegment(encodedServerId));
|
||||
if (!serverId) {
|
||||
return null;
|
||||
}
|
||||
return { serverId };
|
||||
}
|
||||
|
||||
export function buildHostAgentDraftRoute(serverId: string): string {
|
||||
const normalized = trimNonEmpty(serverId);
|
||||
if (!normalized) {
|
||||
@@ -115,4 +129,3 @@ export function mapPathnameToServer(
|
||||
}
|
||||
return `${base}/agent`;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ import { describe, expect, it } from "vitest";
|
||||
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
|
||||
import {
|
||||
buildNewAgentRoute,
|
||||
parseAgentKey,
|
||||
resolveNewAgentWorkingDir,
|
||||
resolveSelectedAgentForNewAgent,
|
||||
} from "./new-agent-routing";
|
||||
|
||||
describe("buildNewAgentRoute", () => {
|
||||
@@ -35,3 +37,60 @@ describe("resolveNewAgentWorkingDir", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAgentKey", () => {
|
||||
it("parses server and agent ids from combined key", () => {
|
||||
expect(parseAgentKey("srv-1:agent-9")).toEqual({
|
||||
serverId: "srv-1",
|
||||
agentId: "agent-9",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the last separator to preserve server ids with colons", () => {
|
||||
expect(parseAgentKey("localhost:6767:agent-9")).toEqual({
|
||||
serverId: "localhost:6767",
|
||||
agentId: "agent-9",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for malformed keys", () => {
|
||||
expect(parseAgentKey("")).toBeNull();
|
||||
expect(parseAgentKey("only-server")).toBeNull();
|
||||
expect(parseAgentKey(":agent-1")).toBeNull();
|
||||
expect(parseAgentKey("srv-1:")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSelectedAgentForNewAgent", () => {
|
||||
it("prefers the agent in the current route", () => {
|
||||
expect(
|
||||
resolveSelectedAgentForNewAgent({
|
||||
pathname: "/h/srv-1/agent/agent-2",
|
||||
selectedAgentId: "srv-9:agent-9",
|
||||
})
|
||||
).toEqual({
|
||||
serverId: "srv-1",
|
||||
agentId: "agent-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to selected agent key when route has no agent", () => {
|
||||
expect(
|
||||
resolveSelectedAgentForNewAgent({
|
||||
pathname: "/h/srv-1/settings",
|
||||
selectedAgentId: "srv-1:agent-7",
|
||||
})
|
||||
).toEqual({
|
||||
serverId: "srv-1",
|
||||
agentId: "agent-7",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when neither route nor selection has an agent", () => {
|
||||
expect(
|
||||
resolveSelectedAgentForNewAgent({
|
||||
pathname: "/h/srv-1/settings",
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
|
||||
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
buildHostAgentDraftRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
export function parseAgentKey(
|
||||
key: string | null | undefined
|
||||
): { serverId: string; agentId: string } | null {
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
const sep = key.lastIndexOf(":");
|
||||
if (sep <= 0 || sep >= key.length - 1) {
|
||||
return null;
|
||||
}
|
||||
const serverId = key.slice(0, sep).trim();
|
||||
const agentId = key.slice(sep + 1).trim();
|
||||
if (!serverId || !agentId) {
|
||||
return null;
|
||||
}
|
||||
return { serverId, agentId };
|
||||
}
|
||||
|
||||
export function resolveSelectedAgentForNewAgent(input: {
|
||||
pathname: string;
|
||||
selectedAgentId?: string;
|
||||
}): { serverId: string; agentId: string } | null {
|
||||
return (
|
||||
parseHostAgentRouteFromPathname(input.pathname) ??
|
||||
parseAgentKey(input.selectedAgentId)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveNewAgentWorkingDir(
|
||||
cwd: string,
|
||||
|
||||
28
packages/app/src/utils/open-external-url.ts
Normal file
28
packages/app/src/utils/open-external-url.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as Linking from "expo-linking";
|
||||
import { Platform } from "react-native";
|
||||
import { getIsTauri } from "@/constants/layout";
|
||||
|
||||
interface TauriWindowWithOpener {
|
||||
__TAURI__?: {
|
||||
opener?: {
|
||||
openUrl?: (url: string) => Promise<void>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export async function openExternalUrl(url: string): Promise<void> {
|
||||
if (Platform.OS === "web") {
|
||||
if (typeof window !== "undefined" && getIsTauri()) {
|
||||
const opener = (window as TauriWindowWithOpener).__TAURI__?.opener?.openUrl;
|
||||
if (typeof opener === "function") {
|
||||
await opener(url);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
|
||||
await Linking.openURL(url);
|
||||
}
|
||||
44
packages/app/src/utils/sidebar-agent-state.ts
Normal file
44
packages/app/src/utils/sidebar-agent-state.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||
|
||||
export type SidebarAttentionReason =
|
||||
| "finished"
|
||||
| "error"
|
||||
| "permission"
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export type SidebarStateBucket =
|
||||
| "needs_input"
|
||||
| "failed"
|
||||
| "running"
|
||||
| "attention"
|
||||
| "done";
|
||||
|
||||
export function deriveSidebarStateBucket(input: {
|
||||
status: AgentLifecycleStatus;
|
||||
requiresAttention?: boolean;
|
||||
attentionReason?: SidebarAttentionReason;
|
||||
}): SidebarStateBucket {
|
||||
if (input.requiresAttention && input.attentionReason === "permission") {
|
||||
return "needs_input";
|
||||
}
|
||||
if (input.status === "error" || input.attentionReason === "error") {
|
||||
return "failed";
|
||||
}
|
||||
if (input.status === "running") {
|
||||
return "running";
|
||||
}
|
||||
if (input.requiresAttention) {
|
||||
// Unread/attention-needed completed agents are active in sidebar logic.
|
||||
return "attention";
|
||||
}
|
||||
return "done";
|
||||
}
|
||||
|
||||
export function isSidebarActiveAgent(input: {
|
||||
status: AgentLifecycleStatus;
|
||||
requiresAttention?: boolean;
|
||||
attentionReason?: SidebarAttentionReason;
|
||||
}): boolean {
|
||||
return deriveSidebarStateBucket(input) !== "done";
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { parser as cssParser } from "@lezer/css";
|
||||
import { parser as htmlParser } from "@lezer/html";
|
||||
import { parser as pythonParser } from "@lezer/python";
|
||||
import { parser as markdownParser } from "@lezer/markdown";
|
||||
import { parser as elixirParser } from "lezer-elixir";
|
||||
import type { Parser } from "@lezer/common";
|
||||
|
||||
// Map file extensions to parsers
|
||||
@@ -26,6 +27,9 @@ const parsersByExtension: Record<string, Parser> = {
|
||||
htm: htmlParser,
|
||||
// Python
|
||||
py: pythonParser,
|
||||
// Elixir
|
||||
ex: elixirParser,
|
||||
exs: elixirParser,
|
||||
// Markdown
|
||||
md: markdownParser,
|
||||
mdx: markdownParser,
|
||||
|
||||
@@ -109,7 +109,21 @@ export function useTrafficLightPadding(): { left: number; top: number } {
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web" || !getIsTauriMac()) return;
|
||||
|
||||
let unlisten: (() => void) | undefined;
|
||||
let disposed = false;
|
||||
let cleanup: (() => void) | undefined;
|
||||
let didCleanup = false;
|
||||
|
||||
function runCleanup() {
|
||||
if (!cleanup || didCleanup) return;
|
||||
didCleanup = true;
|
||||
try {
|
||||
void Promise.resolve(cleanup()).catch((error) => {
|
||||
console.warn("[TauriWindow] Failed to remove resize listener", error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[TauriWindow] Failed to remove resize listener", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const win = await getTauriWindow();
|
||||
@@ -117,19 +131,28 @@ export function useTrafficLightPadding(): { left: number; top: number } {
|
||||
|
||||
// Check initial fullscreen state
|
||||
const fullscreen = await win.isFullscreen();
|
||||
if (disposed) return;
|
||||
setIsFullscreen(fullscreen);
|
||||
|
||||
// Listen for resize events which include fullscreen changes
|
||||
unlisten = await win.onResized(async () => {
|
||||
const unlisten = await win.onResized(async () => {
|
||||
if (disposed) return;
|
||||
const fs = await win.isFullscreen();
|
||||
if (disposed) return;
|
||||
setIsFullscreen(fs);
|
||||
});
|
||||
|
||||
cleanup = unlisten;
|
||||
if (disposed) {
|
||||
runCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
setup();
|
||||
void setup();
|
||||
|
||||
return () => {
|
||||
unlisten?.();
|
||||
disposed = true;
|
||||
runCleanup();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
91
packages/app/src/utils/terminal-attach.test.ts
Normal file
91
packages/app/src/utils/terminal-attach.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getTerminalResumeOffset,
|
||||
getTerminalAttachRetryDelayMs,
|
||||
isTerminalAttachRetryableError,
|
||||
updateTerminalResumeOffset,
|
||||
withPromiseTimeout,
|
||||
} from "./terminal-attach";
|
||||
|
||||
describe("terminal-attach", () => {
|
||||
it("computes bounded exponential retry delays", () => {
|
||||
expect(getTerminalAttachRetryDelayMs({ attempt: 0 })).toBe(250);
|
||||
expect(getTerminalAttachRetryDelayMs({ attempt: 1 })).toBe(500);
|
||||
expect(getTerminalAttachRetryDelayMs({ attempt: 2 })).toBe(1_000);
|
||||
expect(getTerminalAttachRetryDelayMs({ attempt: 3 })).toBe(2_000);
|
||||
expect(getTerminalAttachRetryDelayMs({ attempt: 8 })).toBe(2_000);
|
||||
});
|
||||
|
||||
it("matches retryable attach errors", () => {
|
||||
expect(
|
||||
isTerminalAttachRetryableError({ message: "Terminal not found while attaching" })
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTerminalAttachRetryableError({ message: "Network disconnected during attach" })
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTerminalAttachRetryableError({ message: "stream ended before ack" })
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTerminalAttachRetryableError({ message: "permission denied" })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("reads and updates resume offsets monotonically", () => {
|
||||
const offsets = new Map<string, number>();
|
||||
const terminalId = "term-1";
|
||||
|
||||
expect(
|
||||
getTerminalResumeOffset({
|
||||
terminalId,
|
||||
resumeOffsetByTerminalId: offsets,
|
||||
})
|
||||
).toBeUndefined();
|
||||
|
||||
updateTerminalResumeOffset({
|
||||
terminalId,
|
||||
offset: 8,
|
||||
resumeOffsetByTerminalId: offsets,
|
||||
});
|
||||
expect(
|
||||
getTerminalResumeOffset({
|
||||
terminalId,
|
||||
resumeOffsetByTerminalId: offsets,
|
||||
})
|
||||
).toBe(8);
|
||||
|
||||
// Stale offsets must not move resume backwards.
|
||||
updateTerminalResumeOffset({
|
||||
terminalId,
|
||||
offset: 3,
|
||||
resumeOffsetByTerminalId: offsets,
|
||||
});
|
||||
expect(
|
||||
getTerminalResumeOffset({
|
||||
terminalId,
|
||||
resumeOffsetByTerminalId: offsets,
|
||||
})
|
||||
).toBe(8);
|
||||
});
|
||||
|
||||
it("resolves before timeout when promise completes", async () => {
|
||||
await expect(
|
||||
withPromiseTimeout({
|
||||
promise: Promise.resolve("ok"),
|
||||
timeoutMs: 50,
|
||||
timeoutMessage: "timed out",
|
||||
})
|
||||
).resolves.toBe("ok");
|
||||
});
|
||||
|
||||
it("rejects when timeout wins", async () => {
|
||||
await expect(
|
||||
withPromiseTimeout({
|
||||
promise: new Promise<string>(() => {}),
|
||||
timeoutMs: 10,
|
||||
timeoutMessage: "timed out",
|
||||
})
|
||||
).rejects.toThrow("timed out");
|
||||
});
|
||||
});
|
||||
83
packages/app/src/utils/terminal-attach.ts
Normal file
83
packages/app/src/utils/terminal-attach.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
const TERMINAL_ATTACH_RETRYABLE_ERROR_PATTERNS = [
|
||||
"terminal not found",
|
||||
"timed out",
|
||||
"timeout",
|
||||
"connection",
|
||||
"network",
|
||||
"disconnected",
|
||||
"stream ended",
|
||||
] as const;
|
||||
|
||||
export function getTerminalResumeOffset(input: {
|
||||
terminalId: string;
|
||||
resumeOffsetByTerminalId: Map<string, number>;
|
||||
}): number | undefined {
|
||||
const offset = input.resumeOffsetByTerminalId.get(input.terminalId);
|
||||
if (typeof offset !== "number" || !Number.isFinite(offset)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedOffset = Math.max(0, Math.floor(offset));
|
||||
return normalizedOffset;
|
||||
}
|
||||
|
||||
export function updateTerminalResumeOffset(input: {
|
||||
terminalId: string;
|
||||
offset: number;
|
||||
resumeOffsetByTerminalId: Map<string, number>;
|
||||
}): void {
|
||||
if (!Number.isFinite(input.offset)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedOffset = Math.max(0, Math.floor(input.offset));
|
||||
const previousOffset =
|
||||
getTerminalResumeOffset({
|
||||
terminalId: input.terminalId,
|
||||
resumeOffsetByTerminalId: input.resumeOffsetByTerminalId,
|
||||
}) ?? -1;
|
||||
if (normalizedOffset <= previousOffset) {
|
||||
return;
|
||||
}
|
||||
|
||||
input.resumeOffsetByTerminalId.set(input.terminalId, normalizedOffset);
|
||||
}
|
||||
|
||||
export function getTerminalAttachRetryDelayMs(input: { attempt: number }): number {
|
||||
const clampedAttempt = Math.max(0, input.attempt);
|
||||
const exponentialDelay = 250 * (2 ** clampedAttempt);
|
||||
return Math.min(2_000, exponentialDelay);
|
||||
}
|
||||
|
||||
export function isTerminalAttachRetryableError(input: { message: string }): boolean {
|
||||
const normalized = input.message.toLowerCase();
|
||||
return TERMINAL_ATTACH_RETRYABLE_ERROR_PATTERNS.some((pattern) =>
|
||||
normalized.includes(pattern)
|
||||
);
|
||||
}
|
||||
|
||||
export async function waitForDuration(input: { durationMs: number }): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, Math.max(0, input.durationMs));
|
||||
});
|
||||
}
|
||||
|
||||
export async function withPromiseTimeout<T>(input: {
|
||||
promise: Promise<T>;
|
||||
timeoutMs: number;
|
||||
timeoutMessage: string;
|
||||
}): Promise<T> {
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
reject(new Error(input.timeoutMessage));
|
||||
}, Math.max(0, input.timeoutMs));
|
||||
});
|
||||
try {
|
||||
return await Promise.race([input.promise, timeoutPromise]);
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
}
|
||||
}
|
||||
137
packages/app/src/utils/terminal-keys.test.ts
Normal file
137
packages/app/src/utils/terminal-keys.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasPendingTerminalModifiers,
|
||||
isTerminalModifierDomKey,
|
||||
mapTerminalDataToKey,
|
||||
mergeTerminalModifiers,
|
||||
normalizeDomTerminalKey,
|
||||
normalizeTerminalTransportKey,
|
||||
resolvePendingModifierDataInput,
|
||||
shouldInterceptDomTerminalKey,
|
||||
} from "./terminal-keys";
|
||||
|
||||
describe("terminal key helpers", () => {
|
||||
it("normalizes supported DOM keys", () => {
|
||||
expect(normalizeDomTerminalKey("Esc")).toBe("Escape");
|
||||
expect(normalizeDomTerminalKey(" ")).toBe(" ");
|
||||
expect(normalizeDomTerminalKey("ArrowUp")).toBe("ArrowUp");
|
||||
expect(normalizeDomTerminalKey("F12")).toBe("F12");
|
||||
});
|
||||
|
||||
it("filters unsupported and composing DOM keys", () => {
|
||||
expect(normalizeDomTerminalKey("Dead")).toBeNull();
|
||||
expect(normalizeDomTerminalKey("Unidentified")).toBeNull();
|
||||
expect(normalizeDomTerminalKey("MediaPlayPause")).toBeNull();
|
||||
});
|
||||
|
||||
it("detects modifier DOM keys", () => {
|
||||
expect(isTerminalModifierDomKey("Control")).toBe(true);
|
||||
expect(isTerminalModifierDomKey("Shift")).toBe(true);
|
||||
expect(isTerminalModifierDomKey("a")).toBe(false);
|
||||
});
|
||||
|
||||
it("lowercases printable transport keys", () => {
|
||||
expect(normalizeTerminalTransportKey("C")).toBe("c");
|
||||
expect(normalizeTerminalTransportKey("Escape")).toBe("Escape");
|
||||
});
|
||||
|
||||
it("merges pending modifiers with native key modifiers", () => {
|
||||
expect(
|
||||
mergeTerminalModifiers({
|
||||
pendingModifiers: { ctrl: true, shift: false, alt: true },
|
||||
ctrlKey: false,
|
||||
shiftKey: true,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
})
|
||||
).toEqual({
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
alt: true,
|
||||
meta: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("intercepts special keys and modifier combos", () => {
|
||||
expect(
|
||||
shouldInterceptDomTerminalKey({
|
||||
key: "Escape",
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldInterceptDomTerminalKey({
|
||||
key: "c",
|
||||
ctrlKey: true,
|
||||
altKey: false,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldInterceptDomTerminalKey({
|
||||
key: "c",
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("detects pending modifier state", () => {
|
||||
expect(hasPendingTerminalModifiers({ ctrl: false, shift: false, alt: false })).toBe(
|
||||
false
|
||||
);
|
||||
expect(hasPendingTerminalModifiers({ ctrl: true, shift: false, alt: false })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("maps onData bytes to terminal keys for modifier fallback", () => {
|
||||
expect(mapTerminalDataToKey("c")).toBe("c");
|
||||
expect(mapTerminalDataToKey("\r")).toBe("Enter");
|
||||
expect(mapTerminalDataToKey("\t")).toBe("Tab");
|
||||
expect(mapTerminalDataToKey("\x7f")).toBe("Backspace");
|
||||
expect(mapTerminalDataToKey("\x1b")).toBe("Escape");
|
||||
expect(mapTerminalDataToKey("\x03")).toBeNull();
|
||||
expect(mapTerminalDataToKey("")).toBeNull();
|
||||
});
|
||||
|
||||
it("clears pending modifiers when fallback input cannot map to a key", () => {
|
||||
expect(
|
||||
resolvePendingModifierDataInput({
|
||||
data: "hello",
|
||||
pendingModifiers: { ctrl: true, shift: false, alt: false },
|
||||
})
|
||||
).toEqual({
|
||||
mode: "raw",
|
||||
clearPendingModifiers: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps pending modifier fallback to key transport when possible", () => {
|
||||
expect(
|
||||
resolvePendingModifierDataInput({
|
||||
data: "c",
|
||||
pendingModifiers: { ctrl: true, shift: false, alt: false },
|
||||
})
|
||||
).toEqual({
|
||||
mode: "key",
|
||||
key: "c",
|
||||
clearPendingModifiers: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps raw mode unchanged when no pending modifiers exist", () => {
|
||||
expect(
|
||||
resolvePendingModifierDataInput({
|
||||
data: "c",
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
})
|
||||
).toEqual({
|
||||
mode: "raw",
|
||||
clearPendingModifiers: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
194
packages/app/src/utils/terminal-keys.ts
Normal file
194
packages/app/src/utils/terminal-keys.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
export type PendingTerminalModifiers = {
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
};
|
||||
|
||||
const MODIFIER_DOM_KEYS = new Set([
|
||||
"Control",
|
||||
"Shift",
|
||||
"Alt",
|
||||
"Meta",
|
||||
"AltGraph",
|
||||
"OS",
|
||||
]);
|
||||
|
||||
const DOM_KEY_ALIASES: Record<string, string> = {
|
||||
Esc: "Escape",
|
||||
Left: "ArrowLeft",
|
||||
Right: "ArrowRight",
|
||||
Up: "ArrowUp",
|
||||
Down: "ArrowDown",
|
||||
Del: "Delete",
|
||||
Spacebar: " ",
|
||||
Space: " ",
|
||||
};
|
||||
|
||||
const SUPPORTED_SPECIAL_KEYS = new Set([
|
||||
"Enter",
|
||||
"Tab",
|
||||
"Backspace",
|
||||
"Escape",
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"Home",
|
||||
"End",
|
||||
"Insert",
|
||||
"Delete",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
"F1",
|
||||
"F2",
|
||||
"F3",
|
||||
"F4",
|
||||
"F5",
|
||||
"F6",
|
||||
"F7",
|
||||
"F8",
|
||||
"F9",
|
||||
"F10",
|
||||
"F11",
|
||||
"F12",
|
||||
]);
|
||||
|
||||
export function isTerminalModifierDomKey(rawKey: string): boolean {
|
||||
return MODIFIER_DOM_KEYS.has(rawKey);
|
||||
}
|
||||
|
||||
export function normalizeDomTerminalKey(rawKey: string): string | null {
|
||||
if (!rawKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = DOM_KEY_ALIASES[rawKey] ?? rawKey;
|
||||
if (
|
||||
key === "Unidentified" ||
|
||||
key === "Dead" ||
|
||||
key === "Compose" ||
|
||||
key === "Process"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (key.length === 1) {
|
||||
return key;
|
||||
}
|
||||
|
||||
if (SUPPORTED_SPECIAL_KEYS.has(key)) {
|
||||
return key;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeTerminalTransportKey(key: string): string {
|
||||
if (key.length === 1) {
|
||||
return key.toLowerCase();
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
export function hasPendingTerminalModifiers(
|
||||
modifiers: PendingTerminalModifiers
|
||||
): boolean {
|
||||
return modifiers.ctrl || modifiers.shift || modifiers.alt;
|
||||
}
|
||||
|
||||
export function shouldInterceptDomTerminalKey(args: {
|
||||
key: string;
|
||||
ctrlKey: boolean;
|
||||
altKey: boolean;
|
||||
pendingModifiers: PendingTerminalModifiers;
|
||||
}): boolean {
|
||||
return (
|
||||
args.key.length > 1 ||
|
||||
args.ctrlKey ||
|
||||
args.altKey ||
|
||||
hasPendingTerminalModifiers(args.pendingModifiers)
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeTerminalModifiers(args: {
|
||||
pendingModifiers: PendingTerminalModifiers;
|
||||
ctrlKey: boolean;
|
||||
shiftKey: boolean;
|
||||
altKey: boolean;
|
||||
metaKey: boolean;
|
||||
}): {
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
meta: boolean;
|
||||
} {
|
||||
const { pendingModifiers, ctrlKey, shiftKey, altKey, metaKey } = args;
|
||||
return {
|
||||
ctrl: ctrlKey || pendingModifiers.ctrl,
|
||||
shift: shiftKey || pendingModifiers.shift,
|
||||
alt: altKey || pendingModifiers.alt,
|
||||
meta: metaKey,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapTerminalDataToKey(data: string): string | null {
|
||||
if (!data || data.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (data === "\r" || data === "\n") {
|
||||
return "Enter";
|
||||
}
|
||||
if (data === "\t") {
|
||||
return "Tab";
|
||||
}
|
||||
if (data === "\x7f" || data === "\b") {
|
||||
return "Backspace";
|
||||
}
|
||||
if (data === "\x1b") {
|
||||
return "Escape";
|
||||
}
|
||||
|
||||
const code = data.charCodeAt(0);
|
||||
// Only map printable ASCII for modifier fallback; keep control bytes raw.
|
||||
if (code >= 0x20 && code <= 0x7e) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolvePendingModifierDataInput(args: {
|
||||
data: string;
|
||||
pendingModifiers: PendingTerminalModifiers;
|
||||
}):
|
||||
| {
|
||||
mode: "key";
|
||||
key: string;
|
||||
clearPendingModifiers: true;
|
||||
}
|
||||
| {
|
||||
mode: "raw";
|
||||
clearPendingModifiers: boolean;
|
||||
} {
|
||||
if (!hasPendingTerminalModifiers(args.pendingModifiers)) {
|
||||
return {
|
||||
mode: "raw",
|
||||
clearPendingModifiers: false,
|
||||
};
|
||||
}
|
||||
|
||||
const mappedKey = mapTerminalDataToKey(args.data);
|
||||
if (!mappedKey) {
|
||||
return {
|
||||
mode: "raw",
|
||||
clearPendingModifiers: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "key",
|
||||
key: mappedKey,
|
||||
clearPendingModifiers: true,
|
||||
};
|
||||
}
|
||||
53
packages/app/src/utils/terminal-list.test.ts
Normal file
53
packages/app/src/utils/terminal-list.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { upsertTerminalListEntry } from "./terminal-list";
|
||||
|
||||
describe("terminal-list", () => {
|
||||
it("adds a created terminal when the list is empty", () => {
|
||||
const result = upsertTerminalListEntry({
|
||||
terminals: [],
|
||||
terminal: {
|
||||
id: "term-1",
|
||||
name: "Terminal 1",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ id: "term-1", name: "Terminal 1" }]);
|
||||
});
|
||||
|
||||
it("appends a created terminal when the id does not already exist", () => {
|
||||
const result = upsertTerminalListEntry({
|
||||
terminals: [{ id: "term-1", name: "Terminal 1" }],
|
||||
terminal: {
|
||||
id: "term-2",
|
||||
name: "Terminal 2",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: "term-1", name: "Terminal 1" },
|
||||
{ id: "term-2", name: "Terminal 2" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces the existing terminal entry when ids match", () => {
|
||||
const result = upsertTerminalListEntry({
|
||||
terminals: [
|
||||
{ id: "term-1", name: "Terminal 1" },
|
||||
{ id: "term-2", name: "Old Name" },
|
||||
],
|
||||
terminal: {
|
||||
id: "term-2",
|
||||
name: "Renamed Terminal",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: "term-1", name: "Terminal 1" },
|
||||
{ id: "term-2", name: "Renamed Terminal" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
32
packages/app/src/utils/terminal-list.ts
Normal file
32
packages/app/src/utils/terminal-list.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type {
|
||||
CreateTerminalResponse,
|
||||
ListTerminalsResponse,
|
||||
} from "@server/shared/messages";
|
||||
|
||||
type TerminalListEntry = ListTerminalsResponse["payload"]["terminals"][number];
|
||||
type CreatedTerminal = NonNullable<CreateTerminalResponse["payload"]["terminal"]>;
|
||||
|
||||
function toTerminalListEntry(input: { terminal: CreatedTerminal }): TerminalListEntry {
|
||||
return {
|
||||
id: input.terminal.id,
|
||||
name: input.terminal.name,
|
||||
};
|
||||
}
|
||||
|
||||
export function upsertTerminalListEntry(input: {
|
||||
terminals: TerminalListEntry[];
|
||||
terminal: CreatedTerminal;
|
||||
}): TerminalListEntry[] {
|
||||
const createdTerminal = toTerminalListEntry({ terminal: input.terminal });
|
||||
const existingIndex = input.terminals.findIndex(
|
||||
(terminal) => terminal.id === createdTerminal.id
|
||||
);
|
||||
|
||||
if (existingIndex < 0) {
|
||||
return [...input.terminals, createdTerminal];
|
||||
}
|
||||
|
||||
const nextTerminals = [...input.terminals];
|
||||
nextTerminals[existingIndex] = createdTerminal;
|
||||
return nextTerminals;
|
||||
}
|
||||
42
packages/app/src/utils/terminal-output-buffer.test.ts
Normal file
42
packages/app/src/utils/terminal-output-buffer.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
appendTerminalOutputBuffer,
|
||||
createTerminalOutputBuffer,
|
||||
readTerminalOutputBuffer,
|
||||
} from "./terminal-output-buffer";
|
||||
|
||||
describe("terminal-output-buffer", () => {
|
||||
it("keeps appended text within max chars without rebuilding from scratch", () => {
|
||||
const buffer = createTerminalOutputBuffer();
|
||||
|
||||
appendTerminalOutputBuffer({ buffer, text: "abc", maxChars: 5 });
|
||||
appendTerminalOutputBuffer({ buffer, text: "de", maxChars: 5 });
|
||||
|
||||
expect(readTerminalOutputBuffer({ buffer })).toBe("abcde");
|
||||
|
||||
appendTerminalOutputBuffer({ buffer, text: "f", maxChars: 5 });
|
||||
expect(readTerminalOutputBuffer({ buffer })).toBe("bcdef");
|
||||
|
||||
appendTerminalOutputBuffer({ buffer, text: "gh", maxChars: 5 });
|
||||
expect(readTerminalOutputBuffer({ buffer })).toBe("defgh");
|
||||
});
|
||||
|
||||
it("ignores empty appends and preserves current content", () => {
|
||||
const buffer = createTerminalOutputBuffer();
|
||||
appendTerminalOutputBuffer({ buffer, text: "hello", maxChars: 10 });
|
||||
appendTerminalOutputBuffer({ buffer, text: "", maxChars: 10 });
|
||||
|
||||
expect(readTerminalOutputBuffer({ buffer })).toBe("hello");
|
||||
});
|
||||
|
||||
it("handles large overflow by trimming entire leading segments", () => {
|
||||
const buffer = createTerminalOutputBuffer();
|
||||
|
||||
appendTerminalOutputBuffer({ buffer, text: "12345", maxChars: 8 });
|
||||
appendTerminalOutputBuffer({ buffer, text: "6789", maxChars: 8 });
|
||||
appendTerminalOutputBuffer({ buffer, text: "ABCDEF", maxChars: 8 });
|
||||
|
||||
expect(readTerminalOutputBuffer({ buffer })).toBe("89ABCDEF");
|
||||
});
|
||||
});
|
||||
95
packages/app/src/utils/terminal-output-buffer.ts
Normal file
95
packages/app/src/utils/terminal-output-buffer.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
export interface TerminalOutputBuffer {
|
||||
segments: string[];
|
||||
startIndex: number;
|
||||
totalChars: number;
|
||||
}
|
||||
|
||||
const COMPACT_SEGMENT_THRESHOLD = 256;
|
||||
|
||||
export function createTerminalOutputBuffer(): TerminalOutputBuffer {
|
||||
return {
|
||||
segments: [],
|
||||
startIndex: 0,
|
||||
totalChars: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMaxChars(input: { maxChars: number }): number {
|
||||
if (!Number.isFinite(input.maxChars)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, Math.floor(input.maxChars));
|
||||
}
|
||||
|
||||
function compactTerminalOutputBuffer(input: { buffer: TerminalOutputBuffer }): void {
|
||||
const { buffer } = input;
|
||||
if (buffer.startIndex <= COMPACT_SEGMENT_THRESHOLD) {
|
||||
return;
|
||||
}
|
||||
|
||||
buffer.segments = buffer.segments.slice(buffer.startIndex);
|
||||
buffer.startIndex = 0;
|
||||
}
|
||||
|
||||
function trimTerminalOutputBufferToMax(input: {
|
||||
buffer: TerminalOutputBuffer;
|
||||
maxChars: number;
|
||||
}): void {
|
||||
const { buffer } = input;
|
||||
const maxChars = normalizeMaxChars({ maxChars: input.maxChars });
|
||||
|
||||
while (buffer.totalChars > maxChars) {
|
||||
const leadingSegment = buffer.segments[buffer.startIndex];
|
||||
if (!leadingSegment) {
|
||||
buffer.segments = [];
|
||||
buffer.startIndex = 0;
|
||||
buffer.totalChars = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const overflowChars = buffer.totalChars - maxChars;
|
||||
if (leadingSegment.length <= overflowChars) {
|
||||
buffer.startIndex += 1;
|
||||
buffer.totalChars -= leadingSegment.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
buffer.segments[buffer.startIndex] = leadingSegment.slice(overflowChars);
|
||||
buffer.totalChars -= overflowChars;
|
||||
break;
|
||||
}
|
||||
|
||||
compactTerminalOutputBuffer({ buffer });
|
||||
}
|
||||
|
||||
export function appendTerminalOutputBuffer(input: {
|
||||
buffer: TerminalOutputBuffer;
|
||||
text: string;
|
||||
maxChars: number;
|
||||
}): void {
|
||||
if (!input.text) {
|
||||
return;
|
||||
}
|
||||
|
||||
input.buffer.segments.push(input.text);
|
||||
input.buffer.totalChars += input.text.length;
|
||||
trimTerminalOutputBufferToMax({
|
||||
buffer: input.buffer,
|
||||
maxChars: input.maxChars,
|
||||
});
|
||||
}
|
||||
|
||||
export function readTerminalOutputBuffer(input: {
|
||||
buffer: TerminalOutputBuffer;
|
||||
}): string {
|
||||
const { buffer } = input;
|
||||
if (buffer.totalChars <= 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (buffer.startIndex === 0) {
|
||||
return buffer.segments.join("");
|
||||
}
|
||||
|
||||
return buffer.segments.slice(buffer.startIndex).join("");
|
||||
}
|
||||
@@ -76,6 +76,34 @@ describe("tool-call-display", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("builds display model from worktree setup detail", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "paseo_worktree_setup",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/tmp/repo/.paseo/worktrees/repo/branch",
|
||||
branchName: "feature-branch",
|
||||
log: "==> [1/1] Running: npm install\n",
|
||||
commands: [
|
||||
{
|
||||
index: 1,
|
||||
command: "npm install",
|
||||
cwd: "/tmp/repo/.paseo/worktrees/repo/branch",
|
||||
status: "running",
|
||||
exitCode: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
displayName: "Worktree Setup",
|
||||
summary: "feature-branch",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not derive command summary from unknown raw detail", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "exec_command",
|
||||
|
||||
@@ -19,6 +19,7 @@ const TOOL_DETAIL_ICONS: Record<ToolCallDetail["type"], ToolCallIconComponent> =
|
||||
edit: Pencil,
|
||||
write: Pencil,
|
||||
search: Search,
|
||||
worktree_setup: SquareTerminal,
|
||||
unknown: Wrench,
|
||||
};
|
||||
|
||||
|
||||
64
packages/app/src/utils/working-directory-suggestions.test.ts
Normal file
64
packages/app/src/utils/working-directory-suggestions.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildWorkingDirectorySuggestions } from "./working-directory-suggestions";
|
||||
|
||||
describe("buildWorkingDirectorySuggestions", () => {
|
||||
it("returns de-duplicated recommendations when query is empty", () => {
|
||||
const results = buildWorkingDirectorySuggestions({
|
||||
recommendedPaths: ["/Users/me/projects/paseo", "/Users/me/projects/paseo"],
|
||||
serverPaths: ["/Users/me/projects/playground"],
|
||||
query: "",
|
||||
});
|
||||
|
||||
expect(results).toEqual(["/Users/me/projects/paseo"]);
|
||||
});
|
||||
|
||||
it("prioritizes matching recommended directories before server matches", () => {
|
||||
const results = buildWorkingDirectorySuggestions({
|
||||
recommendedPaths: ["/Users/me/projects/paseo", "/Users/me/documents"],
|
||||
serverPaths: [
|
||||
"/Users/me/projects/playground",
|
||||
"/Users/me/projects/paseo",
|
||||
"/Users/me/projects/planbook",
|
||||
],
|
||||
query: "pla",
|
||||
});
|
||||
|
||||
expect(results).toEqual([
|
||||
"/Users/me/projects/playground",
|
||||
"/Users/me/projects/planbook",
|
||||
]);
|
||||
});
|
||||
|
||||
it("puts matching recommended items first when they also match query", () => {
|
||||
const results = buildWorkingDirectorySuggestions({
|
||||
recommendedPaths: ["/Users/me/projects/playground", "/Users/me/projects/paseo"],
|
||||
serverPaths: [
|
||||
"/Users/me/projects/planbook",
|
||||
"/Users/me/projects/playground",
|
||||
],
|
||||
query: "pla",
|
||||
});
|
||||
|
||||
expect(results).toEqual([
|
||||
"/Users/me/projects/playground",
|
||||
"/Users/me/projects/planbook",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats '~' as an active query and includes server suggestions", () => {
|
||||
const results = buildWorkingDirectorySuggestions({
|
||||
recommendedPaths: ["/Users/me/projects/paseo"],
|
||||
serverPaths: [
|
||||
"/Users/me/documents",
|
||||
"/Users/me/projects",
|
||||
],
|
||||
query: "~",
|
||||
});
|
||||
|
||||
expect(results).toEqual([
|
||||
"/Users/me/projects/paseo",
|
||||
"/Users/me/documents",
|
||||
"/Users/me/projects",
|
||||
]);
|
||||
});
|
||||
});
|
||||
72
packages/app/src/utils/working-directory-suggestions.ts
Normal file
72
packages/app/src/utils/working-directory-suggestions.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export interface BuildWorkingDirectorySuggestionsInput {
|
||||
recommendedPaths: string[];
|
||||
serverPaths: string[];
|
||||
query: string;
|
||||
}
|
||||
|
||||
export function buildWorkingDirectorySuggestions(
|
||||
input: BuildWorkingDirectorySuggestionsInput
|
||||
): string[] {
|
||||
const rawQuery = input.query.trim();
|
||||
const recommended = uniquePaths(input.recommendedPaths);
|
||||
if (!rawQuery) {
|
||||
return recommended;
|
||||
}
|
||||
|
||||
const normalizedQuery = normalizeQuery(rawQuery);
|
||||
const shouldFilterByQuery = normalizedQuery.length > 0;
|
||||
|
||||
const recommendedMatches = shouldFilterByQuery
|
||||
? recommended.filter((entry) => pathMatchesQuery(entry, normalizedQuery))
|
||||
: recommended;
|
||||
const seen = new Set(recommendedMatches);
|
||||
const ordered = [...recommendedMatches];
|
||||
|
||||
for (const entry of uniquePaths(input.serverPaths)) {
|
||||
if (shouldFilterByQuery && !pathMatchesQuery(entry, normalizedQuery)) {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(entry)) {
|
||||
continue;
|
||||
}
|
||||
ordered.push(entry);
|
||||
seen.add(entry);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function uniquePaths(paths: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const ordered: string[] = [];
|
||||
for (const pathEntry of paths) {
|
||||
const trimmed = pathEntry.trim();
|
||||
if (!trimmed || seen.has(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(trimmed);
|
||||
ordered.push(trimmed);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function normalizeQuery(query: string): string {
|
||||
let normalized = query.trim();
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
if (normalized.startsWith("~")) {
|
||||
normalized = normalized.slice(1);
|
||||
}
|
||||
normalized = normalized.replace(/^\/+/, "").toLowerCase();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function pathMatchesQuery(candidatePath: string, query: string): boolean {
|
||||
const lowerPath = candidatePath.toLowerCase();
|
||||
if (lowerPath.includes(query)) {
|
||||
return true;
|
||||
}
|
||||
const segments = lowerPath.split("/");
|
||||
return (segments[segments.length - 1] ?? "").includes(query);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.12",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -22,8 +22,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.4",
|
||||
"@getpaseo/server": "0.1.4",
|
||||
"@getpaseo/relay": "0.1.12",
|
||||
"@getpaseo/server": "0.1.12",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Command } from 'commander'
|
||||
import { createRequire } from 'node:module'
|
||||
import { createAgentCommand } from './commands/agent/index.js'
|
||||
import { createDaemonCommand } from './commands/daemon/index.js'
|
||||
import { createPermitCommand } from './commands/permit/index.js'
|
||||
@@ -20,7 +21,9 @@ import { runUpdateCommand } from './commands/agent/update.js'
|
||||
import { withOutput } from './output/index.js'
|
||||
import { onboardCommand } from './commands/onboard.js'
|
||||
|
||||
const VERSION = '0.1.0'
|
||||
const require = createRequire(import.meta.url)
|
||||
const packageJson = require('../package.json') as { version?: string }
|
||||
const VERSION = typeof packageJson.version === 'string' ? packageJson.version : '0.0.0'
|
||||
|
||||
// Helper function to collect multiple option values into an array
|
||||
function collectMultiple(value: string, previous: string[]): string[] {
|
||||
|
||||
@@ -111,7 +111,10 @@ export async function runLsCommand(
|
||||
}
|
||||
|
||||
try {
|
||||
let agents = await client.fetchAgents()
|
||||
const fetchPayload = await client.fetchAgents({
|
||||
filter: options.all ? { includeArchived: true } : undefined,
|
||||
})
|
||||
let agents = fetchPayload.entries.map((entry) => entry.agent)
|
||||
|
||||
// By default, exclude archived agents. `-a` includes them.
|
||||
if (!options.all) {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { Command } from 'commander'
|
||||
import type { AgentSnapshotPayload } from '@getpaseo/server'
|
||||
import {
|
||||
getStructuredAgentResponse,
|
||||
StructuredAgentResponseError,
|
||||
type AgentSnapshotPayload,
|
||||
} from '@getpaseo/server'
|
||||
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||
import { readFileSync } from 'node:fs'
|
||||
@@ -105,98 +109,55 @@ function loadOutputSchema(value: string): Record<string, unknown> {
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
|
||||
function extractFirstJsonObject(text: string): string | null {
|
||||
const source = text.trim()
|
||||
if (!source) {
|
||||
return null
|
||||
class StructuredRunStatusError extends Error {
|
||||
readonly kind: 'timeout' | 'permission' | 'error' | 'empty'
|
||||
|
||||
constructor(kind: 'timeout' | 'permission' | 'error' | 'empty', message: string) {
|
||||
super(message)
|
||||
this.name = 'StructuredRunStatusError'
|
||||
this.kind = kind
|
||||
}
|
||||
}
|
||||
|
||||
type ConnectedDaemonClient = Awaited<ReturnType<typeof connectToDaemon>>
|
||||
|
||||
export interface StructuredResponseTimelineClient {
|
||||
fetchAgentTimeline: ConnectedDaemonClient['fetchAgentTimeline']
|
||||
}
|
||||
|
||||
export async function resolveStructuredResponseMessage(options: {
|
||||
client: StructuredResponseTimelineClient
|
||||
agentId: string
|
||||
lastMessage: string | null
|
||||
}): Promise<string | null> {
|
||||
const direct = options.lastMessage?.trim()
|
||||
if (direct) {
|
||||
return direct
|
||||
}
|
||||
|
||||
const startIndexes: number[] = []
|
||||
for (let i = 0; i < source.length; i += 1) {
|
||||
if (source[i] === '{') {
|
||||
startIndexes.push(i)
|
||||
}
|
||||
}
|
||||
|
||||
for (const start of startIndexes) {
|
||||
let depth = 0
|
||||
let inString = false
|
||||
let escaped = false
|
||||
|
||||
for (let i = start; i < source.length; i += 1) {
|
||||
const ch = source[i]!
|
||||
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = false
|
||||
}
|
||||
try {
|
||||
const timeline = await options.client.fetchAgentTimeline(options.agentId, {
|
||||
direction: 'tail',
|
||||
projection: 'projected',
|
||||
limit: 200,
|
||||
})
|
||||
for (let index = timeline.entries.length - 1; index >= 0; index -= 1) {
|
||||
const entry = timeline.entries[index]
|
||||
if (!entry || entry.item.type !== 'assistant_message') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch === '"') {
|
||||
inString = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch === '{') {
|
||||
depth += 1
|
||||
continue
|
||||
}
|
||||
if (ch === '}') {
|
||||
depth -= 1
|
||||
if (depth === 0) {
|
||||
const candidate = source.slice(start, i + 1).trim()
|
||||
try {
|
||||
JSON.parse(candidate)
|
||||
return candidate
|
||||
} catch {
|
||||
// Keep scanning.
|
||||
}
|
||||
}
|
||||
const text = entry.item.text.trim()
|
||||
if (text.length > 0) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Leave empty; caller will surface a consistent structured-output failure message.
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function parseStructuredOutput(lastMessage: string): Record<string, unknown> {
|
||||
const trimmed = lastMessage.trim()
|
||||
const fenced = trimmed.match(/```(?:json)?\s*\n([\s\S]*?)\n```/)
|
||||
const jsonText = fenced?.[1]?.trim() ?? extractFirstJsonObject(trimmed) ?? trimmed
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(jsonText)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const error: CommandError = {
|
||||
code: 'OUTPUT_SCHEMA_FAILED',
|
||||
message: 'Agent response is not valid JSON',
|
||||
details: message,
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
const error: CommandError = {
|
||||
code: 'OUTPUT_SCHEMA_FAILED',
|
||||
message: 'Agent response JSON must be an object',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
|
||||
function structuredRunSchema(output: Record<string, unknown>): OutputSchema<AgentRunResult> {
|
||||
return {
|
||||
...agentRunSchema,
|
||||
@@ -318,6 +279,107 @@ export async function runRunCommand(
|
||||
labels['ui'] = 'true'
|
||||
}
|
||||
|
||||
if (outputSchema) {
|
||||
let structuredAgent: AgentSnapshotPayload | null = null
|
||||
|
||||
const callStructuredTurn = async (structuredPrompt: string): Promise<string> => {
|
||||
if (!structuredAgent) {
|
||||
structuredAgent = await client.createAgent({
|
||||
provider: (options.provider as 'claude' | 'codex' | 'opencode') ?? 'claude',
|
||||
cwd,
|
||||
title: options.name,
|
||||
modeId: options.mode,
|
||||
model: options.model,
|
||||
initialPrompt: structuredPrompt,
|
||||
images,
|
||||
git,
|
||||
worktreeName: options.worktree,
|
||||
labels: Object.keys(labels).length > 0 ? labels : undefined,
|
||||
})
|
||||
} else {
|
||||
await client.sendMessage(structuredAgent.id, structuredPrompt)
|
||||
}
|
||||
|
||||
const state = await client.waitForFinish(structuredAgent.id, 10 * 60 * 1000)
|
||||
if (state.status === 'timeout') {
|
||||
throw new StructuredRunStatusError('timeout', 'Timed out waiting for structured output')
|
||||
}
|
||||
if (state.status === 'permission') {
|
||||
throw new StructuredRunStatusError(
|
||||
'permission',
|
||||
'Agent is waiting for permission before producing structured output'
|
||||
)
|
||||
}
|
||||
if (state.status === 'error') {
|
||||
throw new StructuredRunStatusError(
|
||||
'error',
|
||||
state.error ?? 'Agent failed before producing structured output'
|
||||
)
|
||||
}
|
||||
|
||||
const lastMessage = await resolveStructuredResponseMessage({
|
||||
client,
|
||||
agentId: structuredAgent.id,
|
||||
lastMessage: state.lastMessage,
|
||||
})
|
||||
if (!lastMessage) {
|
||||
throw new StructuredRunStatusError(
|
||||
'empty',
|
||||
'Agent finished without a structured output message'
|
||||
)
|
||||
}
|
||||
|
||||
return lastMessage
|
||||
}
|
||||
|
||||
let output: Record<string, unknown>
|
||||
try {
|
||||
output = await getStructuredAgentResponse<Record<string, unknown>>({
|
||||
caller: callStructuredTurn,
|
||||
prompt,
|
||||
schema: outputSchema,
|
||||
schemaName: 'RunOutput',
|
||||
maxRetries: 2,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof StructuredRunStatusError) {
|
||||
const error: CommandError = {
|
||||
code: 'OUTPUT_SCHEMA_FAILED',
|
||||
message: err.message,
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (err instanceof StructuredAgentResponseError) {
|
||||
const error: CommandError = {
|
||||
code: 'OUTPUT_SCHEMA_FAILED',
|
||||
message: 'Agent response did not match the required output schema',
|
||||
details:
|
||||
err.validationErrors.length > 0
|
||||
? err.validationErrors.join('\n')
|
||||
: err.lastResponse || 'No response',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
if (!structuredAgent) {
|
||||
const error: CommandError = {
|
||||
code: 'OUTPUT_SCHEMA_FAILED',
|
||||
message: 'Agent finished without a structured output message',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
await client.close()
|
||||
|
||||
return {
|
||||
type: 'single',
|
||||
data: toRunResult(structuredAgent, 'completed'),
|
||||
schema: structuredRunSchema(output),
|
||||
}
|
||||
}
|
||||
|
||||
// Create the agent
|
||||
const agent = await client.createAgent({
|
||||
provider: (options.provider as 'claude' | 'codex' | 'opencode') ?? 'claude',
|
||||
@@ -326,59 +388,12 @@ export async function runRunCommand(
|
||||
modeId: options.mode,
|
||||
model: options.model,
|
||||
initialPrompt: prompt,
|
||||
outputSchema,
|
||||
images,
|
||||
git,
|
||||
worktreeName: options.worktree,
|
||||
labels: Object.keys(labels).length > 0 ? labels : undefined,
|
||||
})
|
||||
|
||||
if (outputSchema) {
|
||||
const state = await client.waitForFinish(agent.id, 10 * 60 * 1000)
|
||||
|
||||
if (state.status === 'timeout') {
|
||||
const error: CommandError = {
|
||||
code: 'OUTPUT_SCHEMA_FAILED',
|
||||
message: 'Timed out waiting for structured output',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (state.status === 'permission') {
|
||||
const error: CommandError = {
|
||||
code: 'OUTPUT_SCHEMA_FAILED',
|
||||
message: 'Agent is waiting for permission before producing structured output',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (state.status === 'error') {
|
||||
const error: CommandError = {
|
||||
code: 'OUTPUT_SCHEMA_FAILED',
|
||||
message: state.error ?? 'Agent failed before producing structured output',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const lastMessage = state.lastMessage?.trim()
|
||||
if (!lastMessage) {
|
||||
const error: CommandError = {
|
||||
code: 'OUTPUT_SCHEMA_FAILED',
|
||||
message: 'Agent finished without a structured output message',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const output = parseStructuredOutput(lastMessage)
|
||||
await client.close()
|
||||
|
||||
return {
|
||||
type: 'single',
|
||||
data: toRunResult(agent, 'completed'),
|
||||
schema: structuredRunSchema(output),
|
||||
}
|
||||
}
|
||||
|
||||
// Default run behavior is foreground: wait for completion unless --detach is set.
|
||||
if (!options.detach) {
|
||||
const state = await client.waitForFinish(agent.id, 10 * 60 * 1000)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user