mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
14 Commits
v0.1.102-b
...
dotfile-cl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c02b9ec7c | ||
|
|
9905b0da31 | ||
|
|
db03b1f3fd | ||
|
|
cb486c3a5a | ||
|
|
7dad7a377c | ||
|
|
e32c50f2d7 | ||
|
|
e3eb633582 | ||
|
|
beac4544fd | ||
|
|
c2271ff0ca | ||
|
|
f91806defe | ||
|
|
e9431517a0 | ||
|
|
4e28b0941b | ||
|
|
7c6152663e | ||
|
|
5fc53c576e |
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@@ -241,6 +241,11 @@ jobs:
|
||||
run: npm run typecheck:examples --workspace=@getpaseo/client
|
||||
|
||||
playwright:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
name: playwright (shard ${{ matrix.shard }}/4)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
@@ -280,7 +285,7 @@ jobs:
|
||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
run: npm run test:e2e --workspace=@getpaseo/app
|
||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
@@ -288,7 +293,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-results
|
||||
name: playwright-results-${{ matrix.shard }}
|
||||
path: |
|
||||
packages/app/test-results/
|
||||
packages/app/playwright-report/
|
||||
|
||||
12
.github/workflows/desktop-release.yml
vendored
12
.github/workflows/desktop-release.yml
vendored
@@ -329,6 +329,18 @@ jobs:
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install Windows arm64 native search packages
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
fff_version="$(node -p "require('./node_modules/@ff-labs/fff-node/package.json').optionalDependencies['@ff-labs/fff-bin-win32-arm64']")"
|
||||
ffi_version="$(node -p "require('./node_modules/ffi-rs/package.json').optionalDependencies['@yuuang/ffi-rs-win32-arm64-msvc']")"
|
||||
npm install --no-save --package-lock=false --ignore-scripts --no-audit --fund=false --os=win32 --cpu=arm64 \
|
||||
"@ff-labs/fff-bin-win32-arm64@${fff_version}" \
|
||||
"@yuuang/ffi-rs-win32-arm64-msvc@${ffi_version}"
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set desktop package version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
60
CHANGELOG.md
60
CHANGELOG.md
@@ -1,51 +1,49 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.102-beta.2 - 2026-06-29
|
||||
## 0.1.102 - 2026-06-30
|
||||
|
||||
### Added
|
||||
|
||||
- Fork a chat from any assistant answer ([#1788](https://github.com/getpaseo/paseo/pull/1788))
|
||||
- Use Paseo from a browser served directly by your daemon ([#1635](https://github.com/getpaseo/paseo/pull/1635))
|
||||
- Fork chats into a new tab or new worktree ([#1788](https://github.com/getpaseo/paseo/pull/1788))
|
||||
- See workspaces from all connected hosts ([#1538](https://github.com/getpaseo/paseo/pull/1538), [#1775](https://github.com/getpaseo/paseo/pull/1775), [#1825](https://github.com/getpaseo/paseo/pull/1825))
|
||||
- Daemon can now serve the web UI ([#1635](https://github.com/getpaseo/paseo/pull/1635), [#1739](https://github.com/getpaseo/paseo/pull/1739))
|
||||
- Run Paseo from an official Docker image ([#1740](https://github.com/getpaseo/paseo/pull/1740) by [@Herbrant](https://github.com/Herbrant))
|
||||
- Update a remote host from the app when a newer daemon is available ([#1513](https://github.com/getpaseo/paseo/pull/1513) by [@thedavidweng](https://github.com/thedavidweng))
|
||||
- Dropped files can be attached from every composer ([#1750](https://github.com/getpaseo/paseo/pull/1750))
|
||||
- MiniMax usage is available in provider quota views ([#1662](https://github.com/getpaseo/paseo/pull/1662) by [@ilteoood](https://github.com/ilteoood))
|
||||
- C# code blocks now have syntax highlighting ([#1651](https://github.com/getpaseo/paseo/pull/1651) by [@dev693](https://github.com/dev693))
|
||||
- Update a daemon remotely from the app ([#1513](https://github.com/getpaseo/paseo/pull/1513) by [@thedavidweng](https://github.com/thedavidweng))
|
||||
- Configure separate OpenAI endpoints for speech-to-text and text-to-speech ([#1823](https://github.com/getpaseo/paseo/pull/1823))
|
||||
- Drop files into any composer ([#1750](https://github.com/getpaseo/paseo/pull/1750), [#1801](https://github.com/getpaseo/paseo/pull/1801))
|
||||
- Show MiniMax usage in quota views ([#1662](https://github.com/getpaseo/paseo/pull/1662) by [@ilteoood](https://github.com/ilteoood))
|
||||
- Highlight C# code blocks ([#1651](https://github.com/getpaseo/paseo/pull/1651) by [@dev693](https://github.com/dev693))
|
||||
|
||||
### Improved
|
||||
|
||||
- The sidebar can show workspaces across all connected hosts ([#1538](https://github.com/getpaseo/paseo/pull/1538))
|
||||
- Multi-host workspace rows show which host they belong to ([#1775](https://github.com/getpaseo/paseo/pull/1775))
|
||||
- Host switcher rows show the connection currently in use
|
||||
- Creating a new workspace is available from an app-wide screen ([#1746](https://github.com/getpaseo/paseo/pull/1746))
|
||||
- New Workspace starts from the current project when opened inside a project ([#1806](https://github.com/getpaseo/paseo/pull/1806))
|
||||
- Project search shows loading progress while results are fetched ([#1762](https://github.com/getpaseo/paseo/pull/1762))
|
||||
- Desktop update checks show feedback while checking for updates ([#1808](https://github.com/getpaseo/paseo/pull/1808))
|
||||
- Slow host requests time out less aggressively ([#1789](https://github.com/getpaseo/paseo/pull/1789))
|
||||
- Pi waits longer for extension results before timing out ([#1732](https://github.com/getpaseo/paseo/pull/1732) by [@theslava](https://github.com/theslava))
|
||||
- New Workspace opens from anywhere ([#1746](https://github.com/getpaseo/paseo/pull/1746), [#1806](https://github.com/getpaseo/paseo/pull/1806))
|
||||
- Project search shows loading progress ([#1762](https://github.com/getpaseo/paseo/pull/1762))
|
||||
- Desktop update checks show clearer status ([#1808](https://github.com/getpaseo/paseo/pull/1808), [#1815](https://github.com/getpaseo/paseo/pull/1815))
|
||||
- Slow remote hosts time out less aggressively ([#1789](https://github.com/getpaseo/paseo/pull/1789))
|
||||
- Pi waits longer for extension results ([#1732](https://github.com/getpaseo/paseo/pull/1732) by [@theslava](https://github.com/theslava))
|
||||
- Open file tabs refresh when you revisit them ([#1699](https://github.com/getpaseo/paseo/pull/1699) by [@cleiter](https://github.com/cleiter))
|
||||
- Web terminals stay smoother while scrolling ([#1622](https://github.com/getpaseo/paseo/pull/1622) by [@TommyLike](https://github.com/TommyLike))
|
||||
- ACP provider catalog updated to the latest registry versions
|
||||
- Web terminals scroll more smoothly ([#1622](https://github.com/getpaseo/paseo/pull/1622) by [@TommyLike](https://github.com/TommyLike))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Freshly added projects can be edited without restarting ([#1761](https://github.com/getpaseo/paseo/pull/1761) by [@huiliaoning](https://github.com/huiliaoning))
|
||||
- Opening projects and directory suggestions work better in large repos ([#1620](https://github.com/getpaseo/paseo/pull/1620) by [@jms830](https://github.com/jms830))
|
||||
- Mobile opens the saved workspace reliably on launch ([#1777](https://github.com/getpaseo/paseo/pull/1777))
|
||||
- Agent prompts no longer rename existing workspaces ([#1779](https://github.com/getpaseo/paseo/pull/1779))
|
||||
- Chat stays at your scroll position when delayed history arrives ([#1776](https://github.com/getpaseo/paseo/pull/1776))
|
||||
- Large repos open more reliably ([#1620](https://github.com/getpaseo/paseo/pull/1620) by [@jms830](https://github.com/jms830))
|
||||
- Mobile restores the saved workspace on launch ([#1777](https://github.com/getpaseo/paseo/pull/1777))
|
||||
- Agent prompts no longer rename workspaces ([#1779](https://github.com/getpaseo/paseo/pull/1779))
|
||||
- Chat stays put when delayed history arrives ([#1776](https://github.com/getpaseo/paseo/pull/1776))
|
||||
- Streamed chat images stay in order ([#1805](https://github.com/getpaseo/paseo/pull/1805))
|
||||
- Chat actions wait until running tool calls finish
|
||||
- Claude subagent narration no longer leaks into chat ([#1807](https://github.com/getpaseo/paseo/pull/1807))
|
||||
- Agent lists keep working when project records go stale ([#1812](https://github.com/getpaseo/paseo/pull/1812))
|
||||
- Windows image previews work with drive-letter paths ([#1811](https://github.com/getpaseo/paseo/pull/1811))
|
||||
- Chat actions stay below tool output ([#1827](https://github.com/getpaseo/paseo/pull/1827))
|
||||
- Claude subagent narration stays out of chat ([#1807](https://github.com/getpaseo/paseo/pull/1807))
|
||||
- Kiro slash commands and skills appear in Paseo ([#1792](https://github.com/getpaseo/paseo/pull/1792) by [@park0er](https://github.com/park0er))
|
||||
- Agent lists survive stale project records ([#1812](https://github.com/getpaseo/paseo/pull/1812))
|
||||
- Windows image previews handle drive-letter paths ([#1811](https://github.com/getpaseo/paseo/pull/1811))
|
||||
- OpenCode closes cleanly on Windows ([#1771](https://github.com/getpaseo/paseo/pull/1771) by [@agamotto](https://github.com/agamotto))
|
||||
- Desktop file uploads keep their file extensions ([#1741](https://github.com/getpaseo/paseo/pull/1741))
|
||||
- Closing Claude Code sessions no longer leaves child processes running ([#1540](https://github.com/getpaseo/paseo/pull/1540) by [@TommyLike](https://github.com/TommyLike))
|
||||
- OpenCode no longer indexes your home directory by mistake ([#1704](https://github.com/getpaseo/paseo/pull/1704) by [@rex-chang](https://github.com/rex-chang))
|
||||
- Desktop file uploads keep their extensions ([#1741](https://github.com/getpaseo/paseo/pull/1741))
|
||||
- Claude Code cleanup kills child processes ([#1540](https://github.com/getpaseo/paseo/pull/1540) by [@TommyLike](https://github.com/TommyLike))
|
||||
- OpenCode no longer indexes your home directory ([#1704](https://github.com/getpaseo/paseo/pull/1704) by [@rex-chang](https://github.com/rex-chang))
|
||||
- Packaged macOS CLI daemon no longer shows extra Dock icons ([#1759](https://github.com/getpaseo/paseo/pull/1759) by [@yzim](https://github.com/yzim))
|
||||
- `paseo daemon status` reports daemon health even when agent details are unavailable ([#1810](https://github.com/getpaseo/paseo/pull/1810))
|
||||
- PR worktrees show pushed state correctly after checkout ([#1804](https://github.com/getpaseo/paseo/pull/1804))
|
||||
- `paseo daemon status` works without loading agents ([#1810](https://github.com/getpaseo/paseo/pull/1810))
|
||||
- PR worktrees show pushed state correctly ([#1804](https://github.com/getpaseo/paseo/pull/1804))
|
||||
|
||||
## 0.1.101 - 2026-06-26
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ npm run typecheck
|
||||
## コミュニティ
|
||||
|
||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 実装のセルフホスト型リレー
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 拡張機能
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -169,6 +169,7 @@ npm run typecheck
|
||||
## Community
|
||||
|
||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ npm run typecheck
|
||||
## 社区
|
||||
|
||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 实现的自托管 relay
|
||||
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 扩展
|
||||
|
||||
### 自托管 relay TLS
|
||||
|
||||
|
||||
@@ -164,7 +164,12 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
|
||||
},
|
||||
providers: {
|
||||
openai: { voice: { apiKey: string, baseUrl: string } },
|
||||
openai: {
|
||||
apiKey?: string,
|
||||
baseUrl?: string,
|
||||
stt?: { apiKey?: string, baseUrl?: string },
|
||||
tts?: { apiKey?: string, baseUrl?: string }
|
||||
},
|
||||
local: { modelsDir: string }
|
||||
},
|
||||
agents: {
|
||||
@@ -202,13 +207,17 @@ Set these to select OpenAI instead of local speech:
|
||||
| `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider |
|
||||
| `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider |
|
||||
|
||||
OpenAI voice can be configured under `providers.openai`:
|
||||
OpenAI speech can be configured under `providers.openai`. STT and TTS resolve independently, so they can point at different endpoints:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openai": {
|
||||
"voice": {
|
||||
"stt": {
|
||||
"apiKey": "sk-...",
|
||||
"baseUrl": "https://stt.example.com/v1"
|
||||
},
|
||||
"tts": {
|
||||
"apiKey": "sk-...",
|
||||
"baseUrl": "https://api.openai.com/v1"
|
||||
}
|
||||
@@ -217,7 +226,7 @@ OpenAI voice can be configured under `providers.openai`:
|
||||
}
|
||||
```
|
||||
|
||||
`providers.openai.voice.apiKey` and `providers.openai.voice.baseUrl` apply only to Paseo OpenAI voice features.
|
||||
`providers.openai.stt` is used for both composer dictation and voice mode speech-to-text; `providers.openai.tts` is used for voice mode text-to-speech. The equivalent env vars are `OPENAI_STT_API_KEY`/`OPENAI_STT_BASE_URL` and `OPENAI_TTS_API_KEY`/`OPENAI_TTS_BASE_URL`. Each feature falls back to `providers.openai.apiKey`/`providers.openai.baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when its own fields are unset. These settings apply only to Paseo OpenAI speech features, not to Codex or other OpenAI-backed tools.
|
||||
|
||||
Paseo uses these paths under the configured OpenAI base URL:
|
||||
|
||||
|
||||
@@ -169,6 +169,14 @@ The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
|
||||
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
|
||||
defaults. The default rotation is `10m` x `3` files everywhere.
|
||||
|
||||
### Workspace search
|
||||
|
||||
Workspace file and directory suggestions are backed by `@ff-labs/fff-node` in
|
||||
`packages/server/src/server/search/workspace-entries.ts`. Treat that backend as
|
||||
the search and ignore boundary: do not add a separate `.gitignore`/`.rgignore`
|
||||
matcher in Paseo. If ignore semantics need to change, change the backend
|
||||
contract and keep the packaged desktop smoke covering dot-prefixed suggestions.
|
||||
|
||||
## paseo.json service scripts
|
||||
|
||||
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
|
||||
|
||||
@@ -131,6 +131,7 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
|
||||
- For full-suite confidence, push to CI and check GitHub Actions.
|
||||
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
|
||||
- App Playwright specs share one isolated daemon per run. Helpers that create projects or workspaces must remove the daemon project record during cleanup, not only delete the temp directory. Agent helpers must pass the intended `workspaceId` through to agent creation; never infer ownership from `cwd`.
|
||||
- CI can shard app Playwright across multiple jobs; each shard still owns a full isolated daemon/relay/Metro stack from global setup. Helpers that restart the daemon must preserve the global setup environment, including disabled speech/local-model settings, so a restart does not change the tested surface or start background downloads.
|
||||
|
||||
## Agent authentication in tests
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-4fS/faM5nUjfRnc0UznCisNAby2fiNg0ROklW0FyptY=
|
||||
sha256-qdRCpAtiqTk5mqz+lE6xTO18PtmD1ROQuDRtqDz6reI=
|
||||
|
||||
374
package-lock.json
generated
374
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.102",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.102",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -6154,6 +6154,141 @@
|
||||
"excpretty": "build/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@ff-labs/fff-bin-darwin-arm64": {
|
||||
"version": "0.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-darwin-arm64/-/fff-bin-darwin-arm64-0.9.6.tgz",
|
||||
"integrity": "sha512-gJzvYuAbudAIvUVJAAMXjManTPxmFqqqqbJSZ0RuZTNWLK2nFMKMo0XUVCXcRVaTENY/VRoHjTTFCHjVebmolw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@ff-labs/fff-bin-darwin-x64": {
|
||||
"version": "0.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-darwin-x64/-/fff-bin-darwin-x64-0.9.6.tgz",
|
||||
"integrity": "sha512-m+p7te06G785VS6fLCFEEeO0twZqmWxy3zzCxcICdwebFqDY3cQGGqDQ6Lmr264Q36bLQjLE7t2iqWIwRoI6dg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@ff-labs/fff-bin-linux-arm64-gnu": {
|
||||
"version": "0.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-arm64-gnu/-/fff-bin-linux-arm64-gnu-0.9.6.tgz",
|
||||
"integrity": "sha512-UjqXuvJc2z2yeQ/6RqkgL1skdbzoZyexOvsBAt1VDIjUnm5AqoUoqh/guRQ2mke0dBWktZYRtvCrdS4CHB7dfA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@ff-labs/fff-bin-linux-arm64-musl": {
|
||||
"version": "0.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-arm64-musl/-/fff-bin-linux-arm64-musl-0.9.6.tgz",
|
||||
"integrity": "sha512-OdERLjrCwx3Ykd0szEe2WxDZ44653ZHftIl5XV4QFnsyn6jLaxu227JV3Ap9BIG3VBrKIBtr5pUqk9Tumbrolg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@ff-labs/fff-bin-linux-x64-gnu": {
|
||||
"version": "0.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-x64-gnu/-/fff-bin-linux-x64-gnu-0.9.6.tgz",
|
||||
"integrity": "sha512-OdROw237Ne7qy3HbTB78eovQMusccJ0PhRNAB6AmFuo+MiAzi1AdYs9iTAPYHqEH3uwEu0fdVFo9GTZcdnm03g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@ff-labs/fff-bin-linux-x64-musl": {
|
||||
"version": "0.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-x64-musl/-/fff-bin-linux-x64-musl-0.9.6.tgz",
|
||||
"integrity": "sha512-/7GJq5sw2WaNU79OKQqymYAF7lceMUm0OBw61M6tMDGi40LG1wNSF7uf8uLkD/p6GNkx8r/knmMDUuAKp208CQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@ff-labs/fff-bin-win32-arm64": {
|
||||
"version": "0.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-win32-arm64/-/fff-bin-win32-arm64-0.9.6.tgz",
|
||||
"integrity": "sha512-o0e8qu0kwqWEoV8rn/0wEmlFYBY09xZwaNdmBoGU6zXWZAviP/V92fzEED/yx0iIshq+qAYsuTuQSOj7gVDbVg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@ff-labs/fff-bin-win32-x64": {
|
||||
"version": "0.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-win32-x64/-/fff-bin-win32-x64-0.9.6.tgz",
|
||||
"integrity": "sha512-fkD3e6FmfR3i5Ymv14gfPT1FeJpnAXwe9Fu0r4sSDhvSE0x0ADQKEvwjMUBosb02O8XB9wWURx9cov1rCHVTnA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@ff-labs/fff-node": {
|
||||
"version": "0.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@ff-labs/fff-node/-/fff-node-0.9.6.tgz",
|
||||
"integrity": "sha512-2LbHJjwW/7vOLh1lSv0ipzA9Du5+LatUGiumQFaO9IcjMT8YgpmrCp/RRMyInsG0iBIX6A0WSxgei8qOFJLlNw==",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux",
|
||||
"win32"
|
||||
],
|
||||
"dependencies": {
|
||||
"ffi-rs": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@ff-labs/fff-bin-darwin-arm64": "0.9.6",
|
||||
"@ff-labs/fff-bin-darwin-x64": "0.9.6",
|
||||
"@ff-labs/fff-bin-linux-arm64-gnu": "0.9.6",
|
||||
"@ff-labs/fff-bin-linux-arm64-musl": "0.9.6",
|
||||
"@ff-labs/fff-bin-linux-x64-gnu": "0.9.6",
|
||||
"@ff-labs/fff-bin-linux-x64-musl": "0.9.6",
|
||||
"@ff-labs/fff-bin-win32-arm64": "0.9.6",
|
||||
"@ff-labs/fff-bin-win32-x64": "0.9.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||
@@ -13761,6 +13896,183 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@yuuang/ffi-rs-android-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-android-arm64/-/ffi-rs-android-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/@yuuang/ffi-rs-darwin-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-darwin-arm64/-/ffi-rs-darwin-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-kRdgPaOM6TfuC5wHUwstlatk4HNie2lwSLJWQL2LiAUIJ7+96CoiWUNVhwBcFrhdfxhnWenYS6F668CV0vit8Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/@yuuang/ffi-rs-darwin-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-darwin-x64/-/ffi-rs-darwin-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-O3AlVgre8FQcZRJe44Xs7A6iDLumoPXqbw40+eJCa2gyXaXyLPdHoWrS1W9rBCa1QZRRnG7zRulPVFw8C5uo8g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/@yuuang/ffi-rs-linux-arm-gnueabihf": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm-gnueabihf/-/ffi-rs-linux-arm-gnueabihf-1.3.2.tgz",
|
||||
"integrity": "sha512-IXiNdTbIcTCPny5eeElijFWYeKSJjQWSjt9ZyJNdLHYiB1Np+XD6K7wNZS6EOMgMelhW1kQE62T654skGkVDIA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/@yuuang/ffi-rs-linux-arm64-gnu": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm64-gnu/-/ffi-rs-linux-arm64-gnu-1.3.2.tgz",
|
||||
"integrity": "sha512-gWFO6xufUK9lPYUqDvKa6IR243dPqdetgl9Q7HrZWaDu7wLo06QQrosw8QTzndafQnOcBKm6LoLujmGCfTgJOA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/@yuuang/ffi-rs-linux-arm64-musl": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm64-musl/-/ffi-rs-linux-arm64-musl-1.3.2.tgz",
|
||||
"integrity": "sha512-lejvOSqypPziQH5rzfkDlJ6e92qhWbDutE9ttOO6z5I2k83zoh9iZhZWhaXSU5VqgQpcshRkrbtXb9gy1ft5dA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/@yuuang/ffi-rs-linux-x64-gnu": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-x64-gnu/-/ffi-rs-linux-x64-gnu-1.3.2.tgz",
|
||||
"integrity": "sha512-s8VCFazaJKmgY2hgMTpWk4TtBY/zy5ovbaGgwyY0FvBD0YvyhcET4IrMsDJpHhFVTPCYfKZ1dN45clD/YiFp6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/@yuuang/ffi-rs-linux-x64-musl": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-x64-musl/-/ffi-rs-linux-x64-musl-1.3.2.tgz",
|
||||
"integrity": "sha512-Ahr5chfKZKWUik20bEZRug+be57LZ2yYrtolyjSRoo7A4ZniBUHBZUNWm6TD6i0CJayqyxWeVk/XiaABD8bY0w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/@yuuang/ffi-rs-win32-arm64-msvc": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-arm64-msvc/-/ffi-rs-win32-arm64-msvc-1.3.2.tgz",
|
||||
"integrity": "sha512-yhpLcj0qel5VNlpzxPZfNmi7+rEX8444QHjUP6WWLxdRfqPllROu/Cp3OpkBpw3BLdxfcDhWkjWMD5QsJN0Pvg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/@yuuang/ffi-rs-win32-ia32-msvc": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-ia32-msvc/-/ffi-rs-win32-ia32-msvc-1.3.2.tgz",
|
||||
"integrity": "sha512-BFVSbdtg/7mJBw5kQFOPKFiA+SF7z3240HpzHN81Umm4Bp4dWkyx0msYn8+Q7/BBJiLQ4F6bi3Nftk58YA9r9w==",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/@yuuang/ffi-rs-win32-x64-msvc": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-x64-msvc/-/ffi-rs-win32-x64-msvc-1.3.2.tgz",
|
||||
"integrity": "sha512-ZL5MJ76n2rjwGo26kCWW7wK6QT/cee00Rx8pfW79pz6vM6jqfhoE7zTnwFiw4aOQUes9+HUc5DeeJ3z+Vb9oLg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/7zip-bin": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
|
||||
@@ -21209,6 +21521,25 @@
|
||||
"integrity": "sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ffi-rs": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ffi-rs/-/ffi-rs-1.3.2.tgz",
|
||||
"integrity": "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"@yuuang/ffi-rs-android-arm64": "1.3.2",
|
||||
"@yuuang/ffi-rs-darwin-arm64": "1.3.2",
|
||||
"@yuuang/ffi-rs-darwin-x64": "1.3.2",
|
||||
"@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2",
|
||||
"@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2",
|
||||
"@yuuang/ffi-rs-linux-arm64-musl": "1.3.2",
|
||||
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.2",
|
||||
"@yuuang/ffi-rs-linux-x64-musl": "1.3.2",
|
||||
"@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2",
|
||||
"@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2",
|
||||
"@yuuang/ffi-rs-win32-x64-msvc": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
@@ -35132,7 +35463,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.102",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -36150,12 +36481,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.102",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.102-beta.2",
|
||||
"@getpaseo/protocol": "0.1.102-beta.2",
|
||||
"@getpaseo/server": "0.1.102-beta.2",
|
||||
"@getpaseo/client": "0.1.102",
|
||||
"@getpaseo/protocol": "0.1.102",
|
||||
"@getpaseo/server": "0.1.102",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -36401,10 +36732,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.102",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.102-beta.2",
|
||||
"@getpaseo/relay": "0.1.102-beta.2",
|
||||
"@getpaseo/protocol": "0.1.102",
|
||||
"@getpaseo/relay": "0.1.102",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36415,7 +36746,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.102",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -36658,7 +36989,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.102",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -37554,7 +37885,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.102",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -37786,7 +38117,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.102",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -37798,7 +38129,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.102",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38016,15 +38347,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.102",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.1.102-beta.2",
|
||||
"@getpaseo/highlight": "0.1.102-beta.2",
|
||||
"@getpaseo/protocol": "0.1.102-beta.2",
|
||||
"@getpaseo/relay": "0.1.102-beta.2",
|
||||
"@ff-labs/fff-node": "^0.9.6",
|
||||
"@getpaseo/client": "0.1.102",
|
||||
"@getpaseo/highlight": "0.1.102",
|
||||
"@getpaseo/protocol": "0.1.102",
|
||||
"@getpaseo/relay": "0.1.102",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38561,7 +38893,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.102",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.102",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
49
packages/app/e2e/command-center-host.spec.ts
Normal file
49
packages/app/e2e/command-center-host.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { createIdleAgent } from "./helpers/archive-tab";
|
||||
import { openCommandCenter } from "./helpers/command-center";
|
||||
import { addOfflineHostAndReload } from "./helpers/hosts";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
const PRIMARY_HOST_LABEL = "Primary Host";
|
||||
const SECONDARY_HOST_ID = "host-command-center-secondary";
|
||||
|
||||
test.describe("Command center host labels", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("agent results show the host they live on when more than one host exists", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "command-center-host-" });
|
||||
const title = `cc-host-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
try {
|
||||
const agent = await createIdleAgent(seeded.client, {
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title,
|
||||
});
|
||||
|
||||
// A second (offline) host makes the view multi-host, which is when the host label earns its space.
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
label: "Secondary Host",
|
||||
primaryLabel: PRIMARY_HOST_LABEL,
|
||||
});
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
|
||||
// The shared daemon may carry agents from other specs, so target this agent by its id.
|
||||
const row = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row).toContainText(title);
|
||||
await expect(row).toContainText(PRIMARY_HOST_LABEL);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { composerLocator, expectComposerVisible } from "./helpers/composer";
|
||||
import {
|
||||
@@ -209,6 +211,25 @@ async function openReadyMockAgent(
|
||||
}
|
||||
}
|
||||
|
||||
async function seedDotPrefixedWorkspaceFiles(cwd: string): Promise<void> {
|
||||
await writeFile(path.join(cwd, ".env.local"), "PASEO_E2E=1\n");
|
||||
await mkdir(path.join(cwd, ".opencode"), { recursive: true });
|
||||
await writeFile(path.join(cwd, ".opencode", "settings.json"), "{}\n");
|
||||
}
|
||||
|
||||
async function expectFileMentionSuggestion(
|
||||
page: Page,
|
||||
query: string,
|
||||
suggestion: string,
|
||||
): Promise<void> {
|
||||
const input = composerLocator(page);
|
||||
await input.fill(query);
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByText(suggestion, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function visiblePopoverBox(
|
||||
page: Page,
|
||||
): Promise<{ top: number; bottom: number; height: number }> {
|
||||
@@ -519,6 +540,28 @@ test.describe("Composer autocomplete", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("suggests dot-prefixed workspace entries for file mentions", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "autocomplete-dot-entries-",
|
||||
title: "Dot file mention autocomplete",
|
||||
});
|
||||
|
||||
try {
|
||||
await seedDotPrefixedWorkspaceFiles(session.cwd);
|
||||
await openAgentRoute(page, session);
|
||||
await expectWorkspaceTabVisible(page, session.agentId);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
await expectFileMentionSuggestion(page, "@.env", ".env.local");
|
||||
await expectFileMentionSuggestion(page, "@.opencode", ".opencode/settings.json");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("stays anchored to the composer when the desktop sidebar is open", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const agent = await openReadyMockAgent(page);
|
||||
|
||||
@@ -4,8 +4,11 @@ import { getServerId } from "./helpers/server-id";
|
||||
import {
|
||||
loadRealDaemonState,
|
||||
injectDesktopBridge,
|
||||
openDesktopAboutSettings,
|
||||
openDesktopSettings,
|
||||
expectUpdateBanner,
|
||||
clickCheckForUpdates,
|
||||
expectPendingUpdateCheckResult,
|
||||
clickInstallUpdate,
|
||||
expectInstallInProgress,
|
||||
interceptDaemonManagementConfirmDialog,
|
||||
@@ -45,6 +48,21 @@ test.describe("Desktop updates", () => {
|
||||
await clickInstallUpdate(page);
|
||||
await expectInstallInProgress(page);
|
||||
});
|
||||
|
||||
test("manual check reports a found update while it downloads", async ({ page }) => {
|
||||
await injectDesktopBridge(page, {
|
||||
serverId: getServerId(),
|
||||
updateAvailable: true,
|
||||
latestVersion: "1.2.3",
|
||||
updateReadyToInstall: false,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openDesktopAboutSettings(page);
|
||||
|
||||
await clickCheckForUpdates(page);
|
||||
|
||||
await expectPendingUpdateCheckResult(page, "1.2.3");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Desktop daemon management", () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import dotenv from "dotenv";
|
||||
import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory";
|
||||
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork";
|
||||
import { withDisabledE2ESpeechEnv } from "./helpers/speech-env";
|
||||
|
||||
const wranglerCliPath = path.resolve(__dirname, "../node_modules/wrangler/bin/wrangler.js");
|
||||
|
||||
@@ -405,15 +406,6 @@ async function waitForPairingOfferFromDaemon(args: {
|
||||
);
|
||||
}
|
||||
|
||||
const LOCAL_SPEECH_ENV_KEYS = [
|
||||
"PASEO_LOCAL_MODELS_DIR",
|
||||
"PASEO_DICTATION_LOCAL_STT_MODEL",
|
||||
"PASEO_VOICE_LOCAL_STT_MODEL",
|
||||
"PASEO_VOICE_LOCAL_TTS_MODEL",
|
||||
"PASEO_VOICE_LOCAL_TTS_SPEAKER_ID",
|
||||
"PASEO_VOICE_LOCAL_TTS_SPEED",
|
||||
] as const;
|
||||
|
||||
async function loadEnvTestFile(repoRoot: string): Promise<void> {
|
||||
const envTestPath = path.join(repoRoot, ".env.test");
|
||||
if (existsSync(envTestPath)) {
|
||||
@@ -675,7 +667,7 @@ interface DaemonSpawnArgs {
|
||||
function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
||||
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
const env = withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
PATH: `${args.fakeEditorBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
PASEO_HOME: args.paseoHome,
|
||||
@@ -684,21 +676,9 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||
// Default app E2E does not cover speech flows. Keep these disabled so
|
||||
// unrelated tests never start background local-model downloads.
|
||||
PASEO_DICTATION_ENABLED: "0",
|
||||
PASEO_VOICE_MODE_ENABLED: "0",
|
||||
PASEO_DICTATION_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_TURN_DETECTION_PROVIDER: "openai",
|
||||
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_TTS_PROVIDER: "openai",
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
};
|
||||
|
||||
for (const key of LOCAL_SPEECH_ENV_KEYS) {
|
||||
delete env[key];
|
||||
}
|
||||
});
|
||||
|
||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
|
||||
9
packages/app/e2e/helpers/command-center.ts
Normal file
9
packages/app/e2e/helpers/command-center.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
// Opens the command center / global search palette from the sidebar and returns its panel.
|
||||
export async function openCommandCenter(page: Page): Promise<Locator> {
|
||||
await page.getByTestId("sidebar-command-center-search").click();
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
return panel;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import net from "node:net";
|
||||
import path from "node:path";
|
||||
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
import { withDisabledE2ESpeechEnv } from "./speech-env";
|
||||
|
||||
/**
|
||||
* Restarts the isolated E2E daemon against the SAME PASEO_HOME and SAME port so
|
||||
@@ -93,20 +94,21 @@ function spawnSupervisor(args: {
|
||||
// inside the Playwright worker (the shim is a .mjs symlink, not an executable),
|
||||
// so resolve the CLI module and load it with node.
|
||||
const tsxCli = createRequire(path.join(serverDir, "package.json")).resolve("tsx/cli");
|
||||
const env = withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
PASEO_HOME: args.paseoHome,
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
});
|
||||
|
||||
const child = spawn(process.execPath, [tsxCli, "scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_HOME: args.paseoHome,
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
},
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
@@ -3,7 +3,8 @@ import { appendFile } from "node:fs/promises";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { openSettings } from "./app";
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
import { openSettingsHost, openSettingsHostSection } from "./settings";
|
||||
import { escapeRegex } from "./regex";
|
||||
import { openSettingsHost, openSettingsHostSection, openSettingsSection } from "./settings";
|
||||
|
||||
interface DaemonApiStatus {
|
||||
version: string;
|
||||
@@ -52,6 +53,7 @@ export interface DesktopBridgeConfig {
|
||||
serverId: string;
|
||||
updateAvailable?: boolean;
|
||||
latestVersion?: string;
|
||||
updateReadyToInstall?: boolean;
|
||||
slowInstall?: boolean;
|
||||
/** Initial PID reported by desktop_daemon_status. Defaults to null. */
|
||||
daemonPid?: number | null;
|
||||
@@ -169,7 +171,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
return cfg.updateAvailable
|
||||
? {
|
||||
hasUpdate: true,
|
||||
readyToInstall: true,
|
||||
readyToInstall: cfg.updateReadyToInstall ?? true,
|
||||
currentVersion: "1.0.0",
|
||||
latestVersion: cfg.latestVersion ?? "1.2.3",
|
||||
body: null,
|
||||
@@ -276,12 +278,33 @@ export async function openDesktopSettings(page: Page, serverId: string): Promise
|
||||
});
|
||||
}
|
||||
|
||||
export async function openDesktopAboutSettings(page: Page): Promise<void> {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "about");
|
||||
await expect(page.getByText("App updates", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectUpdateBanner(page: Page, version: string): Promise<void> {
|
||||
const callout = page.getByTestId("update-callout");
|
||||
await expect(callout).toBeVisible({ timeout: 15_000 });
|
||||
await expect(callout).toContainText(`v${version.replace(/^v/i, "")}`);
|
||||
}
|
||||
|
||||
export async function clickCheckForUpdates(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Check" }).click();
|
||||
}
|
||||
|
||||
export async function expectPendingUpdateCheckResult(page: Page, version: string): Promise<void> {
|
||||
const normalizedVersion = `v${version.replace(/^v/i, "")}`;
|
||||
await expect(
|
||||
page.getByText(
|
||||
new RegExp(`Update found: ${escapeRegex(normalizedVersion)}\\. Downloading\\.\\.\\.`),
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(`Ready to install: ${normalizedVersion}`)).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Update" })).toBeDisabled();
|
||||
}
|
||||
|
||||
export async function clickInstallUpdate(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Install & restart" }).click();
|
||||
}
|
||||
|
||||
79
packages/app/e2e/helpers/hosts.ts
Normal file
79
packages/app/e2e/helpers/hosts.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildSeededHost } from "./daemon-registry";
|
||||
|
||||
const REGISTRY_KEY = "@paseo:daemon-registry";
|
||||
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
||||
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
||||
|
||||
// The multi-host UI (the command-center host label, the sidebar host filter) only renders once
|
||||
// more than one host exists. The e2e harness runs a single real daemon, so we add an extra registry
|
||||
// entry pointing at an unreachable endpoint: it stays offline, which is enough to make the UI treat
|
||||
// the view as multi-host without standing up a second daemon.
|
||||
//
|
||||
// Must run AFTER the first navigation: the auto-seed fixture writes the registry + nonce on load,
|
||||
// and reseeds on every navigation. We write the full registry here and set the fixture's
|
||||
// disable-once flag, then reload — so the fixture skips its reset and the registry survives. This
|
||||
// avoids depending on the (unspecified) ordering of multiple Playwright init scripts. Optionally
|
||||
// relabels the seeded primary host so assertions can target a distinctive name.
|
||||
export async function addOfflineHostAndReload(
|
||||
page: Page,
|
||||
input: { serverId: string; label: string; primaryLabel?: string },
|
||||
): Promise<void> {
|
||||
const offlineHost = buildSeededHost({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
endpoint: "127.0.0.1:59999",
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await page.evaluate(
|
||||
({ host, keys, primaryLabel }) => {
|
||||
const nonce = localStorage.getItem(keys.nonce);
|
||||
if (!nonce) {
|
||||
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
|
||||
}
|
||||
const raw = localStorage.getItem(keys.registry);
|
||||
const registry: Array<{ serverId: string; label?: string }> = raw ? JSON.parse(raw) : [];
|
||||
if (primaryLabel && registry[0]) {
|
||||
registry[0].label = primaryLabel;
|
||||
}
|
||||
if (!registry.some((entry) => entry.serverId === host.serverId)) {
|
||||
registry.push(host);
|
||||
}
|
||||
localStorage.setItem(keys.registry, JSON.stringify(registry));
|
||||
localStorage.setItem(keys.disableSeedOnce, nonce);
|
||||
},
|
||||
{
|
||||
host: offlineHost,
|
||||
keys: {
|
||||
registry: REGISTRY_KEY,
|
||||
nonce: SEED_NONCE_KEY,
|
||||
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||
},
|
||||
primaryLabel: input.primaryLabel,
|
||||
},
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
// A host's filter row carries a status dot on the left next to its label.
|
||||
export async function expectHostFilterRow(page: Page, serverId: string): Promise<void> {
|
||||
await expect(page.getByTestId(`sidebar-host-filter-${serverId}`)).toBeVisible();
|
||||
await expect(page.getByTestId(`sidebar-host-filter-status-${serverId}`)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function toggleHostFilter(page: Page, serverId: string): Promise<void> {
|
||||
await page.getByTestId(`sidebar-host-filter-${serverId}`).click();
|
||||
}
|
||||
|
||||
export async function selectAllHostsFilter(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-host-filter-all").click();
|
||||
}
|
||||
32
packages/app/e2e/helpers/speech-env.ts
Normal file
32
packages/app/e2e/helpers/speech-env.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
const LOCAL_SPEECH_ENV_KEYS = [
|
||||
"PASEO_LOCAL_MODELS_DIR",
|
||||
"PASEO_DICTATION_LOCAL_STT_MODEL",
|
||||
"PASEO_VOICE_LOCAL_STT_MODEL",
|
||||
"PASEO_VOICE_LOCAL_TTS_MODEL",
|
||||
"PASEO_VOICE_LOCAL_TTS_SPEAKER_ID",
|
||||
"PASEO_VOICE_LOCAL_TTS_SPEED",
|
||||
] as const;
|
||||
|
||||
const DISABLED_E2E_SPEECH_ENV = {
|
||||
PASEO_DICTATION_ENABLED: "0",
|
||||
PASEO_VOICE_MODE_ENABLED: "0",
|
||||
PASEO_DICTATION_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_TURN_DETECTION_PROVIDER: "openai",
|
||||
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_TTS_PROVIDER: "openai",
|
||||
} as const;
|
||||
|
||||
export function withDisabledE2ESpeechEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
// Default app E2E does not cover speech flows; keep restarts from starting
|
||||
// background local-model downloads for unrelated tests.
|
||||
const next: NodeJS.ProcessEnv = {
|
||||
...env,
|
||||
...DISABLED_E2E_SPEECH_ENV,
|
||||
};
|
||||
|
||||
for (const key of LOCAL_SPEECH_ENV_KEYS) {
|
||||
delete next[key];
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
56
packages/app/e2e/sidebar-host-filter-multi.spec.ts
Normal file
56
packages/app/e2e/sidebar-host-filter-multi.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
addOfflineHostAndReload,
|
||||
expectHostFilterRow,
|
||||
openSidebarDisplayPreferences,
|
||||
selectAllHostsFilter,
|
||||
toggleHostFilter,
|
||||
} from "./helpers/hosts";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
const SECONDARY_HOST_ID = "host-filter-secondary";
|
||||
|
||||
test.describe("Sidebar host filter (multi-select)", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("pins the sidebar to multiple selected hosts at once", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "host-filter-" });
|
||||
const serverId = getServerId();
|
||||
const workspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${serverId}:${seeded.workspaceId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
// A second (offline) host is enough to surface the host filter without a second daemon.
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, { serverId: SECONDARY_HOST_ID, label: "Secondary Host" });
|
||||
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await openSidebarDisplayPreferences(page);
|
||||
await expectHostFilterRow(page, serverId);
|
||||
await expectHostFilterRow(page, SECONDARY_HOST_ID);
|
||||
|
||||
// Pin the primary host — its workspace stays visible.
|
||||
await toggleHostFilter(page, serverId);
|
||||
await expect(workspaceRow).toBeVisible();
|
||||
|
||||
// Add the secondary host without clearing the primary. Under single-select this would replace
|
||||
// the primary and hide the workspace; multi-select keeps both pinned, so it stays visible.
|
||||
await toggleHostFilter(page, SECONDARY_HOST_ID);
|
||||
await expect(workspaceRow).toBeVisible();
|
||||
|
||||
// Drop the primary host — only the (empty) secondary host remains pinned, so the workspace hides.
|
||||
await toggleHostFilter(page, serverId);
|
||||
await expect(workspaceRow).toHaveCount(0, { timeout: 10_000 });
|
||||
|
||||
// Back to all hosts — the workspace returns.
|
||||
await selectAllHostsFilter(page);
|
||||
await expect(workspaceRow).toBeVisible({ timeout: 10_000 });
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment } from "@/uti
|
||||
import { buildSeededHost } from "./helpers/daemon-registry";
|
||||
import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory";
|
||||
import { withDisabledE2ESpeechEnv } from "./helpers/speech-env";
|
||||
import {
|
||||
expectNewWorkspaceProjectSelected,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
@@ -243,18 +244,16 @@ async function startRestartDaemon(input: {
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
env: withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
PASEO_HOME: input.paseoHome,
|
||||
PASEO_SERVER_ID: SERVER_ID,
|
||||
PASEO_LISTEN: `127.0.0.1:${port}`,
|
||||
PASEO_CORS_ORIGINS: input.origin,
|
||||
PASEO_RELAY_ENABLED: "0",
|
||||
PASEO_DICTATION_ENABLED: "0",
|
||||
PASEO_VOICE_MODE_ENABLED: "0",
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
},
|
||||
}),
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.102",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -112,6 +112,26 @@ function footerOwners(layout: StreamLayout): string[] {
|
||||
return owners;
|
||||
}
|
||||
|
||||
function footerAssistantIds(layout: StreamLayout): string[] {
|
||||
return [
|
||||
...layout.history.flatMap((item) =>
|
||||
item.completedFooter ? [item.completedFooter.itemId] : [],
|
||||
),
|
||||
...layout.liveHead.flatMap((item) =>
|
||||
item.completedFooter ? [item.completedFooter.itemId] : [],
|
||||
),
|
||||
...(layout.auxiliaryTurnFooter ? [layout.auxiliaryTurnFooter.itemId] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function inlineFooterPlacementByItemId(layout: StreamLayout): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
[...layout.history, ...layout.liveHead].flatMap((item) =>
|
||||
item.completedFooter ? [[item.item.id, item.completedFooter.itemId]] : [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
|
||||
const item = [...layout.history, ...layout.liveHead].find(
|
||||
(candidate) => candidate.item.id === id,
|
||||
@@ -291,7 +311,7 @@ describe("layoutStream", () => {
|
||||
});
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"keeps inline footer on an assistant turn with trailing tool rows before the next user on %s",
|
||||
"places inline footer after trailing visible tool rows before the next user on %s",
|
||||
(platform) => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const tool = toolCall("tool-1", 3);
|
||||
@@ -302,13 +322,36 @@ describe("layoutStream", () => {
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, assistant.id).completedFooter?.itemId).toBe(assistant.id);
|
||||
expect(footerOwners(layout)).toEqual([assistant.id]);
|
||||
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, tool.id).completedFooter?.itemId).toBe(assistant.id);
|
||||
expect(footerOwners(layout)).toEqual([tool.id]);
|
||||
expect(footerAssistantIds(layout)).toEqual([assistant.id]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"uses the latest assistant for an inline footer when a turn has multiple assistant blocks on %s",
|
||||
"places split live-head tool footer using the assistant from history on %s",
|
||||
(platform) => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const tool = toolCall("tool-1", 3);
|
||||
const layout = layoutFor({
|
||||
platform,
|
||||
tail: [userMessage("u1", 1), assistant],
|
||||
head: [tool, userMessage("u2", 4)],
|
||||
timingIds: [assistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, tool.id).completedFooter?.itemId).toBe(assistant.id);
|
||||
expect(inlineFooterPlacementByItemId(layout)).toEqual({
|
||||
[tool.id]: assistant.id,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"uses the latest assistant for footer content while placing after the visible turn end on %s",
|
||||
(platform) => {
|
||||
const firstAssistant = assistantMessage("a1", 2);
|
||||
const firstTool = toolCall("tool-1", 3);
|
||||
@@ -329,10 +372,46 @@ describe("layoutStream", () => {
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, firstAssistant.id).completedFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, latestAssistant.id).completedFooter?.itemId).toBe(
|
||||
expect(findLayoutItem(layout, latestAssistant.id).completedFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, latestTool.id).completedFooter?.itemId).toBe(
|
||||
latestAssistant.id,
|
||||
);
|
||||
expect(footerOwners(layout)).toEqual([latestAssistant.id]);
|
||||
expect(footerOwners(layout)).toEqual([latestTool.id]);
|
||||
expect(footerAssistantIds(layout)).toEqual([latestAssistant.id]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"keeps every completed turn footer while placing each one after that turn's last visible item on %s",
|
||||
(platform) => {
|
||||
const firstAssistant = assistantMessage("a1", 2);
|
||||
const secondAssistant = assistantMessage("a2", 4);
|
||||
const secondTool = toolCall("tool-2", 5);
|
||||
const layout = layoutFor({
|
||||
platform,
|
||||
tail: [
|
||||
userMessage("u1", 1),
|
||||
firstAssistant,
|
||||
userMessage("u2", 3),
|
||||
secondAssistant,
|
||||
secondTool,
|
||||
userMessage("u3", 6),
|
||||
],
|
||||
timingIds: [firstAssistant.id, secondAssistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, firstAssistant.id).completedFooter?.itemId).toBe(
|
||||
firstAssistant.id,
|
||||
);
|
||||
expect(findLayoutItem(layout, secondAssistant.id).completedFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, secondTool.id).completedFooter?.itemId).toBe(
|
||||
secondAssistant.id,
|
||||
);
|
||||
expect(inlineFooterPlacementByItemId(layout)).toEqual({
|
||||
[firstAssistant.id]: firstAssistant.id,
|
||||
[secondTool.id]: secondAssistant.id,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ export interface StreamLayoutInput {
|
||||
|
||||
interface LayoutSegmentInput {
|
||||
strategy: StreamStrategy;
|
||||
agentStatus: string;
|
||||
items: StreamItem[];
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
@@ -52,6 +51,14 @@ interface LayoutSegmentInput {
|
||||
boundaryIndex: number | null;
|
||||
boundaryAboveItem: StreamItem | null;
|
||||
boundaryBelowItem: StreamItem | null;
|
||||
boundaryAboveItems: StreamItem[] | null;
|
||||
boundaryAboveIndex: number | null;
|
||||
}
|
||||
|
||||
interface AssistantFooterSource {
|
||||
item: Extract<StreamItem, { kind: "assistant_message" }>;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
}
|
||||
|
||||
function createTurnFooterHost(input: {
|
||||
@@ -68,25 +75,45 @@ function createTurnFooterHost(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function findLatestAssistantIndexInTurn(input: {
|
||||
function findLatestAssistantInTurn(input: {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
startIndex: number;
|
||||
}): number | null {
|
||||
for (
|
||||
let index = input.startIndex;
|
||||
index >= 0 && index < input.items.length;
|
||||
index = input.strategy.getNeighborIndex(index, "above")
|
||||
) {
|
||||
const item = input.items[index];
|
||||
if (!item || item.kind === "user_message") {
|
||||
boundaryAboveItems?: StreamItem[] | null;
|
||||
boundaryAboveIndex?: number | null;
|
||||
}): AssistantFooterSource | null {
|
||||
let items = input.items;
|
||||
let index = input.startIndex;
|
||||
let canCrossBoundary = true;
|
||||
|
||||
while (true) {
|
||||
for (
|
||||
;
|
||||
index >= 0 && index < items.length;
|
||||
index = input.strategy.getNeighborIndex(index, "above")
|
||||
) {
|
||||
const item = items[index];
|
||||
if (!item || item.kind === "user_message") {
|
||||
return null;
|
||||
}
|
||||
if (item.kind === "assistant_message") {
|
||||
return { item, items, index };
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!canCrossBoundary ||
|
||||
!input.boundaryAboveItems ||
|
||||
input.boundaryAboveIndex === null ||
|
||||
input.boundaryAboveIndex === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (item.kind === "assistant_message") {
|
||||
return index;
|
||||
}
|
||||
|
||||
items = input.boundaryAboveItems;
|
||||
index = input.boundaryAboveIndex;
|
||||
canCrossBoundary = false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | null {
|
||||
@@ -100,104 +127,54 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
|
||||
return null;
|
||||
}
|
||||
|
||||
const assistantIndex = findLatestAssistantIndexInTurn({
|
||||
const assistant = findLatestAssistantInTurn({
|
||||
strategy: input.strategy,
|
||||
items: footerItems,
|
||||
startIndex: latestIndex,
|
||||
});
|
||||
if (assistantIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = footerItems[assistantIndex];
|
||||
if (!item || item.kind !== "assistant_message") {
|
||||
if (!assistant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createTurnFooterHost({
|
||||
item,
|
||||
items: footerItems,
|
||||
index: assistantIndex,
|
||||
item: assistant.item,
|
||||
items: assistant.items,
|
||||
index: assistant.index,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
});
|
||||
}
|
||||
|
||||
function findTurnEndIndexInSegment(input: {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
startIndex: number;
|
||||
}): number {
|
||||
let endIndex = input.startIndex;
|
||||
for (
|
||||
let index = input.strategy.getNeighborIndex(input.startIndex, "below");
|
||||
index >= 0 && index < input.items.length;
|
||||
index = input.strategy.getNeighborIndex(index, "below")
|
||||
) {
|
||||
const item = input.items[index];
|
||||
if (!item || item.kind === "user_message") {
|
||||
break;
|
||||
}
|
||||
endIndex = index;
|
||||
}
|
||||
return endIndex;
|
||||
}
|
||||
|
||||
function shouldRenderCompletedFooter(input: {
|
||||
function resolveCompletedFooter(input: {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
item: StreamItem;
|
||||
belowItem: StreamItem | null;
|
||||
agentStatus: string;
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
boundaryIndex: number | null;
|
||||
boundaryBelowItem: StreamItem | null;
|
||||
}): boolean {
|
||||
if (
|
||||
input.item.kind !== "assistant_message" ||
|
||||
input.auxiliaryTurnFooter?.itemId === input.item.id
|
||||
) {
|
||||
return false;
|
||||
boundaryAboveItems: StreamItem[] | null;
|
||||
boundaryAboveIndex: number | null;
|
||||
}): TurnFooterHost | null {
|
||||
if (input.item.kind === "user_message" || input.belowItem?.kind !== "user_message") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
input.belowItem?.kind === "user_message" ||
|
||||
(input.belowItem === null && input.agentStatus !== "running")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isToolSequenceItem(input.belowItem)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sameSegmentBelowItem = input.strategy.getNeighborItem(input.items, input.index, "below");
|
||||
if (sameSegmentBelowItem?.id !== input.belowItem.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const turnEndIndex = findTurnEndIndexInSegment({
|
||||
const assistant = findLatestAssistantInTurn({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
startIndex: input.index,
|
||||
boundaryAboveItems: input.boundaryAboveItems,
|
||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||
});
|
||||
const belowTurnItem = getSegmentNeighbor({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
index: turnEndIndex,
|
||||
relation: "below",
|
||||
boundaryIndex: input.boundaryIndex,
|
||||
boundaryItem: input.boundaryBelowItem,
|
||||
});
|
||||
if (input.agentStatus === "running" && belowTurnItem?.kind !== "user_message") {
|
||||
return false;
|
||||
if (!assistant || input.auxiliaryTurnFooter?.itemId === assistant.item.id) {
|
||||
return null;
|
||||
}
|
||||
const assistantIndex = findLatestAssistantIndexInTurn({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
startIndex: turnEndIndex,
|
||||
return createTurnFooterHost({
|
||||
item: assistant.item,
|
||||
items: assistant.items,
|
||||
index: assistant.index,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
});
|
||||
return assistantIndex === input.index;
|
||||
}
|
||||
|
||||
function isToolSequenceItem(
|
||||
@@ -270,24 +247,17 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
|
||||
aboveItem,
|
||||
belowItem,
|
||||
});
|
||||
const completedFooter = shouldRenderCompletedFooter({
|
||||
const completedFooter = resolveCompletedFooter({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
index,
|
||||
item,
|
||||
belowItem,
|
||||
agentStatus: input.agentStatus,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
|
||||
boundaryIndex: input.boundaryIndex,
|
||||
boundaryBelowItem: input.boundaryBelowItem,
|
||||
})
|
||||
? createTurnFooterHost({
|
||||
item,
|
||||
items: input.items,
|
||||
index,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
})
|
||||
: null;
|
||||
boundaryAboveItems: input.boundaryAboveItems,
|
||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||
});
|
||||
|
||||
return {
|
||||
item,
|
||||
@@ -328,7 +298,6 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
// and .kind are stable across text-only flushes (text growth doesn't change what kind of
|
||||
// item borders history), so cached layout stays valid between flushes.
|
||||
const historyCacheKey = [
|
||||
input.agentStatus,
|
||||
frameOrder,
|
||||
historyBoundaryIndex ?? "null",
|
||||
liveHeadBoundaryItem?.id ?? "null",
|
||||
@@ -346,7 +315,6 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
} else {
|
||||
history = layoutSegment({
|
||||
strategy: input.strategy,
|
||||
agentStatus: input.agentStatus,
|
||||
items: input.history,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter,
|
||||
@@ -354,6 +322,8 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
boundaryIndex: historyBoundaryIndex,
|
||||
boundaryAboveItem: null,
|
||||
boundaryBelowItem: liveHeadBoundaryItem,
|
||||
boundaryAboveItems: null,
|
||||
boundaryAboveIndex: null,
|
||||
});
|
||||
byKey.set(historyCacheKey, history);
|
||||
}
|
||||
@@ -363,7 +333,6 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
|
||||
const liveHead = layoutSegment({
|
||||
strategy: input.strategy,
|
||||
agentStatus: input.agentStatus,
|
||||
items: input.liveHead,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter,
|
||||
@@ -371,6 +340,8 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
boundaryIndex: liveHeadBoundaryIndex,
|
||||
boundaryAboveItem: historyBoundaryItem,
|
||||
boundaryBelowItem: null,
|
||||
boundaryAboveItems: input.history,
|
||||
boundaryAboveIndex: historyBoundaryIndex,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Home, Plus, Settings } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { useCommandCenter } from "@/hooks/use-command-center";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
@@ -199,9 +200,10 @@ function CommandCenterAgentRow({
|
||||
|
||||
interface CommandCenterAgentRowContentProps {
|
||||
agent: AggregatedAgent;
|
||||
showHost: boolean;
|
||||
}
|
||||
|
||||
function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentProps) {
|
||||
function CommandCenterAgentRowContent({ agent, showHost }: CommandCenterAgentRowContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const titleStyle = useMemo(
|
||||
@@ -213,7 +215,7 @@ function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentPro
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
return (
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
|
||||
<View style={styles.rowMain}>
|
||||
<View style={styles.iconSlot}>
|
||||
<AgentStatusDot
|
||||
@@ -226,7 +228,8 @@ function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentPro
|
||||
<Text style={titleStyle} numberOfLines={1}>
|
||||
{agent.title || t("shell.commandCenter.newAgent")}
|
||||
</Text>
|
||||
<Text style={subtitleStyle} numberOfLines={1}>
|
||||
<Text style={subtitleStyle} numberOfLines={1} testID="command-center-agent-subtitle">
|
||||
{showHost ? `${agent.serverLabel} · ` : ""}
|
||||
{shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -246,6 +249,7 @@ interface AgentItemsSectionProps {
|
||||
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
|
||||
sectionDividerStyle: React.ComponentProps<typeof View>["style"];
|
||||
sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
|
||||
showHost: boolean;
|
||||
}
|
||||
|
||||
function AgentItemsSection({
|
||||
@@ -257,6 +261,7 @@ function AgentItemsSection({
|
||||
onSelect,
|
||||
sectionDividerStyle,
|
||||
sectionLabelStyle,
|
||||
showHost,
|
||||
}: AgentItemsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -277,7 +282,7 @@ function AgentItemsSection({
|
||||
onLayout={onRowLayout(rowIndex)}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
<CommandCenterAgentRowContent agent={agent} />
|
||||
<CommandCenterAgentRowContent agent={agent} showHost={showHost} />
|
||||
</CommandCenterAgentRow>
|
||||
);
|
||||
})}
|
||||
@@ -302,6 +307,8 @@ export function CommandCenter() {
|
||||
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const showBottomSheet = isCompact && isNative;
|
||||
// Host names only earn their space once results can span more than one host.
|
||||
const showHost = useHosts().length > 1;
|
||||
|
||||
const rowRefs = useRef<Map<number, View>>(new Map());
|
||||
const rowLayouts = useRef<Map<number, { y: number; height: number }>>(new Map());
|
||||
@@ -477,6 +484,7 @@ export function CommandCenter() {
|
||||
onSelect={handleSelectItem}
|
||||
sectionDividerStyle={sectionDividerStyle}
|
||||
sectionLabelStyle={sectionLabelStyle}
|
||||
showHost={showHost}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -256,7 +256,7 @@ function WorkspaceSelectionProbe({
|
||||
|
||||
function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
|
||||
counts.frame += 1;
|
||||
const { projects } = useSidebarWorkspacesList({ hostFilter: SERVER_ID });
|
||||
const { projects } = useSidebarWorkspacesList({ hostFilters: [SERVER_ID] });
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Settings2 } from "lucide-react-native";
|
||||
@@ -10,11 +10,11 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { HostStatusDot } from "@/components/host-status-dot";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { useAppSettings, type WorkspaceTitleSource } from "@/hooks/use-settings";
|
||||
import { useHosts, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
|
||||
const ThemedSettings2 = withUnistyles(Settings2);
|
||||
const filterColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
@@ -36,9 +36,10 @@ interface DisplayPreferenceOption<Value extends string> {
|
||||
|
||||
export function SidebarDisplayPreferencesMenu() {
|
||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||
const hostFilter = useSidebarViewStore((state) => state.hostFilter);
|
||||
const hostFilters = useSidebarViewStore((state) => state.hostFilters);
|
||||
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
|
||||
const setHostFilter = useSidebarViewStore((state) => state.setHostFilter);
|
||||
const toggleHostFilter = useSidebarViewStore((state) => state.toggleHostFilter);
|
||||
const clearHostFilters = useSidebarViewStore((state) => state.clearHostFilters);
|
||||
const hosts = useHosts();
|
||||
const {
|
||||
settings: { workspaceTitleSource },
|
||||
@@ -52,13 +53,6 @@ export function SidebarDisplayPreferencesMenu() {
|
||||
[setGroupMode],
|
||||
);
|
||||
|
||||
const handleSelectHost = useCallback(
|
||||
(serverId: string | null) => {
|
||||
setHostFilter(serverId);
|
||||
},
|
||||
[setHostFilter],
|
||||
);
|
||||
|
||||
const handleWorkspaceTitleSourceSelect = useCallback(
|
||||
(source: WorkspaceTitleSource) => {
|
||||
void updateSettings({ workspaceTitleSource: source });
|
||||
@@ -75,6 +69,7 @@ export function SidebarDisplayPreferencesMenu() {
|
||||
);
|
||||
|
||||
const showHostFilter = hosts.length > 1;
|
||||
const allHostsSelected = hostFilters.length === 0;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -105,20 +100,21 @@ export function SidebarDisplayPreferencesMenu() {
|
||||
<View style={styles.menuHeader}>
|
||||
<Text style={styles.menuHeaderLabel}>Filter</Text>
|
||||
</View>
|
||||
<HostFilterItem
|
||||
label="All hosts"
|
||||
value={null}
|
||||
hostFilter={hostFilter}
|
||||
onSelect={handleSelectHost}
|
||||
/>
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-host-filter-all"
|
||||
selected={allHostsSelected}
|
||||
closeOnSelect={false}
|
||||
onSelect={clearHostFilters}
|
||||
>
|
||||
All hosts
|
||||
</DropdownMenuItem>
|
||||
{hosts.map((host) => (
|
||||
<HostFilterItem
|
||||
key={host.serverId}
|
||||
label={host.label?.trim() || host.serverId}
|
||||
serverId={host.serverId}
|
||||
value={host.serverId}
|
||||
hostFilter={hostFilter}
|
||||
onSelect={handleSelectHost}
|
||||
selected={hostFilters.includes(host.serverId)}
|
||||
onToggle={toggleHostFilter}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -167,25 +163,32 @@ function DisplayPreferenceMenuItem<Value extends string>({
|
||||
function HostFilterItem({
|
||||
label,
|
||||
serverId,
|
||||
value,
|
||||
hostFilter,
|
||||
onSelect,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
serverId?: string;
|
||||
value: string | null;
|
||||
hostFilter: string | null;
|
||||
onSelect: (serverId: string | null) => void;
|
||||
serverId: string;
|
||||
selected: boolean;
|
||||
onToggle: (serverId: string) => void;
|
||||
}) {
|
||||
const isSelected = hostFilter === value;
|
||||
const handleSelect = useCallback(() => onSelect(value), [value, onSelect]);
|
||||
const status = useHostRuntimeSnapshot(serverId ?? "");
|
||||
const subtitle = serverId
|
||||
? formatConnectionStatus(status?.connectionStatus ?? "idle")
|
||||
: undefined;
|
||||
const handleSelect = useCallback(() => onToggle(serverId), [serverId, onToggle]);
|
||||
const leading = useMemo(
|
||||
() => (
|
||||
<View testID={`sidebar-host-filter-status-${serverId}`}>
|
||||
<HostStatusDot serverId={serverId} />
|
||||
</View>
|
||||
),
|
||||
[serverId],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem selected={isSelected} description={subtitle} onSelect={handleSelect}>
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-host-filter-${serverId}`}
|
||||
selected={selected}
|
||||
closeOnSelect={false}
|
||||
leading={leading}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
@@ -86,7 +86,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
});
|
||||
useSidebarViewStore.setState({
|
||||
groupMode: "project",
|
||||
hostFilter: null,
|
||||
hostFilters: [],
|
||||
});
|
||||
|
||||
act(() => {
|
||||
@@ -216,7 +216,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
);
|
||||
useSessionStore.getState().setHasHydratedWorkspaces("host-a", true);
|
||||
useSessionStore.getState().setHasHydratedWorkspaces("host-b", true);
|
||||
useSidebarViewStore.getState().setHostFilter("host-b");
|
||||
useSidebarViewStore.getState().toggleHostFilter("host-b");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -159,10 +159,10 @@ const CATALOG_DATA = [
|
||||
id: "factory-droid",
|
||||
title: "Factory Droid",
|
||||
description: "Factory Droid - AI coding agent powered by Factory AI",
|
||||
version: "0.159.1",
|
||||
version: "0.161.0",
|
||||
iconId: "factory-droid",
|
||||
installLink: "https://factory.ai/product/cli",
|
||||
command: ["npx", "-y", "droid@0.159.1", "exec", "--output-format", "acp-daemon"],
|
||||
command: ["npx", "-y", "droid@0.161.0", "exec", "--output-format", "acp-daemon"],
|
||||
env: {
|
||||
DROID_DISABLE_AUTO_UPDATE: "true",
|
||||
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
||||
@@ -173,10 +173,10 @@ const CATALOG_DATA = [
|
||||
id: "fast-agent",
|
||||
title: "fast-agent",
|
||||
description: "Code and build agents with comprehensive multi-provider support",
|
||||
version: "0.8.0",
|
||||
version: "0.8.1",
|
||||
iconId: "fast-agent",
|
||||
installLink: "https://fast-agent.ai/acp/",
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.8.0", "fast-agent-acp", "-x"],
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.8.1", "fast-agent-acp", "-x"],
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
@@ -284,10 +284,10 @@ const CATALOG_DATA = [
|
||||
id: "nova",
|
||||
title: "Nova",
|
||||
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
|
||||
version: "1.1.21",
|
||||
version: "1.1.22",
|
||||
iconId: "nova",
|
||||
installLink: "https://www.compassap.ai/portfolio/nova.html",
|
||||
command: ["npx", "-y", "@compass-ai/nova@1.1.21", "acp"],
|
||||
command: ["npx", "-y", "@compass-ai/nova@1.1.22", "acp"],
|
||||
},
|
||||
{
|
||||
id: "poolside",
|
||||
|
||||
@@ -114,15 +114,17 @@ describe("desktop app updater — check", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports 'pending' when the check resolves with an update that is not yet downloaded", async () => {
|
||||
it("reports the found update while it is still preparing", async () => {
|
||||
const { updater, port } = createUpdater();
|
||||
port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: false }));
|
||||
port.nextCheckResult(
|
||||
buildFakeCheckResult({ hasUpdate: true, readyToInstall: false, latestVersion: "1.2.3" }),
|
||||
);
|
||||
|
||||
await updater.checkForUpdates({ releaseChannel: "stable" });
|
||||
|
||||
expect(updater.getSnapshot()).toMatchObject({
|
||||
status: "pending",
|
||||
availableUpdate: null,
|
||||
availableUpdate: { latestVersion: "1.2.3", readyToInstall: false },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -408,17 +410,17 @@ describe("formatStatusText", () => {
|
||||
).toBe("Update ready: v1.2.3");
|
||||
});
|
||||
|
||||
it("keeps manual check feedback visible while an update is pending", () => {
|
||||
it("shows the found version while an update is pending", () => {
|
||||
expect(
|
||||
formatStatusText({
|
||||
status: "pending",
|
||||
availableUpdate: null,
|
||||
availableUpdate: buildFakeCheckResult({ latestVersion: "1.2.3" }),
|
||||
installMessage: null,
|
||||
lastCheckedAt: 42,
|
||||
formatVersion,
|
||||
formatLastCheckedAt,
|
||||
}),
|
||||
).toBe("We'll let you know when the update is ready. Last checked at time-42.");
|
||||
).toBe("Update found: v1.2.3. Downloading... Last checked at time-42.");
|
||||
});
|
||||
|
||||
it("keeps manual check feedback visible when an update is available", () => {
|
||||
|
||||
@@ -137,6 +137,18 @@ export function formatStatusText(input: {
|
||||
}
|
||||
|
||||
if (status === "pending") {
|
||||
if (availableUpdate?.latestVersion) {
|
||||
return i18n.t(
|
||||
lastCheckedAt != null
|
||||
? "desktop.updates.status.pendingWithVersionAndLastChecked"
|
||||
: "desktop.updates.status.pendingWithVersion",
|
||||
{
|
||||
version: formatVersion(availableUpdate.latestVersion),
|
||||
time: lastCheckedAt != null ? formatLastCheckedAt(lastCheckedAt) : undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (lastCheckedAt != null) {
|
||||
return i18n.t("desktop.updates.status.pendingWithLastChecked", {
|
||||
time: formatLastCheckedAt(lastCheckedAt),
|
||||
@@ -244,7 +256,7 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp
|
||||
nextAvailable = result;
|
||||
} else if (result.hasUpdate) {
|
||||
nextStatus = "pending";
|
||||
nextAvailable = null;
|
||||
nextAvailable = result;
|
||||
} else {
|
||||
nextStatus = "up-to-date";
|
||||
nextAvailable = null;
|
||||
|
||||
@@ -89,7 +89,7 @@ export interface SidebarWorkspacesListResult {
|
||||
}
|
||||
|
||||
export function useSidebarWorkspacesList(options?: {
|
||||
hostFilter?: string | null;
|
||||
hostFilters?: readonly string[];
|
||||
enabled?: boolean;
|
||||
}): SidebarWorkspacesListResult {
|
||||
const runtime = getHostRuntimeStore();
|
||||
@@ -97,27 +97,31 @@ export function useSidebarWorkspacesList(options?: {
|
||||
const hostRegistryLoaded = useHostRegistryLoaded();
|
||||
const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]);
|
||||
|
||||
const storeHostFilter = useSidebarViewStore((state) => state.hostFilter);
|
||||
const hostFilter = options?.hostFilter ?? storeHostFilter;
|
||||
const reconcileHostFilter = useSidebarViewStore((state) => state.reconcileHostFilter);
|
||||
const hasHostFilterMatch = hostFilter ? allServerIds.includes(hostFilter) : false;
|
||||
const effectiveHostFilter =
|
||||
hostFilter && (!hostRegistryLoaded || hasHostFilterMatch) ? hostFilter : null;
|
||||
const storeHostFilters = useSidebarViewStore((state) => state.hostFilters);
|
||||
const hostFilters = options?.hostFilters ?? storeHostFilters;
|
||||
const reconcileHostFilters = useSidebarViewStore((state) => state.reconcileHostFilters);
|
||||
const isActive = options?.enabled !== false;
|
||||
|
||||
const serverIds = useMemo(() => {
|
||||
if (effectiveHostFilter) {
|
||||
return allServerIds.filter((id) => id === effectiveHostFilter);
|
||||
if (hostFilters.length === 0) {
|
||||
return allServerIds;
|
||||
}
|
||||
return allServerIds;
|
||||
}, [allServerIds, effectiveHostFilter]);
|
||||
const selected = new Set(hostFilters);
|
||||
const matched = allServerIds.filter((id) => selected.has(id));
|
||||
// Registry has settled but none of the pinned hosts still exist — fall back to every
|
||||
// host rather than leaving the sidebar empty.
|
||||
if (hostRegistryLoaded && matched.length === 0) {
|
||||
return allServerIds;
|
||||
}
|
||||
return matched;
|
||||
}, [allServerIds, hostFilters, hostRegistryLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostRegistryLoaded) {
|
||||
return;
|
||||
}
|
||||
reconcileHostFilter(allServerIds);
|
||||
}, [allServerIds, hostRegistryLoaded, reconcileHostFilter]);
|
||||
reconcileHostFilters(allServerIds);
|
||||
}, [allServerIds, hostRegistryLoaded, reconcileHostFilters]);
|
||||
|
||||
const persistedProjectOrder = useSidebarOrderStore((state) => state.projectOrder ?? EMPTY_ORDER);
|
||||
|
||||
|
||||
@@ -524,6 +524,12 @@ describe("translation resources", () => {
|
||||
expect(en.desktop.updates.status.pendingWithLastChecked).toBe(
|
||||
"We'll let you know when the update is ready. Last checked at {{time}}.",
|
||||
);
|
||||
expect(en.desktop.updates.status.pendingWithVersion).toBe(
|
||||
"Update found: {{version}}. Downloading...",
|
||||
);
|
||||
expect(en.desktop.updates.status.pendingWithVersionAndLastChecked).toBe(
|
||||
"Update found: {{version}}. Downloading... Last checked at {{time}}.",
|
||||
);
|
||||
expect(en.desktop.updates.status.availableWithVersion).toBe("Update ready: {{version}}");
|
||||
expect(en.desktop.updates.status.availableWithVersionAndLastChecked).toBe(
|
||||
"Update ready: {{version}}. Last checked at {{time}}.",
|
||||
|
||||
@@ -939,6 +939,9 @@ export const ar: TranslationResources = {
|
||||
upToDateWithLastChecked: "Up to date. Last checked at {{time}}.",
|
||||
pending: "سنخبرك عندما يصبح التحديث جاهزًا.",
|
||||
pendingWithLastChecked: "سنخبرك عندما يصبح التحديث جاهزًا. آخر فحص في {{time}}.",
|
||||
pendingWithVersion: "تم العثور على تحديث: {{version}}. جارٍ التنزيل...",
|
||||
pendingWithVersionAndLastChecked:
|
||||
"تم العثور على تحديث: {{version}}. جارٍ التنزيل... آخر فحص في {{time}}.",
|
||||
availableWithVersion: "التحديث جاهز:{{version}}",
|
||||
availableWithVersionAndLastChecked: "التحديث جاهز:{{version}}. آخر فحص في {{time}}.",
|
||||
available: "تحديث التطبيق جاهز للتثبيت.",
|
||||
|
||||
@@ -947,6 +947,9 @@ export const en = {
|
||||
pending: "We'll let you know when the update is ready.",
|
||||
pendingWithLastChecked:
|
||||
"We'll let you know when the update is ready. Last checked at {{time}}.",
|
||||
pendingWithVersion: "Update found: {{version}}. Downloading...",
|
||||
pendingWithVersionAndLastChecked:
|
||||
"Update found: {{version}}. Downloading... Last checked at {{time}}.",
|
||||
availableWithVersion: "Update ready: {{version}}",
|
||||
availableWithVersionAndLastChecked: "Update ready: {{version}}. Last checked at {{time}}.",
|
||||
available: "An app update is ready to install.",
|
||||
|
||||
@@ -967,6 +967,9 @@ export const es: TranslationResources = {
|
||||
pending: "Le avisaremos cuando la actualización esté lista.",
|
||||
pendingWithLastChecked:
|
||||
"Le avisaremos cuando la actualización esté lista. Última comprobación a las {{time}}.",
|
||||
pendingWithVersion: "Actualización encontrada: {{version}}. Descargando...",
|
||||
pendingWithVersionAndLastChecked:
|
||||
"Actualización encontrada: {{version}}. Descargando... Última comprobación a las {{time}}.",
|
||||
availableWithVersion: "Actualización lista:{{version}}",
|
||||
availableWithVersionAndLastChecked:
|
||||
"Actualización lista:{{version}}. Última comprobación a las {{time}}.",
|
||||
|
||||
@@ -966,6 +966,9 @@ export const fr: TranslationResources = {
|
||||
pending: "Nous vous informerons lorsque la mise à jour sera prête.",
|
||||
pendingWithLastChecked:
|
||||
"Nous vous informerons lorsque la mise à jour sera prête. Dernière vérification à {{time}}.",
|
||||
pendingWithVersion: "Mise à jour trouvée : {{version}}. Téléchargement...",
|
||||
pendingWithVersionAndLastChecked:
|
||||
"Mise à jour trouvée : {{version}}. Téléchargement... Dernière vérification à {{time}}.",
|
||||
availableWithVersion: "Mise à jour prête:{{version}}",
|
||||
availableWithVersionAndLastChecked:
|
||||
"Mise à jour prête:{{version}}. Dernière vérification à {{time}}.",
|
||||
|
||||
@@ -951,6 +951,9 @@ export const ja: TranslationResources = {
|
||||
upToDateWithLastChecked: "最新の状態です。最終確認: {{time}}。",
|
||||
pending: "更新の準備ができたらお知らせします。",
|
||||
pendingWithLastChecked: "更新の準備ができたらお知らせします。最終確認: {{time}}。",
|
||||
pendingWithVersion: "更新が見つかりました: {{version}}。ダウンロード中...",
|
||||
pendingWithVersionAndLastChecked:
|
||||
"更新が見つかりました: {{version}}。ダウンロード中... 最終確認: {{time}}。",
|
||||
availableWithVersion: "更新の準備ができました: {{version}}",
|
||||
availableWithVersionAndLastChecked:
|
||||
"更新の準備ができました: {{version}}。最終確認: {{time}}。",
|
||||
|
||||
@@ -958,6 +958,9 @@ export const ptBR: TranslationResources = {
|
||||
pending: "Avisaremos quando a atualização estiver pronta.",
|
||||
pendingWithLastChecked:
|
||||
"Avisaremos quando a atualização estiver pronta. Última verificação às {{time}}.",
|
||||
pendingWithVersion: "Atualização encontrada: {{version}}. Baixando...",
|
||||
pendingWithVersionAndLastChecked:
|
||||
"Atualização encontrada: {{version}}. Baixando... Última verificação às {{time}}.",
|
||||
availableWithVersion: "Atualização pronta: {{version}}",
|
||||
availableWithVersionAndLastChecked:
|
||||
"Atualização pronta: {{version}}. Última verificação às {{time}}.",
|
||||
|
||||
@@ -959,6 +959,9 @@ export const ru: TranslationResources = {
|
||||
pending: "Мы сообщим вам, когда обновление будет готово.",
|
||||
pendingWithLastChecked:
|
||||
"Мы сообщим вам, когда обновление будет готово. Последняя проверка в {{time}}.",
|
||||
pendingWithVersion: "Найдено обновление: {{version}}. Загрузка...",
|
||||
pendingWithVersionAndLastChecked:
|
||||
"Найдено обновление: {{version}}. Загрузка... Последняя проверка в {{time}}.",
|
||||
availableWithVersion: "Обновление готово:{{version}}",
|
||||
availableWithVersionAndLastChecked:
|
||||
"Обновление готово:{{version}}. Последняя проверка в {{time}}.",
|
||||
|
||||
@@ -928,6 +928,9 @@ export const zhCN: TranslationResources = {
|
||||
upToDateWithLastChecked: "已是最新版本。上次检查时间:{{time}}。",
|
||||
pending: "更新准备好后会通知你。",
|
||||
pendingWithLastChecked: "更新准备好后会通知你。上次检查时间:{{time}}。",
|
||||
pendingWithVersion: "发现更新:{{version}}。正在下载...",
|
||||
pendingWithVersionAndLastChecked:
|
||||
"发现更新:{{version}}。正在下载... 上次检查时间:{{time}}。",
|
||||
availableWithVersion: "更新已就绪:{{version}}",
|
||||
availableWithVersionAndLastChecked: "更新已就绪:{{version}}。上次检查时间:{{time}}。",
|
||||
available: "有 app 更新可安装。",
|
||||
|
||||
@@ -701,6 +701,9 @@ function DesktopAppUpdateRow() {
|
||||
});
|
||||
}, [installUpdate, isDesktopApp, t]);
|
||||
|
||||
const isUpdateReady = availableUpdate?.readyToInstall === true;
|
||||
const readyUpdateVersion = isUpdateReady ? availableUpdate?.latestVersion : null;
|
||||
|
||||
if (!isDesktopApp) {
|
||||
return null;
|
||||
}
|
||||
@@ -725,10 +728,10 @@ function DesktopAppUpdateRow() {
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.about.updates.label")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{statusText}</Text>
|
||||
{availableUpdate?.latestVersion ? (
|
||||
{readyUpdateVersion ? (
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.about.updates.readyToInstall", {
|
||||
version: formatVersionWithPrefix(availableUpdate.latestVersion),
|
||||
version: formatVersionWithPrefix(readyUpdateVersion),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -747,9 +750,9 @@ function DesktopAppUpdateRow() {
|
||||
variant="default"
|
||||
size="sm"
|
||||
onPress={handleInstallUpdate}
|
||||
disabled={isChecking || isInstalling || !availableUpdate}
|
||||
disabled={isChecking || isInstalling || !isUpdateReady}
|
||||
>
|
||||
{getUpdateButtonLabel(t, isInstalling, availableUpdate?.latestVersion)}
|
||||
{getUpdateButtonLabel(t, isInstalling, readyUpdateVersion)}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -39,24 +39,44 @@ describe("sidebar view store", () => {
|
||||
beforeEach(() => {
|
||||
useSidebarViewStore.setState({
|
||||
groupMode: "project",
|
||||
hostFilter: null,
|
||||
hostFilters: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a host filter that still points at an available host", () => {
|
||||
useSidebarViewStore.getState().setHostFilter("host-a");
|
||||
it("toggles multiple hosts into and out of the filter", () => {
|
||||
const store = useSidebarViewStore.getState();
|
||||
store.toggleHostFilter("host-a");
|
||||
store.toggleHostFilter("host-b");
|
||||
|
||||
useSidebarViewStore.getState().reconcileHostFilter(["host-a", "host-b"]);
|
||||
expect(useSidebarViewStore.getState().hostFilters).toEqual(["host-a", "host-b"]);
|
||||
|
||||
expect(useSidebarViewStore.getState().hostFilter).toBe("host-a");
|
||||
store.toggleHostFilter("host-a");
|
||||
|
||||
expect(useSidebarViewStore.getState().hostFilters).toEqual(["host-b"]);
|
||||
|
||||
store.clearHostFilters();
|
||||
|
||||
expect(useSidebarViewStore.getState().hostFilters).toEqual([]);
|
||||
});
|
||||
|
||||
it("clears a host filter after that host is removed", () => {
|
||||
useSidebarViewStore.getState().setHostFilter("removed-host");
|
||||
it("keeps host filters that still point at available hosts", () => {
|
||||
const store = useSidebarViewStore.getState();
|
||||
store.toggleHostFilter("host-a");
|
||||
store.toggleHostFilter("host-b");
|
||||
|
||||
useSidebarViewStore.getState().reconcileHostFilter(["host-a"]);
|
||||
store.reconcileHostFilters(["host-a", "host-b", "host-c"]);
|
||||
|
||||
expect(useSidebarViewStore.getState().hostFilter).toBeNull();
|
||||
expect(useSidebarViewStore.getState().hostFilters).toEqual(["host-a", "host-b"]);
|
||||
});
|
||||
|
||||
it("drops a host filter after that host is removed", () => {
|
||||
const store = useSidebarViewStore.getState();
|
||||
store.toggleHostFilter("host-a");
|
||||
store.toggleHostFilter("removed-host");
|
||||
|
||||
store.reconcileHostFilters(["host-a"]);
|
||||
|
||||
expect(useSidebarViewStore.getState().hostFilters).toEqual(["host-a"]);
|
||||
});
|
||||
|
||||
it("migrates legacy per-host group modes to the new global mode", () => {
|
||||
@@ -69,11 +89,11 @@ describe("sidebar view store", () => {
|
||||
}),
|
||||
).toEqual({
|
||||
groupMode: "status",
|
||||
hostFilter: null,
|
||||
hostFilters: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps current persisted sidebar view state during version migration", () => {
|
||||
it("migrates a pre-v2 single host filter to the multi-host list", () => {
|
||||
expect(
|
||||
migrateSidebarViewState({
|
||||
groupMode: "status",
|
||||
@@ -81,7 +101,19 @@ describe("sidebar view store", () => {
|
||||
}),
|
||||
).toEqual({
|
||||
groupMode: "status",
|
||||
hostFilter: "host-a",
|
||||
hostFilters: ["host-a"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps current persisted sidebar view state during version migration", () => {
|
||||
expect(
|
||||
migrateSidebarViewState({
|
||||
groupMode: "status",
|
||||
hostFilters: ["host-a", "host-b"],
|
||||
}),
|
||||
).toEqual({
|
||||
groupMode: "status",
|
||||
hostFilters: ["host-a", "host-b"],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,8 +140,8 @@ describe("sidebar view store", () => {
|
||||
it("uses the new storage key without reading the legacy key when current state exists", async () => {
|
||||
const storage = createMemoryStorage({
|
||||
"sidebar-view": JSON.stringify({
|
||||
state: { groupMode: "project", hostFilter: "host-a" },
|
||||
version: 1,
|
||||
state: { groupMode: "project", hostFilters: ["host-a"] },
|
||||
version: 2,
|
||||
}),
|
||||
"sidebar-group-mode": JSON.stringify({
|
||||
state: { groupModeByServerId: { "host-b": "status" } },
|
||||
@@ -121,8 +153,8 @@ describe("sidebar view store", () => {
|
||||
|
||||
expect(value).toBe(
|
||||
JSON.stringify({
|
||||
state: { groupMode: "project", hostFilter: "host-a" },
|
||||
version: 1,
|
||||
state: { groupMode: "project", hostFilters: ["host-a"] },
|
||||
version: 2,
|
||||
}),
|
||||
);
|
||||
expect(storage.reads).toEqual(["sidebar-view"]);
|
||||
|
||||
@@ -6,19 +6,21 @@ export type SidebarGroupMode = "project" | "status";
|
||||
|
||||
const SIDEBAR_VIEW_STORAGE_KEY = "sidebar-view";
|
||||
const LEGACY_SIDEBAR_GROUP_MODE_STORAGE_KEY = "sidebar-group-mode";
|
||||
const SIDEBAR_VIEW_STORE_VERSION = 1;
|
||||
const SIDEBAR_VIEW_STORE_VERSION = 2;
|
||||
|
||||
interface SidebarViewStoreState {
|
||||
groupMode: SidebarGroupMode;
|
||||
hostFilter: string | null;
|
||||
// Empty means "all hosts". A non-empty list pins the sidebar to those hosts.
|
||||
hostFilters: string[];
|
||||
setGroupMode: (mode: SidebarGroupMode) => void;
|
||||
setHostFilter: (serverId: string | null) => void;
|
||||
reconcileHostFilter: (serverIds: readonly string[]) => void;
|
||||
toggleHostFilter: (serverId: string) => void;
|
||||
clearHostFilters: () => void;
|
||||
reconcileHostFilters: (serverIds: readonly string[]) => void;
|
||||
}
|
||||
|
||||
interface SidebarViewPersistedState {
|
||||
groupMode: SidebarGroupMode;
|
||||
hostFilter: string | null;
|
||||
hostFilters: string[];
|
||||
}
|
||||
|
||||
function isSidebarGroupMode(value: unknown): value is SidebarGroupMode {
|
||||
@@ -40,19 +42,32 @@ function readLegacyGroupMode(persistedState: Record<string, unknown>): SidebarGr
|
||||
return modes.includes("status") ? "status" : "project";
|
||||
}
|
||||
|
||||
// Reads the host filter from any persisted shape: the current `hostFilters` array, or the
|
||||
// pre-v2 single `hostFilter` string (null/absent meant "all hosts").
|
||||
function readHostFilters(persistedState: Record<string, unknown>): string[] {
|
||||
const hostFilters = persistedState.hostFilters;
|
||||
if (Array.isArray(hostFilters)) {
|
||||
return hostFilters.filter((value): value is string => typeof value === "string");
|
||||
}
|
||||
// COMPAT(sidebarHostFilters): added in v0.1.102, remove after 2026-12-30 once pre-v2 persisted
|
||||
// sidebar state (a single `hostFilter` string) has aged out.
|
||||
const legacyHostFilter = persistedState.hostFilter;
|
||||
return typeof legacyHostFilter === "string" ? [legacyHostFilter] : [];
|
||||
}
|
||||
|
||||
export function migrateSidebarViewState(persistedState: unknown): SidebarViewPersistedState {
|
||||
if (!isRecord(persistedState)) {
|
||||
return { groupMode: "project", hostFilter: null };
|
||||
return { groupMode: "project", hostFilters: [] };
|
||||
}
|
||||
|
||||
const legacyGroupMode = readLegacyGroupMode(persistedState);
|
||||
if (legacyGroupMode) {
|
||||
return { groupMode: legacyGroupMode, hostFilter: null };
|
||||
return { groupMode: legacyGroupMode, hostFilters: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
groupMode: isSidebarGroupMode(persistedState.groupMode) ? persistedState.groupMode : "project",
|
||||
hostFilter: typeof persistedState.hostFilter === "string" ? persistedState.hostFilter : null,
|
||||
hostFilters: readHostFilters(persistedState),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,15 +91,26 @@ export const useSidebarViewStore = create<SidebarViewStoreState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
groupMode: "project",
|
||||
hostFilter: null,
|
||||
hostFilters: [],
|
||||
setGroupMode: (mode) => set({ groupMode: mode }),
|
||||
setHostFilter: (serverId) => set({ hostFilter: serverId }),
|
||||
reconcileHostFilter: (serverIds) =>
|
||||
toggleHostFilter: (serverId) =>
|
||||
set((state) => ({
|
||||
hostFilters: state.hostFilters.includes(serverId)
|
||||
? state.hostFilters.filter((id) => id !== serverId)
|
||||
: [...state.hostFilters, serverId],
|
||||
})),
|
||||
clearHostFilters: () => set({ hostFilters: [] }),
|
||||
reconcileHostFilters: (serverIds) =>
|
||||
set((state) => {
|
||||
if (!state.hostFilter || serverIds.includes(state.hostFilter)) {
|
||||
if (state.hostFilters.length === 0) {
|
||||
return state;
|
||||
}
|
||||
return { hostFilter: null };
|
||||
const allowed = new Set(serverIds);
|
||||
const next = state.hostFilters.filter((id) => allowed.has(id));
|
||||
if (next.length === state.hostFilters.length) {
|
||||
return state;
|
||||
}
|
||||
return { hostFilters: next };
|
||||
}),
|
||||
}),
|
||||
{
|
||||
@@ -93,7 +119,7 @@ export const useSidebarViewStore = create<SidebarViewStoreState>()(
|
||||
storage: createJSONStorage(createSidebarViewStorage),
|
||||
partialize: (state) => ({
|
||||
groupMode: state.groupMode,
|
||||
hostFilter: state.hostFilter,
|
||||
hostFilters: state.hostFilters,
|
||||
}),
|
||||
migrate: migrateSidebarViewState,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.102",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -27,9 +27,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.102-beta.2",
|
||||
"@getpaseo/protocol": "0.1.102-beta.2",
|
||||
"@getpaseo/server": "0.1.102-beta.2",
|
||||
"@getpaseo/client": "0.1.102",
|
||||
"@getpaseo/protocol": "0.1.102",
|
||||
"@getpaseo/server": "0.1.102",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.102",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -35,8 +35,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.102-beta.2",
|
||||
"@getpaseo/relay": "0.1.102-beta.2",
|
||||
"@getpaseo/protocol": "0.1.102",
|
||||
"@getpaseo/relay": "0.1.102",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -16,6 +16,10 @@ files:
|
||||
asarUnpack:
|
||||
- dist/daemon/node-entrypoint-runner.js
|
||||
- node_modules/@getpaseo/server/dist/server/terminal/shell-integration/**/*
|
||||
- node_modules/@ff-labs/fff-node/**/*
|
||||
- node_modules/@ff-labs/fff-bin-*/**/*
|
||||
- node_modules/ffi-rs/**/*
|
||||
- node_modules/@yuuang/ffi-rs-*/**/*
|
||||
extraResources:
|
||||
- from: ../app/dist
|
||||
to: app-dist
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.102",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -14,6 +14,38 @@ const RIPGREP_PLATFORM_DIR = {
|
||||
win32: { arm64: "arm64-win32", x64: "x64-win32" },
|
||||
};
|
||||
|
||||
const FFF_BIN_PACKAGE = {
|
||||
darwin: {
|
||||
arm64: ["fff-bin-darwin-arm64"],
|
||||
x64: ["fff-bin-darwin-x64"],
|
||||
universal: ["fff-bin-darwin-arm64", "fff-bin-darwin-x64"],
|
||||
},
|
||||
linux: {
|
||||
arm64: ["fff-bin-linux-arm64-gnu"],
|
||||
x64: ["fff-bin-linux-x64-gnu"],
|
||||
},
|
||||
win32: {
|
||||
arm64: ["fff-bin-win32-arm64"],
|
||||
x64: ["fff-bin-win32-x64"],
|
||||
},
|
||||
};
|
||||
|
||||
const FFI_RS_PACKAGE = {
|
||||
darwin: {
|
||||
arm64: ["ffi-rs-darwin-arm64"],
|
||||
x64: ["ffi-rs-darwin-x64"],
|
||||
universal: ["ffi-rs-darwin-arm64", "ffi-rs-darwin-x64"],
|
||||
},
|
||||
linux: {
|
||||
arm64: ["ffi-rs-linux-arm64-gnu"],
|
||||
x64: ["ffi-rs-linux-x64-gnu"],
|
||||
},
|
||||
win32: {
|
||||
arm64: ["ffi-rs-win32-arm64-msvc"],
|
||||
x64: ["ffi-rs-win32-x64-msvc"],
|
||||
},
|
||||
};
|
||||
|
||||
function rmSafe(target) {
|
||||
fs.rmSync(target, { recursive: true, force: true });
|
||||
}
|
||||
@@ -73,6 +105,43 @@ function pruneSharpLibvips(nodeModules, platform, arch) {
|
||||
}
|
||||
}
|
||||
|
||||
function keepPackagesForPlatform(packagesByPlatform, platform, arch) {
|
||||
return packagesByPlatform[platform]?.[arch] ?? [];
|
||||
}
|
||||
|
||||
function pruneScopedNativePackages(nodeModules, scope, prefix, keepNames) {
|
||||
if (keepNames.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scopeDir = path.join(nodeModules, scope);
|
||||
if (!fs.existsSync(scopeDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keep = new Set(keepNames);
|
||||
for (const entry of fs.readdirSync(scopeDir)) {
|
||||
if (entry.startsWith(prefix) && !keep.has(entry)) {
|
||||
rmSafe(path.join(scopeDir, entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pruneFffNativePackages(nodeModules, platform, arch) {
|
||||
pruneScopedNativePackages(
|
||||
nodeModules,
|
||||
"@ff-labs",
|
||||
"fff-bin-",
|
||||
keepPackagesForPlatform(FFF_BIN_PACKAGE, platform, arch),
|
||||
);
|
||||
pruneScopedNativePackages(
|
||||
nodeModules,
|
||||
"@yuuang",
|
||||
"ffi-rs-",
|
||||
keepPackagesForPlatform(FFI_RS_PACKAGE, platform, arch),
|
||||
);
|
||||
}
|
||||
|
||||
function pruneNativeModules(appOutDir, platform, arch) {
|
||||
const resourcesDir =
|
||||
platform === "darwin"
|
||||
@@ -87,6 +156,7 @@ function pruneNativeModules(appOutDir, platform, arch) {
|
||||
pruneClaudeAgentSdk(nodeModules, platform, arch);
|
||||
pruneNodePty(nodeModules, platform, arch);
|
||||
pruneSharpLibvips(nodeModules, platform, arch);
|
||||
pruneFffNativePackages(nodeModules, platform, arch);
|
||||
|
||||
const after = dirSizeSync(nodeModules);
|
||||
const savedMB = ((before - after) / 1024 / 1024).toFixed(1);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
const { spawn, spawnSync } = require("node:child_process");
|
||||
const { execFileSync, spawn, spawnSync } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const net = require("node:net");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { setTimeout: delay } = require("node:timers/promises");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
|
||||
const EXECUTABLE_NAME = "Paseo";
|
||||
const SMOKE_TIMEOUT_MS = 60_000;
|
||||
@@ -621,6 +622,97 @@ async function smokeCliTerminal({ appPath, env }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConnectToDaemon() {
|
||||
const clientModulePath = path.join(__dirname, "..", "..", "cli", "dist", "utils", "client.js");
|
||||
const clientModule = await import(pathToFileURL(clientModulePath).href);
|
||||
if (typeof clientModule.connectToDaemon !== "function") {
|
||||
throw new Error(`CLI client module did not export connectToDaemon: ${clientModulePath}`);
|
||||
}
|
||||
return clientModule.connectToDaemon;
|
||||
}
|
||||
|
||||
function createWorkspaceSearchFixture() {
|
||||
const cwd = createTempDir("paseo-smoke-search-cwd-");
|
||||
execFileSync("git", ["init"], { cwd, stdio: "ignore" });
|
||||
fs.writeFileSync(path.join(cwd, ".env.local"), "PASEO_PACKAGED_SMOKE=1\n");
|
||||
fs.mkdirSync(path.join(cwd, ".opencode"), { recursive: true });
|
||||
fs.writeFileSync(path.join(cwd, ".opencode", "settings.json"), "{}\n");
|
||||
fs.writeFileSync(path.join(cwd, ".gitignore"), "ignored.secret\n");
|
||||
fs.writeFileSync(path.join(cwd, "ignored.secret"), "ignore me\n");
|
||||
return cwd;
|
||||
}
|
||||
|
||||
function assertSuggestionPayload(payload, label) {
|
||||
if (payload?.error) {
|
||||
throw new Error(`${label} returned error: ${payload.error}`);
|
||||
}
|
||||
if (!Array.isArray(payload?.entries)) {
|
||||
throw new Error(`${label} returned invalid payload: ${JSON.stringify(payload)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSuggestionEntry(payload, expected, label) {
|
||||
assertSuggestionPayload(payload, label);
|
||||
const found = payload.entries.some(
|
||||
(entry) => entry.path === expected.path && entry.kind === expected.kind,
|
||||
);
|
||||
if (!found) {
|
||||
throw new Error(
|
||||
`${label} did not include ${expected.kind} ${expected.path}. Entries: ${JSON.stringify(
|
||||
payload.entries,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function smokeWorkspaceSearch({ host }) {
|
||||
console.log("Packaged desktop smoke: querying workspace search through daemon RPC");
|
||||
const cwd = createWorkspaceSearchFixture();
|
||||
const connectToDaemon = await loadConnectToDaemon();
|
||||
const client = await connectToDaemon({ host, timeout: SMOKE_TIMEOUT_MS });
|
||||
|
||||
try {
|
||||
const envResults = await client.getDirectorySuggestions({
|
||||
cwd,
|
||||
query: "env",
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
limit: 20,
|
||||
});
|
||||
assertSuggestionEntry(envResults, { path: ".env.local", kind: "file" }, "Dotfile search");
|
||||
|
||||
const opencodeResults = await client.getDirectorySuggestions({
|
||||
cwd,
|
||||
query: "opencode",
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
limit: 20,
|
||||
});
|
||||
assertSuggestionEntry(
|
||||
opencodeResults,
|
||||
{ path: ".opencode/settings.json", kind: "file" },
|
||||
"Dot-directory search",
|
||||
);
|
||||
|
||||
const ignoredResults = await client.getDirectorySuggestions({
|
||||
cwd,
|
||||
query: "ignored",
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
limit: 20,
|
||||
});
|
||||
assertSuggestionPayload(ignoredResults, "Ignored file search");
|
||||
if (ignoredResults.entries.some((entry) => entry.path === "ignored.secret")) {
|
||||
throw new Error(
|
||||
`Ignored file search returned ignored.secret: ${JSON.stringify(ignoredResults.entries)}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
await removeTempDir(cwd);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopCliDaemon({ appPath, env }) {
|
||||
console.log("Packaged desktop smoke: stopping daemon through bundled CLI shim");
|
||||
await runCliShimCommand({
|
||||
@@ -677,9 +769,10 @@ async function smokePackagedDesktopApp({ appPath }) {
|
||||
const cliEnv = createDefaultDaemonEnv();
|
||||
await smokeCliShim({ appPath, env: cliEnv });
|
||||
await smokeCliTerminal({ appPath, env: cliEnv });
|
||||
await smokeWorkspaceSearch({ host: message.status.listen });
|
||||
await stopDaemonForCleanup();
|
||||
console.log(
|
||||
`Packaged desktop smoke passed: desktop-managed daemon pid ${message.status.pid}, listen ${message.status.listen}; CLI shim daemon status and terminal smoke succeeded`,
|
||||
`Packaged desktop smoke passed: desktop-managed daemon pid ${message.status.pid}, listen ${message.status.listen}; CLI shim daemon status, terminal smoke, and workspace search succeeded`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (smokeStarted && !daemonStopped) {
|
||||
|
||||
@@ -74,6 +74,15 @@ describe("desktop packaging", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("unpacks native workspace search packages", () => {
|
||||
const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8");
|
||||
|
||||
expect(config).toContain("node_modules/@ff-labs/fff-node/**/*");
|
||||
expect(config).toContain("node_modules/@ff-labs/fff-bin-*/**/*");
|
||||
expect(config).toContain("node_modules/ffi-rs/**/*");
|
||||
expect(config).toContain("node_modules/@yuuang/ffi-rs-*/**/*");
|
||||
});
|
||||
|
||||
it("excludes package debug/source files from the packaged app", () => {
|
||||
const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8");
|
||||
|
||||
|
||||
@@ -58,6 +58,10 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime {
|
||||
this.configuration?.onUpdateAvailable(info);
|
||||
}
|
||||
|
||||
finishUpdateDownload(info: RuntimeUpdateInfo): void {
|
||||
this.configuration?.onUpdateDownloaded(info);
|
||||
}
|
||||
|
||||
async checkForUpdates(): Promise<{
|
||||
isUpdateAvailable: boolean;
|
||||
updateInfo: RuntimeUpdateInfo;
|
||||
@@ -144,6 +148,37 @@ describe("app update service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("performs a fresh manual check when an update is already cached", async () => {
|
||||
const { runtime, service } = createService({ bucket: async () => 0 });
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
|
||||
await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "automatic",
|
||||
});
|
||||
|
||||
runtime.nextCheck({
|
||||
isUpdateAvailable: true,
|
||||
updateInfo: { ...rolledOutUpdate, version: "1.2.5" },
|
||||
});
|
||||
const result = await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "manual",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
hasUpdate: true,
|
||||
readyToInstall: false,
|
||||
currentVersion: "1.2.3",
|
||||
latestVersion: "1.2.5",
|
||||
body: null,
|
||||
date: "2026-04-28T00:00:00.000Z",
|
||||
errorMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("trusts the runtime availability decision before comparing versions", async () => {
|
||||
const { runtime, service } = createService({ bucket: async () => 0 });
|
||||
runtime.nextCheck({ isUpdateAvailable: false, updateInfo: rolledOutUpdate });
|
||||
@@ -347,6 +382,70 @@ describe("app update service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("performs a fresh manual check after an update preparation error", async () => {
|
||||
const { runtime, service } = createService();
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
|
||||
await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "manual",
|
||||
});
|
||||
runtime.failRuntime(new Error("sha512 checksum mismatch"));
|
||||
|
||||
runtime.nextCheck({
|
||||
isUpdateAvailable: true,
|
||||
updateInfo: { ...rolledOutUpdate, version: "1.2.5" },
|
||||
});
|
||||
const result = await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "manual",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
hasUpdate: true,
|
||||
readyToInstall: false,
|
||||
currentVersion: "1.2.3",
|
||||
latestVersion: "1.2.5",
|
||||
body: null,
|
||||
date: "2026-04-28T00:00:00.000Z",
|
||||
errorMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a downloaded update ready when a manual check re-announces it", async () => {
|
||||
const { runtime, service } = createService();
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
|
||||
await service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "manual",
|
||||
});
|
||||
runtime.finishUpdateDownload(rolledOutUpdate);
|
||||
|
||||
const recheck = runtime.deferNextCheck();
|
||||
const pending = service.checkForAppUpdate({
|
||||
currentVersion: "1.2.3",
|
||||
releaseChannel: "stable",
|
||||
intent: "manual",
|
||||
});
|
||||
runtime.prepareUpdate(rolledOutUpdate);
|
||||
recheck.resolve({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
const result = await pending;
|
||||
|
||||
expect(result).toEqual({
|
||||
hasUpdate: true,
|
||||
readyToInstall: true,
|
||||
currentVersion: "1.2.3",
|
||||
latestVersion: "1.2.4",
|
||||
body: null,
|
||||
date: "2026-04-28T00:00:00.000Z",
|
||||
errorMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns runtime update errors to multiple automatic checks before a manual retry clears them", async () => {
|
||||
const { runtime, service } = createService();
|
||||
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
|
||||
|
||||
@@ -163,9 +163,10 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
|
||||
});
|
||||
},
|
||||
onUpdateAvailable(info) {
|
||||
const alreadyReady = downloadedUpdateVersion === info.version;
|
||||
cachedUpdateInfo = info;
|
||||
downloadedUpdateVersion = null;
|
||||
downloading = true;
|
||||
downloadedUpdateVersion = alreadyReady ? info.version : null;
|
||||
downloading = !alreadyReady;
|
||||
runtimeErrorMessage = null;
|
||||
},
|
||||
onUpdateDownloaded(info) {
|
||||
@@ -212,7 +213,12 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer
|
||||
}
|
||||
|
||||
const cachedVersion = cachedUpdateInfo?.version ?? null;
|
||||
if (!runtimeErrorResult && cachedVersion && cachedVersion !== currentVersion) {
|
||||
if (
|
||||
!runtimeErrorResult &&
|
||||
intent === "automatic" &&
|
||||
cachedVersion &&
|
||||
cachedVersion !== currentVersion
|
||||
) {
|
||||
return buildCheckResult({
|
||||
currentVersion,
|
||||
hasUpdate: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.102",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.102",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.102",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.102",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
# OpenAI API Key (for GPT-4, Whisper STT, and TTS)
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# Optional: point speech-to-text and text-to-speech at different OpenAI-compatible
|
||||
# endpoints. Each falls back to OPENAI_API_KEY / OPENAI_BASE_URL when unset.
|
||||
# STT covers composer dictation + voice mode STT; TTS covers voice mode TTS.
|
||||
OPENAI_STT_API_KEY=
|
||||
OPENAI_STT_BASE_URL=
|
||||
OPENAI_TTS_API_KEY=
|
||||
OPENAI_TTS_BASE_URL=
|
||||
|
||||
# TTS Configuration (optional - defaults shown)
|
||||
TTS_VOICE=alloy
|
||||
# Available voices: alloy, echo, fable, onyx, nova, shimmer
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.102",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -65,10 +65,11 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.1.102-beta.2",
|
||||
"@getpaseo/highlight": "0.1.102-beta.2",
|
||||
"@getpaseo/protocol": "0.1.102-beta.2",
|
||||
"@getpaseo/relay": "0.1.102-beta.2",
|
||||
"@ff-labs/fff-node": "^0.9.6",
|
||||
"@getpaseo/client": "0.1.102",
|
||||
"@getpaseo/highlight": "0.1.102",
|
||||
"@getpaseo/protocol": "0.1.102",
|
||||
"@getpaseo/relay": "0.1.102",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -26,7 +26,7 @@ async function main(): Promise<void> {
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
logger,
|
||||
agentClients: {},
|
||||
openai: { apiKey },
|
||||
openai: { stt: { apiKey }, tts: { apiKey } },
|
||||
speech: {
|
||||
providers: {
|
||||
dictationStt: { provider: "openai", explicit: true },
|
||||
|
||||
@@ -33,6 +33,7 @@ import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js
|
||||
import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
|
||||
import { CursorACPAgentClient } from "./providers/cursor-acp-agent.js";
|
||||
import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
|
||||
import { KiroACPAgentClient } from "./providers/kiro-acp-agent.js";
|
||||
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
|
||||
import { PiRpcAgentClient } from "./providers/pi/agent.js";
|
||||
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
|
||||
@@ -644,24 +645,23 @@ function addDerivedProviders(
|
||||
enabled: override.enabled !== false,
|
||||
derivedFromProviderId: null,
|
||||
providerParams: override.params,
|
||||
createBaseClient: (logger) =>
|
||||
providerId === "cursor"
|
||||
? new CursorACPAgentClient({
|
||||
logger,
|
||||
command,
|
||||
env: override.env,
|
||||
providerId,
|
||||
label: override.label ?? providerId,
|
||||
providerParams: override.params,
|
||||
})
|
||||
: new GenericACPAgentClient({
|
||||
logger,
|
||||
command,
|
||||
env: override.env,
|
||||
providerId,
|
||||
label: override.label ?? providerId,
|
||||
providerParams: override.params,
|
||||
}),
|
||||
createBaseClient: (logger) => {
|
||||
const acpOptions = {
|
||||
logger,
|
||||
command,
|
||||
env: override.env,
|
||||
providerId,
|
||||
label: override.label ?? providerId,
|
||||
providerParams: override.params,
|
||||
};
|
||||
if (providerId === "cursor") {
|
||||
return new CursorACPAgentClient(acpOptions);
|
||||
}
|
||||
if (providerId === "kiro") {
|
||||
return new KiroACPAgentClient(acpOptions);
|
||||
}
|
||||
return new GenericACPAgentClient(acpOptions);
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
writeCopilotProviderMode,
|
||||
} from "./copilot-acp-agent.js";
|
||||
import { GenericACPAgentClient } from "./generic-acp-agent.js";
|
||||
import { parseKiroExtensionCommands } from "./kiro-acp-agent.js";
|
||||
import { transformPiModels } from "./pi/agent.js";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import type { AgentCapabilityFlags, AgentPersistenceHandle } from "../agent-sdk-types.js";
|
||||
@@ -165,6 +166,35 @@ function createSessionWithConfig(
|
||||
);
|
||||
}
|
||||
|
||||
function createKiroSession(
|
||||
options: { waitForInitialCommands?: boolean; initialCommandsWaitTimeoutMs?: number } = {},
|
||||
logger: ReturnType<typeof createTestLogger> = createTestLogger(),
|
||||
): ACPAgentSession {
|
||||
return new ACPAgentSession(
|
||||
{
|
||||
provider: "kiro",
|
||||
cwd: "/tmp/paseo-acp-test",
|
||||
},
|
||||
{
|
||||
provider: "kiro",
|
||||
logger,
|
||||
defaultCommand: ["kiro-cli", "acp"],
|
||||
defaultModes: [],
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
extensionCommandsParser: parseKiroExtensionCommands,
|
||||
waitForInitialCommands: options.waitForInitialCommands ?? false,
|
||||
initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createTerminalChildStub(): ChildProcess {
|
||||
const child = new EventEmitter() as ChildProcess;
|
||||
child.stdout = new EventEmitter() as ChildProcess["stdout"];
|
||||
@@ -1902,6 +1932,86 @@ describe("ACPAgentSession", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("maps the Kiro _kiro.dev/commands/available notification into slash commands and skills", async () => {
|
||||
const session = createKiroSession({
|
||||
waitForInitialCommands: true,
|
||||
initialCommandsWaitTimeoutMs: 1500,
|
||||
});
|
||||
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
||||
|
||||
const listCommandsPromise = session.listCommands();
|
||||
|
||||
await session.extNotification("_kiro.dev/commands/available", {
|
||||
sessionId: "session-1",
|
||||
commands: [
|
||||
{
|
||||
name: "/agent",
|
||||
description: "Select or list available agents",
|
||||
meta: { inputType: "selection", hint: "swap <name>" },
|
||||
},
|
||||
],
|
||||
prompts: [
|
||||
{
|
||||
name: "agent-sync-doctor",
|
||||
description: "Hand off Claude or Codex state across Macs",
|
||||
arguments: [],
|
||||
serverName: "skill:config",
|
||||
},
|
||||
],
|
||||
// Tools are not slash commands and must be ignored.
|
||||
tools: [{ name: "code", description: "Code intelligence", source: "built-in" }],
|
||||
});
|
||||
|
||||
expect(await listCommandsPromise).toEqual([
|
||||
{
|
||||
name: "agent",
|
||||
description: "Select or list available agents",
|
||||
argumentHint: "swap <name>",
|
||||
kind: "command",
|
||||
},
|
||||
{
|
||||
name: "agent-sync-doctor",
|
||||
description: "Hand off Claude or Codex state across Macs",
|
||||
argumentHint: "",
|
||||
kind: "skill",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("ignores Kiro _kiro.dev/commands/available for a different session", async () => {
|
||||
const session = createKiroSession();
|
||||
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
||||
|
||||
await session.extNotification("_kiro.dev/commands/available", {
|
||||
sessionId: "other-session",
|
||||
commands: [{ name: "/agent", description: "Select or list available agents" }],
|
||||
prompts: [],
|
||||
});
|
||||
|
||||
expect(await session.listCommands()).toEqual([]);
|
||||
});
|
||||
|
||||
test("settles listCommands() immediately on an empty Kiro commands batch", async () => {
|
||||
// A long timeout means a resolution can only come from settleCommandsReady()
|
||||
// firing — not from the wait timer — so this test would hang if the empty
|
||||
// batch failed to unblock listCommands() (the P1 regression).
|
||||
const session = createKiroSession({
|
||||
waitForInitialCommands: true,
|
||||
initialCommandsWaitTimeoutMs: 60_000,
|
||||
});
|
||||
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
||||
|
||||
const listCommandsPromise = session.listCommands();
|
||||
|
||||
await session.extNotification("_kiro.dev/commands/available", {
|
||||
sessionId: "session-1",
|
||||
commands: [],
|
||||
prompts: [],
|
||||
});
|
||||
|
||||
expect(await listCommandsPromise).toEqual([]);
|
||||
});
|
||||
|
||||
test("emits assistant and reasoning chunks as deltas while user chunks stay accumulated", async () => {
|
||||
const session = createSession();
|
||||
const events: Array<{ type: string; item?: { type: string; text?: string } }> = [];
|
||||
|
||||
@@ -334,6 +334,17 @@ export function createLoggedNdJsonStream(
|
||||
return { readable, writable };
|
||||
}
|
||||
|
||||
// Lets a provider that publishes its slash commands through a vendor-specific
|
||||
// ACP extension notification (rather than the standard
|
||||
// `available_commands_update` session update) translate that payload into Paseo
|
||||
// slash commands, without the generic ACP session/client carrying any vendor
|
||||
// knowledge. Return the parsed commands (possibly empty) for a notification this
|
||||
// provider owns, or null to ignore notifications it does not handle.
|
||||
export type ACPExtensionCommandsParser = (
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
) => AgentSlashCommand[] | null;
|
||||
|
||||
interface ACPAgentClientOptions {
|
||||
provider: string;
|
||||
logger: Logger;
|
||||
@@ -356,6 +367,7 @@ interface ACPAgentClientOptions {
|
||||
thinkingOptionId: string,
|
||||
) => Promise<void>;
|
||||
capabilities?: AgentCapabilityFlags;
|
||||
extensionCommandsParser?: ACPExtensionCommandsParser;
|
||||
waitForInitialCommands?: boolean;
|
||||
initialCommandsWaitTimeoutMs?: number;
|
||||
terminateProcess?: ProcessTerminator;
|
||||
@@ -383,6 +395,7 @@ interface ACPAgentSessionOptions {
|
||||
thinkingOptionId: string,
|
||||
) => Promise<void>;
|
||||
capabilities: AgentCapabilityFlags;
|
||||
extensionCommandsParser?: ACPExtensionCommandsParser;
|
||||
handle?: AgentPersistenceHandle;
|
||||
agentId?: string;
|
||||
launchEnv?: Record<string, string>;
|
||||
@@ -692,6 +705,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
) => Promise<void>;
|
||||
private readonly waitForInitialCommands: boolean;
|
||||
private readonly initialCommandsWaitTimeoutMs: number;
|
||||
private readonly extensionCommandsParser?: ACPExtensionCommandsParser;
|
||||
protected readonly terminateProcess: ProcessTerminator;
|
||||
|
||||
constructor(options: ACPAgentClientOptions) {
|
||||
@@ -716,6 +730,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
this.thinkingOptionWriter = options.thinkingOptionWriter;
|
||||
this.waitForInitialCommands = options.waitForInitialCommands ?? false;
|
||||
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
|
||||
this.extensionCommandsParser = options.extensionCommandsParser;
|
||||
}
|
||||
|
||||
async createSession(
|
||||
@@ -743,6 +758,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
capabilities: this.capabilities,
|
||||
agentId: launchContext?.agentId,
|
||||
launchEnv: launchContext?.env,
|
||||
extensionCommandsParser: this.extensionCommandsParser,
|
||||
waitForInitialCommands: this.waitForInitialCommands,
|
||||
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
|
||||
},
|
||||
@@ -791,6 +807,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
handle,
|
||||
agentId: launchContext?.agentId,
|
||||
launchEnv: launchContext?.env,
|
||||
extensionCommandsParser: this.extensionCommandsParser,
|
||||
waitForInitialCommands: this.waitForInitialCommands,
|
||||
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
|
||||
});
|
||||
@@ -1273,6 +1290,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
private commandsReadySettled = false;
|
||||
private waitForInitialCommands: boolean;
|
||||
private initialCommandsWaitTimeoutMs: number;
|
||||
private readonly extensionCommandsParser?: ACPExtensionCommandsParser;
|
||||
private currentTurnUsage: AgentUsage | undefined;
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
private closed = false;
|
||||
@@ -1309,6 +1327,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
this.currentTitle = config.title ?? null;
|
||||
this.waitForInitialCommands = options.waitForInitialCommands ?? false;
|
||||
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
|
||||
this.extensionCommandsParser = options.extensionCommandsParser;
|
||||
}
|
||||
|
||||
get id(): string | null {
|
||||
@@ -2082,6 +2101,39 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
},
|
||||
"provider.acp.extension_notification",
|
||||
);
|
||||
|
||||
const parsedCommands = this.extensionCommandsParser?.(method, params);
|
||||
if (parsedCommands) {
|
||||
this.applyResolvedCommands(parsedCommands, {
|
||||
sessionId: typeof params.sessionId === "string" ? params.sessionId : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Cache an asynchronously-delivered slash-command batch and unblock any
|
||||
// listCommands() call that is waiting on the initial batch. Used when a
|
||||
// provider supplies an extensionCommandsParser whose result arrives after
|
||||
// session/new (e.g. via a vendor extension notification). The ready gate is
|
||||
// always settled — even for an empty batch — so a provider that legitimately
|
||||
// reports no commands does not leave listCommands() blocked for the full
|
||||
// initial-commands timeout. An optional sessionId scopes the batch to this
|
||||
// session; notifications addressed to a different session are ignored.
|
||||
private applyResolvedCommands(
|
||||
commands: AgentSlashCommand[],
|
||||
options?: { sessionId?: string },
|
||||
): void {
|
||||
if (
|
||||
options?.sessionId !== undefined &&
|
||||
this.sessionId !== null &&
|
||||
options.sessionId !== this.sessionId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (commands.length > 0) {
|
||||
this.cachedCommands = commands;
|
||||
}
|
||||
this.settleCommandsReady();
|
||||
}
|
||||
|
||||
async readTextFile(params: ReadTextFileRequest): Promise<{ content: string }> {
|
||||
|
||||
@@ -3,7 +3,11 @@ import { z } from "zod";
|
||||
|
||||
import type { AgentCapabilityFlags } from "../agent-sdk-types.js";
|
||||
import { checkProviderLaunchAvailable, resolveProviderLaunch } from "../provider-launch-config.js";
|
||||
import { ACPAgentClient, DEFAULT_ACP_CAPABILITIES } from "./acp-agent.js";
|
||||
import {
|
||||
ACPAgentClient,
|
||||
DEFAULT_ACP_CAPABILITIES,
|
||||
type ACPExtensionCommandsParser,
|
||||
} from "./acp-agent.js";
|
||||
import {
|
||||
buildBinaryDiagnosticRows,
|
||||
formatProviderDiagnostic,
|
||||
@@ -29,6 +33,7 @@ interface GenericACPAgentClientOptions {
|
||||
waitForInitialCommands?: boolean;
|
||||
initialCommandsWaitTimeoutMs?: number;
|
||||
diagnosticPhaseTimeoutMs?: number;
|
||||
extensionCommandsParser?: ACPExtensionCommandsParser;
|
||||
}
|
||||
|
||||
export class GenericACPAgentClient extends ACPAgentClient {
|
||||
@@ -48,6 +53,7 @@ export class GenericACPAgentClient extends ACPAgentClient {
|
||||
capabilities: buildGenericACPCapabilities(options),
|
||||
waitForInitialCommands: options.waitForInitialCommands,
|
||||
initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs,
|
||||
extensionCommandsParser: options.extensionCommandsParser,
|
||||
});
|
||||
|
||||
this.command = options.command;
|
||||
|
||||
101
packages/server/src/server/agent/providers/kiro-acp-agent.ts
Normal file
101
packages/server/src/server/agent/providers/kiro-acp-agent.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { ACPExtensionCommandsParser } from "./acp-agent.js";
|
||||
import { GenericACPAgentClient } from "./generic-acp-agent.js";
|
||||
import type { AgentSlashCommand, AgentSlashCommandKind } from "../agent-sdk-types.js";
|
||||
|
||||
interface KiroACPAgentClientOptions {
|
||||
logger: Logger;
|
||||
command: [string, ...string[]];
|
||||
env?: Record<string, string>;
|
||||
providerId?: string;
|
||||
label?: string;
|
||||
providerParams?: unknown;
|
||||
}
|
||||
|
||||
// Kiro CLI publishes its slash commands and skills asynchronously through the
|
||||
// `_kiro.dev/commands/available` extension notification shortly after
|
||||
// `session/new` resolves. Wait for that first batch so listCommands() doesn't
|
||||
// resolve to an empty list before Kiro has reported its commands.
|
||||
const KIRO_INITIAL_COMMANDS_WAIT_TIMEOUT_MS = 10_000;
|
||||
|
||||
// ACP extension method (per the `_`-prefixed vendor namespace convention) that
|
||||
// Kiro CLI uses to publish its slash commands and skills after session/new.
|
||||
const KIRO_COMMANDS_AVAILABLE_METHOD = "_kiro.dev/commands/available";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
// Maps a `_kiro.dev/commands/available` payload onto Paseo slash commands.
|
||||
// Kiro reports built-in slash commands under `commands` (names arrive with a
|
||||
// leading "/", e.g. "/agent") and skills/prompts under `prompts` (names without
|
||||
// a slash, tagged with a `skill:` serverName). Paseo stores command names
|
||||
// without the leading slash — the composer prepends it on insertion.
|
||||
function mapKiroAvailableCommands(params: Record<string, unknown>): AgentSlashCommand[] {
|
||||
const result: AgentSlashCommand[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const pushEntry = (entry: unknown): void => {
|
||||
if (!isRecord(entry)) {
|
||||
return;
|
||||
}
|
||||
const rawName = typeof entry.name === "string" ? entry.name.trim() : "";
|
||||
const name = rawName.replace(/^\/+/, "");
|
||||
if (!name || seen.has(name)) {
|
||||
return;
|
||||
}
|
||||
seen.add(name);
|
||||
|
||||
const description = typeof entry.description === "string" ? entry.description : "";
|
||||
const meta = isRecord(entry.meta) ? entry.meta : null;
|
||||
const argumentHint = meta && typeof meta.hint === "string" ? meta.hint : "";
|
||||
const serverName = typeof entry.serverName === "string" ? entry.serverName : "";
|
||||
const kind: AgentSlashCommandKind = serverName.startsWith("skill:") ? "skill" : "command";
|
||||
|
||||
result.push({ name, description, argumentHint, kind });
|
||||
};
|
||||
|
||||
if (Array.isArray(params.commands)) {
|
||||
for (const entry of params.commands) {
|
||||
pushEntry(entry);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(params.prompts)) {
|
||||
for (const entry of params.prompts) {
|
||||
pushEntry(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Provider-specific parser injected into the generic ACP session via the
|
||||
// `extensionCommandsParser` option (mirrors how Cursor/Copilot inject their
|
||||
// behavior through constructor options). Kiro advertises its slash commands and
|
||||
// skills through the `_kiro.dev/commands/available` extension notification
|
||||
// instead of the standard `available_commands_update` session update; this
|
||||
// recognizes that one method and returns the parsed commands (possibly empty),
|
||||
// or null for any other notification so the base session ignores it.
|
||||
export const parseKiroExtensionCommands: ACPExtensionCommandsParser = (method, params) => {
|
||||
if (method !== KIRO_COMMANDS_AVAILABLE_METHOD) {
|
||||
return null;
|
||||
}
|
||||
return mapKiroAvailableCommands(params);
|
||||
};
|
||||
|
||||
export class KiroACPAgentClient extends GenericACPAgentClient {
|
||||
constructor(options: KiroACPAgentClientOptions) {
|
||||
super({
|
||||
logger: options.logger,
|
||||
command: options.command,
|
||||
env: options.env,
|
||||
providerId: options.providerId,
|
||||
label: options.label,
|
||||
providerParams: options.providerParams,
|
||||
waitForInitialCommands: true,
|
||||
initialCommandsWaitTimeoutMs: KIRO_INITIAL_COMMANDS_WAIT_TIMEOUT_MS,
|
||||
extensionCommandsParser: parseKiroExtensionCommands,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ describe("paseo daemon bootstrap", () => {
|
||||
|
||||
test("starts and serves health endpoint", async () => {
|
||||
const daemonHandle = await createTestPaseoDaemon({
|
||||
openai: { apiKey: "test-openai-api-key" },
|
||||
openai: { stt: { apiKey: "test-openai-api-key" }, tts: { apiKey: "test-openai-api-key" } },
|
||||
speech: {
|
||||
providers: {
|
||||
dictationStt: { provider: "openai", explicit: true },
|
||||
|
||||
@@ -547,7 +547,9 @@ beforeAll(async () => {
|
||||
|
||||
ctx = await createDaemonTestContext({
|
||||
dictationFinalTimeoutMs: 5000,
|
||||
...(openaiApiKey ? { openai: { apiKey: openaiApiKey } } : {}),
|
||||
...(openaiApiKey
|
||||
? { openai: { stt: { apiKey: openaiApiKey }, tts: { apiKey: openaiApiKey } } }
|
||||
: {}),
|
||||
...(speechConfig ? { speech: speechConfig } : {}),
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
@@ -116,20 +116,26 @@ describe("PersistedConfigSchema worktrees config", () => {
|
||||
});
|
||||
|
||||
describe("PersistedConfigSchema provider credentials", () => {
|
||||
test("accepts OpenAI voice credentials", () => {
|
||||
test("accepts separate OpenAI STT and TTS credentials", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
providers: {
|
||||
openai: {
|
||||
voice: {
|
||||
apiKey: " voice-secret ",
|
||||
baseUrl: " https://voice.example.com/v1 ",
|
||||
stt: {
|
||||
apiKey: " stt-secret ",
|
||||
baseUrl: " https://stt.example.com/v1 ",
|
||||
},
|
||||
tts: {
|
||||
apiKey: " tts-secret ",
|
||||
baseUrl: " https://tts.example.com/v1 ",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.providers?.openai?.voice?.apiKey).toBe("voice-secret");
|
||||
expect(parsed.providers?.openai?.voice?.baseUrl).toBe("https://voice.example.com/v1");
|
||||
expect(parsed.providers?.openai?.stt?.apiKey).toBe("stt-secret");
|
||||
expect(parsed.providers?.openai?.stt?.baseUrl).toBe("https://stt.example.com/v1");
|
||||
expect(parsed.providers?.openai?.tts?.apiKey).toBe("tts-secret");
|
||||
expect(parsed.providers?.openai?.tts?.baseUrl).toBe("https://tts.example.com/v1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -650,6 +656,38 @@ describe("loadPersistedConfig", () => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("loads a config that still uses the removed providers.openai.voice block", () => {
|
||||
const home = createTempHome();
|
||||
const configPath = path.join(home, "config.json");
|
||||
try {
|
||||
writeFileSync(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: "global-key",
|
||||
voice: { apiKey: "voice-key", baseUrl: "https://voice.example.com/v1" },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
const config = loadPersistedConfig(home);
|
||||
|
||||
expect(config.providers?.openai?.apiKey).toBe("global-key");
|
||||
expect((config.providers?.openai as Record<string, unknown>)?.voice).toBeUndefined();
|
||||
expect(config.providers?.openai?.stt).toBeUndefined();
|
||||
expect(config.providers?.openai?.tts).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === "win32")("persisted config file permissions", () => {
|
||||
|
||||
@@ -45,7 +45,7 @@ const LogConfigSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const OpenAiVoiceProviderSchema = z
|
||||
const OpenAiSpeechEndpointSchema = z
|
||||
.object({
|
||||
apiKey: z.string().trim().min(1).optional(),
|
||||
baseUrl: z.string().trim().min(1).optional(),
|
||||
@@ -55,8 +55,9 @@ const OpenAiVoiceProviderSchema = z
|
||||
const OpenAiProviderSchema = z
|
||||
.object({
|
||||
apiKey: z.string().min(1).optional(),
|
||||
voice: OpenAiVoiceProviderSchema.optional(),
|
||||
baseUrl: z.string().trim().min(1).optional(),
|
||||
stt: OpenAiSpeechEndpointSchema.optional(),
|
||||
tts: OpenAiSpeechEndpointSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -346,7 +347,11 @@ function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined {
|
||||
return logger?.child({ module: "config" });
|
||||
}
|
||||
|
||||
function stripDeprecatedLocalSpeechConfigFields(parsed: unknown): unknown {
|
||||
// Removed config fields are stripped before parsing so the strict schema does not
|
||||
// reject a config written by an older release. The stripped values are discarded,
|
||||
// not migrated — there is no back-compat for the removed `providers.openai.voice`
|
||||
// block (use `providers.openai.stt` / `providers.openai.tts`).
|
||||
function stripRemovedConfigFields(parsed: unknown): unknown {
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
@@ -358,18 +363,25 @@ function stripDeprecatedLocalSpeechConfigFields(parsed: unknown): unknown {
|
||||
}
|
||||
|
||||
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) {
|
||||
if (local && typeof local === "object" && !Array.isArray(local)) {
|
||||
const localRecord = { ...(local as Record<string, unknown>) };
|
||||
delete localRecord.autoDownload;
|
||||
providersRecord.local = localRecord;
|
||||
}
|
||||
|
||||
const openai = providersRecord.openai;
|
||||
if (openai && typeof openai === "object" && !Array.isArray(openai)) {
|
||||
const openaiRecord = { ...(openai as Record<string, unknown>) };
|
||||
// COMPAT(openaiVoiceConfig): added 2026-06-30, remove after 2026-12-30.
|
||||
// Drop a `providers.openai.voice` block left by an older release so the strict
|
||||
// schema doesn't reject it. The value is discarded, not migrated — there is no
|
||||
// back-compat; configure `providers.openai.stt` / `providers.openai.tts` instead.
|
||||
delete openaiRecord.voice;
|
||||
providersRecord.openai = openaiRecord;
|
||||
}
|
||||
|
||||
providersRecord.local = localRecord;
|
||||
root.providers = providersRecord;
|
||||
return root;
|
||||
}
|
||||
@@ -412,7 +424,7 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per
|
||||
});
|
||||
}
|
||||
|
||||
const migrated = stripDeprecatedLocalSpeechConfigFields(parsed);
|
||||
const migrated = stripRemovedConfigFields(parsed);
|
||||
const result = PersistedConfigSchema.safeParse(migrated);
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import { searchHomeDirectories, searchWorkspaceEntries } from "./directory-suggestions.js";
|
||||
import { isPlatform } from "../../test-utils/platform.js";
|
||||
import { searchHomeDirectories } from "./home-directories.js";
|
||||
import { clearWorkspaceSearchCacheForTests, searchWorkspaceEntries } from "./workspace-entries.js";
|
||||
|
||||
const isWindows = isPlatform("win32");
|
||||
|
||||
@@ -204,6 +206,7 @@ describe("searchWorkspaceEntries", () => {
|
||||
});
|
||||
mkdirSync(path.join(workspaceDir, "docs"), { recursive: true });
|
||||
mkdirSync(path.join(outsideDir, "escaped"), { recursive: true });
|
||||
execFileSync("git", ["init"], { cwd: workspaceDir, stdio: "ignore" });
|
||||
|
||||
writeFileSync(path.join(workspaceDir, "README.md"), "# paseo\n");
|
||||
writeFileSync(
|
||||
@@ -218,6 +221,7 @@ describe("searchWorkspaceEntries", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearWorkspaceSearchCacheForTests();
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -322,17 +326,17 @@ describe("searchWorkspaceEntries", () => {
|
||||
matchMode: "suffix",
|
||||
});
|
||||
|
||||
expect(basenameResults).toEqual([
|
||||
{ path: "src/file.ts", kind: "file" },
|
||||
{ path: "packages/app/src/file.ts", kind: "file" },
|
||||
]);
|
||||
expect(suffixResults).toEqual([
|
||||
{ path: "src/file.ts", kind: "file" },
|
||||
{ path: "packages/app/src/file.ts", kind: "file" },
|
||||
]);
|
||||
const expectedMatches = [
|
||||
{ path: "src/file.ts", kind: "file" as const },
|
||||
{ path: "packages/app/src/file.ts", kind: "file" as const },
|
||||
];
|
||||
expect(basenameResults).toHaveLength(2);
|
||||
expect(basenameResults).toEqual(expect.arrayContaining(expectedMatches));
|
||||
expect(suffixResults).toHaveLength(2);
|
||||
expect(suffixResults).toEqual(expect.arrayContaining(expectedMatches));
|
||||
});
|
||||
|
||||
it("suffix mode resolves exact workspace file paths before broad traversal", async () => {
|
||||
it("suffix mode resolves exact workspace file paths", async () => {
|
||||
const targetPath = path.join(
|
||||
workspaceDir,
|
||||
"packages",
|
||||
@@ -353,7 +357,6 @@ describe("searchWorkspaceEntries", () => {
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
maxEntriesScanned: 1,
|
||||
});
|
||||
|
||||
expect(results).toEqual([
|
||||
@@ -364,7 +367,7 @@ describe("searchWorkspaceEntries", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("suffix mode resolves explicit hidden file paths without broad hidden traversal", async () => {
|
||||
it("suffix mode resolves explicit dot-prefixed file paths", async () => {
|
||||
const targetPath = path.join(workspaceDir, ".dev", "paseo-home", "daemon.log");
|
||||
mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
writeFileSync(targetPath, "daemon log\n");
|
||||
@@ -376,13 +379,40 @@ describe("searchWorkspaceEntries", () => {
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
maxEntriesScanned: 1,
|
||||
});
|
||||
|
||||
expect(results).toEqual([{ path: ".dev/paseo-home/daemon.log", kind: "file" }]);
|
||||
});
|
||||
|
||||
it("suffix mode finds files under allowlisted hidden workspace directories", async () => {
|
||||
it("finds dot-prefixed files and files under dot-prefixed directories", async () => {
|
||||
mkdirSync(path.join(workspaceDir, ".opencode"), { recursive: true });
|
||||
writeFileSync(path.join(workspaceDir, ".env.local"), "PASEO_TEST=1\n");
|
||||
writeFileSync(path.join(workspaceDir, ".opencode", "settings.json"), "{}");
|
||||
|
||||
const envResults = await searchWorkspaceEntries({
|
||||
cwd: workspaceDir,
|
||||
query: "env",
|
||||
limit: 20,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
});
|
||||
const opencodeResults = await searchWorkspaceEntries({
|
||||
cwd: workspaceDir,
|
||||
query: "opencode",
|
||||
limit: 20,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
});
|
||||
|
||||
expect(envResults).toContainEqual({ path: ".env.local", kind: "file" });
|
||||
expect(opencodeResults).toContainEqual({ path: ".opencode", kind: "directory" });
|
||||
expect(opencodeResults).toContainEqual({
|
||||
path: ".opencode/settings.json",
|
||||
kind: "file",
|
||||
});
|
||||
});
|
||||
|
||||
it("suffix mode finds files under dot-prefixed workspace directories", async () => {
|
||||
mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true });
|
||||
mkdirSync(path.join(workspaceDir, ".github", "workflows"), { recursive: true });
|
||||
writeFileSync(path.join(workspaceDir, ".claude", "settings.local.json"), "{}");
|
||||
@@ -409,7 +439,7 @@ describe("searchWorkspaceEntries", () => {
|
||||
expect(githubResults).toEqual([{ path: ".github/workflows/ci.yml", kind: "file" }]);
|
||||
});
|
||||
|
||||
it("does not broadly traverse unlisted hidden workspace directories", async () => {
|
||||
it("searches files under arbitrary dot-prefixed workspace directories", async () => {
|
||||
mkdirSync(path.join(workspaceDir, ".dev", "cache"), { recursive: true });
|
||||
writeFileSync(path.join(workspaceDir, ".dev", "cache", "needle.ts"), "");
|
||||
writeFileSync(path.join(workspaceDir, "src", "needle.ts"), "");
|
||||
@@ -423,10 +453,15 @@ describe("searchWorkspaceEntries", () => {
|
||||
matchMode: "suffix",
|
||||
});
|
||||
|
||||
expect(results).toEqual([{ path: "src/needle.ts", kind: "file" }]);
|
||||
expect(results).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ path: ".dev/cache/needle.ts", kind: "file" },
|
||||
{ path: "src/needle.ts", kind: "file" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not suggest hidden directories even when includeDirectories is true", async () => {
|
||||
it("suggests dot-prefixed directories when includeDirectories is true", async () => {
|
||||
mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true });
|
||||
writeFileSync(path.join(workspaceDir, ".claude", "settings.local.json"), "{}");
|
||||
|
||||
@@ -439,35 +474,52 @@ describe("searchWorkspaceEntries", () => {
|
||||
matchMode: "fuzzy",
|
||||
});
|
||||
|
||||
expect(results.some((entry) => entry.path === ".claude" && entry.kind === "directory")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(results).toContainEqual({ path: ".claude", kind: "directory" });
|
||||
expect(results).toContainEqual({
|
||||
path: ".claude/settings.local.json",
|
||||
kind: "file",
|
||||
});
|
||||
});
|
||||
|
||||
it("path mode does not suggest hidden workspace directories", async () => {
|
||||
it("path-style queries include dot-prefixed workspace directories", async () => {
|
||||
mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true });
|
||||
writeFileSync(path.join(workspaceDir, ".claude", "settings.local.json"), "{}");
|
||||
|
||||
const results = await searchWorkspaceEntries({
|
||||
cwd: workspaceDir,
|
||||
query: "./",
|
||||
query: "./.claude",
|
||||
limit: 20,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
matchMode: "fuzzy",
|
||||
});
|
||||
|
||||
expect(results).toContainEqual({
|
||||
path: "README.md",
|
||||
expect(results).toContainEqual({ path: ".claude", kind: "directory" });
|
||||
expect(results).not.toContainEqual({
|
||||
path: ".claude/settings.local.json",
|
||||
kind: "file",
|
||||
});
|
||||
expect(results.some((entry) => entry.path === ".claude" && entry.kind === "directory")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("constrains fuzzy path-style queries to the typed parent directory", async () => {
|
||||
mkdirSync(path.join(workspaceDir, "src", "nested"), { recursive: true });
|
||||
writeFileSync(path.join(workspaceDir, "src", "config.ts"), "");
|
||||
writeFileSync(path.join(workspaceDir, "src", "nested", "config.ts"), "");
|
||||
writeFileSync(path.join(workspaceDir, "docs", "config.ts"), "");
|
||||
|
||||
const results = await searchWorkspaceEntries({
|
||||
cwd: workspaceDir,
|
||||
query: "src/co",
|
||||
limit: 20,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
matchMode: "fuzzy",
|
||||
});
|
||||
|
||||
expect(results).toContainEqual({ path: "src/components", kind: "directory" });
|
||||
expect(results).toContainEqual({ path: "src/config.ts", kind: "file" });
|
||||
expect(results).not.toContainEqual({ path: "src/nested/config.ts", kind: "file" });
|
||||
expect(results).not.toContainEqual({ path: "docs/config.ts", kind: "file" });
|
||||
});
|
||||
|
||||
it("does not traverse .git while searching workspace files", async () => {
|
||||
@@ -513,7 +565,8 @@ describe("searchWorkspaceEntries", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("ignores node_modules entries so deep workspace files still resolve under scan limits", async () => {
|
||||
it("respects workspace ignore rules for node_modules", async () => {
|
||||
writeFileSync(path.join(workspaceDir, ".gitignore"), "node_modules/\n");
|
||||
mkdirSync(path.join(workspaceDir, "packages", "app", "src", "app"), { recursive: true });
|
||||
writeFileSync(path.join(workspaceDir, "packages", "app", "src", "app", "_layout.tsx"), "");
|
||||
|
||||
@@ -532,7 +585,6 @@ describe("searchWorkspaceEntries", () => {
|
||||
limit: 20,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
maxEntriesScanned: 60,
|
||||
});
|
||||
|
||||
expect(results).toContainEqual({
|
||||
@@ -542,11 +594,12 @@ describe("searchWorkspaceEntries", () => {
|
||||
expect(results.some((entry) => entry.path.startsWith("node_modules/"))).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores common build/cache directories so large generated trees do not exhaust scan budget", async () => {
|
||||
it("respects workspace ignore rules for generated trees", async () => {
|
||||
mkdirSync(path.join(workspaceDir, "packages", "app", "src"), { recursive: true });
|
||||
writeFileSync(path.join(workspaceDir, "packages", "app", "src", "needle.ts"), "");
|
||||
|
||||
const heavyDirs = ["dist", "build", "target", "out", "coverage", "vendor", "__pycache__"];
|
||||
writeFileSync(path.join(workspaceDir, ".gitignore"), `${heavyDirs.join("/\n")}/\n`);
|
||||
for (const heavyDir of heavyDirs) {
|
||||
for (let index = 0; index < 30; index += 1) {
|
||||
mkdirSync(path.join(workspaceDir, heavyDir, `bundle-${index}`), { recursive: true });
|
||||
@@ -560,7 +613,6 @@ describe("searchWorkspaceEntries", () => {
|
||||
limit: 20,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
maxEntriesScanned: 80,
|
||||
});
|
||||
|
||||
expect(results).toContainEqual({
|
||||
478
packages/server/src/server/search/home-directories.ts
Normal file
478
packages/server/src/server/search/home-directories.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
import type { Dirent } from "node:fs";
|
||||
import { readdir, realpath, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isPathInsideRoot } from "../../utils/path.js";
|
||||
|
||||
export interface SearchHomeDirectoriesOptions {
|
||||
homeDir: string;
|
||||
query: string;
|
||||
limit?: number;
|
||||
maxDepth?: number;
|
||||
maxDirectoriesScanned?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_LIMIT = 30;
|
||||
const MAX_LIMIT = 100;
|
||||
const DEFAULT_MAX_DEPTH = 12;
|
||||
const DEFAULT_MAX_DIRECTORIES_SCANNED = 20000;
|
||||
const DIRECTORY_LIST_CACHE_TTL_MS = 8_000;
|
||||
const DIRECTORY_LIST_CACHE_MAX_ENTRIES = 4_000;
|
||||
const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER;
|
||||
const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER;
|
||||
const IGNORED_SUGGESTION_DIRECTORY_NAMES = new Set([
|
||||
"node_modules",
|
||||
"venv",
|
||||
"env",
|
||||
"virtualenv",
|
||||
"dist",
|
||||
"build",
|
||||
"target",
|
||||
"out",
|
||||
"coverage",
|
||||
"vendor",
|
||||
"__pycache__",
|
||||
".git",
|
||||
]);
|
||||
|
||||
interface QueryParts {
|
||||
isPathQuery: boolean;
|
||||
parentPart: string;
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
interface RankedDirectory {
|
||||
absolutePath: string;
|
||||
matchTier: number;
|
||||
segmentIndex: number;
|
||||
matchOffset: number;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
interface ChildDirectoryEntry {
|
||||
name: string;
|
||||
absolutePath: string;
|
||||
}
|
||||
|
||||
interface DirectoryListCacheEntry {
|
||||
expiresAt: number;
|
||||
entries: ChildDirectoryEntry[];
|
||||
}
|
||||
|
||||
const directoryListCache = new Map<string, DirectoryListCacheEntry>();
|
||||
|
||||
export async function searchHomeDirectories(
|
||||
options: SearchHomeDirectoriesOptions,
|
||||
): Promise<string[]> {
|
||||
const query = options.query.trim();
|
||||
if (!query) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const limit = normalizeLimit(options.limit);
|
||||
const homeRoot = await resolveDirectory(options.homeDir);
|
||||
if (!homeRoot) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const queryParts = normalizeQueryParts(query, homeRoot);
|
||||
if (!queryParts) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (queryParts.isPathQuery) {
|
||||
return searchWithinParentDirectory({
|
||||
homeRoot,
|
||||
parentPart: queryParts.parentPart,
|
||||
searchTerm: queryParts.searchTerm,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
return searchAcrossHomeTree({
|
||||
homeRoot,
|
||||
searchTerm: queryParts.searchTerm,
|
||||
limit,
|
||||
maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH,
|
||||
maxDirectoriesScanned: options.maxDirectoriesScanned ?? DEFAULT_MAX_DIRECTORIES_SCANNED,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeLimit(limit: number | undefined): number {
|
||||
const candidate = limit ?? DEFAULT_LIMIT;
|
||||
if (!Number.isFinite(candidate)) {
|
||||
return DEFAULT_LIMIT;
|
||||
}
|
||||
const bounded = Math.trunc(candidate);
|
||||
return Math.max(1, Math.min(MAX_LIMIT, bounded));
|
||||
}
|
||||
|
||||
async function searchWithinParentDirectory(input: {
|
||||
homeRoot: string;
|
||||
parentPart: string;
|
||||
searchTerm: string;
|
||||
limit: number;
|
||||
}): Promise<string[]> {
|
||||
const parentPath = path.resolve(input.homeRoot, input.parentPart || ".");
|
||||
const parentRoot = await resolveDirectory(parentPath);
|
||||
if (!parentRoot || !isPathInsideRoot(input.homeRoot, parentRoot)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const searchLower = input.searchTerm.toLowerCase();
|
||||
const ranked: RankedDirectory[] = [];
|
||||
const entries = await listChildDirectories({
|
||||
directory: parentRoot,
|
||||
homeRoot: input.homeRoot,
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
if (searchLower && !entry.name.toLowerCase().includes(searchLower)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ranked.push(
|
||||
rankDirectory({
|
||||
absolutePath: entry.absolutePath,
|
||||
homeRoot: input.homeRoot,
|
||||
searchLower,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return dedupeAndSort(ranked).slice(0, input.limit);
|
||||
}
|
||||
|
||||
async function searchAcrossHomeTree(input: {
|
||||
homeRoot: string;
|
||||
searchTerm: string;
|
||||
limit: number;
|
||||
maxDepth: number;
|
||||
maxDirectoriesScanned: number;
|
||||
}): Promise<string[]> {
|
||||
const queue: Array<{ directory: string; depth: number }> = [
|
||||
{ directory: input.homeRoot, depth: 0 },
|
||||
];
|
||||
const visited = new Set<string>([input.homeRoot]);
|
||||
const ranked: RankedDirectory[] = [];
|
||||
let scanned = 0;
|
||||
const searchLower = input.searchTerm.toLowerCase();
|
||||
|
||||
for (
|
||||
let queueIndex = 0;
|
||||
queueIndex < queue.length && scanned < input.maxDirectoriesScanned;
|
||||
queueIndex += 1
|
||||
) {
|
||||
const current = queue[queueIndex];
|
||||
if (!current) continue;
|
||||
const entries = await listChildDirectories({
|
||||
directory: current.directory,
|
||||
homeRoot: input.homeRoot,
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
const resolvedCandidate = entry.absolutePath;
|
||||
if (visited.has(resolvedCandidate)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(resolvedCandidate);
|
||||
scanned += 1;
|
||||
|
||||
const relativePath = normalizeRelativePath(input.homeRoot, resolvedCandidate);
|
||||
if (
|
||||
relativePath.toLowerCase().includes(searchLower) ||
|
||||
entry.name.toLowerCase().includes(searchLower)
|
||||
) {
|
||||
ranked.push(
|
||||
rankDirectory({
|
||||
absolutePath: resolvedCandidate,
|
||||
homeRoot: input.homeRoot,
|
||||
searchLower,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (current.depth < input.maxDepth && scanned < input.maxDirectoriesScanned) {
|
||||
queue.push({ directory: resolvedCandidate, depth: current.depth + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeAndSort(ranked).slice(0, input.limit);
|
||||
}
|
||||
|
||||
function dedupeAndSort(ranked: RankedDirectory[]): string[] {
|
||||
const byPath = new Map<string, RankedDirectory>();
|
||||
for (const entry of ranked) {
|
||||
const existing = byPath.get(entry.absolutePath);
|
||||
if (!existing || compareRankedDirectories(entry, existing) < 0) {
|
||||
byPath.set(entry.absolutePath, entry);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byPath.values())
|
||||
.sort(compareRankedDirectories)
|
||||
.map((entry) => entry.absolutePath);
|
||||
}
|
||||
|
||||
function compareRankedDirectories(left: RankedDirectory, right: RankedDirectory): number {
|
||||
if (left.matchTier !== right.matchTier) {
|
||||
return left.matchTier - right.matchTier;
|
||||
}
|
||||
if (left.segmentIndex !== right.segmentIndex) {
|
||||
return left.segmentIndex - right.segmentIndex;
|
||||
}
|
||||
if (left.matchOffset !== right.matchOffset) {
|
||||
return left.matchOffset - right.matchOffset;
|
||||
}
|
||||
if (left.depth !== right.depth) {
|
||||
return left.depth - right.depth;
|
||||
}
|
||||
return left.absolutePath.localeCompare(right.absolutePath);
|
||||
}
|
||||
|
||||
function rankDirectory(input: {
|
||||
absolutePath: string;
|
||||
homeRoot: string;
|
||||
searchLower: string;
|
||||
}): RankedDirectory {
|
||||
const relative = normalizeRelativePath(input.homeRoot, input.absolutePath);
|
||||
const relativeLower = relative.toLowerCase();
|
||||
const depth = relative === "." ? 0 : relative.split("/").length;
|
||||
const searchLower = input.searchLower;
|
||||
if (!searchLower) {
|
||||
return {
|
||||
absolutePath: input.absolutePath,
|
||||
matchTier: 3,
|
||||
segmentIndex: NO_SEGMENT_INDEX,
|
||||
matchOffset: 0,
|
||||
depth,
|
||||
};
|
||||
}
|
||||
const segments = relativeLower === "." ? [] : relativeLower.split("/");
|
||||
const exactSegmentIndex = findSegmentMatchIndex(segments, (segment) => segment === searchLower);
|
||||
const prefixSegmentIndex = findSegmentMatchIndex(segments, (segment) =>
|
||||
segment.startsWith(searchLower),
|
||||
);
|
||||
const partialSegmentIndex = findSegmentMatchIndex(segments, (segment) =>
|
||||
segment.includes(searchLower),
|
||||
);
|
||||
const matchOffset = relativeLower.indexOf(searchLower);
|
||||
let matchTier = 4;
|
||||
let segmentIndex = NO_SEGMENT_INDEX;
|
||||
|
||||
if (exactSegmentIndex >= 0) {
|
||||
matchTier = 0;
|
||||
segmentIndex = exactSegmentIndex;
|
||||
} else if (prefixSegmentIndex >= 0) {
|
||||
matchTier = 1;
|
||||
segmentIndex = prefixSegmentIndex;
|
||||
} else if (partialSegmentIndex >= 0) {
|
||||
matchTier = 2;
|
||||
segmentIndex = partialSegmentIndex;
|
||||
} else if (relativeLower.startsWith(searchLower)) {
|
||||
matchTier = 3;
|
||||
}
|
||||
|
||||
return {
|
||||
absolutePath: input.absolutePath,
|
||||
matchTier,
|
||||
segmentIndex,
|
||||
matchOffset: matchOffset >= 0 ? matchOffset : NO_MATCH_OFFSET,
|
||||
depth,
|
||||
};
|
||||
}
|
||||
|
||||
function findSegmentMatchIndex(
|
||||
segments: string[],
|
||||
predicate: (segment: string) => boolean,
|
||||
): number {
|
||||
for (let index = 0; index < segments.length; index += 1) {
|
||||
const segment = segments[index];
|
||||
if (!segment) {
|
||||
continue;
|
||||
}
|
||||
if (predicate(segment)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function normalizeRelativePath(homeRoot: string, absolutePath: string): string {
|
||||
const relative = path.relative(homeRoot, absolutePath);
|
||||
if (!relative) {
|
||||
return ".";
|
||||
}
|
||||
return relative.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function normalizeQueryParts(query: string, homeRoot: string): QueryParts | null {
|
||||
const typedQuery = query.trim().replace(/\\/g, "/");
|
||||
let normalized = typedQuery;
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only treat the query as a literal path when the user explicitly roots it
|
||||
// with ~, ~/, ./, or an absolute path. Bare queries like "faro/main" are
|
||||
// search terms, not paths.
|
||||
let isRooted = false;
|
||||
|
||||
if (normalized.startsWith("~")) {
|
||||
isRooted = true;
|
||||
normalized = normalized.slice(1);
|
||||
if (normalized.startsWith("/")) {
|
||||
normalized = normalized.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (path.isAbsolute(normalized)) {
|
||||
isRooted = true;
|
||||
const absolute = path.resolve(normalized);
|
||||
if (!isPathInsideRoot(homeRoot, absolute)) {
|
||||
return null;
|
||||
}
|
||||
normalized = normalizeRelativePath(homeRoot, absolute);
|
||||
}
|
||||
|
||||
if (normalized.startsWith("./")) {
|
||||
isRooted = true;
|
||||
}
|
||||
normalized = normalized.replace(/^\.\/+/, "").replace(/\/{2,}/g, "/");
|
||||
if (!normalized) {
|
||||
// Treat "~" and "~/" as a request to browse the home root.
|
||||
if (typedQuery === "~" || typedQuery === "~/") {
|
||||
return {
|
||||
isPathQuery: true,
|
||||
parentPart: "",
|
||||
searchTerm: "",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPathQuery = isRooted && normalized.includes("/");
|
||||
if (!isPathQuery) {
|
||||
return {
|
||||
isPathQuery: false,
|
||||
parentPart: "",
|
||||
searchTerm: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
const slashIndex = normalized.lastIndexOf("/");
|
||||
const parentPart = normalized.slice(0, slashIndex);
|
||||
const searchTerm = normalized.slice(slashIndex + 1);
|
||||
|
||||
return {
|
||||
isPathQuery: true,
|
||||
parentPart,
|
||||
searchTerm,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveDirectory(inputPath: string): Promise<string | null> {
|
||||
try {
|
||||
const resolved = await realpath(path.resolve(inputPath));
|
||||
const stats = await stat(resolved);
|
||||
if (!stats.isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function listChildDirectories(input: {
|
||||
directory: string;
|
||||
homeRoot: string;
|
||||
}): Promise<ChildDirectoryEntry[]> {
|
||||
const now = Date.now();
|
||||
const cached = directoryListCache.get(input.directory);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.entries;
|
||||
}
|
||||
|
||||
const dirents = await readdir(input.directory, { withFileTypes: true }).catch(
|
||||
() => [] as Dirent[],
|
||||
);
|
||||
const candidates = dirents.filter(
|
||||
(dirent) =>
|
||||
!isHiddenDirectoryName(dirent.name) &&
|
||||
!isIgnoredSuggestionDirectoryName(dirent.name) &&
|
||||
(dirent.isDirectory() || dirent.isSymbolicLink()),
|
||||
);
|
||||
const resolved = await Promise.all(
|
||||
candidates.map(async (dirent) => {
|
||||
const candidatePath = path.join(input.directory, dirent.name);
|
||||
const absolutePath = await resolveDirectoryCandidate({
|
||||
candidatePath,
|
||||
dirent,
|
||||
homeRoot: input.homeRoot,
|
||||
});
|
||||
return absolutePath ? { name: dirent.name, absolutePath } : null;
|
||||
}),
|
||||
);
|
||||
const entries: ChildDirectoryEntry[] = resolved.filter(
|
||||
(entry): entry is ChildDirectoryEntry => entry !== null,
|
||||
);
|
||||
|
||||
setDirectoryListCache(input.directory, {
|
||||
expiresAt: now + DIRECTORY_LIST_CACHE_TTL_MS,
|
||||
entries,
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function resolveDirectoryCandidate(input: {
|
||||
candidatePath: string;
|
||||
dirent: Dirent;
|
||||
homeRoot: string;
|
||||
}): Promise<string | null> {
|
||||
if (input.dirent.isDirectory()) {
|
||||
const resolved = path.resolve(input.candidatePath);
|
||||
return isPathInsideRoot(input.homeRoot, resolved) ? resolved : null;
|
||||
}
|
||||
|
||||
const resolved = await resolveDirectory(input.candidatePath);
|
||||
if (!resolved || !isPathInsideRoot(input.homeRoot, resolved)) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function isHiddenDirectoryName(name: string): boolean {
|
||||
return name.startsWith(".");
|
||||
}
|
||||
|
||||
function isIgnoredSuggestionDirectoryName(name: string): boolean {
|
||||
return IGNORED_SUGGESTION_DIRECTORY_NAMES.has(name);
|
||||
}
|
||||
|
||||
function setDirectoryListCache(cacheKey: string, entry: DirectoryListCacheEntry): void {
|
||||
directoryListCache.set(cacheKey, entry);
|
||||
pruneDirectoryListCache();
|
||||
}
|
||||
|
||||
function pruneDirectoryListCache(): void {
|
||||
if (directoryListCache.size <= DIRECTORY_LIST_CACHE_MAX_ENTRIES) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
for (const [cacheKey, entry] of directoryListCache) {
|
||||
if (entry.expiresAt <= now) {
|
||||
directoryListCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
while (directoryListCache.size > DIRECTORY_LIST_CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = directoryListCache.keys().next().value;
|
||||
if (!oldestKey) {
|
||||
return;
|
||||
}
|
||||
directoryListCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
706
packages/server/src/server/search/workspace-entries.ts
Normal file
706
packages/server/src/server/search/workspace-entries.ts
Normal file
@@ -0,0 +1,706 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { realpath, stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type {
|
||||
DirItem,
|
||||
FileFinder,
|
||||
FileItem,
|
||||
InitOptions,
|
||||
MixedItem,
|
||||
Result,
|
||||
} from "@ff-labs/fff-node";
|
||||
import { isPathInsideRoot } from "../../utils/path.js";
|
||||
|
||||
export type WorkspaceSuggestionKind = "file" | "directory";
|
||||
|
||||
export interface WorkspaceSuggestionEntry {
|
||||
path: string;
|
||||
kind: WorkspaceSuggestionKind;
|
||||
}
|
||||
|
||||
export type WorkspaceMatchMode = "fuzzy" | "suffix";
|
||||
|
||||
export interface SearchWorkspaceEntriesOptions {
|
||||
cwd: string;
|
||||
query: string;
|
||||
limit?: number;
|
||||
includeFiles?: boolean;
|
||||
includeDirectories?: boolean;
|
||||
matchMode?: WorkspaceMatchMode;
|
||||
}
|
||||
|
||||
type FffModule = typeof import("@ff-labs/fff-node");
|
||||
|
||||
interface WorkspaceFinderCacheEntry {
|
||||
finder: FileFinder;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface WorkspaceQueryParts {
|
||||
isPathQuery: boolean;
|
||||
normalizedQuery: string;
|
||||
parentPart: string;
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
interface PackagedFffPackageJson {
|
||||
main?: unknown;
|
||||
exports?: unknown;
|
||||
}
|
||||
|
||||
const DEFAULT_LIMIT = 30;
|
||||
const MAX_LIMIT = 100;
|
||||
const FFF_SCAN_WAIT_TIMEOUT_MS = 5_000;
|
||||
const FFF_SEARCH_PAGE_SIZE = 1_000;
|
||||
const FFF_FINDER_CACHE_TTL_MS = 120_000;
|
||||
const FFF_FINDER_CACHE_MAX_ENTRIES = 24;
|
||||
|
||||
const workspaceFinderCache = new Map<string, WorkspaceFinderCacheEntry>();
|
||||
let fffModulePromise: Promise<FffModule> | null = null;
|
||||
|
||||
export async function searchWorkspaceEntries(
|
||||
options: SearchWorkspaceEntriesOptions,
|
||||
): Promise<WorkspaceSuggestionEntry[]> {
|
||||
const limit = normalizeLimit(options.limit);
|
||||
const includeDirectories = options.includeDirectories ?? true;
|
||||
const includeFiles = options.includeFiles ?? false;
|
||||
if (!includeDirectories && !includeFiles) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const workspaceRoot = await resolveDirectory(options.cwd);
|
||||
if (!workspaceRoot) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const queryParts = normalizeWorkspaceQueryParts(options.query, workspaceRoot);
|
||||
if (!queryParts) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const matchMode = options.matchMode ?? "fuzzy";
|
||||
const exactEntry =
|
||||
matchMode === "suffix"
|
||||
? await resolveWorkspaceExactEntry({
|
||||
workspaceRoot,
|
||||
query: options.query,
|
||||
includeDirectories,
|
||||
includeFiles,
|
||||
})
|
||||
: null;
|
||||
if (exactEntry && limit <= 1) {
|
||||
return [exactEntry];
|
||||
}
|
||||
|
||||
const searchQuery =
|
||||
matchMode === "suffix" || queryParts.isPathQuery
|
||||
? queryParts.normalizedQuery
|
||||
: queryParts.searchTerm;
|
||||
const parentConstraint =
|
||||
matchMode === "fuzzy" && queryParts.isPathQuery ? queryParts.parentPart : null;
|
||||
const finder = await getWorkspaceFinder(workspaceRoot);
|
||||
const scan = await finder.waitForScan(FFF_SCAN_WAIT_TIMEOUT_MS);
|
||||
if (!scan.ok) {
|
||||
throw new Error(`Workspace search scan failed: ${scan.error}`);
|
||||
}
|
||||
if (!scan.value) {
|
||||
throw new Error(`Workspace search scan timed out after ${FFF_SCAN_WAIT_TIMEOUT_MS}ms`);
|
||||
}
|
||||
|
||||
const candidates = await searchFffEntries({
|
||||
finder,
|
||||
query: searchQuery,
|
||||
includeDirectories,
|
||||
includeFiles,
|
||||
});
|
||||
const entries = await normalizeFffEntries({
|
||||
workspaceRoot,
|
||||
candidates,
|
||||
includeDirectories,
|
||||
includeFiles,
|
||||
matchMode,
|
||||
parentConstraint,
|
||||
suffixQuery: searchQuery,
|
||||
});
|
||||
const deduped = dedupeWorkspaceEntries(entries);
|
||||
return exactEntry
|
||||
? prependWorkspaceEntry(exactEntry, deduped).slice(0, limit)
|
||||
: deduped.slice(0, limit);
|
||||
}
|
||||
|
||||
export function clearWorkspaceSearchCacheForTests(): void {
|
||||
for (const entry of workspaceFinderCache.values()) {
|
||||
destroyFinder(entry.finder);
|
||||
}
|
||||
workspaceFinderCache.clear();
|
||||
fffModulePromise = null;
|
||||
}
|
||||
|
||||
async function getWorkspaceFinder(workspaceRoot: string): Promise<FileFinder> {
|
||||
pruneWorkspaceFinderCache();
|
||||
|
||||
const now = Date.now();
|
||||
const cached = workspaceFinderCache.get(workspaceRoot);
|
||||
if (cached && cached.expiresAt > now && !cached.finder.isDestroyed) {
|
||||
cached.expiresAt = now + FFF_FINDER_CACHE_TTL_MS;
|
||||
return cached.finder;
|
||||
}
|
||||
if (cached) {
|
||||
workspaceFinderCache.delete(workspaceRoot);
|
||||
destroyFinder(cached.finder);
|
||||
}
|
||||
|
||||
const { FileFinder } = await loadFffModule();
|
||||
pruneWorkspaceFinderCache();
|
||||
|
||||
const afterImportCached = workspaceFinderCache.get(workspaceRoot);
|
||||
if (
|
||||
afterImportCached &&
|
||||
afterImportCached.expiresAt > Date.now() &&
|
||||
!afterImportCached.finder.isDestroyed
|
||||
) {
|
||||
afterImportCached.expiresAt = Date.now() + FFF_FINDER_CACHE_TTL_MS;
|
||||
return afterImportCached.finder;
|
||||
}
|
||||
|
||||
const created = FileFinder.create({
|
||||
basePath: workspaceRoot,
|
||||
aiMode: true,
|
||||
disableMmapCache: true,
|
||||
disableContentIndexing: true,
|
||||
...(await getRootScanningOptions(workspaceRoot)),
|
||||
});
|
||||
if (!created.ok) {
|
||||
throw new Error(`Workspace search initialization failed: ${created.error}`);
|
||||
}
|
||||
|
||||
workspaceFinderCache.set(workspaceRoot, {
|
||||
finder: created.value,
|
||||
expiresAt: Date.now() + FFF_FINDER_CACHE_TTL_MS,
|
||||
});
|
||||
pruneWorkspaceFinderCache();
|
||||
return created.value;
|
||||
}
|
||||
|
||||
async function loadFffModule(): Promise<FffModule> {
|
||||
if (!fffModulePromise) {
|
||||
const packagedEntrypoint = resolvePackagedFffEntrypoint();
|
||||
fffModulePromise = packagedEntrypoint
|
||||
? import(pathToFileURL(packagedEntrypoint).href)
|
||||
: import("@ff-labs/fff-node");
|
||||
}
|
||||
return fffModulePromise;
|
||||
}
|
||||
|
||||
function resolvePackagedFffEntrypoint(): string | null {
|
||||
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
|
||||
if (!resourcesPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const packageRoot = path.join(
|
||||
resourcesPath,
|
||||
"app.asar.unpacked",
|
||||
"node_modules",
|
||||
"@ff-labs",
|
||||
"fff-node",
|
||||
);
|
||||
// Load the unpacked package entry declared by FFF instead of duplicating its dist layout here.
|
||||
const candidate = resolvePackagedFffEntrypointFromPackage(packageRoot);
|
||||
return candidate && existsSync(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
async function searchFffEntries(input: {
|
||||
finder: FileFinder;
|
||||
query: string;
|
||||
includeDirectories: boolean;
|
||||
includeFiles: boolean;
|
||||
}): Promise<WorkspaceSuggestionEntry[]> {
|
||||
if (input.includeDirectories && input.includeFiles) {
|
||||
const result = unwrapFffResult(
|
||||
input.finder.mixedSearch(input.query, { pageSize: FFF_SEARCH_PAGE_SIZE }),
|
||||
"Workspace mixed search failed",
|
||||
);
|
||||
return result.items.map((item) => normalizeMixedItem(item));
|
||||
}
|
||||
|
||||
if (input.includeFiles) {
|
||||
const result = unwrapFffResult(
|
||||
input.finder.fileSearch(input.query, { pageSize: FFF_SEARCH_PAGE_SIZE }),
|
||||
"Workspace file search failed",
|
||||
);
|
||||
return result.items.map((item) => normalizeFileItem(item));
|
||||
}
|
||||
|
||||
const result = unwrapFffResult(
|
||||
input.finder.directorySearch(input.query, { pageSize: FFF_SEARCH_PAGE_SIZE }),
|
||||
"Workspace directory search failed",
|
||||
);
|
||||
// FFF directory search only returns directories whose own names match. File search lets us
|
||||
// derive matching ancestor directories from files nested below them.
|
||||
const fileResult = unwrapFffResult(
|
||||
input.finder.fileSearch(input.query, { pageSize: FFF_SEARCH_PAGE_SIZE }),
|
||||
"Workspace file search failed",
|
||||
);
|
||||
return [
|
||||
...result.items.map((item) => normalizeDirectoryItem(item)),
|
||||
...fileResult.items.map((item) => normalizeFileItem(item)),
|
||||
];
|
||||
}
|
||||
|
||||
function unwrapFffResult<T>(result: Result<T>, message: string): T {
|
||||
if (result.ok) {
|
||||
return result.value;
|
||||
}
|
||||
throw new Error(`${message}: ${result.error}`);
|
||||
}
|
||||
|
||||
function normalizeMixedItem(item: MixedItem): WorkspaceSuggestionEntry {
|
||||
return item.type === "file" ? normalizeFileItem(item.item) : normalizeDirectoryItem(item.item);
|
||||
}
|
||||
|
||||
function normalizeFileItem(item: FileItem): WorkspaceSuggestionEntry {
|
||||
return {
|
||||
path: normalizeFffRelativePath(item.relativePath),
|
||||
kind: "file",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDirectoryItem(item: DirItem): WorkspaceSuggestionEntry {
|
||||
return {
|
||||
path: normalizeFffRelativePath(item.relativePath).replace(/\/+$/, ""),
|
||||
kind: "directory",
|
||||
};
|
||||
}
|
||||
|
||||
async function normalizeFffEntries(input: {
|
||||
workspaceRoot: string;
|
||||
candidates: WorkspaceSuggestionEntry[];
|
||||
includeDirectories: boolean;
|
||||
includeFiles: boolean;
|
||||
matchMode: WorkspaceMatchMode;
|
||||
parentConstraint: string | null;
|
||||
suffixQuery: string;
|
||||
}): Promise<WorkspaceSuggestionEntry[]> {
|
||||
const entries: WorkspaceSuggestionEntry[] = [];
|
||||
for (const candidate of input.candidates) {
|
||||
for (const entry of expandFffCandidate({
|
||||
candidate,
|
||||
includeDirectories: input.includeDirectories,
|
||||
matchMode: input.matchMode,
|
||||
query: input.suffixQuery,
|
||||
})) {
|
||||
if (entry.kind === "directory" && !input.includeDirectories) {
|
||||
continue;
|
||||
}
|
||||
if (entry.kind === "file" && !input.includeFiles) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
input.parentConstraint !== null &&
|
||||
!workspaceEntryIsDirectChildOfParent({
|
||||
relativePath: entry.path,
|
||||
parentPart: input.parentConstraint,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
input.matchMode === "suffix" &&
|
||||
!workspaceEntryMatchesSuffixQuery({
|
||||
relativePath: entry.path,
|
||||
query: input.suffixQuery,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const resolved = await resolveIndexedWorkspaceEntry({
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
relativePath: entry.path,
|
||||
expectedKind: entry.kind,
|
||||
});
|
||||
if (resolved) {
|
||||
entries.push(resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function expandFffCandidate(input: {
|
||||
candidate: WorkspaceSuggestionEntry;
|
||||
includeDirectories: boolean;
|
||||
matchMode: WorkspaceMatchMode;
|
||||
query: string;
|
||||
}): WorkspaceSuggestionEntry[] {
|
||||
if (!input.includeDirectories || input.candidate.kind !== "file") {
|
||||
return [input.candidate];
|
||||
}
|
||||
|
||||
return [
|
||||
...collectMatchingAncestorDirectories({
|
||||
relativePath: input.candidate.path,
|
||||
matchMode: input.matchMode,
|
||||
query: input.query,
|
||||
}),
|
||||
input.candidate,
|
||||
];
|
||||
}
|
||||
|
||||
function collectMatchingAncestorDirectories(input: {
|
||||
relativePath: string;
|
||||
matchMode: WorkspaceMatchMode;
|
||||
query: string;
|
||||
}): WorkspaceSuggestionEntry[] {
|
||||
const segments = normalizeFffRelativePath(input.relativePath).split("/").filter(Boolean);
|
||||
const directories: WorkspaceSuggestionEntry[] = [];
|
||||
for (let segmentCount = 1; segmentCount < segments.length; segmentCount += 1) {
|
||||
const directoryPath = segments.slice(0, segmentCount).join("/");
|
||||
if (
|
||||
workspaceDirectoryMatchesQuery({
|
||||
relativePath: directoryPath,
|
||||
matchMode: input.matchMode,
|
||||
query: input.query,
|
||||
})
|
||||
) {
|
||||
directories.push({ path: directoryPath, kind: "directory" });
|
||||
}
|
||||
}
|
||||
return directories;
|
||||
}
|
||||
|
||||
function workspaceDirectoryMatchesQuery(input: {
|
||||
relativePath: string;
|
||||
matchMode: WorkspaceMatchMode;
|
||||
query: string;
|
||||
}): boolean {
|
||||
const normalizedQuery = normalizeFffRelativePath(input.query)
|
||||
.trim()
|
||||
.replace(/^\.\/+/, "")
|
||||
.toLowerCase();
|
||||
if (!normalizedQuery) {
|
||||
return true;
|
||||
}
|
||||
if (input.matchMode === "suffix") {
|
||||
return workspaceEntryMatchesSuffixQuery({
|
||||
relativePath: input.relativePath,
|
||||
query: normalizedQuery,
|
||||
});
|
||||
}
|
||||
return normalizeFffRelativePath(input.relativePath).toLowerCase().includes(normalizedQuery);
|
||||
}
|
||||
|
||||
async function resolveIndexedWorkspaceEntry(input: {
|
||||
workspaceRoot: string;
|
||||
relativePath: string;
|
||||
expectedKind: WorkspaceSuggestionKind;
|
||||
}): Promise<WorkspaceSuggestionEntry | null> {
|
||||
const candidatePath = path.resolve(input.workspaceRoot, input.relativePath);
|
||||
if (!isPathInsideRoot(input.workspaceRoot, candidatePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = await realpath(candidatePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isPathInsideRoot(input.workspaceRoot, resolvedPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stats = await stat(resolvedPath).catch(() => null);
|
||||
if (!stats) {
|
||||
return null;
|
||||
}
|
||||
if (input.expectedKind === "file" && !stats.isFile()) {
|
||||
return null;
|
||||
}
|
||||
if (input.expectedKind === "directory" && !stats.isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path: normalizeRelativePath(input.workspaceRoot, candidatePath),
|
||||
kind: input.expectedKind,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveWorkspaceExactEntry(input: {
|
||||
workspaceRoot: string;
|
||||
query: string;
|
||||
includeDirectories: boolean;
|
||||
includeFiles: boolean;
|
||||
}): Promise<WorkspaceSuggestionEntry | null> {
|
||||
const normalized = normalizeFffRelativePath(input.query)
|
||||
.trim()
|
||||
.replace(/^\.\/+/, "")
|
||||
.replace(/\/{2,}/g, "/");
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidatePath = path.isAbsolute(normalized)
|
||||
? path.resolve(normalized)
|
||||
: path.resolve(input.workspaceRoot, normalized);
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = await realpath(candidatePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isPathInsideRoot(input.workspaceRoot, resolvedPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stats = await stat(resolvedPath).catch(() => null);
|
||||
if (!stats) {
|
||||
return null;
|
||||
}
|
||||
if (stats.isFile() && input.includeFiles) {
|
||||
return {
|
||||
path: normalizeRelativePath(input.workspaceRoot, candidatePath),
|
||||
kind: "file",
|
||||
};
|
||||
}
|
||||
if (stats.isDirectory() && input.includeDirectories) {
|
||||
return {
|
||||
path: normalizeRelativePath(input.workspaceRoot, candidatePath),
|
||||
kind: "directory",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function prependWorkspaceEntry(
|
||||
entry: WorkspaceSuggestionEntry,
|
||||
entries: WorkspaceSuggestionEntry[],
|
||||
): WorkspaceSuggestionEntry[] {
|
||||
return [
|
||||
entry,
|
||||
...entries.filter(
|
||||
(candidate) => candidate.kind !== entry.kind || candidate.path !== entry.path,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function dedupeWorkspaceEntries(entries: WorkspaceSuggestionEntry[]): WorkspaceSuggestionEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: WorkspaceSuggestionEntry[] = [];
|
||||
for (const entry of entries) {
|
||||
const key = `${entry.kind}:${entry.path}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
deduped.push(entry);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function workspaceEntryMatchesSuffixQuery(input: { relativePath: string; query: string }): boolean {
|
||||
const querySegments = normalizeFffRelativePath(input.query)
|
||||
.trim()
|
||||
.replace(/^\.\/+/, "")
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment.toLowerCase());
|
||||
if (querySegments.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pathSegments = normalizeFffRelativePath(input.relativePath)
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment.toLowerCase());
|
||||
if (querySegments.length > pathSegments.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const offset = pathSegments.length - querySegments.length;
|
||||
return querySegments.every((segment, index) => pathSegments[offset + index] === segment);
|
||||
}
|
||||
|
||||
function normalizeWorkspaceQueryParts(
|
||||
query: string,
|
||||
workspaceRoot: string,
|
||||
): WorkspaceQueryParts | null {
|
||||
let normalized = normalizeFffRelativePath(query.trim());
|
||||
let isPathQuery = normalized.startsWith("./") || normalized.startsWith("../");
|
||||
|
||||
if (path.isAbsolute(normalized)) {
|
||||
const absolute = path.resolve(normalized);
|
||||
if (!isPathInsideRoot(workspaceRoot, absolute)) {
|
||||
return null;
|
||||
}
|
||||
normalized = normalizeRelativePath(workspaceRoot, absolute);
|
||||
isPathQuery = true;
|
||||
}
|
||||
|
||||
normalized = normalized.replace(/^\.\/+/, "").replace(/\/{2,}/g, "/");
|
||||
isPathQuery = isPathQuery || normalized.includes("/");
|
||||
if (!normalized) {
|
||||
return {
|
||||
isPathQuery,
|
||||
normalizedQuery: "",
|
||||
parentPart: "",
|
||||
searchTerm: "",
|
||||
};
|
||||
}
|
||||
|
||||
const slashIndex = normalized.lastIndexOf("/");
|
||||
return {
|
||||
isPathQuery,
|
||||
normalizedQuery: normalized,
|
||||
parentPart: slashIndex >= 0 ? normalized.slice(0, slashIndex) : "",
|
||||
searchTerm: slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized,
|
||||
};
|
||||
}
|
||||
|
||||
async function getRootScanningOptions(
|
||||
workspaceRoot: string,
|
||||
): Promise<Pick<InitOptions, "enableFsRootScanning" | "enableHomeDirScanning">> {
|
||||
const homeRoot = await resolveDirectory(homedir());
|
||||
return {
|
||||
enableFsRootScanning: workspaceRoot === path.parse(workspaceRoot).root,
|
||||
enableHomeDirScanning: homeRoot === workspaceRoot,
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePackagedFffEntrypointFromPackage(packageRoot: string): string | null {
|
||||
const packageJsonPath = path.join(packageRoot, "package.json");
|
||||
if (!existsSync(packageJsonPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let packageJson: PackagedFffPackageJson;
|
||||
try {
|
||||
packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as PackagedFffPackageJson;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entrypoint = getPackageEntrypoint(packageJson) ?? null;
|
||||
return entrypoint ? path.resolve(packageRoot, entrypoint) : null;
|
||||
}
|
||||
|
||||
function getPackageEntrypoint(packageJson: PackagedFffPackageJson): string | null {
|
||||
const exportedEntrypoint = getRootExportEntrypoint(packageJson.exports);
|
||||
if (exportedEntrypoint) {
|
||||
return exportedEntrypoint;
|
||||
}
|
||||
return typeof packageJson.main === "string" ? packageJson.main : null;
|
||||
}
|
||||
|
||||
function getRootExportEntrypoint(exportsField: unknown): string | null {
|
||||
if (typeof exportsField === "string") {
|
||||
return exportsField;
|
||||
}
|
||||
if (!exportsField || typeof exportsField !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootExport = (exportsField as Record<string, unknown>)["."];
|
||||
if (typeof rootExport === "string") {
|
||||
return rootExport;
|
||||
}
|
||||
if (!rootExport || typeof rootExport !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const conditions = rootExport as Record<string, unknown>;
|
||||
if (typeof conditions.import === "string") {
|
||||
return conditions.import;
|
||||
}
|
||||
return typeof conditions.default === "string" ? conditions.default : null;
|
||||
}
|
||||
|
||||
function workspaceEntryIsDirectChildOfParent(input: {
|
||||
relativePath: string;
|
||||
parentPart: string;
|
||||
}): boolean {
|
||||
const relativePath = normalizeFffRelativePath(input.relativePath).replace(/^\.\/+/, "");
|
||||
const parentPart = normalizeFffRelativePath(input.parentPart)
|
||||
.replace(/^\.\/+/, "")
|
||||
.replace(/\/+$/, "");
|
||||
|
||||
if (!parentPart) {
|
||||
return relativePath.length > 0 && !relativePath.includes("/");
|
||||
}
|
||||
if (!relativePath.startsWith(`${parentPart}/`)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const childPart = relativePath.slice(parentPart.length + 1);
|
||||
return childPart.length > 0 && !childPart.includes("/");
|
||||
}
|
||||
|
||||
function normalizeLimit(limit: number | undefined): number {
|
||||
const candidate = limit ?? DEFAULT_LIMIT;
|
||||
if (!Number.isFinite(candidate)) {
|
||||
return DEFAULT_LIMIT;
|
||||
}
|
||||
const bounded = Math.trunc(candidate);
|
||||
return Math.max(1, Math.min(MAX_LIMIT, bounded));
|
||||
}
|
||||
|
||||
async function resolveDirectory(inputPath: string): Promise<string | null> {
|
||||
try {
|
||||
const resolved = await realpath(path.resolve(inputPath));
|
||||
const stats = await stat(resolved);
|
||||
if (!stats.isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRelativePath(root: string, absolutePath: string): string {
|
||||
const relative = path.relative(root, absolutePath);
|
||||
if (!relative) {
|
||||
return ".";
|
||||
}
|
||||
return relative.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function normalizeFffRelativePath(value: string): string {
|
||||
return value.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function pruneWorkspaceFinderCache(): void {
|
||||
const now = Date.now();
|
||||
for (const [cacheKey, entry] of workspaceFinderCache) {
|
||||
if (entry.expiresAt <= now || entry.finder.isDestroyed) {
|
||||
workspaceFinderCache.delete(cacheKey);
|
||||
destroyFinder(entry.finder);
|
||||
}
|
||||
}
|
||||
|
||||
while (workspaceFinderCache.size > FFF_FINDER_CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = workspaceFinderCache.keys().next().value;
|
||||
if (!oldestKey) {
|
||||
return;
|
||||
}
|
||||
const oldest = workspaceFinderCache.get(oldestKey);
|
||||
workspaceFinderCache.delete(oldestKey);
|
||||
if (oldest) {
|
||||
destroyFinder(oldest.finder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function destroyFinder(finder: FileFinder): void {
|
||||
if (finder.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
finder.destroy();
|
||||
} catch {
|
||||
// Destroy is best-effort cleanup for native handles.
|
||||
}
|
||||
}
|
||||
@@ -174,7 +174,8 @@ import {
|
||||
type AgentUpdatesService,
|
||||
} from "./session/agent-updates/agent-updates-service.js";
|
||||
import { expandTilde } from "../utils/path.js";
|
||||
import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js";
|
||||
import { searchHomeDirectories } from "./search/home-directories.js";
|
||||
import { searchWorkspaceEntries } from "./search/workspace-entries.js";
|
||||
import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||
import type { Resolvable } from "./speech/provider-resolver.js";
|
||||
import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js";
|
||||
|
||||
@@ -2,6 +2,14 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import { PersistedConfigSchema } from "../../../persisted-config.js";
|
||||
import { resolveOpenAiSpeechConfig } from "./config.js";
|
||||
import type { RequestedSpeechProviders } from "../../speech-types.js";
|
||||
|
||||
const ALL_OPENAI: RequestedSpeechProviders = {
|
||||
dictationStt: { provider: "openai", explicit: true },
|
||||
voiceTurnDetection: { provider: "local", explicit: false },
|
||||
voiceStt: { provider: "openai", explicit: true },
|
||||
voiceTts: { provider: "openai", explicit: true },
|
||||
};
|
||||
|
||||
describe("resolveOpenAiSpeechConfig", () => {
|
||||
test("treats empty OPENAI_API_KEY as unset", () => {
|
||||
@@ -14,6 +22,7 @@ describe("resolveOpenAiSpeechConfig", () => {
|
||||
env,
|
||||
persisted,
|
||||
providers: {
|
||||
...ALL_OPENAI,
|
||||
dictationStt: { provider: "local", explicit: false },
|
||||
voiceStt: { provider: "local", explicit: false },
|
||||
voiceTts: { provider: "local", explicit: false },
|
||||
@@ -23,138 +32,175 @@ describe("resolveOpenAiSpeechConfig", () => {
|
||||
expect(resolved).toBeUndefined();
|
||||
});
|
||||
|
||||
test("uses trimmed OPENAI_API_KEY when configured", () => {
|
||||
test("applies trimmed OPENAI_API_KEY to both STT and TTS", () => {
|
||||
const persisted = PersistedConfigSchema.parse({});
|
||||
const env = {
|
||||
OPENAI_API_KEY: " sk-test ",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({
|
||||
env,
|
||||
persisted,
|
||||
providers: {
|
||||
dictationStt: { provider: "openai", explicit: true },
|
||||
voiceStt: { provider: "openai", explicit: true },
|
||||
voiceTts: { provider: "openai", explicit: true },
|
||||
},
|
||||
});
|
||||
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
|
||||
|
||||
expect(resolved?.apiKey).toBe("sk-test");
|
||||
expect(resolved?.stt?.apiKey).toBe("sk-test");
|
||||
expect(resolved?.tts?.apiKey).toBe("sk-test");
|
||||
});
|
||||
|
||||
test("uses nested voice config before env and non-voice fallbacks", () => {
|
||||
test("resolves distinct endpoints for STT and TTS", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: "fallback-config-key",
|
||||
voice: {
|
||||
apiKey: "voice-config-key",
|
||||
baseUrl: " https://voice.example.com/v1 ",
|
||||
stt: {
|
||||
apiKey: "stt-key",
|
||||
baseUrl: " https://stt.example.com/v1 ",
|
||||
},
|
||||
tts: {
|
||||
apiKey: "tts-key",
|
||||
baseUrl: " https://tts.example.com/v1 ",
|
||||
},
|
||||
baseUrl: "https://legacy-config.example.com/v1",
|
||||
},
|
||||
},
|
||||
});
|
||||
const env = {
|
||||
OPENAI_API_KEY: "env-key",
|
||||
OPENAI_VOICE_API_KEY: "voice-env-key",
|
||||
OPENAI_VOICE_BASE_URL: "https://voice-env.example.com/v1",
|
||||
OPENAI_BASE_URL: "https://env.example.com/v1",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({
|
||||
env,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
persisted,
|
||||
providers: {
|
||||
dictationStt: { provider: "openai", explicit: true },
|
||||
voiceStt: { provider: "openai", explicit: true },
|
||||
voiceTts: { provider: "openai", explicit: true },
|
||||
},
|
||||
providers: ALL_OPENAI,
|
||||
});
|
||||
|
||||
expect(resolved?.apiKey).toBe("voice-config-key");
|
||||
expect(resolved?.baseUrl).toBe("https://voice.example.com/v1");
|
||||
expect(resolved?.stt?.apiKey).toBe("voice-config-key");
|
||||
expect(resolved?.stt?.baseUrl).toBe("https://voice.example.com/v1");
|
||||
expect(resolved?.tts?.apiKey).toBe("voice-config-key");
|
||||
expect(resolved?.tts?.baseUrl).toBe("https://voice.example.com/v1");
|
||||
expect(resolved?.stt?.apiKey).toBe("stt-key");
|
||||
expect(resolved?.stt?.baseUrl).toBe("https://stt.example.com/v1");
|
||||
expect(resolved?.tts?.apiKey).toBe("tts-key");
|
||||
expect(resolved?.tts?.baseUrl).toBe("https://tts.example.com/v1");
|
||||
});
|
||||
|
||||
test("uses voice env config when nested voice config is unset", () => {
|
||||
const persisted = PersistedConfigSchema.parse({});
|
||||
const env = {
|
||||
OPENAI_API_KEY: "sk-test",
|
||||
OPENAI_VOICE_API_KEY: "voice-env-key",
|
||||
OPENAI_VOICE_BASE_URL: " https://voice-env.example.com/v1 ",
|
||||
OPENAI_BASE_URL: "https://env.example.com/v1",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({
|
||||
env,
|
||||
persisted,
|
||||
providers: {
|
||||
dictationStt: { provider: "openai", explicit: true },
|
||||
voiceStt: { provider: "openai", explicit: true },
|
||||
voiceTts: { provider: "openai", explicit: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved?.apiKey).toBe("voice-env-key");
|
||||
expect(resolved?.stt?.apiKey).toBe("voice-env-key");
|
||||
expect(resolved?.tts?.apiKey).toBe("voice-env-key");
|
||||
expect(resolved?.baseUrl).toBe("https://voice-env.example.com/v1");
|
||||
expect(resolved?.stt?.baseUrl).toBe("https://voice-env.example.com/v1");
|
||||
expect(resolved?.tts?.baseUrl).toBe("https://voice-env.example.com/v1");
|
||||
});
|
||||
|
||||
test("falls back to non-voice OpenAI config", () => {
|
||||
test("prefers nested STT/TTS config over env and global fallbacks", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: "fallback-config-key",
|
||||
baseUrl: " https://legacy-config.example.com/v1 ",
|
||||
baseUrl: "https://global-config.example.com/v1",
|
||||
stt: { apiKey: "stt-config-key", baseUrl: " https://stt.example.com/v1 " },
|
||||
tts: { apiKey: "tts-config-key", baseUrl: " https://tts.example.com/v1 " },
|
||||
},
|
||||
},
|
||||
});
|
||||
const env = {
|
||||
OPENAI_API_KEY: "sk-test",
|
||||
OPENAI_BASE_URL: " https://env.example.com/v1 ",
|
||||
OPENAI_API_KEY: "env-key",
|
||||
OPENAI_STT_API_KEY: "stt-env-key",
|
||||
OPENAI_STT_BASE_URL: "https://stt-env.example.com/v1",
|
||||
OPENAI_TTS_API_KEY: "tts-env-key",
|
||||
OPENAI_TTS_BASE_URL: "https://tts-env.example.com/v1",
|
||||
OPENAI_BASE_URL: "https://env.example.com/v1",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({
|
||||
env,
|
||||
persisted,
|
||||
providers: {
|
||||
dictationStt: { provider: "openai", explicit: true },
|
||||
voiceStt: { provider: "openai", explicit: true },
|
||||
voiceTts: { provider: "openai", explicit: true },
|
||||
},
|
||||
});
|
||||
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
|
||||
|
||||
expect(resolved?.apiKey).toBe("fallback-config-key");
|
||||
expect(resolved?.baseUrl).toBe("https://legacy-config.example.com/v1");
|
||||
expect(resolved?.stt?.apiKey).toBe("stt-config-key");
|
||||
expect(resolved?.stt?.baseUrl).toBe("https://stt.example.com/v1");
|
||||
expect(resolved?.tts?.apiKey).toBe("tts-config-key");
|
||||
expect(resolved?.tts?.baseUrl).toBe("https://tts.example.com/v1");
|
||||
});
|
||||
|
||||
test("falls back to global OpenAI env config when voice-specific inputs are unset", () => {
|
||||
test("uses STT/TTS env config when nested config is unset", () => {
|
||||
const persisted = PersistedConfigSchema.parse({});
|
||||
const env = {
|
||||
OPENAI_API_KEY: "sk-test",
|
||||
OPENAI_STT_API_KEY: "stt-env-key",
|
||||
OPENAI_STT_BASE_URL: " https://stt-env.example.com/v1 ",
|
||||
OPENAI_TTS_API_KEY: "tts-env-key",
|
||||
OPENAI_TTS_BASE_URL: " https://tts-env.example.com/v1 ",
|
||||
OPENAI_BASE_URL: "https://env.example.com/v1",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
|
||||
|
||||
expect(resolved?.stt?.apiKey).toBe("stt-env-key");
|
||||
expect(resolved?.stt?.baseUrl).toBe("https://stt-env.example.com/v1");
|
||||
expect(resolved?.tts?.apiKey).toBe("tts-env-key");
|
||||
expect(resolved?.tts?.baseUrl).toBe("https://tts-env.example.com/v1");
|
||||
});
|
||||
|
||||
test("falls back to global OpenAI config for both STT and TTS", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: "fallback-config-key",
|
||||
baseUrl: " https://global-config.example.com/v1 ",
|
||||
},
|
||||
},
|
||||
});
|
||||
const env = {} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
|
||||
|
||||
expect(resolved?.stt?.apiKey).toBe("fallback-config-key");
|
||||
expect(resolved?.stt?.baseUrl).toBe("https://global-config.example.com/v1");
|
||||
expect(resolved?.tts?.apiKey).toBe("fallback-config-key");
|
||||
expect(resolved?.tts?.baseUrl).toBe("https://global-config.example.com/v1");
|
||||
});
|
||||
|
||||
test("falls back to global OpenAI env config when feature inputs are unset", () => {
|
||||
const persisted = PersistedConfigSchema.parse({});
|
||||
const env = {
|
||||
OPENAI_API_KEY: "env-key",
|
||||
OPENAI_BASE_URL: " https://env.example.com/v1 ",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({
|
||||
env,
|
||||
persisted,
|
||||
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
|
||||
|
||||
expect(resolved?.stt?.apiKey).toBe("env-key");
|
||||
expect(resolved?.stt?.baseUrl).toBe("https://env.example.com/v1");
|
||||
expect(resolved?.tts?.apiKey).toBe("env-key");
|
||||
expect(resolved?.tts?.baseUrl).toBe("https://env.example.com/v1");
|
||||
});
|
||||
|
||||
test("ignores empty endpoint env vars and falls back to OPENAI_API_KEY", () => {
|
||||
const persisted = PersistedConfigSchema.parse({});
|
||||
const env = {
|
||||
OPENAI_API_KEY: "global-key",
|
||||
OPENAI_STT_API_KEY: "",
|
||||
OPENAI_STT_BASE_URL: " ",
|
||||
OPENAI_TTS_API_KEY: "",
|
||||
OPENAI_TTS_BASE_URL: "",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
|
||||
|
||||
expect(resolved?.stt?.apiKey).toBe("global-key");
|
||||
expect(resolved?.tts?.apiKey).toBe("global-key");
|
||||
});
|
||||
|
||||
test("omits TTS when only an STT key is configured", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
providers: {
|
||||
dictationStt: { provider: "openai", explicit: true },
|
||||
voiceStt: { provider: "openai", explicit: true },
|
||||
voiceTts: { provider: "openai", explicit: true },
|
||||
openai: {
|
||||
stt: { apiKey: "stt-only-key" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved?.apiKey).toBe("env-key");
|
||||
expect(resolved?.baseUrl).toBe("https://env.example.com/v1");
|
||||
const resolved = resolveOpenAiSpeechConfig({
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
persisted,
|
||||
providers: ALL_OPENAI,
|
||||
});
|
||||
|
||||
expect(resolved?.stt?.apiKey).toBe("stt-only-key");
|
||||
expect(resolved?.tts).toBeUndefined();
|
||||
});
|
||||
|
||||
test("resolves STT even when an unused TTS env var is invalid", () => {
|
||||
const persisted = PersistedConfigSchema.parse({
|
||||
providers: {
|
||||
openai: {
|
||||
stt: { apiKey: "stt-only-key" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const env = { TTS_VOICE: "not-a-real-voice", TTS_MODEL: "bogus-model" } as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI });
|
||||
|
||||
expect(resolved?.stt?.apiKey).toBe("stt-only-key");
|
||||
expect(resolved?.tts).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,8 +8,6 @@ import type { TTSConfig } from "./tts.js";
|
||||
export const DEFAULT_OPENAI_TTS_MODEL = "tts-1";
|
||||
|
||||
export interface OpenAiSpeechProviderConfig {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
stt?: Partial<STTConfig> & { apiKey?: string };
|
||||
tts?: Partial<TTSConfig> & { apiKey?: string };
|
||||
}
|
||||
@@ -30,11 +28,23 @@ const OptionalTrimmedStringSchema = z
|
||||
.optional()
|
||||
.transform((value) => (value && value.length > 0 ? value : undefined));
|
||||
|
||||
const OpenAiSpeechResolutionSchema = z.object({
|
||||
apiKey: OptionalTrimmedStringSchema,
|
||||
baseUrl: OptionalTrimmedStringSchema,
|
||||
// Endpoint credentials only — plain trimmed strings, so this never throws on a
|
||||
// malformed value. The STT/TTS option groups parse separately and only for the
|
||||
// endpoint that is actually configured, so a stale env var for an unused endpoint
|
||||
// (e.g. a leftover TTS_VOICE in an STT-only setup) can't break the other one.
|
||||
const OpenAiEndpointKeysSchema = z.object({
|
||||
sttApiKey: OptionalTrimmedStringSchema,
|
||||
sttBaseUrl: OptionalTrimmedStringSchema,
|
||||
ttsApiKey: OptionalTrimmedStringSchema,
|
||||
ttsBaseUrl: OptionalTrimmedStringSchema,
|
||||
});
|
||||
|
||||
const OpenAiSttOptionsSchema = z.object({
|
||||
sttConfidenceThreshold: OptionalFiniteNumberSchema,
|
||||
sttModel: OptionalTrimmedStringSchema,
|
||||
});
|
||||
|
||||
const OpenAiTtsOptionsSchema = z.object({
|
||||
ttsVoice: z.string().trim().toLowerCase().pipe(OpenAiTtsVoiceSchema).default("alloy"),
|
||||
ttsModel: z
|
||||
.string()
|
||||
@@ -57,9 +67,15 @@ function pickIfOpenAi<T>(
|
||||
|
||||
function firstDefined<T>(values: Array<T | null | undefined>): T | undefined {
|
||||
for (const value of values) {
|
||||
if (value !== undefined && value !== null) {
|
||||
return value;
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
// Empty/whitespace env vars (e.g. a copied .env.example with OPENAI_STT_API_KEY=)
|
||||
// must not shadow a later fallback such as OPENAI_API_KEY.
|
||||
if (typeof value === "string" && value.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -108,18 +124,32 @@ function buildOpenAiResolutionInput(params: {
|
||||
persisted: PersistedConfig;
|
||||
providers: RequestedSpeechProviders;
|
||||
}): Record<string, unknown> {
|
||||
const { env } = params;
|
||||
const openai = params.persisted.providers?.openai;
|
||||
return {
|
||||
apiKey: firstDefined<string>([
|
||||
params.persisted.providers?.openai?.voice?.apiKey,
|
||||
params.env.OPENAI_VOICE_API_KEY,
|
||||
params.persisted.providers?.openai?.apiKey,
|
||||
params.env.OPENAI_API_KEY,
|
||||
sttApiKey: firstDefined<string>([
|
||||
openai?.stt?.apiKey,
|
||||
env.OPENAI_STT_API_KEY,
|
||||
openai?.apiKey,
|
||||
env.OPENAI_API_KEY,
|
||||
]),
|
||||
baseUrl: firstDefined<string>([
|
||||
params.persisted.providers?.openai?.voice?.baseUrl,
|
||||
params.env.OPENAI_VOICE_BASE_URL,
|
||||
params.persisted.providers?.openai?.baseUrl,
|
||||
params.env.OPENAI_BASE_URL,
|
||||
sttBaseUrl: firstDefined<string>([
|
||||
openai?.stt?.baseUrl,
|
||||
env.OPENAI_STT_BASE_URL,
|
||||
openai?.baseUrl,
|
||||
env.OPENAI_BASE_URL,
|
||||
]),
|
||||
ttsApiKey: firstDefined<string>([
|
||||
openai?.tts?.apiKey,
|
||||
env.OPENAI_TTS_API_KEY,
|
||||
openai?.apiKey,
|
||||
env.OPENAI_API_KEY,
|
||||
]),
|
||||
ttsBaseUrl: firstDefined<string>([
|
||||
openai?.tts?.baseUrl,
|
||||
env.OPENAI_TTS_BASE_URL,
|
||||
openai?.baseUrl,
|
||||
env.OPENAI_BASE_URL,
|
||||
]),
|
||||
...buildOpenAiSttInput(params),
|
||||
...buildOpenAiTtsInput(params),
|
||||
@@ -131,29 +161,46 @@ export function resolveOpenAiSpeechConfig(params: {
|
||||
persisted: PersistedConfig;
|
||||
providers: RequestedSpeechProviders;
|
||||
}): OpenAiSpeechProviderConfig | undefined {
|
||||
const parsed = OpenAiSpeechResolutionSchema.parse(buildOpenAiResolutionInput(params));
|
||||
const input = buildOpenAiResolutionInput(params);
|
||||
const keys = OpenAiEndpointKeysSchema.parse(input);
|
||||
|
||||
if (!parsed.apiKey) {
|
||||
if (!keys.sttApiKey && !keys.ttsApiKey) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey: parsed.apiKey,
|
||||
...(parsed.baseUrl ? { baseUrl: parsed.baseUrl } : {}),
|
||||
stt: {
|
||||
apiKey: parsed.apiKey,
|
||||
...(parsed.baseUrl ? { baseUrl: parsed.baseUrl } : {}),
|
||||
...(parsed.sttConfidenceThreshold !== undefined
|
||||
? { confidenceThreshold: parsed.sttConfidenceThreshold }
|
||||
: {}),
|
||||
...(parsed.sttModel ? { model: parsed.sttModel } : {}),
|
||||
},
|
||||
tts: {
|
||||
apiKey: parsed.apiKey,
|
||||
...(parsed.baseUrl ? { baseUrl: parsed.baseUrl } : {}),
|
||||
voice: parsed.ttsVoice,
|
||||
model: parsed.ttsModel,
|
||||
responseFormat: "pcm",
|
||||
},
|
||||
...(keys.sttApiKey ? { stt: buildSttConfig(keys.sttApiKey, keys.sttBaseUrl, input) } : {}),
|
||||
...(keys.ttsApiKey ? { tts: buildTtsConfig(keys.ttsApiKey, keys.ttsBaseUrl, input) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSttConfig(
|
||||
apiKey: string,
|
||||
baseUrl: string | undefined,
|
||||
input: Record<string, unknown>,
|
||||
): OpenAiSpeechProviderConfig["stt"] {
|
||||
const options = OpenAiSttOptionsSchema.parse(input);
|
||||
return {
|
||||
apiKey,
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
...(options.sttConfidenceThreshold !== undefined
|
||||
? { confidenceThreshold: options.sttConfidenceThreshold }
|
||||
: {}),
|
||||
...(options.sttModel ? { model: options.sttModel } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildTtsConfig(
|
||||
apiKey: string,
|
||||
baseUrl: string | undefined,
|
||||
input: Record<string, unknown>,
|
||||
): OpenAiSpeechProviderConfig["tts"] {
|
||||
const options = OpenAiTtsOptionsSchema.parse(input);
|
||||
return {
|
||||
apiKey,
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
voice: options.ttsVoice,
|
||||
model: options.ttsModel,
|
||||
responseFormat: "pcm",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ describe("initializeOpenAiSpeechServices", () => {
|
||||
voiceTts: { provider: "openai", explicit: true },
|
||||
},
|
||||
openaiConfig: {
|
||||
apiKey: "sk-test",
|
||||
stt: { apiKey: "sk-test" },
|
||||
tts: { apiKey: "sk-test" },
|
||||
},
|
||||
|
||||
@@ -29,11 +29,11 @@ export interface SpeechServices {
|
||||
function resolveOpenAiCredentials(
|
||||
openaiConfig: OpenAiSpeechProviderConfig | undefined,
|
||||
): OpenAiCredentialState {
|
||||
const openaiApiKey = openaiConfig?.apiKey;
|
||||
const sttApiKey = openaiConfig?.stt?.apiKey;
|
||||
return {
|
||||
openaiSttApiKey: openaiConfig?.stt?.apiKey ?? openaiApiKey,
|
||||
openaiTtsApiKey: openaiConfig?.tts?.apiKey ?? openaiApiKey,
|
||||
openaiDictationApiKey: openaiApiKey,
|
||||
openaiSttApiKey: sttApiKey,
|
||||
openaiTtsApiKey: openaiConfig?.tts?.apiKey,
|
||||
openaiDictationApiKey: sttApiKey,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,8 @@ describe("resolveSpeechConfig", () => {
|
||||
dictation: "es",
|
||||
voice: "pt",
|
||||
});
|
||||
expect(result.openai?.apiKey).toBe("persisted-key");
|
||||
expect(result.openai?.stt?.apiKey).toBe("persisted-key");
|
||||
expect(result.openai?.tts?.apiKey).toBe("persisted-key");
|
||||
expect(result.openai?.stt?.model).toBe("gpt-4o-transcribe");
|
||||
});
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ function waitForSignal<T>(
|
||||
beforeAll(async () => {
|
||||
ctx = await createDaemonTestContext({
|
||||
agentClients: {},
|
||||
openai: { apiKey: openaiApiKey! },
|
||||
openai: { stt: { apiKey: openaiApiKey! }, tts: { apiKey: openaiApiKey! } },
|
||||
speech: {
|
||||
providers: {
|
||||
dictationStt: { provider: "openai", explicit: true },
|
||||
|
||||
@@ -161,7 +161,7 @@ let ctx: DaemonTestContext;
|
||||
beforeAll(async () => {
|
||||
ctx = await createDaemonTestContext({
|
||||
agentClients: {},
|
||||
openai: { apiKey: openaiApiKey! },
|
||||
openai: { stt: { apiKey: openaiApiKey! }, tts: { apiKey: openaiApiKey! } },
|
||||
speech: {
|
||||
providers: {
|
||||
dictationStt: { provider: "openai", explicit: true },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.102-beta.2",
|
||||
"version": "0.1.102",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -174,6 +174,38 @@
|
||||
"apiKey": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"baseUrl": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"stt": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"baseUrl": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"tts": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"baseUrl": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -16,6 +16,8 @@ Projects built by the Paseo community. These **aren't official Paseo projects**
|
||||
|
||||
- **[zenghongtu/paseo-relay](https://github.com/zenghongtu/paseo-relay)**, a lightweight self-hosted relay server for Paseo, written in Go. Run your own relay instead of the hosted one for fully self-hosted remote access. For how the relay fits into Paseo's connection model, see [Security](/docs/security).
|
||||
|
||||
- **[paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode)**, a VS Code extension.
|
||||
|
||||
## Add your project
|
||||
|
||||
Built something for Paseo, a relay, a deployment recipe, an integration, a client? Open a pull request or an issue on [GitHub](https://github.com/getpaseo/paseo) to get it listed here.
|
||||
|
||||
@@ -204,6 +204,8 @@ In the mobile app, enter the password in the direct connection setup screen.
|
||||
- `PASEO_LOG_FILE_ROTATE_COUNT`, override `log.file.rotate.maxFiles`
|
||||
- `PASEO_LOG`, `PASEO_LOG_FORMAT`, legacy log overrides (still supported)
|
||||
- `OPENAI_API_KEY`, override OpenAI provider key
|
||||
- `OPENAI_STT_API_KEY`, `OPENAI_STT_BASE_URL`, OpenAI speech-to-text endpoint (dictation + voice mode STT)
|
||||
- `OPENAI_TTS_API_KEY`, `OPENAI_TTS_BASE_URL`, OpenAI text-to-speech endpoint (voice mode TTS)
|
||||
- `PASEO_VOICE_LLM_PROVIDER`, override voice LLM provider (`claude`, `codex`, `opencode`)
|
||||
- `PASEO_DICTATION_STT_PROVIDER`, `PASEO_VOICE_STT_PROVIDER`, `PASEO_VOICE_TTS_PROVIDER`, override voice provider selection (`local` or `openai`)
|
||||
- `PASEO_LOCAL_MODELS_DIR`, control local model directory
|
||||
|
||||
@@ -90,7 +90,11 @@ You can switch dictation, voice STT, and voice TTS to OpenAI by setting provider
|
||||
},
|
||||
"providers": {
|
||||
"openai": {
|
||||
"voice": {
|
||||
"stt": {
|
||||
"apiKey": "...",
|
||||
"baseUrl": "https://api.openai.com/v1"
|
||||
},
|
||||
"tts": {
|
||||
"apiKey": "...",
|
||||
"baseUrl": "https://api.openai.com/v1"
|
||||
}
|
||||
@@ -99,7 +103,7 @@ You can switch dictation, voice STT, and voice TTS to OpenAI by setting provider
|
||||
}
|
||||
```
|
||||
|
||||
`providers.openai.voice.apiKey` and `providers.openai.voice.baseUrl` configure only Paseo OpenAI voice traffic, without changing Codex or other OpenAI-backed tools.
|
||||
`providers.openai.stt` covers dictation and voice mode speech-to-text, and `providers.openai.tts` covers voice mode text-to-speech. Because they resolve independently, you can point STT and TTS at different endpoints. Each falls back to `providers.openai.apiKey`/`baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when unset. These settings configure only Paseo OpenAI speech traffic, without changing Codex or other OpenAI-backed tools.
|
||||
|
||||
Paseo uses these paths under the configured OpenAI base URL:
|
||||
|
||||
@@ -111,6 +115,8 @@ Paseo uses these paths under the configured OpenAI base URL:
|
||||
|
||||
- `PASEO_VOICE_LLM_PROVIDER`, voice agent provider override
|
||||
- `PASEO_DICTATION_STT_PROVIDER`, `PASEO_VOICE_STT_PROVIDER`, `PASEO_VOICE_TTS_PROVIDER`, speech provider selection (`local` or `openai`)
|
||||
- `OPENAI_STT_API_KEY`, `OPENAI_STT_BASE_URL`, OpenAI speech-to-text endpoint (dictation + voice mode STT)
|
||||
- `OPENAI_TTS_API_KEY`, `OPENAI_TTS_BASE_URL`, OpenAI text-to-speech endpoint (voice mode TTS)
|
||||
- `PASEO_LOCAL_MODELS_DIR`, local model storage directory
|
||||
- `PASEO_DICTATION_LOCAL_STT_MODEL`, local dictation STT model ID
|
||||
- `PASEO_VOICE_LOCAL_STT_MODEL`, `PASEO_VOICE_LOCAL_TTS_MODEL`, local voice STT/TTS model IDs
|
||||
|
||||
@@ -27,6 +27,29 @@ const entries = [
|
||||
"packages/server/dist/server/terminal/terminal-worker-process.js",
|
||||
];
|
||||
|
||||
const FFF_BIN_PACKAGE = {
|
||||
darwin: { arm64: "@ff-labs/fff-bin-darwin-arm64", x64: "@ff-labs/fff-bin-darwin-x64" },
|
||||
linux: { arm64: "@ff-labs/fff-bin-linux-arm64-gnu", x64: "@ff-labs/fff-bin-linux-x64-gnu" },
|
||||
win32: { arm64: "@ff-labs/fff-bin-win32-arm64", x64: "@ff-labs/fff-bin-win32-x64" },
|
||||
};
|
||||
|
||||
const FFI_RS_PACKAGE = {
|
||||
darwin: { arm64: "@yuuang/ffi-rs-darwin-arm64", x64: "@yuuang/ffi-rs-darwin-x64" },
|
||||
linux: { arm64: "@yuuang/ffi-rs-linux-arm64-gnu", x64: "@yuuang/ffi-rs-linux-x64-gnu" },
|
||||
win32: {
|
||||
arm64: "@yuuang/ffi-rs-win32-arm64-msvc",
|
||||
x64: "@yuuang/ffi-rs-win32-x64-msvc",
|
||||
},
|
||||
};
|
||||
|
||||
function packageGlob(packageName) {
|
||||
return packageName ? [`node_modules/${packageName}/**`] : [];
|
||||
}
|
||||
|
||||
function packageForHost(packagesByPlatform) {
|
||||
return packagesByPlatform[process.platform]?.[process.arch] ?? null;
|
||||
}
|
||||
|
||||
// Files read at runtime via fs APIs rather than `require`. nft only
|
||||
// traces the module graph; data files have to be listed explicitly.
|
||||
const additionalInputs = [
|
||||
@@ -44,6 +67,11 @@ const additionalInputs = [
|
||||
// the Nix derivation builds for one platform at a time and ships only
|
||||
// its own binaries.
|
||||
`node_modules/node-pty/prebuilds/${process.platform}-${process.arch}/**`,
|
||||
// FFF resolves its native search library and ffi-rs binding dynamically.
|
||||
"node_modules/@ff-labs/fff-node/**",
|
||||
"node_modules/ffi-rs/**",
|
||||
...packageGlob(packageForHost(FFF_BIN_PACKAGE)),
|
||||
...packageGlob(packageForHost(FFI_RS_PACKAGE)),
|
||||
];
|
||||
|
||||
// Trace.
|
||||
@@ -65,6 +93,10 @@ const { fileList, warnings } = await nodeFileTrace(entries, {
|
||||
// node-fetch optional peer for non-UTF-8 charset decoding; not
|
||||
// loaded in our usage.
|
||||
"encoding/**",
|
||||
// Host packages are added explicitly above; other optional native
|
||||
// variants are intentionally not part of the daemon closure.
|
||||
"@ff-labs/fff-bin-*/**",
|
||||
"@yuuang/ffi-rs-*/**",
|
||||
// Tests are stripped during the daemon build; nft sometimes still
|
||||
// tries to walk into them via index files. Belt and suspenders.
|
||||
"**/*.test.js",
|
||||
|
||||
Reference in New Issue
Block a user