mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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.
|
||||
@@ -191,7 +191,7 @@ Notes:
|
||||
- If `release:publish` fails after a successful publish of one workspace, re-run `npm run release:publish`; npm will skip already-published versions and continue where possible.
|
||||
- If a user asks to "release paseo" (without specifying major/minor), treat it as a patch release and run `npm run release:patch`.
|
||||
- All workspaces share one version by design. Keep versions synchronized and release together.
|
||||
- After each release, update the website Mac download CTA URL to the new version tag in `packages/website/src/routes/index.tsx`.
|
||||
- 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.
|
||||
|
||||
97
README.md
97
README.md
@@ -17,91 +17,42 @@
|
||||
|
||||
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
|
||||
|
||||
Optional configuration:
|
||||
# run individual surfaces
|
||||
npm run dev:server
|
||||
npm run dev:app
|
||||
npm run dev:website
|
||||
|
||||
- `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.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
`npm run release:patch` bumps all workspace versions together, publishes npm packages (`@getpaseo/relay`, `@getpaseo/server`, `@getpaseo/cli`), and pushes the matching `v*` tag.
|
||||
|
||||
The tag triggers:
|
||||
- GitHub `Desktop Release` workflow (`.github/workflows/desktop-release.yml`)
|
||||
- Expo EAS mobile workflow (`packages/app/.eas/workflows/release-mobile.yml`) to build + submit Android/iOS
|
||||
|
||||
Useful monitoring commands after a release push:
|
||||
|
||||
```bash
|
||||
# Desktop (GitHub Actions)
|
||||
gh run list --workflow "Desktop Release" --limit 10
|
||||
gh run watch <run-id>
|
||||
|
||||
# Mobile (EAS Workflows)
|
||||
cd packages/app && npx eas workflow:runs --workflow release-mobile.yml --limit 10
|
||||
cd packages/app && npx eas workflow:view <run-id>
|
||||
# repo-wide checks
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
35
package-lock.json
generated
35
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.9",
|
||||
"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.6",
|
||||
"version": "0.1.9",
|
||||
"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.6",
|
||||
"@getpaseo/server": "0.1.9",
|
||||
"@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.6",
|
||||
"version": "0.1.9",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.6",
|
||||
"@getpaseo/server": "0.1.6",
|
||||
"@getpaseo/relay": "0.1.9",
|
||||
"@getpaseo/server": "0.1.9",
|
||||
"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.6",
|
||||
"version": "0.1.9",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.6"
|
||||
}
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.9",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -20523,12 +20533,12 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.9",
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/relay": "0.1.6",
|
||||
"@getpaseo/relay": "0.1.9",
|
||||
"@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.6",
|
||||
"version": "0.1.9",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.9",
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/')) {
|
||||
|
||||
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);
|
||||
});
|
||||
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": {
|
||||
@@ -33,6 +37,9 @@
|
||||
"production": {
|
||||
"ios": {
|
||||
"ascAppId": "6758887924"
|
||||
},
|
||||
"android": {
|
||||
"releaseStatus": "draft"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.9",
|
||||
"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.6",
|
||||
"@getpaseo/server": "0.1.9",
|
||||
"@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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -233,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[]) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]";
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -592,11 +592,6 @@ export function SidebarAgentList({
|
||||
return;
|
||||
}
|
||||
|
||||
const session = useSessionStore.getState().sessions[entry.agent.serverId];
|
||||
if (session?.client) {
|
||||
session.client.clearAgentAttention(entry.agent.id);
|
||||
}
|
||||
|
||||
const navigationKey = buildAgentNavigationKey(entry.agent.serverId, entry.agent.id);
|
||||
startNavigationTiming(navigationKey, {
|
||||
from: "home",
|
||||
|
||||
@@ -16,7 +16,7 @@ import { SidebarAgentList } from "./sidebar-agent-list";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped";
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
import { useTauriDragHandlers } from "@/utils/tauri-window";
|
||||
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
@@ -121,6 +121,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
closeGestureRef,
|
||||
} = useSidebarAnimation();
|
||||
const dragHandlers = useTauriDragHandlers();
|
||||
const trafficLightPadding = useTrafficLightPadding();
|
||||
|
||||
// Track user-initiated refresh to avoid showing spinner on background revalidation
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
@@ -465,7 +466,10 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
|
||||
return (
|
||||
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
|
||||
<View style={styles.sidebarHeader} {...dragHandlers}>
|
||||
<View
|
||||
style={[styles.sidebarHeader, { paddingLeft: theme.spacing[2] + trafficLightPadding.left }]}
|
||||
{...dragHandlers}
|
||||
>
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<Pressable
|
||||
style={styles.newAgentButton}
|
||||
|
||||
@@ -15,12 +15,14 @@ import Svg, {
|
||||
Stop,
|
||||
} from "react-native-svg";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
hasPendingTerminalModifiers,
|
||||
normalizeTerminalTransportKey,
|
||||
resolvePendingModifierDataInput,
|
||||
} from "@/utils/terminal-keys";
|
||||
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||
import {
|
||||
TerminalOutputPump,
|
||||
type TerminalOutputChunk,
|
||||
@@ -89,6 +91,8 @@ type PendingTerminalInput =
|
||||
};
|
||||
};
|
||||
|
||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
|
||||
const EMPTY_MODIFIERS: ModifierState = {
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
@@ -134,6 +138,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
);
|
||||
|
||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
||||
const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]);
|
||||
const selectedTerminalByScopeRef = useRef<Map<string, string>>(new Map());
|
||||
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
||||
@@ -245,8 +250,12 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
return () => clearHoverOutTimeout();
|
||||
}, [clearHoverOutTimeout]);
|
||||
|
||||
const requestTerminalFocus = useCallback(() => {
|
||||
setFocusRequestToken((current) => current + 1);
|
||||
}, []);
|
||||
|
||||
const terminalsQuery = useQuery({
|
||||
queryKey: ["terminals", serverId, cwd] as const,
|
||||
queryKey: terminalsQueryKey,
|
||||
enabled: Boolean(client && isConnected && cwd.startsWith("/")),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
@@ -281,14 +290,14 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
setModifiers({ ...EMPTY_MODIFIERS });
|
||||
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["terminals", serverId, cwd],
|
||||
queryKey: terminalsQueryKey,
|
||||
});
|
||||
void queryClient.refetchQueries({
|
||||
queryKey: ["terminals", serverId, cwd],
|
||||
queryKey: terminalsQueryKey,
|
||||
type: "active",
|
||||
});
|
||||
});
|
||||
}, [client, cwd, isConnected, queryClient, serverId]);
|
||||
}, [client, isConnected, queryClient, terminalsQueryKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected || !cwd.startsWith("/")) {
|
||||
@@ -303,10 +312,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
return;
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["terminals", serverId, cwd],
|
||||
queryKey: terminalsQueryKey,
|
||||
});
|
||||
void queryClient.refetchQueries({
|
||||
queryKey: ["terminals", serverId, cwd],
|
||||
queryKey: terminalsQueryKey,
|
||||
type: "active",
|
||||
});
|
||||
});
|
||||
@@ -317,7 +326,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
unsubscribe();
|
||||
client.unsubscribeTerminals({ cwd });
|
||||
};
|
||||
}, [client, cwd, isConnected, queryClient, serverId]);
|
||||
}, [client, cwd, isConnected, queryClient, terminalsQueryKey]);
|
||||
|
||||
const createTerminalMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -327,12 +336,26 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
return await client.createTerminal(cwd);
|
||||
},
|
||||
onSuccess: (payload) => {
|
||||
if (payload.terminal) {
|
||||
selectedTerminalByScopeRef.current.set(scopeKey, payload.terminal.id);
|
||||
setSelectedTerminalId(payload.terminal.id);
|
||||
const createdTerminal = payload.terminal;
|
||||
if (createdTerminal) {
|
||||
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
|
||||
const nextTerminals = upsertTerminalListEntry({
|
||||
terminals: current?.terminals ?? [],
|
||||
terminal: createdTerminal,
|
||||
});
|
||||
|
||||
return {
|
||||
cwd: current?.cwd ?? cwd,
|
||||
terminals: nextTerminals,
|
||||
requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
|
||||
};
|
||||
});
|
||||
selectedTerminalByScopeRef.current.set(scopeKey, createdTerminal.id);
|
||||
setSelectedTerminalId(createdTerminal.id);
|
||||
requestTerminalFocus();
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["terminals", serverId, cwd],
|
||||
queryKey: terminalsQueryKey,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -358,10 +381,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
setModifiers({ ...EMPTY_MODIFIERS });
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["terminals", serverId, cwd],
|
||||
queryKey: terminalsQueryKey,
|
||||
});
|
||||
void queryClient.refetchQueries({
|
||||
queryKey: ["terminals", serverId, cwd],
|
||||
queryKey: terminalsQueryKey,
|
||||
type: "active",
|
||||
});
|
||||
},
|
||||
@@ -550,10 +573,6 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
[killTerminalMutation]
|
||||
);
|
||||
|
||||
const requestTerminalFocus = useCallback(() => {
|
||||
setFocusRequestToken((current) => current + 1);
|
||||
}, []);
|
||||
|
||||
const clearPendingModifiers = useCallback(() => {
|
||||
setModifiers({ ...EMPTY_MODIFIERS });
|
||||
}, []);
|
||||
|
||||
@@ -393,6 +393,12 @@ const styles = StyleSheet.create((theme) => {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 18,
|
||||
...(Platform.OS === "web"
|
||||
? {
|
||||
whiteSpace: "pre",
|
||||
overflowWrap: "normal",
|
||||
}
|
||||
: null),
|
||||
},
|
||||
shellPrompt: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
|
||||
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,17 +20,20 @@ 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[];
|
||||
@@ -43,14 +47,35 @@ export interface ComboboxProps {
|
||||
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
|
||||
@@ -110,6 +135,7 @@ function SearchInput({
|
||||
export interface ComboboxItemProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
kind?: "directory";
|
||||
selected?: boolean;
|
||||
active?: boolean;
|
||||
onPress: () => void;
|
||||
@@ -119,6 +145,7 @@ export interface ComboboxItemProps {
|
||||
export function ComboboxItem({
|
||||
label,
|
||||
description,
|
||||
kind,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
@@ -136,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 ? (
|
||||
@@ -152,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({
|
||||
@@ -167,21 +199,32 @@ export function Combobox({
|
||||
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);
|
||||
@@ -227,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 }) {
|
||||
@@ -245,7 +288,7 @@ export function Combobox({
|
||||
},
|
||||
}),
|
||||
],
|
||||
[collisionPadding]
|
||||
[collisionPadding, isDesktopAboveSearch]
|
||||
);
|
||||
|
||||
const { refs, floatingStyles, update } = useFloating({
|
||||
@@ -263,10 +306,57 @@ export function Combobox({
|
||||
setReferenceWidth(null);
|
||||
return;
|
||||
}
|
||||
const raf = requestAnimationFrame(() => update());
|
||||
const raf = requestAnimationFrame(() => void update());
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [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;
|
||||
if (isOpen) {
|
||||
@@ -298,78 +388,105 @@ export function Combobox({
|
||||
);
|
||||
|
||||
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : "";
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!normalizedSearch) {
|
||||
return options;
|
||||
}
|
||||
return options.filter(
|
||||
(opt) =>
|
||||
opt.label.toLowerCase().includes(normalizedSearch) ||
|
||||
opt.id.toLowerCase().includes(normalizedSearch) ||
|
||||
opt.description?.toLowerCase().includes(normalizedSearch)
|
||||
);
|
||||
}, [options, normalizedSearch]);
|
||||
|
||||
const sanitizedSearchValue = searchQuery.trim();
|
||||
const showCustomOption =
|
||||
searchable &&
|
||||
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) => {
|
||||
@@ -395,7 +512,7 @@ export function Combobox({
|
||||
setActiveIndex((currentIndex) =>
|
||||
getNextActiveIndex({
|
||||
currentIndex,
|
||||
itemCount: visibleOptions.length,
|
||||
itemCount: orderedVisibleOptions.length,
|
||||
key,
|
||||
})
|
||||
);
|
||||
@@ -403,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;
|
||||
}
|
||||
|
||||
@@ -416,7 +533,7 @@ export function Combobox({
|
||||
handleClose();
|
||||
}
|
||||
},
|
||||
[activeIndex, handleClose, handleSelect, isMobile, isOpen, visibleOptions]
|
||||
[activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -449,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)}
|
||||
@@ -466,13 +584,16 @@ export function Combobox({
|
||||
</>
|
||||
);
|
||||
|
||||
const content = children ?? (
|
||||
const defaultContent = (
|
||||
<>
|
||||
{effectiveOptionsPosition === "above-search" ? optionsList : null}
|
||||
{searchable ? searchInput : null}
|
||||
{optionsList}
|
||||
{effectiveOptionsPosition === "below-search" ? optionsList : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const content = children ?? defaultContent;
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<BottomSheetModal
|
||||
@@ -514,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,
|
||||
{
|
||||
@@ -523,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}
|
||||
@@ -542,15 +664,32 @@ export function Combobox({
|
||||
</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}
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.desktopScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={styles.desktopScroll}
|
||||
>
|
||||
{optionsList}
|
||||
</ScrollView>
|
||||
{effectiveOptionsPosition === "below-search" ? (
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.desktopScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={styles.desktopScroll}
|
||||
>
|
||||
{optionsList}
|
||||
</ScrollView>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Animated.View>
|
||||
@@ -613,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,
|
||||
@@ -675,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",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -216,9 +216,6 @@ 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;
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
derivePendingPermissionKey,
|
||||
normalizeAgentSnapshot,
|
||||
} from "@/utils/agent-snapshots";
|
||||
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
|
||||
import type { FetchAgentsEntry } from "@server/client/daemon-client";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -512,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",
|
||||
@@ -860,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) {
|
||||
|
||||
@@ -27,10 +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 { 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";
|
||||
@@ -232,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);
|
||||
@@ -246,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;
|
||||
@@ -307,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) {
|
||||
@@ -325,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
|
||||
@@ -511,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) {
|
||||
@@ -644,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"
|
||||
@@ -1100,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],
|
||||
},
|
||||
@@ -983,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
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.6",
|
||||
"version": "0.1.9",
|
||||
"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.6",
|
||||
"@getpaseo/server": "0.1.6",
|
||||
"@getpaseo/relay": "0.1.9",
|
||||
"@getpaseo/server": "0.1.9",
|
||||
"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[] {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { closeSync, existsSync, openSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
@@ -99,6 +99,22 @@ function buildRunnerArgs(options: DaemonStartOptions): string[] {
|
||||
return args
|
||||
}
|
||||
|
||||
function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv {
|
||||
const childEnv: NodeJS.ProcessEnv = { ...process.env }
|
||||
if (options.home) {
|
||||
childEnv.PASEO_HOME = options.home
|
||||
}
|
||||
if (options.listen) {
|
||||
childEnv.PASEO_LISTEN = options.listen
|
||||
} else if (options.port) {
|
||||
childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}`
|
||||
}
|
||||
if (options.allowedHosts) {
|
||||
childEnv.PASEO_ALLOWED_HOSTS = options.allowedHosts
|
||||
}
|
||||
return childEnv
|
||||
}
|
||||
|
||||
function resolveDaemonRunnerEntry(): string {
|
||||
const serverExportPath = require.resolve('@getpaseo/server')
|
||||
let currentDir = path.dirname(serverExportPath)
|
||||
@@ -279,18 +295,7 @@ export async function startLocalDaemonDetached(
|
||||
throw new Error('Cannot use --listen and --port together')
|
||||
}
|
||||
|
||||
const childEnv: NodeJS.ProcessEnv = { ...process.env }
|
||||
if (options.home) {
|
||||
childEnv.PASEO_HOME = options.home
|
||||
}
|
||||
if (options.listen) {
|
||||
childEnv.PASEO_LISTEN = options.listen
|
||||
} else if (options.port) {
|
||||
childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}`
|
||||
}
|
||||
if (options.allowedHosts) {
|
||||
childEnv.PASEO_ALLOWED_HOSTS = options.allowedHosts
|
||||
}
|
||||
const childEnv = buildChildEnv(options)
|
||||
|
||||
const paseoHome = resolvePaseoHome(childEnv)
|
||||
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME)
|
||||
@@ -356,6 +361,29 @@ export async function startLocalDaemonDetached(
|
||||
}
|
||||
}
|
||||
|
||||
export function startLocalDaemonForeground(options: DaemonStartOptions): number {
|
||||
if (options.listen && options.port) {
|
||||
throw new Error('Cannot use --listen and --port together')
|
||||
}
|
||||
|
||||
const childEnv = buildChildEnv(options)
|
||||
const daemonRunnerEntry = resolveDaemonRunnerEntry()
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
|
||||
{
|
||||
env: childEnv,
|
||||
stdio: 'inherit',
|
||||
}
|
||||
)
|
||||
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
return result.status ?? 1
|
||||
}
|
||||
|
||||
export async function stopLocalDaemon(
|
||||
options: StopLocalDaemonOptions = {}
|
||||
): Promise<StopLocalDaemonResult> {
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import { Command } from 'commander'
|
||||
import chalk from 'chalk'
|
||||
import {
|
||||
createPaseoDaemon,
|
||||
loadConfig,
|
||||
resolvePaseoHome,
|
||||
createRootLogger,
|
||||
loadPersistedConfig,
|
||||
} from '@getpaseo/server'
|
||||
import type { CliConfigOverrides } from '@getpaseo/server'
|
||||
import {
|
||||
startLocalDaemonForeground,
|
||||
startLocalDaemonDetached,
|
||||
type DaemonStartOptions as StartOptions,
|
||||
} from './local-daemon.js'
|
||||
@@ -34,34 +27,6 @@ export function startCommand(): Command {
|
||||
})
|
||||
}
|
||||
|
||||
function toCliOverrides(options: StartOptions): CliConfigOverrides {
|
||||
const cliOverrides: CliConfigOverrides = {}
|
||||
|
||||
if (options.listen) {
|
||||
cliOverrides.listen = options.listen
|
||||
} else if (options.port) {
|
||||
cliOverrides.listen = `127.0.0.1:${options.port}`
|
||||
}
|
||||
|
||||
if (options.relay === false) {
|
||||
cliOverrides.relayEnabled = false
|
||||
}
|
||||
|
||||
if (options.allowedHosts) {
|
||||
const raw = options.allowedHosts.trim()
|
||||
cliOverrides.allowedHosts =
|
||||
raw.toLowerCase() === 'true'
|
||||
? true
|
||||
: raw.split(',').map(h => h.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
if (options.mcp === false) {
|
||||
cliOverrides.mcpEnabled = false
|
||||
}
|
||||
|
||||
return cliOverrides
|
||||
}
|
||||
|
||||
export async function runStart(options: StartOptions): Promise<void> {
|
||||
if (options.listen && options.port) {
|
||||
console.error(chalk.red('Cannot use --listen and --port together'))
|
||||
@@ -78,63 +43,9 @@ export async function runStart(options: StartOptions): Promise<void> {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (options.home) {
|
||||
process.env.PASEO_HOME = options.home
|
||||
}
|
||||
|
||||
let paseoHome: string
|
||||
let logger: ReturnType<typeof createRootLogger>
|
||||
let config: ReturnType<typeof loadConfig>
|
||||
|
||||
try {
|
||||
paseoHome = resolvePaseoHome()
|
||||
const persistedConfig = loadPersistedConfig(paseoHome)
|
||||
logger = createRootLogger(persistedConfig)
|
||||
config = loadConfig(paseoHome, { cli: toCliOverrides(options) })
|
||||
} catch (err) {
|
||||
exitWithError(getErrorMessage(err))
|
||||
}
|
||||
|
||||
let daemon: Awaited<ReturnType<typeof createPaseoDaemon>>
|
||||
try {
|
||||
daemon = await createPaseoDaemon(config, logger)
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err)
|
||||
exitWithError(`Failed to initialize daemon: ${message}`)
|
||||
}
|
||||
|
||||
let shuttingDown = false
|
||||
const handleShutdown = async (signal: string) => {
|
||||
if (shuttingDown) {
|
||||
logger.info('Forcing exit...')
|
||||
process.exit(1)
|
||||
}
|
||||
shuttingDown = true
|
||||
logger.info(`${signal} received, shutting down gracefully... (press Ctrl+C again to force exit)`)
|
||||
|
||||
const forceExit = setTimeout(() => {
|
||||
logger.warn('Forcing shutdown - HTTP server didn\'t close in time')
|
||||
process.exit(1)
|
||||
}, 10000)
|
||||
|
||||
try {
|
||||
await daemon.stop()
|
||||
clearTimeout(forceExit)
|
||||
logger.info('Server closed')
|
||||
process.exit(0)
|
||||
} catch (err) {
|
||||
clearTimeout(forceExit)
|
||||
logger.error({ err }, 'Shutdown failed')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => handleShutdown('SIGTERM'))
|
||||
process.on('SIGINT', () => handleShutdown('SIGINT'))
|
||||
|
||||
try {
|
||||
await daemon.start()
|
||||
const status = startLocalDaemonForeground(options)
|
||||
process.exit(status)
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err)
|
||||
exitWithError(`Failed to start daemon: ${message}`)
|
||||
|
||||
@@ -25,9 +25,6 @@ interface OnboardOptions extends DaemonStartOptions {
|
||||
}
|
||||
|
||||
type OnboardPersistedConfig = PersistedConfig & {
|
||||
providers?: PersistedConfig['providers'] & {
|
||||
local?: PersistedConfig['providers'] extends { local?: infer T } ? T : { autoDownload?: boolean }
|
||||
}
|
||||
features?: PersistedConfig['features'] & {
|
||||
dictation?: PersistedConfig['features'] extends { dictation?: infer T }
|
||||
? T & { enabled?: boolean }
|
||||
@@ -103,13 +100,6 @@ function savePersistedConfig(paseoHome: string, config: OnboardPersistedConfig):
|
||||
function applyVoiceSelection(config: OnboardPersistedConfig, enabled: boolean): OnboardPersistedConfig {
|
||||
return {
|
||||
...config,
|
||||
providers: {
|
||||
...config.providers,
|
||||
local: {
|
||||
...config.providers?.local,
|
||||
autoDownload: enabled,
|
||||
},
|
||||
},
|
||||
features: {
|
||||
...config.features,
|
||||
dictation: {
|
||||
@@ -366,7 +356,7 @@ export async function runOnboard(options: OnboardOptions): Promise<void> {
|
||||
const config = loadConfig(paseoHome, { cli: toCliOverrides(options) })
|
||||
|
||||
const voiceStatus = voiceEnabled
|
||||
? 'Voice features enabled. Local speech models will download in the background if missing.'
|
||||
? 'Voice features enabled. Local speech models will be downloaded automatically if missing.'
|
||||
: 'Voice features disabled. Local speech models will not be downloaded.'
|
||||
log.message(voiceStatus)
|
||||
|
||||
|
||||
@@ -46,14 +46,10 @@ try {
|
||||
dictation?: { enabled?: boolean }
|
||||
voiceMode?: { enabled?: boolean }
|
||||
}
|
||||
providers?: {
|
||||
local?: { autoDownload?: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
assert.strictEqual(config.features?.dictation?.enabled, false, 'dictation.enabled should be false')
|
||||
assert.strictEqual(config.features?.voiceMode?.enabled, false, 'voiceMode.enabled should be false')
|
||||
assert.strictEqual(config.providers?.local?.autoDownload, false, 'local.autoDownload should be false')
|
||||
const daemonLog = await readFile(join(paseoHome, 'daemon.log'), 'utf-8')
|
||||
assert(
|
||||
!daemonLog.includes('Ensuring local speech models'),
|
||||
|
||||
97
packages/cli/tests/21-run-output-schema-helper.test.ts
Normal file
97
packages/cli/tests/21-run-output-schema-helper.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from 'node:assert'
|
||||
import {
|
||||
resolveStructuredResponseMessage,
|
||||
type StructuredResponseTimelineClient,
|
||||
} from '../src/commands/agent/run.ts'
|
||||
|
||||
type TimelineEntry = {
|
||||
item: {
|
||||
type: string
|
||||
text?: string
|
||||
}
|
||||
}
|
||||
|
||||
function createClient(options: {
|
||||
entries?: TimelineEntry[]
|
||||
throwOnFetch?: boolean
|
||||
onFetch?: () => void
|
||||
}): StructuredResponseTimelineClient {
|
||||
return {
|
||||
fetchAgentTimeline: async () => {
|
||||
options.onFetch?.()
|
||||
if (options.throwOnFetch) {
|
||||
throw new Error('timeline unavailable')
|
||||
}
|
||||
return {
|
||||
entries: options.entries ?? [],
|
||||
} as Awaited<ReturnType<StructuredResponseTimelineClient['fetchAgentTimeline']>>
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
console.log('=== Run Output Schema Helper Tests ===\n')
|
||||
|
||||
// Test 1: Direct lastMessage is returned without timeline fetch.
|
||||
{
|
||||
let fetchCount = 0
|
||||
const client = createClient({ onFetch: () => fetchCount += 1 })
|
||||
const result = await resolveStructuredResponseMessage({
|
||||
client,
|
||||
agentId: 'agent-1',
|
||||
lastMessage: ' {"summary":"ok"} ',
|
||||
})
|
||||
assert.strictEqual(result, '{"summary":"ok"}')
|
||||
assert.strictEqual(fetchCount, 0, 'should not fetch timeline when lastMessage exists')
|
||||
console.log('✓ returns direct lastMessage when present')
|
||||
}
|
||||
|
||||
// Test 2: Falls back to latest assistant message from timeline.
|
||||
{
|
||||
const client = createClient({
|
||||
entries: [
|
||||
{ item: { type: 'user_message', text: 'prompt' } },
|
||||
{ item: { type: 'assistant_message', text: '' } },
|
||||
{ item: { type: 'assistant_message', text: ' {"summary":"from timeline"} ' } },
|
||||
],
|
||||
})
|
||||
const result = await resolveStructuredResponseMessage({
|
||||
client,
|
||||
agentId: 'agent-2',
|
||||
lastMessage: null,
|
||||
})
|
||||
assert.strictEqual(result, '{"summary":"from timeline"}')
|
||||
console.log('✓ falls back to latest assistant timeline entry')
|
||||
}
|
||||
|
||||
// Test 3: Returns null when timeline has no assistant messages.
|
||||
{
|
||||
const client = createClient({
|
||||
entries: [
|
||||
{ item: { type: 'user_message', text: 'prompt' } },
|
||||
{ item: { type: 'reasoning', text: 'thinking' } },
|
||||
],
|
||||
})
|
||||
const result = await resolveStructuredResponseMessage({
|
||||
client,
|
||||
agentId: 'agent-3',
|
||||
lastMessage: null,
|
||||
})
|
||||
assert.strictEqual(result, null)
|
||||
console.log('✓ returns null when no assistant messages exist')
|
||||
}
|
||||
|
||||
// Test 4: Returns null if timeline fetch fails.
|
||||
{
|
||||
const client = createClient({ throwOnFetch: true })
|
||||
const result = await resolveStructuredResponseMessage({
|
||||
client,
|
||||
agentId: 'agent-4',
|
||||
lastMessage: null,
|
||||
})
|
||||
assert.strictEqual(result, null)
|
||||
console.log('✓ returns null when timeline fetch throws')
|
||||
}
|
||||
|
||||
console.log('\n=== All helper tests passed ===')
|
||||
139
packages/cli/tests/e2e/run-output-schema.test.ts
Normal file
139
packages/cli/tests/e2e/run-output-schema.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { createE2ETestContext, type TestDaemonContext } from '../helpers/test-daemon.ts'
|
||||
|
||||
interface E2EContext extends TestDaemonContext {
|
||||
paseo: (args: string[], opts?: { timeout?: number; cwd?: string }) => Promise<{
|
||||
exitCode: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
}>
|
||||
}
|
||||
|
||||
const schema = JSON.stringify({
|
||||
type: 'object',
|
||||
properties: {
|
||||
summary: { type: 'string' },
|
||||
},
|
||||
required: ['summary'],
|
||||
additionalProperties: false,
|
||||
})
|
||||
|
||||
const impossibleSchema = JSON.stringify({
|
||||
type: 'object',
|
||||
properties: {
|
||||
summary: { type: 'string' },
|
||||
},
|
||||
required: ['summary'],
|
||||
allOf: [
|
||||
{ properties: { summary: { type: 'string' } } },
|
||||
{ properties: { summary: { type: 'number' } } },
|
||||
],
|
||||
})
|
||||
|
||||
let ctx: E2EContext
|
||||
|
||||
async function setup(): Promise<void> {
|
||||
ctx = await createE2ETestContext({ timeout: 180000 })
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
if (ctx) {
|
||||
await ctx.stop()
|
||||
}
|
||||
}
|
||||
|
||||
async function runProviderCase(input: {
|
||||
provider: 'claude' | 'codex' | 'opencode'
|
||||
mode: string
|
||||
model: string
|
||||
}): Promise<void> {
|
||||
const result = await ctx.paseo(
|
||||
[
|
||||
'run',
|
||||
'--provider',
|
||||
input.provider,
|
||||
'--mode',
|
||||
input.mode,
|
||||
'--model',
|
||||
input.model,
|
||||
'--output-schema',
|
||||
schema,
|
||||
`Return valid JSON with a short summary for provider ${input.provider}.`,
|
||||
],
|
||||
{ timeout: 180000 }
|
||||
)
|
||||
|
||||
assert.strictEqual(
|
||||
result.exitCode,
|
||||
0,
|
||||
`expected ${input.provider} run to succeed, got ${result.exitCode}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`
|
||||
)
|
||||
|
||||
const parsed = JSON.parse(result.stdout.trim()) as { summary?: unknown }
|
||||
assert.strictEqual(
|
||||
typeof parsed.summary,
|
||||
'string',
|
||||
`expected ${input.provider} output to contain string summary, got: ${result.stdout}`
|
||||
)
|
||||
assert(parsed.summary && parsed.summary.length > 0, 'summary must not be empty')
|
||||
}
|
||||
|
||||
async function test_all_providers_return_structured_output(): Promise<void> {
|
||||
await runProviderCase({
|
||||
provider: 'claude',
|
||||
mode: 'bypassPermissions',
|
||||
model: 'haiku',
|
||||
})
|
||||
await runProviderCase({
|
||||
provider: 'codex',
|
||||
mode: 'full-access',
|
||||
model: 'gpt-5.3-codex',
|
||||
})
|
||||
await runProviderCase({
|
||||
provider: 'opencode',
|
||||
mode: 'default',
|
||||
model: 'opencode/kimi-k2.5-free',
|
||||
})
|
||||
}
|
||||
|
||||
async function test_schema_validation_is_enforced(): Promise<void> {
|
||||
const result = await ctx.paseo(
|
||||
[
|
||||
'run',
|
||||
'--provider',
|
||||
'claude',
|
||||
'--mode',
|
||||
'bypassPermissions',
|
||||
'--model',
|
||||
'haiku',
|
||||
'--output-schema',
|
||||
impossibleSchema,
|
||||
'Return exactly {"summary":"ok"} and nothing else.',
|
||||
],
|
||||
{ timeout: 180000 }
|
||||
)
|
||||
|
||||
assert.notStrictEqual(result.exitCode, 0, 'expected impossible schema to fail')
|
||||
const output = `${result.stdout}\n${result.stderr}`
|
||||
assert(
|
||||
output.includes('OUTPUT_SCHEMA_FAILED'),
|
||||
`expected OUTPUT_SCHEMA_FAILED for impossible schema\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`
|
||||
)
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
await setup()
|
||||
await test_all_providers_return_structured_output()
|
||||
await test_schema_validation_is_enforced()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
await cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.9",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Tauri wrapper)",
|
||||
"scripts": {
|
||||
|
||||
570
packages/desktop/src-tauri/Cargo.lock
generated
570
packages/desktop/src-tauri/Cargo.lock
generated
@@ -81,6 +81,137 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
|
||||
dependencies = [
|
||||
"async-task",
|
||||
"concurrent-queue",
|
||||
"fastrand",
|
||||
"futures-lite",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-io"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"parking",
|
||||
"polling",
|
||||
"rustix",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-lock"
|
||||
version = "3.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-process"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-signal",
|
||||
"async-task",
|
||||
"blocking",
|
||||
"cfg-if",
|
||||
"event-listener",
|
||||
"futures-lite",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-recursion"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-signal"
|
||||
version = "0.2.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"atomic-waker",
|
||||
"cfg-if",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"rustix",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-task"
|
||||
version = "4.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atk"
|
||||
version = "0.18.2"
|
||||
@@ -173,6 +304,19 @@ dependencies = [
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blocking"
|
||||
version = "1.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-task",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.6.0"
|
||||
@@ -416,6 +560,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
@@ -660,6 +813,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
@@ -753,6 +908,33 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
||||
dependencies = [
|
||||
"enumflags2_derive",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2_derive"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "0.1.4"
|
||||
@@ -780,6 +962,43 @@ dependencies = [
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener-strategy"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
@@ -914,6 +1133,19 @@ version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
||||
|
||||
[[package]]
|
||||
name = "futures-lite"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.31"
|
||||
@@ -1281,6 +1513,12 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
@@ -1573,6 +1811,25 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-wsl"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||
dependencies = [
|
||||
"is-docker",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
@@ -1735,6 +1992,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.1"
|
||||
@@ -2165,12 +2428,34 @@ version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"is-wsl",
|
||||
"libc",
|
||||
"pathdiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -2196,6 +2481,12 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -2228,10 +2519,18 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-websocket",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -2384,6 +2683,17 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "piper"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"fastrand",
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.32"
|
||||
@@ -2416,6 +2726,20 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"hermit-abi",
|
||||
"pin-project-lite",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.4"
|
||||
@@ -2793,6 +3117,30 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -2861,6 +3209,19 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.36"
|
||||
@@ -3205,6 +3566,16 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.8"
|
||||
@@ -3602,6 +3973,46 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"toml 0.9.11+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-log"
|
||||
version = "2.8.0"
|
||||
@@ -3624,6 +4035,28 @@ dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"glob",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"open",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
"windows",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-websocket"
|
||||
version = "2.4.2"
|
||||
@@ -3745,6 +4178,19 @@ dependencies = [
|
||||
"toml 0.9.11+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tendril"
|
||||
version = "0.4.3"
|
||||
@@ -4055,9 +4501,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
@@ -4126,6 +4584,17 @@ version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unic-char-property"
|
||||
version = "0.9.0"
|
||||
@@ -5070,6 +5539,67 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-executor",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-process",
|
||||
"async-recursion",
|
||||
"async-task",
|
||||
"async-trait",
|
||||
"blocking",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
"libc",
|
||||
"ordered-stream",
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 0.7.14",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.4.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_names"
|
||||
version = "4.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow 0.7.14",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.33"
|
||||
@@ -5155,3 +5685,43 @@ name = "zmij"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94f63c051f4fe3c1509da62131a678643c5b6fbdc9273b2b79d4378ebda003d2"
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"winnow 0.7.14",
|
||||
"zvariant_derive",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "5.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.4.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 2.0.114",
|
||||
"winnow 0.7.14",
|
||||
]
|
||||
|
||||
@@ -26,5 +26,7 @@ serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
tauri = { version = "2.9.5", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-websocket = "2"
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-set-badge-count",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"dialog:default",
|
||||
"opener:default",
|
||||
"websocket:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ fn set_zoom_factor(webview: &WebviewWindow, factor: f64) {
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_websocket::init())
|
||||
.setup(|app| {
|
||||
if cfg!(debug_assertions) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.9",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.9",
|
||||
"description": "Paseo backend server",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -53,7 +53,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@getpaseo/relay": "0.1.6",
|
||||
"@getpaseo/relay": "0.1.9",
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
@@ -73,6 +73,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",
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
import { fileURLToPath } from "url";
|
||||
import { existsSync } from "node:fs";
|
||||
import { runSupervisor } from "./supervisor.js";
|
||||
import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js";
|
||||
|
||||
type DaemonRunnerConfig = {
|
||||
devMode: boolean;
|
||||
workerArgs: string[];
|
||||
};
|
||||
|
||||
function parseConfig(argv: string[]): DaemonRunnerConfig {
|
||||
let devMode = false;
|
||||
const workerArgs: string[] = [];
|
||||
|
||||
for (const arg of argv) {
|
||||
if (arg === "--dev") {
|
||||
devMode = true;
|
||||
continue;
|
||||
}
|
||||
workerArgs.push(arg);
|
||||
}
|
||||
|
||||
return { devMode, workerArgs };
|
||||
}
|
||||
|
||||
function resolveWorkerEntry(): string {
|
||||
const candidates = [
|
||||
@@ -19,18 +40,32 @@ function resolveWorkerEntry(): string {
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function resolveWorkerExecArgv(): string[] {
|
||||
const workerEntry = resolveWorkerEntry();
|
||||
function resolveDevWorkerEntry(): string {
|
||||
const candidate = fileURLToPath(new URL("../src/server/index.ts", import.meta.url));
|
||||
if (!existsSync(candidate)) {
|
||||
throw new Error(`Dev worker entry not found: ${candidate}`);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function resolveWorkerExecArgv(workerEntry: string): string[] {
|
||||
return workerEntry.endsWith(".ts") ? ["--import", "tsx"] : [];
|
||||
}
|
||||
|
||||
const config = parseConfig(process.argv.slice(2));
|
||||
const workerEntry = config.devMode ? resolveDevWorkerEntry() : resolveWorkerEntry();
|
||||
|
||||
applySherpaLoaderEnv(process.env);
|
||||
|
||||
runSupervisor({
|
||||
name: "DaemonRunner",
|
||||
startupMessage: "Starting daemon worker (IPC restart enabled)",
|
||||
resolveWorkerEntry,
|
||||
workerArgs: process.argv.slice(2),
|
||||
startupMessage: config.devMode
|
||||
? "Starting daemon worker (dev mode, crash restarts enabled)"
|
||||
: "Starting daemon worker (IPC restart enabled)",
|
||||
resolveWorkerEntry: () => workerEntry,
|
||||
workerArgs: config.workerArgs,
|
||||
workerEnv: process.env,
|
||||
workerExecArgv: resolveWorkerExecArgv(),
|
||||
restartOnCrash: false,
|
||||
workerExecArgv: resolveWorkerExecArgv(workerEntry),
|
||||
restartOnCrash: config.devMode,
|
||||
shutdownReasons: ["cli_shutdown"],
|
||||
});
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { fileURLToPath } from "url";
|
||||
import { existsSync } from "node:fs";
|
||||
import dotenv from "dotenv";
|
||||
import { runSupervisor } from "./supervisor.js";
|
||||
|
||||
dotenv.config({
|
||||
path: fileURLToPath(new URL("../.env", import.meta.url)),
|
||||
quiet: true,
|
||||
});
|
||||
|
||||
const WORKER_ENTRY = fileURLToPath(new URL("../src/server/index.ts", import.meta.url));
|
||||
if (!existsSync(WORKER_ENTRY)) {
|
||||
throw new Error(`Dev worker entry not found: ${WORKER_ENTRY}`);
|
||||
|
||||
@@ -42,5 +42,5 @@ function parseArgs(argv: string[]): { modelsDir: string; modelIds: LocalSpeechMo
|
||||
const logger = createRootLogger({ level: "info", format: "pretty" });
|
||||
|
||||
const { modelsDir, modelIds } = parseArgs(process.argv.slice(2));
|
||||
await ensureLocalSpeechModels({ modelsDir, modelIds, autoDownload: true, logger });
|
||||
await ensureLocalSpeechModels({ modelsDir, modelIds, logger });
|
||||
logger.info({ modelsDir, modelIds }, "Done downloading speech models");
|
||||
|
||||
@@ -284,7 +284,6 @@ logger.info(
|
||||
await ensureLocalSpeechModels({
|
||||
modelsDir: options.modelsDir,
|
||||
modelIds: options.modelIds,
|
||||
autoDownload: true,
|
||||
logger,
|
||||
});
|
||||
|
||||
|
||||
@@ -18,12 +18,11 @@ type CliOptions = {
|
||||
outPath?: string;
|
||||
model: LocalSttModelId;
|
||||
modelsDir: string;
|
||||
autoDownload: boolean;
|
||||
};
|
||||
|
||||
function usage(): string {
|
||||
return [
|
||||
"Usage: npm run speech:transcribe:local -- <wavPath> [--out <outPath>] [--model <modelId>] [--models-dir <dir>] [--no-auto-download]",
|
||||
"Usage: npm run speech:transcribe:local -- <wavPath> [--out <outPath>] [--model <modelId>] [--models-dir <dir>]",
|
||||
"",
|
||||
"Examples:",
|
||||
" npm run speech:transcribe:local -- ./sample.wav",
|
||||
@@ -52,7 +51,6 @@ function parseArgs(argv: string[]): CliOptions {
|
||||
let outPath: string | undefined;
|
||||
let model = LocalSttModelIdSchema.parse(process.env.PASEO_LOCAL_STT_MODEL ?? DEFAULT_LOCAL_STT_MODEL);
|
||||
let modelsDir = defaultModelsDir;
|
||||
let autoDownload = true;
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
@@ -87,11 +85,6 @@ function parseArgs(argv: string[]): CliOptions {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--no-auto-download") {
|
||||
autoDownload = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith("-")) {
|
||||
throw new Error(`Unknown option: ${arg}`);
|
||||
}
|
||||
@@ -108,7 +101,6 @@ function parseArgs(argv: string[]): CliOptions {
|
||||
...(outPath ? { outPath } : {}),
|
||||
model,
|
||||
modelsDir,
|
||||
autoDownload,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,7 +131,6 @@ async function main(): Promise<void> {
|
||||
providers,
|
||||
local: {
|
||||
modelsDir: options.modelsDir,
|
||||
autoDownload: options.autoDownload,
|
||||
models: {
|
||||
dictationStt: options.model,
|
||||
voiceStt: options.model,
|
||||
|
||||
@@ -347,6 +347,63 @@ describe("DaemonClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("requests directory suggestions via RPC", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const promise = client.getDirectorySuggestions(
|
||||
{ query: "proj", limit: 10 },
|
||||
"req-directories"
|
||||
);
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "directory_suggestions_request";
|
||||
query: string;
|
||||
limit?: number;
|
||||
requestId: string;
|
||||
};
|
||||
};
|
||||
expect(request.message.type).toBe("directory_suggestions_request");
|
||||
expect(request.message.query).toBe("proj");
|
||||
expect(request.message.limit).toBe(10);
|
||||
expect(request.message.requestId).toBe("req-directories");
|
||||
|
||||
mock.triggerMessage(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "directory_suggestions_response",
|
||||
payload: {
|
||||
directories: ["/Users/test/projects/paseo"],
|
||||
error: null,
|
||||
requestId: "req-directories",
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await expect(promise).resolves.toEqual({
|
||||
directories: ["/Users/test/projects/paseo"],
|
||||
error: null,
|
||||
requestId: "req-directories",
|
||||
});
|
||||
});
|
||||
|
||||
test("requests checkout merge from base via RPC", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
CheckoutPrStatusResponse,
|
||||
ValidateBranchResponse,
|
||||
BranchSuggestionsResponse,
|
||||
DirectorySuggestionsResponse,
|
||||
PaseoWorktreeListResponse,
|
||||
PaseoWorktreeArchiveResponse,
|
||||
ProjectIconResponse,
|
||||
@@ -197,6 +198,7 @@ type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
|
||||
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
||||
type ValidateBranchPayload = ValidateBranchResponse["payload"];
|
||||
type BranchSuggestionsPayload = BranchSuggestionsResponse["payload"];
|
||||
type DirectorySuggestionsPayload = DirectorySuggestionsResponse["payload"];
|
||||
type PaseoWorktreeListPayload = PaseoWorktreeListResponse["payload"];
|
||||
type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse["payload"];
|
||||
type FileExplorerPayload = FileExplorerResponse["payload"];
|
||||
@@ -2082,6 +2084,22 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getDirectorySuggestions(
|
||||
options: { query: string; limit?: number },
|
||||
requestId?: string
|
||||
): Promise<DirectorySuggestionsPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "directory_suggestions_request",
|
||||
query: options.query,
|
||||
limit: options.limit,
|
||||
},
|
||||
responseType: "directory_suggestions_response",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// File Explorer
|
||||
// ============================================================================
|
||||
|
||||
@@ -351,6 +351,150 @@ describe("AgentManager", () => {
|
||||
expect(afterHydrate).toEqual(beforeReload);
|
||||
});
|
||||
|
||||
test("reloadAgentSession cancels active run and resumes existing session once thread_started is observed", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-active-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class DelayedPersistenceSession extends TestAgentSession {
|
||||
private persistenceReady = false;
|
||||
private interrupted = false;
|
||||
private releaseGate: (() => void) | null = null;
|
||||
private readonly gate = new Promise<void>((resolve) => {
|
||||
this.releaseGate = resolve;
|
||||
});
|
||||
|
||||
constructor(
|
||||
config: AgentSessionConfig,
|
||||
private readonly stableSessionId: string,
|
||||
initiallyReady = false
|
||||
) {
|
||||
super(config);
|
||||
this.persistenceReady = initiallyReady;
|
||||
}
|
||||
|
||||
async *stream(): AsyncGenerator<AgentStreamEvent> {
|
||||
yield { type: "turn_started", provider: this.provider };
|
||||
this.persistenceReady = true;
|
||||
yield {
|
||||
type: "thread_started",
|
||||
provider: this.provider,
|
||||
sessionId: this.stableSessionId,
|
||||
};
|
||||
await this.gate;
|
||||
if (this.interrupted) {
|
||||
yield { type: "turn_canceled", provider: this.provider, reason: "Interrupted" };
|
||||
return;
|
||||
}
|
||||
yield { type: "turn_completed", provider: this.provider };
|
||||
}
|
||||
|
||||
async getRuntimeInfo() {
|
||||
return {
|
||||
provider: this.provider,
|
||||
sessionId: this.persistenceReady ? this.stableSessionId : null,
|
||||
model: null,
|
||||
modeId: null,
|
||||
};
|
||||
}
|
||||
|
||||
describePersistence() {
|
||||
if (!this.persistenceReady) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: this.provider,
|
||||
sessionId: this.stableSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
async interrupt(): Promise<void> {
|
||||
this.interrupted = true;
|
||||
this.releaseGate?.();
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.interrupted = true;
|
||||
this.releaseGate?.();
|
||||
}
|
||||
}
|
||||
|
||||
class DelayedPersistenceClient implements AgentClient {
|
||||
readonly provider = "codex" as const;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
createSessionCalls = 0;
|
||||
resumeSessionCalls = 0;
|
||||
private nextSessionNumber = 1;
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
const sessionId = `delayed-session-${this.nextSessionNumber++}`;
|
||||
this.createSessionCalls += 1;
|
||||
return new DelayedPersistenceSession(config, sessionId);
|
||||
}
|
||||
|
||||
async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>
|
||||
): Promise<AgentSession> {
|
||||
this.resumeSessionCalls += 1;
|
||||
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
||||
const merged: AgentSessionConfig = {
|
||||
...metadata,
|
||||
...overrides,
|
||||
provider: "codex",
|
||||
cwd: overrides?.cwd ?? metadata.cwd ?? process.cwd(),
|
||||
};
|
||||
return new DelayedPersistenceSession(merged, handle.sessionId, true);
|
||||
}
|
||||
}
|
||||
|
||||
const client = new DelayedPersistenceClient();
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000114",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
expect(snapshot.persistence).toBeNull();
|
||||
|
||||
const stream = manager.streamAgent(snapshot.id, "hello");
|
||||
const first = await stream.next();
|
||||
expect(first.done).toBe(false);
|
||||
expect(first.value?.type).toBe("turn_started");
|
||||
const second = await stream.next();
|
||||
expect(second.done).toBe(false);
|
||||
expect(second.value?.type).toBe("thread_started");
|
||||
|
||||
const active = manager.getAgent(snapshot.id);
|
||||
expect(active?.lifecycle).toBe("running");
|
||||
expect(active?.persistence?.sessionId).toBe("delayed-session-1");
|
||||
|
||||
const reloaded = await manager.reloadAgentSession(snapshot.id, {
|
||||
systemPrompt: "voice mode on",
|
||||
});
|
||||
|
||||
expect(client.createSessionCalls).toBe(1);
|
||||
expect(client.resumeSessionCalls).toBe(1);
|
||||
expect(reloaded.persistence?.sessionId).toBe("delayed-session-1");
|
||||
|
||||
// Drain stream after cancellation to ensure clean shutdown.
|
||||
while (true) {
|
||||
const next = await stream.next();
|
||||
if (next.done) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("fetchTimeline returns full timeline with reset when cursor epoch is stale", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-stale-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -666,7 +666,11 @@ export class AgentManager {
|
||||
agentId: string,
|
||||
overrides?: Partial<AgentSessionConfig>
|
||||
): Promise<ManagedAgent> {
|
||||
const existing = this.requireAgent(agentId);
|
||||
let existing = this.requireAgent(agentId);
|
||||
if (existing.lifecycle === "running" || existing.pendingRun) {
|
||||
await this.cancelAgentRun(agentId);
|
||||
existing = this.requireAgent(agentId);
|
||||
}
|
||||
const timelineState = this.ensureTimelineState(existing);
|
||||
const preservedTimeline = [...existing.timeline];
|
||||
const preservedTimelineRows = timelineState.rows.map((row) => ({ ...row }));
|
||||
@@ -1561,9 +1565,13 @@ export class AgentManager {
|
||||
// Update persistence with the new session ID from the provider.
|
||||
// persistence.sessionId is the single source of truth for session identity.
|
||||
{
|
||||
const previousSessionId = agent.persistence?.sessionId ?? null;
|
||||
const handle = agent.session.describePersistence();
|
||||
if (handle) {
|
||||
agent.persistence = attachPersistenceCwd(handle, agent.cwd);
|
||||
if (agent.persistence?.sessionId !== previousSessionId) {
|
||||
this.emitState(agent);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -4,8 +4,10 @@ import type { Logger } from "pino";
|
||||
|
||||
import type { AgentManager } from "./agent-manager.js";
|
||||
import {
|
||||
DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
StructuredAgentFallbackError,
|
||||
StructuredAgentResponseError,
|
||||
generateStructuredAgentResponse,
|
||||
generateStructuredAgentResponseWithFallback,
|
||||
} from "./agent-response-loop.js";
|
||||
import { validateBranchSlug } from "../../utils/worktree.js";
|
||||
import {
|
||||
@@ -14,12 +16,8 @@ import {
|
||||
type CheckoutStatusResult,
|
||||
} from "../../utils/checkout-git.js";
|
||||
|
||||
const AUTO_GEN_PROVIDER = "codex" as const;
|
||||
const AUTO_GEN_MODEL = "gpt-5.1-codex-mini";
|
||||
const AUTO_GEN_REASONING_EFFORT = "low";
|
||||
|
||||
export type AgentMetadataGeneratorDeps = {
|
||||
generateStructuredAgentResponse?: typeof generateStructuredAgentResponse;
|
||||
generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback;
|
||||
getCheckoutStatus?: typeof getCheckoutStatus;
|
||||
renameCurrentBranch?: typeof renameCurrentBranch;
|
||||
};
|
||||
@@ -148,7 +146,9 @@ export async function generateAndApplyAgentMetadata(
|
||||
return;
|
||||
}
|
||||
|
||||
const generator = options.deps?.generateStructuredAgentResponse ?? generateStructuredAgentResponse;
|
||||
const generator =
|
||||
options.deps?.generateStructuredAgentResponseWithFallback ??
|
||||
generateStructuredAgentResponseWithFallback;
|
||||
const getCheckoutStatusImpl = options.deps?.getCheckoutStatus ?? getCheckoutStatus;
|
||||
const renameCurrentBranchImpl = options.deps?.renameCurrentBranch ?? renameCurrentBranch;
|
||||
|
||||
@@ -157,21 +157,22 @@ export async function generateAndApplyAgentMetadata(
|
||||
try {
|
||||
result = await generator({
|
||||
manager: options.agentManager,
|
||||
agentConfig: {
|
||||
provider: AUTO_GEN_PROVIDER,
|
||||
model: AUTO_GEN_MODEL,
|
||||
thinkingOptionId: AUTO_GEN_REASONING_EFFORT,
|
||||
cwd: options.cwd,
|
||||
title: "Agent metadata generator",
|
||||
internal: true,
|
||||
},
|
||||
cwd: options.cwd,
|
||||
prompt: buildPrompt(needs),
|
||||
schema,
|
||||
schemaName: "AgentMetadata",
|
||||
maxRetries: 2,
|
||||
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
agentConfigOverrides: {
|
||||
title: "Agent metadata generator",
|
||||
internal: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof StructuredAgentResponseError) {
|
||||
if (
|
||||
error instanceof StructuredAgentResponseError ||
|
||||
error instanceof StructuredAgentFallbackError
|
||||
) {
|
||||
options.logger.warn(
|
||||
{ err: error, agentId: options.agentId },
|
||||
"Structured metadata generation failed"
|
||||
|
||||
@@ -2,9 +2,12 @@ import { describe, it, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
getStructuredAgentResponse,
|
||||
generateStructuredAgentResponseWithFallback,
|
||||
StructuredAgentFallbackError,
|
||||
StructuredAgentResponseError,
|
||||
type AgentCaller,
|
||||
} from "./agent-response-loop.js";
|
||||
import type { AgentManager } from "./agent-manager.js";
|
||||
|
||||
function createScriptedCaller(responses: string[]) {
|
||||
const prompts: string[] = [];
|
||||
@@ -142,3 +145,134 @@ describe("getStructuredAgentResponse", () => {
|
||||
expect(result).toEqual({ value: 42 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateStructuredAgentResponseWithFallback", () => {
|
||||
const schema = z.object({ summary: z.string() });
|
||||
|
||||
function createManager(availability: Array<{ provider: string; available: boolean; error: string | null }>) {
|
||||
return {
|
||||
listProviderAvailability: async () => availability,
|
||||
} as unknown as AgentManager;
|
||||
}
|
||||
|
||||
it("uses the first available provider in the waterfall", async () => {
|
||||
const calls: Array<{ provider: string; model?: string }> = [];
|
||||
const manager = createManager([
|
||||
{ provider: "claude", available: true, error: null },
|
||||
{ provider: "codex", available: true, error: null },
|
||||
{ provider: "opencode", available: true, error: null },
|
||||
]);
|
||||
|
||||
const result = await generateStructuredAgentResponseWithFallback({
|
||||
manager,
|
||||
cwd: "/tmp/project",
|
||||
prompt: "Return JSON",
|
||||
schema,
|
||||
providers: [
|
||||
{ provider: "claude", model: "haiku" },
|
||||
{ provider: "codex", model: "gpt-5.1-codex-mini" },
|
||||
],
|
||||
runner: async (options) => {
|
||||
calls.push({
|
||||
provider: options.agentConfig.provider,
|
||||
model: options.agentConfig.model ?? undefined,
|
||||
});
|
||||
return { summary: "ok" };
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ summary: "ok" });
|
||||
expect(calls).toEqual([{ provider: "claude", model: "haiku" }]);
|
||||
});
|
||||
|
||||
it("skips unavailable providers and uses the next available one", async () => {
|
||||
const calls: Array<{ provider: string; model?: string }> = [];
|
||||
const manager = createManager([
|
||||
{ provider: "claude", available: false, error: "missing auth" },
|
||||
{ provider: "codex", available: true, error: null },
|
||||
{ provider: "opencode", available: true, error: null },
|
||||
]);
|
||||
|
||||
const result = await generateStructuredAgentResponseWithFallback({
|
||||
manager,
|
||||
cwd: "/tmp/project",
|
||||
prompt: "Return JSON",
|
||||
schema,
|
||||
providers: [
|
||||
{ provider: "claude", model: "haiku" },
|
||||
{ provider: "codex", model: "gpt-5.1-codex-mini" },
|
||||
],
|
||||
runner: async (options) => {
|
||||
calls.push({
|
||||
provider: options.agentConfig.provider,
|
||||
model: options.agentConfig.model ?? undefined,
|
||||
});
|
||||
return { summary: "ok" };
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ summary: "ok" });
|
||||
expect(calls).toEqual([{ provider: "codex", model: "gpt-5.1-codex-mini" }]);
|
||||
});
|
||||
|
||||
it("falls back when an available provider fails", async () => {
|
||||
const calls: Array<{ provider: string; model?: string }> = [];
|
||||
const manager = createManager([
|
||||
{ provider: "claude", available: true, error: null },
|
||||
{ provider: "codex", available: true, error: null },
|
||||
{ provider: "opencode", available: true, error: null },
|
||||
]);
|
||||
|
||||
const result = await generateStructuredAgentResponseWithFallback({
|
||||
manager,
|
||||
cwd: "/tmp/project",
|
||||
prompt: "Return JSON",
|
||||
schema,
|
||||
providers: [
|
||||
{ provider: "claude", model: "haiku" },
|
||||
{ provider: "codex", model: "gpt-5.1-codex-mini" },
|
||||
],
|
||||
runner: async (options) => {
|
||||
calls.push({
|
||||
provider: options.agentConfig.provider,
|
||||
model: options.agentConfig.model ?? undefined,
|
||||
});
|
||||
if (options.agentConfig.provider === "claude") {
|
||||
throw new Error("claude failed");
|
||||
}
|
||||
return { summary: "ok" };
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ summary: "ok" });
|
||||
expect(calls).toEqual([
|
||||
{ provider: "claude", model: "haiku" },
|
||||
{ provider: "codex", model: "gpt-5.1-codex-mini" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws a fallback error when all providers are unavailable or fail", async () => {
|
||||
const manager = createManager([
|
||||
{ provider: "claude", available: false, error: "missing auth" },
|
||||
{ provider: "codex", available: true, error: null },
|
||||
{ provider: "opencode", available: false, error: "not installed" },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
generateStructuredAgentResponseWithFallback({
|
||||
manager,
|
||||
cwd: "/tmp/project",
|
||||
prompt: "Return JSON",
|
||||
schema,
|
||||
providers: [
|
||||
{ provider: "claude", model: "haiku" },
|
||||
{ provider: "codex", model: "gpt-5.1-codex-mini" },
|
||||
{ provider: "opencode", model: "opencode/kimi-k2.5-free" },
|
||||
],
|
||||
runner: async () => {
|
||||
throw new Error("failed");
|
||||
},
|
||||
})
|
||||
).rejects.toBeInstanceOf(StructuredAgentFallbackError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||
import Ajv, { type ErrorObject, type Options as AjvOptions } from "ajv";
|
||||
import type { AgentSessionConfig } from "./agent-sdk-types.js";
|
||||
import type { AgentProvider, AgentSessionConfig } from "./agent-sdk-types.js";
|
||||
import type { AgentManager } from "./agent-manager.js";
|
||||
import { getAgentProviderDefinition } from "./provider-manifest.js";
|
||||
|
||||
@@ -21,6 +21,43 @@ export class StructuredAgentResponseError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export type StructuredGenerationProvider = {
|
||||
provider: AgentProvider;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
};
|
||||
|
||||
export type StructuredGenerationAttempt = {
|
||||
provider: AgentProvider;
|
||||
model: string | null;
|
||||
available: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export class StructuredAgentFallbackError extends Error {
|
||||
readonly attempts: StructuredGenerationAttempt[];
|
||||
|
||||
constructor(attempts: StructuredGenerationAttempt[]) {
|
||||
const summary = attempts
|
||||
.map((attempt) => {
|
||||
const modelSuffix = attempt.model ? ` (${attempt.model})` : "";
|
||||
if (!attempt.available) {
|
||||
return `${attempt.provider}${modelSuffix}: unavailable${attempt.error ? ` (${attempt.error})` : ""}`;
|
||||
}
|
||||
return `${attempt.provider}${modelSuffix}: failed${attempt.error ? ` (${attempt.error})` : ""}`;
|
||||
})
|
||||
.join("; ");
|
||||
|
||||
super(
|
||||
summary.length > 0
|
||||
? `Structured generation failed for all providers: ${summary}`
|
||||
: "Structured generation failed for all providers"
|
||||
);
|
||||
this.name = "StructuredAgentFallbackError";
|
||||
this.attempts = attempts;
|
||||
}
|
||||
}
|
||||
|
||||
export interface StructuredAgentResponseOptions<T> {
|
||||
caller: AgentCaller;
|
||||
prompt: string;
|
||||
@@ -39,6 +76,27 @@ export interface StructuredAgentGenerationOptions<T> {
|
||||
schemaName?: string;
|
||||
}
|
||||
|
||||
export interface StructuredAgentGenerationWithFallbackOptions<T> {
|
||||
manager: AgentManager;
|
||||
cwd: string;
|
||||
prompt: string;
|
||||
schema: z.ZodType<T> | JsonSchema;
|
||||
providers: readonly StructuredGenerationProvider[];
|
||||
agentConfigOverrides?: Omit<
|
||||
AgentSessionConfig,
|
||||
"provider" | "cwd" | "model" | "thinkingOptionId"
|
||||
>;
|
||||
maxRetries?: number;
|
||||
schemaName?: string;
|
||||
runner?: <TResult>(options: StructuredAgentGenerationOptions<TResult>) => Promise<TResult>;
|
||||
}
|
||||
|
||||
export const DEFAULT_STRUCTURED_GENERATION_PROVIDERS: readonly StructuredGenerationProvider[] = [
|
||||
{ provider: "claude", model: "haiku" },
|
||||
{ provider: "codex", model: "gpt-5.1-codex-mini" },
|
||||
{ provider: "opencode", model: "opencode/kimi-k2.5-free" },
|
||||
] as const;
|
||||
|
||||
interface SchemaValidator<T> {
|
||||
jsonSchema: JsonSchema;
|
||||
validate: (value: unknown) => { ok: true; value: T } | { ok: false; errors: string[] };
|
||||
@@ -293,3 +351,82 @@ export async function generateStructuredAgentResponse<T>(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export async function generateStructuredAgentResponseWithFallback<T>(
|
||||
options: StructuredAgentGenerationWithFallbackOptions<T>
|
||||
): Promise<T> {
|
||||
const {
|
||||
manager,
|
||||
cwd,
|
||||
prompt,
|
||||
schema,
|
||||
providers,
|
||||
agentConfigOverrides,
|
||||
maxRetries,
|
||||
schemaName,
|
||||
runner,
|
||||
} = options;
|
||||
|
||||
if (providers.length === 0) {
|
||||
throw new StructuredAgentFallbackError([]);
|
||||
}
|
||||
|
||||
const runStructured =
|
||||
runner ??
|
||||
((input: StructuredAgentGenerationOptions<T>) =>
|
||||
generateStructuredAgentResponse<T>(input));
|
||||
const availability = await manager.listProviderAvailability();
|
||||
const availabilityByProvider = new Map(
|
||||
availability.map((entry) => [entry.provider, entry])
|
||||
);
|
||||
const attempts: StructuredGenerationAttempt[] = [];
|
||||
|
||||
for (const candidate of providers) {
|
||||
const availabilityEntry = availabilityByProvider.get(candidate.provider);
|
||||
if (availabilityEntry && !availabilityEntry.available) {
|
||||
attempts.push({
|
||||
provider: candidate.provider,
|
||||
model: candidate.model ?? null,
|
||||
available: false,
|
||||
error: availabilityEntry.error ?? null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runStructured({
|
||||
manager,
|
||||
prompt,
|
||||
schema,
|
||||
maxRetries,
|
||||
schemaName,
|
||||
agentConfig: {
|
||||
...agentConfigOverrides,
|
||||
provider: candidate.provider,
|
||||
cwd,
|
||||
...(candidate.model ? { model: candidate.model } : {}),
|
||||
...(candidate.thinkingOptionId
|
||||
? { thinkingOptionId: candidate.thinkingOptionId }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
attempts.push({
|
||||
provider: candidate.provider,
|
||||
model: candidate.model ?? null,
|
||||
available: true,
|
||||
error: errorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new StructuredAgentFallbackError(attempts);
|
||||
}
|
||||
|
||||
@@ -1428,7 +1428,14 @@ class ClaudeAgentSession implements AgentSession {
|
||||
switch (message.type) {
|
||||
case "system":
|
||||
if (message.subtype === "init") {
|
||||
this.handleSystemMessage(message);
|
||||
const threadSessionId = this.handleSystemMessage(message);
|
||||
if (threadSessionId) {
|
||||
events.push({
|
||||
type: "thread_started",
|
||||
provider: "claude",
|
||||
sessionId: threadSessionId,
|
||||
});
|
||||
}
|
||||
} else if (message.subtype === "status") {
|
||||
const status = (message as { status?: string }).status;
|
||||
if (status === "compacting") {
|
||||
@@ -1513,9 +1520,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return events;
|
||||
}
|
||||
|
||||
private handleSystemMessage(message: SDKSystemMessage): void {
|
||||
private handleSystemMessage(message: SDKSystemMessage): string | null {
|
||||
if (message.subtype !== "init") {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
const msg = message as unknown as {
|
||||
@@ -1533,13 +1540,15 @@ class ClaudeAgentSession implements AgentSession {
|
||||
: "";
|
||||
const newSessionId = newSessionIdRaw.trim();
|
||||
if (!newSessionId) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
const existingSessionId = this.claudeSessionId;
|
||||
let threadStartedSessionId: string | null = null;
|
||||
|
||||
if (existingSessionId === null) {
|
||||
// First time setting session ID (empty → filled) - this is expected
|
||||
this.claudeSessionId = newSessionId;
|
||||
threadStartedSessionId = newSessionId;
|
||||
this.logger.debug(
|
||||
{ sessionId: newSessionId },
|
||||
"Claude session ID set for the first time"
|
||||
@@ -1578,6 +1587,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
// Invalidate cached runtime info so it picks up the new model
|
||||
this.cachedRuntimeInfo = null;
|
||||
}
|
||||
return threadStartedSessionId;
|
||||
}
|
||||
|
||||
private convertUsage(message: SDKResultMessage): AgentUsage | undefined {
|
||||
|
||||
@@ -63,7 +63,7 @@ import type {
|
||||
AgentProvider,
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
|
||||
import { acquirePidLock, releasePidLock, isLocked, PidLockError } from "./pid-lock.js";
|
||||
import { acquirePidLock, releasePidLock } from "./pid-lock.js";
|
||||
import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
|
||||
import {
|
||||
createVoiceMcpSocketBridgeManager,
|
||||
@@ -137,16 +137,6 @@ export async function createPaseoDaemon(
|
||||
): Promise<PaseoDaemon> {
|
||||
const logger = rootLogger.child({ module: "bootstrap" });
|
||||
|
||||
// Fail fast before expensive bootstrap (speech/runtime/model checks) if another daemon is active.
|
||||
const lockState = await isLocked(config.paseoHome);
|
||||
if (lockState.locked) {
|
||||
const existingLock = lockState.info;
|
||||
throw new PidLockError(
|
||||
`Another Paseo daemon is already running (PID ${existingLock?.pid ?? "unknown"}, started ${existingLock?.startedAt ?? "unknown"})`,
|
||||
existingLock
|
||||
);
|
||||
}
|
||||
|
||||
// Acquire PID lock before expensive bootstrap work so duplicate starts fail immediately.
|
||||
await acquirePidLock(config.paseoHome, config.listen);
|
||||
|
||||
|
||||
@@ -168,6 +168,32 @@ describe("daemon client E2E", () => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 30000);
|
||||
|
||||
test("returns home-scoped directory suggestions", async () => {
|
||||
const insideHomeDir = mkdtempSync(path.join(homedir(), "paseo-dir-suggestion-"));
|
||||
const outsideHomeDir = mkdtempSync(path.join(tmpdir(), "paseo-dir-suggestion-outside-"));
|
||||
|
||||
try {
|
||||
const insideQuery = path.basename(insideHomeDir);
|
||||
const insideResult = await ctx.client.getDirectorySuggestions({
|
||||
query: insideQuery,
|
||||
limit: 25,
|
||||
});
|
||||
expect(insideResult.error).toBeNull();
|
||||
expect(insideResult.directories).toContain(insideHomeDir);
|
||||
|
||||
const outsideQuery = path.basename(outsideHomeDir);
|
||||
const outsideResult = await ctx.client.getDirectorySuggestions({
|
||||
query: outsideQuery,
|
||||
limit: 25,
|
||||
});
|
||||
expect(outsideResult.error).toBeNull();
|
||||
expect(outsideResult.directories).not.toContain(outsideHomeDir);
|
||||
} finally {
|
||||
rmSync(insideHomeDir, { recursive: true, force: true });
|
||||
rmSync(outsideHomeDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test("emits server_info on websocket connect", async () => {
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
|
||||
|
||||
@@ -186,7 +186,8 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
|
||||
PASEO_HOME: tempHome,
|
||||
PASEO_LISTEN: `0.0.0.0:${port}`,
|
||||
OPENAI_API_KEY: "",
|
||||
PASEO_LOCAL_AUTO_DOWNLOAD: "0",
|
||||
PASEO_DICTATION_ENABLED: "0",
|
||||
PASEO_VOICE_MODE_ENABLED: "0",
|
||||
PASEO_LOG_FORMAT: "json",
|
||||
};
|
||||
|
||||
|
||||
@@ -105,6 +105,43 @@ async function waitForPathExists(
|
||||
);
|
||||
}
|
||||
|
||||
async function withShell<T>(shell: string, run: () => Promise<T>): Promise<T> {
|
||||
const originalShell = process.env.SHELL;
|
||||
process.env.SHELL = shell;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
if (originalShell === undefined) {
|
||||
delete process.env.SHELL;
|
||||
} else {
|
||||
process.env.SHELL = originalShell;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface WorktreeTerminalBootstrapEntry {
|
||||
name: string | null;
|
||||
command: string;
|
||||
status: "started" | "failed";
|
||||
terminalId: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function getWorktreeTerminalBootstrapEntries(
|
||||
item: Extract<AgentTimelineItem, { type: "tool_call" }>
|
||||
): WorktreeTerminalBootstrapEntry[] | null {
|
||||
const detail = item.detail;
|
||||
if (!detail || detail.type !== "unknown" || !detail.output) {
|
||||
return null;
|
||||
}
|
||||
const output = detail.output as Record<string, unknown>;
|
||||
const terminals = output.terminals;
|
||||
if (!Array.isArray(terminals)) {
|
||||
return null;
|
||||
}
|
||||
return terminals as WorktreeTerminalBootstrapEntry[];
|
||||
}
|
||||
|
||||
// Use gpt-5.1-codex-mini with low thinking preset for faster test execution
|
||||
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||
const CODEX_TEST_THINKING_OPTION_ID = "low";
|
||||
@@ -413,100 +450,136 @@ describe("daemon E2E", () => {
|
||||
test(
|
||||
"bootstraps configured worktree terminals after setup succeeds",
|
||||
async () => {
|
||||
const repoRoot = tmpCwd();
|
||||
await withShell("/bin/sh", async () => {
|
||||
const repoRoot = tmpCwd();
|
||||
|
||||
const { execSync } = await import("child_process");
|
||||
execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git config user.email 'test@test.com'", {
|
||||
cwd: repoRoot,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" });
|
||||
|
||||
writeFileSync(path.join(repoRoot, "file.txt"), "hello\n");
|
||||
execSync("git add .", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", {
|
||||
cwd: repoRoot,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" });
|
||||
|
||||
const setupCommand =
|
||||
'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"';
|
||||
writeFileSync(
|
||||
path.join(repoRoot, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: [setupCommand],
|
||||
terminals: [
|
||||
{
|
||||
name: "Dev Server",
|
||||
command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null',
|
||||
},
|
||||
{
|
||||
command: 'echo "lint-watch" > lint-terminal.txt; tail -f /dev/null',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add setup and terminals'", {
|
||||
cwd: repoRoot,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const agent = await withTimeout({
|
||||
promise: ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
const { execSync } = await import("child_process");
|
||||
execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git config user.email 'test@test.com'", {
|
||||
cwd: repoRoot,
|
||||
title: "Async Worktree Setup + Terminals Test",
|
||||
git: {
|
||||
createWorktree: true,
|
||||
createNewBranch: true,
|
||||
baseBranch: "main",
|
||||
newBranchName: "async-setup-terminals-test",
|
||||
worktreeSlug: "async-setup-terminals-test",
|
||||
},
|
||||
}),
|
||||
timeoutMs: 2500,
|
||||
label: "createAgent should not block on setup",
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" });
|
||||
|
||||
writeFileSync(path.join(repoRoot, "file.txt"), "hello\n");
|
||||
execSync("git add .", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", {
|
||||
cwd: repoRoot,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" });
|
||||
|
||||
const setupCommand =
|
||||
'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"; echo "$PASEO_WORKTREE_PORT" > "$PASEO_WORKTREE_PATH/setup-port.txt"';
|
||||
writeFileSync(
|
||||
path.join(repoRoot, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: [setupCommand],
|
||||
terminals: [
|
||||
{
|
||||
name: "Dev Server",
|
||||
command: "tail -f /dev/null",
|
||||
},
|
||||
{
|
||||
command: "tail -f /dev/null",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add setup and terminals'", {
|
||||
cwd: repoRoot,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const agent = await withTimeout({
|
||||
promise: ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd: repoRoot,
|
||||
title: "Async Worktree Setup + Terminals Test",
|
||||
git: {
|
||||
createWorktree: true,
|
||||
createNewBranch: true,
|
||||
baseBranch: "main",
|
||||
newBranchName: "async-setup-terminals-test",
|
||||
worktreeSlug: "async-setup-terminals-test",
|
||||
},
|
||||
}),
|
||||
timeoutMs: 2500,
|
||||
label: "createAgent should not block on setup",
|
||||
});
|
||||
|
||||
expect(agent.cwd).toContain(path.join(".paseo", "worktrees"));
|
||||
expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(false);
|
||||
expect(existsSync(path.join(agent.cwd, "dev-terminal.txt"))).toBe(false);
|
||||
expect(existsSync(path.join(agent.cwd, "lint-terminal.txt"))).toBe(false);
|
||||
|
||||
writeFileSync(path.join(agent.cwd, "allow-setup"), "ok\n");
|
||||
|
||||
await waitForTimelineToolCall(
|
||||
collector.messages,
|
||||
agent.id,
|
||||
(item) => item.name === "paseo_worktree_setup" && item.status === "completed",
|
||||
20000
|
||||
);
|
||||
const terminalsBootstrapToolCall = await waitForTimelineToolCall(
|
||||
collector.messages,
|
||||
agent.id,
|
||||
(item) => item.name === "paseo_worktree_terminals" && item.status === "completed",
|
||||
30000
|
||||
);
|
||||
const bootstrappedTerminals = getWorktreeTerminalBootstrapEntries(
|
||||
terminalsBootstrapToolCall
|
||||
);
|
||||
expect(bootstrappedTerminals).toBeTruthy();
|
||||
expect(bootstrappedTerminals?.length ?? 0).toBeGreaterThanOrEqual(2);
|
||||
const failedBootstraps =
|
||||
bootstrappedTerminals?.filter((terminal) => terminal.status === "failed") ?? [];
|
||||
expect(failedBootstraps).toEqual([]);
|
||||
|
||||
const list = await ctx.client.listTerminals(agent.cwd);
|
||||
expect(list.error).toBeUndefined();
|
||||
expect(list.terminals.some((terminal) => terminal.name === "Dev Server")).toBe(true);
|
||||
expect(list.terminals.length).toBeGreaterThanOrEqual(2);
|
||||
await waitForPathExists({
|
||||
targetPath: path.join(agent.cwd, "setup-port.txt"),
|
||||
timeoutMs: 30000,
|
||||
label: "setup runtime port marker",
|
||||
});
|
||||
|
||||
const setupPort = readFileSync(path.join(agent.cwd, "setup-port.txt"), "utf8").trim();
|
||||
expect(setupPort.length).toBeGreaterThan(0);
|
||||
|
||||
const createdTerminal = await ctx.client.createTerminal(agent.cwd, "Manual Port Check");
|
||||
expect(createdTerminal.error).toBeNull();
|
||||
expect(createdTerminal.terminal).toBeTruthy();
|
||||
const manualTerminalId = createdTerminal.terminal?.id;
|
||||
expect(manualTerminalId).toBeTruthy();
|
||||
if (!manualTerminalId) {
|
||||
throw new Error("Expected manual terminal id");
|
||||
}
|
||||
ctx.client.sendTerminalInput(manualTerminalId, {
|
||||
type: "input",
|
||||
data: 'echo "$PASEO_WORKTREE_PORT" > "$PASEO_WORKTREE_PATH/manual-terminal-port.txt"\r',
|
||||
});
|
||||
await waitForPathExists({
|
||||
targetPath: path.join(agent.cwd, "manual-terminal-port.txt"),
|
||||
timeoutMs: 30000,
|
||||
label: "manual terminal runtime port marker",
|
||||
});
|
||||
const manualTerminalPort = readFileSync(
|
||||
path.join(agent.cwd, "manual-terminal-port.txt"),
|
||||
"utf8"
|
||||
).trim();
|
||||
expect(manualTerminalPort).toBe(setupPort);
|
||||
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
rmSync(repoRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
expect(agent.cwd).toContain(path.join(".paseo", "worktrees"));
|
||||
expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(false);
|
||||
expect(existsSync(path.join(agent.cwd, "dev-terminal.txt"))).toBe(false);
|
||||
expect(existsSync(path.join(agent.cwd, "lint-terminal.txt"))).toBe(false);
|
||||
|
||||
writeFileSync(path.join(agent.cwd, "allow-setup"), "ok\n");
|
||||
|
||||
await waitForTimelineToolCall(
|
||||
collector.messages,
|
||||
agent.id,
|
||||
(item) => item.name === "paseo_worktree_setup" && item.status === "completed",
|
||||
20000
|
||||
);
|
||||
|
||||
await waitForPathExists({
|
||||
targetPath: path.join(agent.cwd, "dev-terminal.txt"),
|
||||
timeoutMs: 15000,
|
||||
label: "dev terminal marker",
|
||||
});
|
||||
await waitForPathExists({
|
||||
targetPath: path.join(agent.cwd, "lint-terminal.txt"),
|
||||
timeoutMs: 15000,
|
||||
label: "lint terminal marker",
|
||||
});
|
||||
|
||||
const list = await ctx.client.listTerminals(agent.cwd);
|
||||
expect(list.error).toBeUndefined();
|
||||
expect(list.terminals.some((terminal) => terminal.name === "Dev Server")).toBe(true);
|
||||
expect(list.terminals.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await ctx.client.deleteAgent(agent.id);
|
||||
rmSync(repoRoot, { recursive: true, force: true });
|
||||
},
|
||||
60000
|
||||
);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { ClaudeAgentClient } from "../agent/providers/claude-agent.js";
|
||||
import { getFullAccessConfig } from "./agent-configs.js";
|
||||
import { applyAgentInputProcessingTransition } from "./send-while-running-stuck-test-utils.js";
|
||||
import { isCommandAvailable } from "../agent/provider-launch-config.js";
|
||||
|
||||
function tmpCwd(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "daemon-real-stuck-claude-"));
|
||||
}
|
||||
|
||||
describe("daemon E2E (real claude) - send while running recovery", () => {
|
||||
test.runIf(isCommandAvailable("claude"))(
|
||||
"clears input processing when the interrupt transition is missed",
|
||||
async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const cwd = tmpCwd();
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { claude: new ClaudeAgentClient({ logger }) },
|
||||
logger,
|
||||
});
|
||||
|
||||
const primary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
const secondary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
|
||||
try {
|
||||
await primary.connect();
|
||||
await secondary.connect();
|
||||
primary.subscribeAgentUpdates({ subscriptionId: "primary" });
|
||||
secondary.subscribeAgentUpdates({ subscriptionId: "secondary" });
|
||||
|
||||
const agent = await primary.createAgent({
|
||||
cwd,
|
||||
title: "stuck-repro-real-claude",
|
||||
...getFullAccessConfig("claude"),
|
||||
});
|
||||
|
||||
await primary.sendMessage(
|
||||
agent.id,
|
||||
"Run bash command sleep 30, wait for completion, then reply done."
|
||||
);
|
||||
await primary.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
60_000
|
||||
);
|
||||
|
||||
let isProcessing = true;
|
||||
let previousIsRunning = true;
|
||||
let latestUpdatedAt = Date.now();
|
||||
|
||||
await primary.close();
|
||||
|
||||
await secondary.sendMessage(agent.id, "Reply with exactly: state saved");
|
||||
await secondary.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
60_000
|
||||
);
|
||||
|
||||
const reconnected = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await reconnected.connect();
|
||||
reconnected.subscribeAgentUpdates({ subscriptionId: "reconnected" });
|
||||
|
||||
reconnected.on("agent_update", (message) => {
|
||||
if (message.type !== "agent_update" || message.payload.kind !== "upsert") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.agent.id !== agent.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = applyAgentInputProcessingTransition({
|
||||
snapshot: message.payload.agent,
|
||||
currentIsProcessing: isProcessing,
|
||||
previousIsRunning,
|
||||
latestUpdatedAt,
|
||||
});
|
||||
isProcessing = next.isProcessing;
|
||||
previousIsRunning = next.previousIsRunning;
|
||||
latestUpdatedAt = next.latestUpdatedAt;
|
||||
});
|
||||
|
||||
await secondary.waitForFinish(agent.id, 180_000);
|
||||
|
||||
// Sending while running should clear processing even if reconnect misses the
|
||||
// not-running -> running transition.
|
||||
expect(isProcessing).toBe(false);
|
||||
} finally {
|
||||
await reconnected.close();
|
||||
}
|
||||
} finally {
|
||||
await secondary.close();
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
300_000
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { AgentSnapshotPayload } from "../messages.js";
|
||||
|
||||
export function applyAgentInputProcessingTransition(input: {
|
||||
snapshot: AgentSnapshotPayload;
|
||||
currentIsProcessing: boolean;
|
||||
previousIsRunning: boolean;
|
||||
latestUpdatedAt: number;
|
||||
}): { isProcessing: boolean; previousIsRunning: boolean; latestUpdatedAt: number } {
|
||||
const updatedAt = new Date(input.snapshot.updatedAt).getTime();
|
||||
if (updatedAt < input.latestUpdatedAt) {
|
||||
return {
|
||||
isProcessing: input.currentIsProcessing,
|
||||
previousIsRunning: input.previousIsRunning,
|
||||
latestUpdatedAt: input.latestUpdatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
const isRunning = input.snapshot.status === "running";
|
||||
const wasRunning = input.previousIsRunning;
|
||||
let isProcessing = input.currentIsProcessing;
|
||||
|
||||
if (isProcessing) {
|
||||
const hasEnteredRunning = !wasRunning && isRunning;
|
||||
const hasFreshRunningUpdateWhileRunning =
|
||||
wasRunning && isRunning && updatedAt > input.latestUpdatedAt;
|
||||
const hasStoppedRunning = wasRunning && !isRunning;
|
||||
|
||||
if (
|
||||
hasEnteredRunning ||
|
||||
hasFreshRunningUpdateWhileRunning ||
|
||||
hasStoppedRunning
|
||||
) {
|
||||
isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isProcessing,
|
||||
previousIsRunning: isRunning,
|
||||
latestUpdatedAt: updatedAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js";
|
||||
import { getFullAccessConfig } from "./agent-configs.js";
|
||||
import { applyAgentInputProcessingTransition } from "./send-while-running-stuck-test-utils.js";
|
||||
import { isCommandAvailable } from "../agent/provider-launch-config.js";
|
||||
|
||||
function tmpCwd(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "daemon-real-stuck-"));
|
||||
}
|
||||
|
||||
describe("daemon E2E (real codex) - send while running recovery", () => {
|
||||
test.runIf(isCommandAvailable("codex"))(
|
||||
"clears input processing when the interrupt transition is missed",
|
||||
async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const cwd = tmpCwd();
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { codex: new CodexAppServerAgentClient(logger) },
|
||||
logger,
|
||||
});
|
||||
|
||||
const primary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
const secondary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
|
||||
try {
|
||||
await primary.connect();
|
||||
await secondary.connect();
|
||||
primary.subscribeAgentUpdates({ subscriptionId: "primary" });
|
||||
secondary.subscribeAgentUpdates({ subscriptionId: "secondary" });
|
||||
|
||||
const agent = await primary.createAgent({
|
||||
cwd,
|
||||
title: "stuck-repro-real-codex",
|
||||
...getFullAccessConfig("codex"),
|
||||
});
|
||||
|
||||
await primary.sendMessage(agent.id, "Run: sleep 30");
|
||||
await primary.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
30_000
|
||||
);
|
||||
|
||||
let isProcessing = true;
|
||||
let previousIsRunning = true;
|
||||
let latestUpdatedAt = Date.now();
|
||||
|
||||
await primary.close();
|
||||
|
||||
await secondary.sendMessage(agent.id, "Reply with exactly: state saved");
|
||||
await secondary.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
30_000
|
||||
);
|
||||
|
||||
const reconnected = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await reconnected.connect();
|
||||
reconnected.subscribeAgentUpdates({ subscriptionId: "reconnected" });
|
||||
|
||||
reconnected.on("agent_update", (message) => {
|
||||
if (message.type !== "agent_update" || message.payload.kind !== "upsert") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.agent.id !== agent.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = applyAgentInputProcessingTransition({
|
||||
snapshot: message.payload.agent,
|
||||
currentIsProcessing: isProcessing,
|
||||
previousIsRunning,
|
||||
latestUpdatedAt,
|
||||
});
|
||||
isProcessing = next.isProcessing;
|
||||
previousIsRunning = next.previousIsRunning;
|
||||
latestUpdatedAt = next.latestUpdatedAt;
|
||||
});
|
||||
|
||||
await secondary.waitForFinish(agent.id, 120_000);
|
||||
|
||||
// Sending while running should clear processing even if reconnect misses the
|
||||
// not-running -> running transition.
|
||||
expect(isProcessing).toBe(false);
|
||||
} finally {
|
||||
await reconnected.close();
|
||||
}
|
||||
} finally {
|
||||
await secondary.close();
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
240_000
|
||||
);
|
||||
});
|
||||
@@ -13,6 +13,15 @@ export {
|
||||
type LocalSttModelId,
|
||||
type LocalTtsModelId,
|
||||
} from "./speech/providers/local/models.js";
|
||||
export {
|
||||
applySherpaLoaderEnv,
|
||||
resolveSherpaLoaderEnv,
|
||||
sherpaLoaderEnvKey,
|
||||
sherpaPlatformArch,
|
||||
sherpaPlatformPackageName,
|
||||
type SherpaLoaderEnvKey,
|
||||
type SherpaLoaderEnvResolution,
|
||||
} from "./speech/providers/local/sherpa/sherpa-runtime-env.js";
|
||||
|
||||
// Agent SDK types for CLI commands
|
||||
export type {
|
||||
@@ -25,6 +34,20 @@ export type {
|
||||
|
||||
// Agent activity curator for CLI logs
|
||||
export { curateAgentActivity } from "./agent/activity-curator.js";
|
||||
export {
|
||||
getStructuredAgentResponse,
|
||||
StructuredAgentResponseError,
|
||||
StructuredAgentFallbackError,
|
||||
DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
generateStructuredAgentResponseWithFallback,
|
||||
type AgentCaller,
|
||||
type JsonSchema,
|
||||
type StructuredGenerationAttempt,
|
||||
type StructuredGenerationProvider,
|
||||
type StructuredAgentGenerationOptions,
|
||||
type StructuredAgentGenerationWithFallbackOptions,
|
||||
type StructuredAgentResponseOptions,
|
||||
} from "./agent/agent-response-loop.js";
|
||||
|
||||
// WebSocket message types for CLI streaming
|
||||
export type {
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import dotenv from "dotenv";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
dotenv.config({ path: path.resolve(__dirname, "../../.env") });
|
||||
import { createPaseoDaemon } from "./bootstrap.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { resolvePaseoHome } from "./paseo-home.js";
|
||||
|
||||
@@ -22,7 +22,6 @@ const ProviderCredentialsSchema = z
|
||||
const LocalSpeechProviderSchema = z
|
||||
.object({
|
||||
modelsDir: z.string().min(1).optional(),
|
||||
autoDownload: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -175,6 +174,34 @@ function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined {
|
||||
return logger?.child({ module: "config" });
|
||||
}
|
||||
|
||||
function stripDeprecatedLocalSpeechConfigFields(parsed: unknown): unknown {
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const root = { ...(parsed as Record<string, unknown>) };
|
||||
const providers = root.providers;
|
||||
if (!providers || typeof providers !== "object" || Array.isArray(providers)) {
|
||||
return root;
|
||||
}
|
||||
|
||||
const providersRecord = { ...(providers as Record<string, unknown>) };
|
||||
const local = providersRecord.local;
|
||||
if (!local || typeof local !== "object" || Array.isArray(local)) {
|
||||
root.providers = providersRecord;
|
||||
return root;
|
||||
}
|
||||
|
||||
const localRecord = { ...(local as Record<string, unknown>) };
|
||||
if ("autoDownload" in localRecord) {
|
||||
delete localRecord.autoDownload;
|
||||
}
|
||||
|
||||
providersRecord.local = localRecord;
|
||||
root.providers = providersRecord;
|
||||
return root;
|
||||
}
|
||||
|
||||
export function loadPersistedConfig(
|
||||
paseoHome: string,
|
||||
logger?: LoggerLike
|
||||
@@ -209,7 +236,8 @@ export function loadPersistedConfig(
|
||||
throw new Error(`[Config] Invalid JSON in ${configPath}: ${message}`);
|
||||
}
|
||||
|
||||
const result = PersistedConfigSchema.safeParse(parsed);
|
||||
const migrated = stripDeprecatedLocalSpeechConfigFields(parsed);
|
||||
const result = PersistedConfigSchema.safeParse(migrated);
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues
|
||||
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { stat } from "fs/promises";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { join, resolve, sep } from "path";
|
||||
import { homedir } from "node:os";
|
||||
import { z } from "zod";
|
||||
import type { ToolSet } from "ai";
|
||||
import {
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
type DetachTerminalStreamRequest,
|
||||
type SubscribeCheckoutDiffRequest,
|
||||
type UnsubscribeCheckoutDiffRequest,
|
||||
type DirectorySuggestionsRequest,
|
||||
type ProjectCheckoutLitePayload,
|
||||
type ProjectPlacementPayload,
|
||||
} from "./messages.js";
|
||||
@@ -79,8 +81,10 @@ import {
|
||||
} from "./agent/timeline-append.js";
|
||||
import { projectTimelineRows, type TimelineProjectionMode } from "./agent/timeline-projection.js";
|
||||
import {
|
||||
DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
StructuredAgentFallbackError,
|
||||
StructuredAgentResponseError,
|
||||
generateStructuredAgentResponse,
|
||||
generateStructuredAgentResponseWithFallback,
|
||||
} from "./agent/agent-response-loop.js";
|
||||
import type {
|
||||
AgentPermissionResponse,
|
||||
@@ -138,6 +142,7 @@ import {
|
||||
} from "../utils/checkout-git.js";
|
||||
import { getProjectIcon } from "../utils/project-icon.js";
|
||||
import { expandTilde } from "../utils/path.js";
|
||||
import { searchHomeDirectories } from "../utils/directory-suggestions.js";
|
||||
import {
|
||||
ensureLocalSpeechModels,
|
||||
getLocalSpeechModelDir,
|
||||
@@ -163,12 +168,6 @@ const TERMINAL_STREAM_WINDOW_BYTES = 256 * 1024;
|
||||
const TERMINAL_STREAM_MAX_PENDING_BYTES = 2 * 1024 * 1024;
|
||||
const TERMINAL_STREAM_MAX_PENDING_CHUNKS = 2048;
|
||||
|
||||
/**
|
||||
* Default model used for auto-generating commit messages and PR descriptions.
|
||||
* Uses Claude Haiku for speed and cost efficiency.
|
||||
*/
|
||||
const AUTO_GEN_MODEL = "haiku";
|
||||
|
||||
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
|
||||
if (!remoteUrl) {
|
||||
return null;
|
||||
@@ -1180,6 +1179,31 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async emitCurrentAgentUpdatesForSubscription(): Promise<void> {
|
||||
const subscription = this.agentUpdatesSubscription;
|
||||
if (!subscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const agents = await this.listAgentPayloads({
|
||||
labels: subscription.filter?.labels,
|
||||
});
|
||||
for (const agent of agents) {
|
||||
const project = await this.buildProjectPlacement(agent.cwd);
|
||||
this.emit({
|
||||
type: "agent_update",
|
||||
payload: { kind: "upsert", agent, project },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error },
|
||||
"Failed to emit current agent updates for subscription bootstrap"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point for processing session messages
|
||||
*/
|
||||
@@ -1211,6 +1235,7 @@ export class Session {
|
||||
subscriptionId: msg.subscriptionId,
|
||||
filter: msg.filter,
|
||||
};
|
||||
await this.emitCurrentAgentUpdatesForSubscription();
|
||||
break;
|
||||
|
||||
case "unsubscribe_agent_updates":
|
||||
@@ -1347,6 +1372,10 @@ export class Session {
|
||||
await this.handleBranchSuggestionsRequest(msg);
|
||||
break;
|
||||
|
||||
case "directory_suggestions_request":
|
||||
await this.handleDirectorySuggestionsRequest(msg);
|
||||
break;
|
||||
|
||||
case "subscribe_checkout_diff_request":
|
||||
await this.handleSubscribeCheckoutDiffRequest(msg);
|
||||
break;
|
||||
@@ -2846,7 +2875,6 @@ export class Session {
|
||||
await ensureLocalSpeechModels({
|
||||
modelsDir,
|
||||
modelIds,
|
||||
autoDownload: true,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
this.emit({
|
||||
@@ -3010,23 +3038,25 @@ export class Session {
|
||||
patch.length > 0 ? patch : "(No diff available)",
|
||||
].join("\n");
|
||||
try {
|
||||
const result = await generateStructuredAgentResponse({
|
||||
const result = await generateStructuredAgentResponseWithFallback({
|
||||
manager: this.agentManager,
|
||||
agentConfig: {
|
||||
provider: "claude",
|
||||
model: AUTO_GEN_MODEL,
|
||||
cwd,
|
||||
title: "Commit generator",
|
||||
internal: true,
|
||||
},
|
||||
cwd,
|
||||
prompt,
|
||||
schema,
|
||||
schemaName: "CommitMessage",
|
||||
maxRetries: 2,
|
||||
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
agentConfigOverrides: {
|
||||
title: "Commit generator",
|
||||
internal: true,
|
||||
},
|
||||
});
|
||||
return result.message;
|
||||
} catch (error) {
|
||||
if (error instanceof StructuredAgentResponseError) {
|
||||
if (
|
||||
error instanceof StructuredAgentResponseError ||
|
||||
error instanceof StructuredAgentFallbackError
|
||||
) {
|
||||
return "Update files";
|
||||
}
|
||||
throw error;
|
||||
@@ -3075,22 +3105,24 @@ export class Session {
|
||||
patch.length > 0 ? patch : "(No diff available)",
|
||||
].join("\n");
|
||||
try {
|
||||
return await generateStructuredAgentResponse({
|
||||
return await generateStructuredAgentResponseWithFallback({
|
||||
manager: this.agentManager,
|
||||
agentConfig: {
|
||||
provider: "claude",
|
||||
model: AUTO_GEN_MODEL,
|
||||
cwd,
|
||||
title: "PR generator",
|
||||
internal: true,
|
||||
},
|
||||
cwd,
|
||||
prompt,
|
||||
schema,
|
||||
schemaName: "PullRequest",
|
||||
maxRetries: 2,
|
||||
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
agentConfigOverrides: {
|
||||
title: "PR generator",
|
||||
internal: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof StructuredAgentResponseError) {
|
||||
if (
|
||||
error instanceof StructuredAgentResponseError ||
|
||||
error instanceof StructuredAgentFallbackError
|
||||
) {
|
||||
return {
|
||||
title: "Update changes",
|
||||
body: "Automated PR generated by Paseo.",
|
||||
@@ -3563,9 +3595,10 @@ export class Session {
|
||||
msg: Extract<SessionInboundMessage, { type: "checkout_status_request" }>
|
||||
): Promise<void> {
|
||||
const { cwd, requestId } = msg;
|
||||
const resolvedCwd = expandTilde(cwd);
|
||||
|
||||
try {
|
||||
const status = await getCheckoutStatus(cwd, { paseoHome: this.paseoHome });
|
||||
const status = await getCheckoutStatus(resolvedCwd, { paseoHome: this.paseoHome });
|
||||
if (!status.isGit) {
|
||||
this.emit({
|
||||
type: "checkout_status_response",
|
||||
@@ -3754,6 +3787,37 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDirectorySuggestionsRequest(
|
||||
msg: DirectorySuggestionsRequest
|
||||
): Promise<void> {
|
||||
const { query, limit, requestId } = msg;
|
||||
|
||||
try {
|
||||
const directories = await searchHomeDirectories({
|
||||
homeDir: process.env.HOME ?? homedir(),
|
||||
query,
|
||||
limit,
|
||||
});
|
||||
this.emit({
|
||||
type: "directory_suggestions_response",
|
||||
payload: {
|
||||
directories,
|
||||
error: null,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.emit({
|
||||
type: "directory_suggestions_response",
|
||||
payload: {
|
||||
directories: [],
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeCheckoutDiffCompare(compare: CheckoutDiffCompareInput): CheckoutDiffCompareInput {
|
||||
if (compare.mode === "uncommitted") {
|
||||
return { mode: "uncommitted" };
|
||||
|
||||
@@ -24,7 +24,6 @@ export type LocalSpeechModelConfig = {
|
||||
|
||||
export type LocalSpeechProviderConfig = {
|
||||
modelsDir: string;
|
||||
autoDownload?: boolean;
|
||||
models: LocalSpeechModelConfig;
|
||||
};
|
||||
|
||||
@@ -36,17 +35,6 @@ export type { LocalSpeechModelId, LocalSttModelId, LocalTtsModelId };
|
||||
|
||||
const DEFAULT_LOCAL_MODELS_SUBDIR = path.join("models", "local-speech");
|
||||
|
||||
const BooleanStringSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.pipe(z.enum(["1", "0", "true", "false", "yes", "no"]))
|
||||
.transform((value) => value === "1" || value === "true" || value === "yes");
|
||||
|
||||
const OptionalBooleanFlagSchema = z
|
||||
.union([z.boolean(), BooleanStringSchema])
|
||||
.optional();
|
||||
|
||||
const NumberLikeSchema = z.union([
|
||||
z.number(),
|
||||
z.string().trim().min(1),
|
||||
@@ -63,7 +51,6 @@ const OptionalIntegerSchema = NumberLikeSchema
|
||||
const LocalSpeechResolutionSchema = z.object({
|
||||
includeProviderConfig: z.boolean(),
|
||||
modelsDir: z.string().trim().min(1),
|
||||
autoDownload: OptionalBooleanFlagSchema.default(true),
|
||||
dictationLocalSttModel: LocalSttModelIdSchema.default(DEFAULT_LOCAL_STT_MODEL),
|
||||
voiceLocalSttModel: LocalSttModelIdSchema.default(DEFAULT_LOCAL_STT_MODEL),
|
||||
voiceLocalTtsModel: LocalTtsModelIdSchema.default(DEFAULT_LOCAL_TTS_MODEL),
|
||||
@@ -98,7 +85,7 @@ function shouldIncludeLocalProviderConfig(params: {
|
||||
return (
|
||||
localRequestedByFeature ||
|
||||
params.env.PASEO_LOCAL_MODELS_DIR !== undefined ||
|
||||
params.persisted.providers?.local !== undefined
|
||||
params.persisted.providers?.local?.modelsDir !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,9 +103,6 @@ export function resolveLocalSpeechConfig(params: {
|
||||
params.env.PASEO_LOCAL_MODELS_DIR ??
|
||||
params.persisted.providers?.local?.modelsDir ??
|
||||
path.join(params.paseoHome, DEFAULT_LOCAL_MODELS_SUBDIR),
|
||||
autoDownload:
|
||||
params.env.PASEO_LOCAL_AUTO_DOWNLOAD ??
|
||||
params.persisted.providers?.local?.autoDownload,
|
||||
dictationLocalSttModel:
|
||||
params.env.PASEO_DICTATION_LOCAL_STT_MODEL ??
|
||||
persistedLocalFeatureModel(
|
||||
@@ -160,7 +144,6 @@ export function resolveLocalSpeechConfig(params: {
|
||||
parsed.includeProviderConfig
|
||||
? {
|
||||
modelsDir: parsed.modelsDir,
|
||||
autoDownload: parsed.autoDownload,
|
||||
models: {
|
||||
dictationStt: parsed.dictationLocalSttModel,
|
||||
voiceStt: parsed.voiceLocalSttModel,
|
||||
|
||||
@@ -36,13 +36,11 @@ export function getLocalSpeechModelDir(modelsDir: string, modelId: LocalSpeechMo
|
||||
export async function ensureLocalSpeechModels(options: {
|
||||
modelsDir: string;
|
||||
modelIds: LocalSpeechModelId[];
|
||||
autoDownload?: boolean;
|
||||
logger: import("pino").Logger;
|
||||
}): Promise<Record<LocalSpeechModelId, string>> {
|
||||
return ensureSherpaOnnxModels({
|
||||
modelsDir: options.modelsDir,
|
||||
modelIds: options.modelIds,
|
||||
autoDownload: options.autoDownload ?? true,
|
||||
logger: options.logger,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ type ResolvedLocalModels = {
|
||||
type LocalSpeechAvailability = {
|
||||
configured: boolean;
|
||||
modelsDir: string | null;
|
||||
autoDownload: boolean | null;
|
||||
};
|
||||
|
||||
export type InitializedLocalSpeech = {
|
||||
@@ -79,7 +78,6 @@ export function getLocalSpeechAvailability(
|
||||
return {
|
||||
configured: Boolean(localConfig),
|
||||
modelsDir: localConfig?.modelsDir ?? null,
|
||||
autoDownload: localConfig?.autoDownload ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -186,12 +184,10 @@ export async function initializeLocalSpeechServices(params: {
|
||||
providers: RequestedSpeechProviders;
|
||||
speechConfig: PaseoSpeechConfig | null;
|
||||
logger: Logger;
|
||||
modelEnsureAutoDownload?: boolean;
|
||||
}): Promise<InitializedLocalSpeech> {
|
||||
const { providers, logger, speechConfig } = params;
|
||||
const localConfig = speechConfig?.local ?? null;
|
||||
const localModels = resolveConfiguredLocalModels(speechConfig);
|
||||
const modelEnsureAutoDownload = params.modelEnsureAutoDownload ?? (localConfig?.autoDownload ?? true);
|
||||
|
||||
let sttService: SpeechToTextProvider | null = null;
|
||||
let ttsService: TextToSpeechProvider | null = null;
|
||||
@@ -209,28 +205,25 @@ export async function initializeLocalSpeechServices(params: {
|
||||
{
|
||||
modelsDir: localConfig.modelsDir,
|
||||
modelIds: requiredLocalModelIds,
|
||||
autoDownload: modelEnsureAutoDownload,
|
||||
},
|
||||
"Ensuring local speech models"
|
||||
);
|
||||
await ensureLocalSpeechModels({
|
||||
modelsDir: localConfig.modelsDir,
|
||||
modelIds: requiredLocalModelIds,
|
||||
autoDownload: modelEnsureAutoDownload,
|
||||
logger,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
logger.warn(
|
||||
{
|
||||
err,
|
||||
modelsDir: localConfig.modelsDir,
|
||||
modelIds: requiredLocalModelIds,
|
||||
autoDownload: modelEnsureAutoDownload,
|
||||
hint:
|
||||
"Use `paseo speech models` to inspect status and " +
|
||||
"`paseo speech download --model <MODEL_ID>` to fetch missing models.",
|
||||
},
|
||||
"Failed to ensure local speech models"
|
||||
"Local speech model bootstrap failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -256,14 +249,14 @@ export async function initializeLocalSpeechServices(params: {
|
||||
localSttEngines.set(modelId, created);
|
||||
return created;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
logger.warn(
|
||||
{
|
||||
err,
|
||||
modelsDir: localConfig.modelsDir,
|
||||
modelId,
|
||||
hint: buildModelDownloadHint(modelId),
|
||||
},
|
||||
"Failed to initialize local STT engine (models missing or invalid)"
|
||||
"Local STT engine unavailable"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -340,14 +333,14 @@ export async function initializeLocalSpeechServices(params: {
|
||||
}
|
||||
ttsService = localVoiceTtsProvider;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
logger.warn(
|
||||
{
|
||||
err,
|
||||
modelsDir: localConfig.modelsDir,
|
||||
modelId: localModels.voiceLocalTtsModel,
|
||||
hint: buildModelDownloadHint(localModels.voiceLocalTtsModel),
|
||||
},
|
||||
"Failed to initialize local TTS engine (models missing or invalid)"
|
||||
"Local TTS engine unavailable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,25 +35,12 @@ describe("sherpa model downloader", () => {
|
||||
const out = await ensureSherpaOnnxModel({
|
||||
modelsDir,
|
||||
modelId: "kitten-nano-en-v0_1-fp16",
|
||||
autoDownload: false,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(out).toBe(modelDir);
|
||||
});
|
||||
|
||||
test("ensureSherpaOnnxModel throws when missing and autoDownload is false", async () => {
|
||||
const modelsDir = makeTmpDir();
|
||||
await expect(
|
||||
ensureSherpaOnnxModel({
|
||||
modelsDir,
|
||||
modelId: "zipformer-bilingual-zh-en-2023-02-20",
|
||||
autoDownload: false,
|
||||
logger,
|
||||
})
|
||||
).rejects.toThrow(/auto-download/i);
|
||||
});
|
||||
|
||||
test("ensureSherpaOnnxModel logs lifecycle events without progress spam", async () => {
|
||||
const modelsDir = makeTmpDir();
|
||||
const infoMessages: string[] = [];
|
||||
@@ -82,7 +69,6 @@ describe("sherpa model downloader", () => {
|
||||
await ensureSherpaOnnxModel({
|
||||
modelsDir,
|
||||
modelId: "pocket-tts-onnx-int8",
|
||||
autoDownload: true,
|
||||
logger: loggerWithSpy,
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -11,7 +11,6 @@ import { getSherpaOnnxModelSpec, type SherpaOnnxModelId } from "./model-catalog.
|
||||
export type EnsureSherpaOnnxModelOptions = {
|
||||
modelsDir: string;
|
||||
modelId: SherpaOnnxModelId;
|
||||
autoDownload: boolean;
|
||||
logger: pino.Logger;
|
||||
};
|
||||
|
||||
@@ -104,13 +103,6 @@ export async function ensureSherpaOnnxModel(options: EnsureSherpaOnnxModelOption
|
||||
return modelDir;
|
||||
}
|
||||
|
||||
if (!options.autoDownload) {
|
||||
throw new Error(
|
||||
`Missing local speech model files for ${options.modelId} in ${modelDir}. ` +
|
||||
`Set PASEO_LOCAL_AUTO_DOWNLOAD=1 to auto-download.`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info({ modelsDir: options.modelsDir }, "Starting model download");
|
||||
|
||||
try {
|
||||
@@ -207,7 +199,6 @@ export async function ensureSherpaOnnxModel(options: EnsureSherpaOnnxModelOption
|
||||
export async function ensureSherpaOnnxModels(options: {
|
||||
modelsDir: string;
|
||||
modelIds: SherpaOnnxModelId[];
|
||||
autoDownload: boolean;
|
||||
logger: pino.Logger;
|
||||
}): Promise<Record<SherpaOnnxModelId, string>> {
|
||||
const uniq = Array.from(new Set(options.modelIds));
|
||||
@@ -216,7 +207,6 @@ export async function ensureSherpaOnnxModels(options: {
|
||||
out[id] = await ensureSherpaOnnxModel({
|
||||
modelsDir: options.modelsDir,
|
||||
modelId: id,
|
||||
autoDownload: options.autoDownload,
|
||||
logger: options.logger,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
applySherpaLoaderEnv,
|
||||
resolveSherpaLoaderEnv,
|
||||
sherpaPlatformPackageName,
|
||||
} from "./sherpa-runtime-env.js";
|
||||
|
||||
export type SherpaOnnxNodeModule = {
|
||||
OfflineRecognizer: new (config: any) => any;
|
||||
@@ -9,21 +16,77 @@ export type SherpaOnnxNodeModule = {
|
||||
|
||||
let cached: SherpaOnnxNodeModule | null = null;
|
||||
|
||||
function platformArch(): string {
|
||||
const platform = process.platform === "win32" ? "win" : process.platform;
|
||||
return `${platform}-${process.arch}`;
|
||||
type LoadAttempt = {
|
||||
target: string;
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
function appendAttempt(attempts: LoadAttempt[], target: string, error: unknown): void {
|
||||
attempts.push({ target, error });
|
||||
}
|
||||
|
||||
function prependLibraryPath(
|
||||
envKey: "DYLD_LIBRARY_PATH" | "LD_LIBRARY_PATH" | "PATH",
|
||||
dir: string
|
||||
): void {
|
||||
const current = process.env[envKey] ?? "";
|
||||
const parts = current.split(path.delimiter).filter(Boolean);
|
||||
if (parts.includes(dir)) {
|
||||
function formatError(error: unknown): string {
|
||||
if (!error) {
|
||||
return "unknown error";
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function maybePatchLinuxAddonRunpath(addonPath: string): void {
|
||||
if (process.platform !== "linux") {
|
||||
return;
|
||||
}
|
||||
process.env[envKey] = [dir, ...parts].join(path.delimiter);
|
||||
const patchelfCheck = spawnSync("patchelf", ["--version"], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
if (patchelfCheck.status !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRpath = spawnSync("patchelf", ["--print-rpath", addonPath], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (currentRpath.status !== 0) {
|
||||
return;
|
||||
}
|
||||
const rpath = (currentRpath.stdout ?? "").trim();
|
||||
if (rpath.includes("$ORIGIN")) {
|
||||
return;
|
||||
}
|
||||
|
||||
spawnSync("patchelf", ["--set-rpath", "$ORIGIN", addonPath], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
}
|
||||
|
||||
function loadWithRequire(
|
||||
requireFn: NodeRequire,
|
||||
target: string,
|
||||
attempts: LoadAttempt[]
|
||||
): SherpaOnnxNodeModule | null {
|
||||
try {
|
||||
return requireFn(target) as SherpaOnnxNodeModule;
|
||||
} catch (error) {
|
||||
appendAttempt(attempts, target, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildFailure(attempts: LoadAttempt[], pkgName: string): Error {
|
||||
const details = attempts
|
||||
.map((attempt) => `- ${attempt.target}: ${formatError(attempt.error)}`)
|
||||
.join("\n");
|
||||
const message = [
|
||||
`Failed to load sherpa-onnx-node for ${process.platform}-${process.arch}.`,
|
||||
`Node ${process.version} (ABI ${process.versions.modules}).`,
|
||||
`Platform package: ${pkgName}.`,
|
||||
"Load attempts:",
|
||||
details || "- (no attempts made)",
|
||||
].join("\n");
|
||||
return new Error(message);
|
||||
}
|
||||
|
||||
export function loadSherpaOnnxNode(): SherpaOnnxNodeModule {
|
||||
@@ -32,26 +95,41 @@ export function loadSherpaOnnxNode(): SherpaOnnxNodeModule {
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const attempts: LoadAttempt[] = [];
|
||||
|
||||
// sherpa-onnx-node depends on a platform-specific package (e.g. sherpa-onnx-darwin-arm64)
|
||||
// that contains the native addon and shared libraries. Ensure the OS loader path includes it.
|
||||
const arch = platformArch();
|
||||
const pkgName = `sherpa-onnx-${arch}`;
|
||||
|
||||
try {
|
||||
const pkgJson = require.resolve(`${pkgName}/package.json`);
|
||||
const pkgDir = path.dirname(pkgJson);
|
||||
if (process.platform === "darwin") {
|
||||
prependLibraryPath("DYLD_LIBRARY_PATH", pkgDir);
|
||||
} else if (process.platform === "linux") {
|
||||
prependLibraryPath("LD_LIBRARY_PATH", pkgDir);
|
||||
} else if (process.platform === "win32") {
|
||||
prependLibraryPath("PATH", pkgDir);
|
||||
}
|
||||
} catch {
|
||||
// Best effort - if the platform package isn't present, require() below will throw a useful error.
|
||||
// Pass through upstream support matrix first.
|
||||
const direct = loadWithRequire(require, "sherpa-onnx-node", attempts);
|
||||
if (direct) {
|
||||
cached = direct;
|
||||
return cached;
|
||||
}
|
||||
|
||||
cached = require("sherpa-onnx-node") as SherpaOnnxNodeModule;
|
||||
return cached;
|
||||
// sherpa-onnx-node depends on a platform-specific package (e.g. sherpa-onnx-darwin-arm64)
|
||||
// that contains the native addon and shared libraries.
|
||||
const pkgName = sherpaPlatformPackageName();
|
||||
const resolvedEnv = resolveSherpaLoaderEnv();
|
||||
const platformPkgDir = resolvedEnv?.libDir ?? null;
|
||||
|
||||
if (platformPkgDir) {
|
||||
applySherpaLoaderEnv(process.env);
|
||||
const addonPath = path.join(platformPkgDir, "sherpa-onnx.node");
|
||||
|
||||
if (existsSync(addonPath)) {
|
||||
const byPath = loadWithRequire(require, addonPath, attempts);
|
||||
if (byPath) {
|
||||
cached = byPath;
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Linux fallback for broken prebuilt RUNPATHs.
|
||||
maybePatchLinuxAddonRunpath(addonPath);
|
||||
const afterPatch = loadWithRequire(require, addonPath, attempts);
|
||||
if (afterPatch) {
|
||||
cached = afterPatch;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw buildFailure(attempts, pkgName);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
|
||||
export type SherpaLoaderEnvKey = "LD_LIBRARY_PATH" | "DYLD_LIBRARY_PATH" | "PATH";
|
||||
|
||||
export type SherpaLoaderEnvResolution = {
|
||||
key: SherpaLoaderEnvKey;
|
||||
libDir: string;
|
||||
packageName: string;
|
||||
};
|
||||
|
||||
export function sherpaPlatformArch(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
arch: string = process.arch
|
||||
): string {
|
||||
const normalizedPlatform = platform === "win32" ? "win" : platform;
|
||||
return `${normalizedPlatform}-${arch}`;
|
||||
}
|
||||
|
||||
export function sherpaPlatformPackageName(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
arch: string = process.arch
|
||||
): string {
|
||||
return `sherpa-onnx-${sherpaPlatformArch(platform, arch)}`;
|
||||
}
|
||||
|
||||
export function sherpaLoaderEnvKey(
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): SherpaLoaderEnvKey | null {
|
||||
if (platform === "linux") {
|
||||
return "LD_LIBRARY_PATH";
|
||||
}
|
||||
if (platform === "darwin") {
|
||||
return "DYLD_LIBRARY_PATH";
|
||||
}
|
||||
if (platform === "win32") {
|
||||
return "PATH";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function prependEnvPath(existing: string | undefined, value: string): string {
|
||||
const parts = (existing ?? "").split(path.delimiter).filter(Boolean);
|
||||
if (parts.includes(value)) {
|
||||
return parts.join(path.delimiter);
|
||||
}
|
||||
return [value, ...parts].join(path.delimiter);
|
||||
}
|
||||
|
||||
export function resolveSherpaLoaderEnv(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
arch: string = process.arch
|
||||
): SherpaLoaderEnvResolution | null {
|
||||
const key = sherpaLoaderEnvKey(platform);
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const packageName = sherpaPlatformPackageName(platform, arch);
|
||||
const require = createRequire(import.meta.url);
|
||||
try {
|
||||
const pkgJson = require.resolve(`${packageName}/package.json`);
|
||||
return {
|
||||
key,
|
||||
libDir: path.dirname(pkgJson),
|
||||
packageName,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function applySherpaLoaderEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
arch: string = process.arch
|
||||
): {
|
||||
changed: boolean;
|
||||
key: SherpaLoaderEnvKey | null;
|
||||
libDir: string | null;
|
||||
packageName: string | null;
|
||||
} {
|
||||
const resolved = resolveSherpaLoaderEnv(platform, arch);
|
||||
if (!resolved) {
|
||||
return {
|
||||
changed: false,
|
||||
key: null,
|
||||
libDir: null,
|
||||
packageName: null,
|
||||
};
|
||||
}
|
||||
|
||||
const next = prependEnvPath(env[resolved.key], resolved.libDir);
|
||||
const changed = next !== (env[resolved.key] ?? "");
|
||||
env[resolved.key] = next;
|
||||
return {
|
||||
changed,
|
||||
key: resolved.key,
|
||||
libDir: resolved.libDir,
|
||||
packageName: resolved.packageName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@ describe("speech models (download E2E)", () => {
|
||||
await ensureSherpaOnnxModels({
|
||||
modelsDir,
|
||||
modelIds,
|
||||
autoDownload: true,
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -121,7 +120,6 @@ describe("speech models (download E2E)", () => {
|
||||
},
|
||||
local: {
|
||||
modelsDir,
|
||||
autoDownload: false,
|
||||
models: {
|
||||
dictationStt:
|
||||
set === "parakeet-pocket"
|
||||
|
||||
@@ -35,7 +35,6 @@ describe("resolveSpeechConfig", () => {
|
||||
});
|
||||
expect(result.speech.local).toEqual({
|
||||
modelsDir: path.join(paseoHome, "models", "local-speech"),
|
||||
autoDownload: true,
|
||||
models: {
|
||||
dictationStt: "parakeet-tdt-0.6b-v2-int8",
|
||||
voiceStt: "parakeet-tdt-0.6b-v2-int8",
|
||||
@@ -67,7 +66,6 @@ describe("resolveSpeechConfig", () => {
|
||||
PASEO_VOICE_LOCAL_TTS_SPEAKER_ID: "5",
|
||||
PASEO_VOICE_LOCAL_TTS_SPEED: "1.35",
|
||||
PASEO_LOCAL_MODELS_DIR: "/tmp/models",
|
||||
PASEO_LOCAL_AUTO_DOWNLOAD: "0",
|
||||
OPENAI_API_KEY: "env-key",
|
||||
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||
PASEO_DICTATION_STT_PROVIDER: "local",
|
||||
@@ -82,7 +80,6 @@ describe("resolveSpeechConfig", () => {
|
||||
|
||||
expect(result.speech.local).toEqual({
|
||||
modelsDir: "/tmp/models",
|
||||
autoDownload: false,
|
||||
models: {
|
||||
dictationStt: "zipformer-bilingual-zh-en-2023-02-20",
|
||||
voiceStt: "parakeet-tdt-0.6b-v3-int8",
|
||||
|
||||
@@ -79,7 +79,6 @@ describe("initializeSpeechRuntime readiness", () => {
|
||||
availability: {
|
||||
configured: false,
|
||||
modelsDir: null,
|
||||
autoDownload: null,
|
||||
},
|
||||
cleanup: () => {},
|
||||
});
|
||||
@@ -115,7 +114,6 @@ describe("initializeSpeechRuntime readiness", () => {
|
||||
availability: {
|
||||
configured: false,
|
||||
modelsDir: null,
|
||||
autoDownload: null,
|
||||
},
|
||||
cleanup: () => {},
|
||||
});
|
||||
|
||||
@@ -205,7 +205,6 @@ function buildVoiceFeatureReadiness(params: {
|
||||
missingLocalModelIds: LocalSpeechModelId[];
|
||||
backgroundDownloadInProgress: boolean;
|
||||
backgroundDownloadError: string | null;
|
||||
localAutoDownloadEnabled: boolean;
|
||||
}): SpeechReadinessState {
|
||||
const enabled = params.realtimeVoice.enabled || params.dictation.enabled;
|
||||
if (!enabled) {
|
||||
@@ -246,7 +245,7 @@ function buildVoiceFeatureReadiness(params: {
|
||||
available: false,
|
||||
reasonCode: "models_missing",
|
||||
message: `Voice features are unavailable: missing local models (${joinModelIds(missingModelIds)}).`,
|
||||
retryable: params.localAutoDownloadEnabled,
|
||||
retryable: true,
|
||||
missingModelIds,
|
||||
};
|
||||
}
|
||||
@@ -328,7 +327,6 @@ export async function initializeSpeechRuntime(params: {
|
||||
const openaiConfig = params.openaiConfig;
|
||||
const providers = resolveRequestedSpeechProviders(speechConfig);
|
||||
const requestedProviders = describeRequestedProviders(providers);
|
||||
const localAutoDownloadEnabled = speechConfig?.local?.autoDownload ?? true;
|
||||
|
||||
validateOpenAiCredentialRequirements({
|
||||
providers,
|
||||
@@ -382,7 +380,6 @@ export async function initializeSpeechRuntime(params: {
|
||||
missingLocalModelIds,
|
||||
backgroundDownloadInProgress,
|
||||
backgroundDownloadError,
|
||||
localAutoDownloadEnabled,
|
||||
});
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
@@ -459,12 +456,11 @@ export async function initializeSpeechRuntime(params: {
|
||||
});
|
||||
};
|
||||
|
||||
const reconcileServices = async (modelEnsureAutoDownload: boolean): Promise<void> => {
|
||||
const reconcileServices = async (): Promise<void> => {
|
||||
const nextLocalSpeech = await initializeLocalSpeechServices({
|
||||
providers,
|
||||
speechConfig,
|
||||
logger,
|
||||
modelEnsureAutoDownload,
|
||||
});
|
||||
const nextOpenAiSpeech = initializeOpenAiSpeechServices({
|
||||
providers,
|
||||
@@ -521,13 +517,13 @@ export async function initializeSpeechRuntime(params: {
|
||||
}
|
||||
};
|
||||
|
||||
const runReconcile = async (params: { modelEnsureAutoDownload: boolean }): Promise<void> => {
|
||||
const runReconcile = async (): Promise<void> => {
|
||||
if (reconcileInFlight) {
|
||||
await reconcileInFlight;
|
||||
publishReadinessIfChanged();
|
||||
return;
|
||||
}
|
||||
reconcileInFlight = reconcileServices(params.modelEnsureAutoDownload).finally(() => {
|
||||
reconcileInFlight = reconcileServices().finally(() => {
|
||||
reconcileInFlight = null;
|
||||
});
|
||||
await reconcileInFlight;
|
||||
@@ -550,7 +546,7 @@ export async function initializeSpeechRuntime(params: {
|
||||
}
|
||||
const modelsDir = localModelConfig?.modelsDir ?? null;
|
||||
const modelIds = [...missingLocalModelIds];
|
||||
if (!modelsDir || modelIds.length === 0 || !localAutoDownloadEnabled) {
|
||||
if (!modelsDir || modelIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -571,10 +567,9 @@ export async function initializeSpeechRuntime(params: {
|
||||
await ensureLocalSpeechModels({
|
||||
modelsDir,
|
||||
modelIds,
|
||||
autoDownload: true,
|
||||
logger,
|
||||
});
|
||||
await runReconcile({ modelEnsureAutoDownload: false });
|
||||
await runReconcile();
|
||||
backgroundDownloadError = null;
|
||||
} catch (error) {
|
||||
backgroundDownloadError = error instanceof Error ? error.message : String(error);
|
||||
@@ -610,12 +605,11 @@ export async function initializeSpeechRuntime(params: {
|
||||
missingLocalModelIds.length === 0 &&
|
||||
!backgroundDownloadInProgress
|
||||
) {
|
||||
await runReconcile({ modelEnsureAutoDownload: false });
|
||||
await runReconcile();
|
||||
}
|
||||
|
||||
if (
|
||||
missingLocalModelIds.length > 0 &&
|
||||
localAutoDownloadEnabled &&
|
||||
!backgroundDownloadInProgress &&
|
||||
!backgroundDownloadError
|
||||
) {
|
||||
@@ -629,10 +623,10 @@ export async function initializeSpeechRuntime(params: {
|
||||
}
|
||||
};
|
||||
|
||||
await runReconcile({ modelEnsureAutoDownload: false });
|
||||
await runReconcile();
|
||||
const snapshot = computeReadinessSnapshot();
|
||||
if (snapshot.voiceFeature.enabled && !snapshot.voiceFeature.available) {
|
||||
if (missingLocalModelIds.length > 0 && localAutoDownloadEnabled) {
|
||||
if (missingLocalModelIds.length > 0) {
|
||||
startBackgroundDownload();
|
||||
}
|
||||
scheduleMonitor();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { execSync } from "child_process";
|
||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs";
|
||||
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
|
||||
@@ -15,6 +15,17 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
let repoDir: string;
|
||||
let paseoHome: string;
|
||||
|
||||
async function waitForPathExists(targetPath: string, timeoutMs = 10000): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (existsSync(targetPath)) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
throw new Error(`Timed out waiting for path: ${targetPath}`);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-bootstrap-test-")));
|
||||
repoDir = join(tempDir, "repo");
|
||||
@@ -236,4 +247,216 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
expect(persistedSetupItem.detail.log).toContain("-suffix");
|
||||
expect(persistedSetupItem.detail.log).toContain("...<output truncated in the middle>...");
|
||||
});
|
||||
|
||||
it("waits for terminal output before sending bootstrap commands", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
terminals: [
|
||||
{
|
||||
name: "Ready Terminal",
|
||||
command: "echo ready",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add terminal bootstrap config'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const worktree = await createAgentWorktree({
|
||||
cwd: repoDir,
|
||||
branchName: "feature-terminal-readiness",
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-terminal-readiness",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
let readyAt = 0;
|
||||
let sendAt = 0;
|
||||
let outputListener: ((chunk: { data: string }) => void) | null = null;
|
||||
|
||||
await runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-terminal-readiness",
|
||||
worktree,
|
||||
terminalManager: {
|
||||
async getTerminals() {
|
||||
return [];
|
||||
},
|
||||
async createTerminal(options) {
|
||||
setTimeout(() => {
|
||||
readyAt = Date.now();
|
||||
outputListener?.({ data: "$ " });
|
||||
}, 25);
|
||||
return {
|
||||
id: "term-ready",
|
||||
name: options.name ?? "Terminal",
|
||||
cwd: options.cwd,
|
||||
send: () => {
|
||||
sendAt = Date.now();
|
||||
},
|
||||
subscribe: () => () => {},
|
||||
onExit: () => () => {},
|
||||
subscribeRaw: (listener) => {
|
||||
outputListener = (chunk) =>
|
||||
listener({
|
||||
data: chunk.data,
|
||||
startOffset: 0,
|
||||
endOffset: chunk.data.length,
|
||||
replay: false,
|
||||
});
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
outputListener = null;
|
||||
},
|
||||
replayedFrom: 0,
|
||||
currentOffset: 0,
|
||||
earliestAvailableOffset: 0,
|
||||
reset: false,
|
||||
};
|
||||
},
|
||||
getOutputOffset: () => 0,
|
||||
getState: () => ({
|
||||
rows: 0,
|
||||
cols: 0,
|
||||
grid: [],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 0 },
|
||||
}),
|
||||
kill: () => {},
|
||||
};
|
||||
},
|
||||
registerCwdEnv() {},
|
||||
getTerminal() {
|
||||
return undefined;
|
||||
},
|
||||
killTerminal() {},
|
||||
listDirectories() {
|
||||
return [];
|
||||
},
|
||||
killAll() {},
|
||||
subscribeTerminalsChanged() {
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
appendTimelineItem: async () => true,
|
||||
emitLiveTimelineItem: async () => true,
|
||||
});
|
||||
|
||||
expect(readyAt).toBeGreaterThan(0);
|
||||
expect(sendAt).toBeGreaterThan(0);
|
||||
expect(sendAt).toBeGreaterThanOrEqual(readyAt);
|
||||
});
|
||||
|
||||
it("shares the same worktree runtime port across setup and bootstrap terminals", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: ['echo "$PASEO_WORKTREE_PORT" > setup-port.txt'],
|
||||
terminals: [
|
||||
{
|
||||
name: "Port Terminal",
|
||||
command: "true",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add port setup and terminals'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const worktree = await createAgentWorktree({
|
||||
cwd: repoDir,
|
||||
branchName: "feature-shared-runtime-port",
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-shared-runtime-port",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const registeredEnvs: Array<{ cwd: string; env: Record<string, string> }> = [];
|
||||
const createTerminalEnvs: Record<string, string>[] = [];
|
||||
const persisted: AgentTimelineItem[] = [];
|
||||
await runAsyncWorktreeBootstrap({
|
||||
agentId: "agent-shared-runtime-port",
|
||||
worktree,
|
||||
terminalManager: {
|
||||
async getTerminals() {
|
||||
return [];
|
||||
},
|
||||
async createTerminal(options) {
|
||||
createTerminalEnvs.push(options.env ?? {});
|
||||
return {
|
||||
id: "term-1",
|
||||
name: options.name ?? "Terminal",
|
||||
cwd: options.cwd,
|
||||
send: () => {},
|
||||
subscribe: () => () => {},
|
||||
onExit: () => () => {},
|
||||
subscribeRaw: () => ({
|
||||
unsubscribe: () => {},
|
||||
replayedFrom: 0,
|
||||
currentOffset: 0,
|
||||
earliestAvailableOffset: 0,
|
||||
reset: false,
|
||||
}),
|
||||
getOutputOffset: () => 1,
|
||||
getState: () => ({
|
||||
rows: 0,
|
||||
cols: 0,
|
||||
grid: [],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 0 },
|
||||
}),
|
||||
kill: () => {},
|
||||
};
|
||||
},
|
||||
registerCwdEnv(options) {
|
||||
registeredEnvs.push({ cwd: options.cwd, env: options.env });
|
||||
},
|
||||
getTerminal() {
|
||||
return undefined;
|
||||
},
|
||||
killTerminal() {},
|
||||
listDirectories() {
|
||||
return [];
|
||||
},
|
||||
killAll() {},
|
||||
subscribeTerminalsChanged() {
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
appendTimelineItem: async (item) => {
|
||||
persisted.push(item);
|
||||
return true;
|
||||
},
|
||||
emitLiveTimelineItem: async () => true,
|
||||
});
|
||||
|
||||
const setupPortPath = join(worktree.worktreePath, "setup-port.txt");
|
||||
await waitForPathExists(setupPortPath);
|
||||
|
||||
const setupPort = readFileSync(setupPortPath, "utf8").trim();
|
||||
expect(setupPort.length).toBeGreaterThan(0);
|
||||
expect(registeredEnvs).toHaveLength(1);
|
||||
expect(registeredEnvs[0]?.cwd).toBe(worktree.worktreePath);
|
||||
expect(registeredEnvs[0]?.env.PASEO_WORKTREE_PORT).toBe(setupPort);
|
||||
expect(createTerminalEnvs.length).toBeGreaterThan(0);
|
||||
expect(createTerminalEnvs[0]?.PASEO_WORKTREE_PORT).toBe(setupPort);
|
||||
|
||||
const terminalToolCall = persisted.find(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
|
||||
item.type === "tool_call" &&
|
||||
item.name === "paseo_worktree_terminals" &&
|
||||
item.status === "completed"
|
||||
);
|
||||
expect(terminalToolCall?.status).toBe("completed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { Logger } from "pino";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type { TerminalSession } from "../terminal/terminal.js";
|
||||
import {
|
||||
createWorktree,
|
||||
getWorktreeTerminalSpecs,
|
||||
resolveWorktreeRuntimeEnv,
|
||||
runWorktreeSetupCommands,
|
||||
WorktreeSetupError,
|
||||
type WorktreeConfig,
|
||||
type WorktreeSetupCommandResult,
|
||||
type WorktreeRuntimeEnv,
|
||||
} from "../utils/worktree.js";
|
||||
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
|
||||
|
||||
@@ -38,6 +41,7 @@ export interface CreateAgentWorktreeOptions {
|
||||
|
||||
const MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES = 64 * 1024;
|
||||
const WORKTREE_SETUP_TRUNCATION_MARKER = "\n...<output truncated in the middle>...\n";
|
||||
const WORKTREE_BOOTSTRAP_TERMINAL_READY_TIMEOUT_MS = 1_500;
|
||||
|
||||
type MiddleTruncationAccumulator = {
|
||||
totalBytes: number;
|
||||
@@ -335,8 +339,51 @@ function buildTerminalTimelineItem(input: {
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForTerminalBootstrapReadiness(
|
||||
terminal: Pick<TerminalSession, "getOutputOffset" | "subscribeRaw">
|
||||
): Promise<void> {
|
||||
if (terminal.getOutputOffset() > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
const finish = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = null;
|
||||
}
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
unsubscribe = null;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
const rawSubscription = terminal.subscribeRaw(() => {
|
||||
finish();
|
||||
});
|
||||
unsubscribe = rawSubscription.unsubscribe;
|
||||
|
||||
if (terminal.getOutputOffset() > 0) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
timeout = setTimeout(finish, WORKTREE_BOOTSTRAP_TERMINAL_READY_TIMEOUT_MS);
|
||||
});
|
||||
}
|
||||
|
||||
async function runWorktreeTerminalBootstrap(
|
||||
options: RunAsyncWorktreeBootstrapOptions
|
||||
options: RunAsyncWorktreeBootstrapOptions,
|
||||
runtimeEnv: WorktreeRuntimeEnv
|
||||
): Promise<void> {
|
||||
const terminalSpecs = getWorktreeTerminalSpecs(options.worktree.worktreePath);
|
||||
if (terminalSpecs.length === 0) {
|
||||
@@ -376,7 +423,9 @@ async function runWorktreeTerminalBootstrap(
|
||||
const terminal = await options.terminalManager.createTerminal({
|
||||
cwd: options.worktree.worktreePath,
|
||||
name: spec.name,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
await waitForTerminalBootstrapReadiness(terminal);
|
||||
terminal.send({
|
||||
type: "input",
|
||||
data: `${spec.command}\r`,
|
||||
@@ -420,6 +469,7 @@ export async function runAsyncWorktreeBootstrap(
|
||||
): Promise<void> {
|
||||
const setupCallId = uuidv4();
|
||||
let setupResults: WorktreeSetupCommandResult[] = [];
|
||||
let runtimeEnv: WorktreeRuntimeEnv | null = null;
|
||||
const emitLiveTimelineItem = options.emitLiveTimelineItem;
|
||||
const runningResultsByIndex = new Map<number, WorktreeSetupCommandResult>();
|
||||
const outputAccumulatorsByIndex = new Map<number, MiddleTruncationAccumulator>();
|
||||
@@ -454,10 +504,20 @@ export async function runAsyncWorktreeBootstrap(
|
||||
};
|
||||
|
||||
try {
|
||||
runtimeEnv = await resolveWorktreeRuntimeEnv({
|
||||
worktreePath: options.worktree.worktreePath,
|
||||
branchName: options.worktree.branchName,
|
||||
});
|
||||
options.terminalManager?.registerCwdEnv({
|
||||
cwd: options.worktree.worktreePath,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
|
||||
setupResults = await runWorktreeSetupCommands({
|
||||
worktreePath: options.worktree.worktreePath,
|
||||
branchName: options.worktree.branchName,
|
||||
cleanupOnFailure: false,
|
||||
runtimeEnv,
|
||||
onEvent: (event) => {
|
||||
const existing = runningResultsByIndex.get(event.index);
|
||||
const baseResult: WorktreeSetupCommandResult = existing ?? {
|
||||
@@ -533,5 +593,5 @@ export async function runAsyncWorktreeBootstrap(
|
||||
return;
|
||||
}
|
||||
|
||||
await runWorktreeTerminalBootstrap(options);
|
||||
await runWorktreeTerminalBootstrap(options, runtimeEnv);
|
||||
}
|
||||
|
||||
@@ -100,6 +100,26 @@ describe("shared messages stream parsing", () => {
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("parses directory suggestions request and response payloads", () => {
|
||||
const requestParsed = SessionInboundMessageSchema.safeParse({
|
||||
type: "directory_suggestions_request",
|
||||
query: "proj",
|
||||
limit: 20,
|
||||
requestId: "req-dir-1",
|
||||
});
|
||||
expect(requestParsed.success).toBe(true);
|
||||
|
||||
const responseParsed = SessionOutboundMessageSchema.safeParse({
|
||||
type: "directory_suggestions_response",
|
||||
payload: {
|
||||
directories: ["/Users/test/projects/paseo"],
|
||||
error: null,
|
||||
requestId: "req-dir-1",
|
||||
},
|
||||
});
|
||||
expect(responseParsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects websocket envelope for removed agent_stream_snapshot message type", () => {
|
||||
const fixture = {
|
||||
type: "agent_stream_snapshot",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user