mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
66 Commits
v0.1.48
...
add-websto
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3876ffe61 | ||
|
|
8f136a1f92 | ||
|
|
6bf8da8087 | ||
|
|
d39414064e | ||
|
|
ffcc35485d | ||
|
|
8ff94dc03e | ||
|
|
dace4f862f | ||
|
|
fc667d6312 | ||
|
|
aae4d9f8dd | ||
|
|
5eb8b300a3 | ||
|
|
6c9a832906 | ||
|
|
35430dab52 | ||
|
|
0eac4bc4b3 | ||
|
|
635de3d76a | ||
|
|
931d3ba81f | ||
|
|
6a4f439541 | ||
|
|
ac5e6df6c9 | ||
|
|
bf7d3c2775 | ||
|
|
5c93fbc955 | ||
|
|
5ac7b3f7c7 | ||
|
|
c742f17080 | ||
|
|
b4d6a5d6b8 | ||
|
|
43f01600ce | ||
|
|
e7cf9ee69d | ||
|
|
9c8b0c3aca | ||
|
|
8088c39fd6 | ||
|
|
1279f1d556 | ||
|
|
0defbc1dc3 | ||
|
|
76c6253ae0 | ||
|
|
5ec25687cd | ||
|
|
33a1557aed | ||
|
|
cbc2ce06e9 | ||
|
|
82466aaa9f | ||
|
|
d3e3a83a0d | ||
|
|
ee611d65b6 | ||
|
|
5ce4562eed | ||
|
|
21c7761403 | ||
|
|
682fc54778 | ||
|
|
e06b691d5d | ||
|
|
022eb33234 | ||
|
|
161b2c2378 | ||
|
|
52dfdb1913 | ||
|
|
bdaa6b65aa | ||
|
|
1900f43049 | ||
|
|
29b6f2a86f | ||
|
|
638c208609 | ||
|
|
7b1144dafe | ||
|
|
940bc6243b | ||
|
|
51b83768c7 | ||
|
|
cdbaa8d29c | ||
|
|
b2229a28b9 | ||
|
|
d888c8f126 | ||
|
|
102ef06c30 | ||
|
|
5cb424b2e6 | ||
|
|
fd9dfb0cc8 | ||
|
|
03380cfad0 | ||
|
|
6ce0e1e91f | ||
|
|
64c2515b94 | ||
|
|
e90241c445 | ||
|
|
06fbeb413b | ||
|
|
390a3402ab | ||
|
|
27ddc95862 | ||
|
|
c63240b18c | ||
|
|
a5aca2312b | ||
|
|
e01a0abdf2 | ||
|
|
3397e6c589 |
43
.github/workflows/desktop-release.yml
vendored
43
.github/workflows/desktop-release.yml
vendored
@@ -35,8 +35,43 @@ env:
|
||||
DESKTOP_PACKAGE_PATH: 'packages/desktop'
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
if: ${{ (github.event_name == 'push' && !startsWith(github.ref_name, 'desktop-macos-v') && !startsWith(github.ref_name, 'desktop-linux-v') && !startsWith(github.ref_name, 'desktop-windows-v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.platform == 'all') }}
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: scripts
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
|
||||
- name: Resolve release metadata
|
||||
shell: bash
|
||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Create GitHub release
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" > /dev/null 2>&1; then
|
||||
echo "Release $RELEASE_TAG already exists, skipping creation"
|
||||
else
|
||||
prerelease_flag=""
|
||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||
prerelease_flag="--prerelease"
|
||||
fi
|
||||
gh release create "$RELEASE_TAG" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--title "Paseo $RELEASE_TAG" \
|
||||
--generate-notes \
|
||||
$prerelease_flag
|
||||
fi
|
||||
|
||||
publish-macos:
|
||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v'))) }}
|
||||
needs: [create-release]
|
||||
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v')))) }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -219,7 +254,8 @@ jobs:
|
||||
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}"
|
||||
|
||||
publish-linux:
|
||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }}
|
||||
needs: [create-release]
|
||||
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v')))) }}
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
@@ -285,7 +321,8 @@ jobs:
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
||||
|
||||
publish-windows:
|
||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v'))) }}
|
||||
needs: [create-release]
|
||||
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v')))) }}
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
|
||||
34
CHANGELOG.md
34
CHANGELOG.md
@@ -1,5 +1,39 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.50 - 2026-04-07
|
||||
|
||||
### Added
|
||||
- Context window meter — see how much of the context window your agent has used, with color thresholds at 70% and 90%. Works with Claude Code, Codex, and OpenCode.
|
||||
- Open in editor — jump from any workspace straight into Cursor, VS Code, Zed, or your file manager. Paseo remembers your choice.
|
||||
- Side-by-side diffs — toggle between unified and split-column diff views, with a whitespace visibility option.
|
||||
- Spoken messages — when using voice mode, agent speech now appears as regular messages in the conversation instead of raw tool output.
|
||||
- Plan actions — plan cards now show the actions your agent supports (e.g. "Implement", "Deny") instead of generic accept/reject buttons.
|
||||
- Background git fetch — ahead/behind counts in the Changes pane stay up to date automatically.
|
||||
|
||||
### Improved
|
||||
- Workspaces load instantly on connect instead of waiting for a full sync.
|
||||
- File explorer and diff pane remember which folders are expanded when you switch tabs.
|
||||
- Closing a workspace tab is now instant.
|
||||
- Settings shows a Refresh button for providers and displays error details inline.
|
||||
- Reload agent moved away from the close button to prevent accidental taps.
|
||||
|
||||
### Fixed
|
||||
- Voice mode no longer drifts into false speech detection during long sessions.
|
||||
- Garbled overlapping text on plan cards.
|
||||
- Changes pane could show stale diffs when working with git worktrees.
|
||||
- Restarting an agent quickly could crash the session.
|
||||
- Copilot no longer pauses for permission prompts in autopilot mode.
|
||||
- Connection and pairing dialogs now display correctly on tablets.
|
||||
- Orchestration errors from agents are now surfaced instead of silently lost.
|
||||
- Diff stats no longer reset to zero when reconnecting.
|
||||
|
||||
## 0.1.49 - 2026-04-07
|
||||
|
||||
### Fixed
|
||||
- Models and providers now load reliably on first connect instead of requiring a manual refresh.
|
||||
- Model picker only shows models from the agent's own provider, not every provider on the server.
|
||||
- Model lists stay consistent regardless of which screen you open first.
|
||||
|
||||
## 0.1.48 - 2026-04-05
|
||||
|
||||
### Added
|
||||
|
||||
@@ -45,7 +45,7 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
|
||||
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
|
||||
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
||||
- **Always run typecheck after every change.**
|
||||
- **NEVER make breaking changes to WebSocket or message schemas.** The mobile app in the App Store always lags behind the daemon, and daemons in the wild lag behind new app releases. Both directions must work. Every schema change MUST be backward-compatible:
|
||||
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
|
||||
- New fields: always `.optional()` with a sensible default or `.transform()` fallback.
|
||||
- Never change a field from optional to required.
|
||||
- Never remove a field — deprecate it (keep accepting it, stop sending it).
|
||||
|
||||
@@ -11,6 +11,12 @@ There are two supported ways to ship from `main`:
|
||||
|
||||
## Standard release (patch)
|
||||
|
||||
Before running any stable patch release command:
|
||||
|
||||
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
|
||||
- Make sure local `npm run typecheck` passes on that commit.
|
||||
- Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready.
|
||||
|
||||
```bash
|
||||
npm run release:patch
|
||||
```
|
||||
@@ -24,6 +30,7 @@ Use the direct stable path when the current `main` changes are ready to become t
|
||||
## Manual step-by-step
|
||||
|
||||
```bash
|
||||
npm run typecheck # Verify the exact commit you intend to release
|
||||
npm run release:check # Typecheck, build, dry-run pack
|
||||
npm run version:all:patch # Bump version, create commit + tag
|
||||
npm run release:publish # Publish to npm
|
||||
@@ -122,6 +129,16 @@ No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to
|
||||
- **Only Claude should write changelog entries.**
|
||||
- If you are Codex and a stable release needs a changelog entry, launch a Claude agent with Paseo to draft it, then review and commit the result.
|
||||
|
||||
## Changelog voice
|
||||
|
||||
The changelog is shown on the Paseo homepage. Write it for **end users**, not developers.
|
||||
|
||||
- **Frame everything from the user's perspective.** Describe what changed in the app, not what changed in the code. Users care that "workspaces load instantly" — not that a component no longer remounts.
|
||||
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`.
|
||||
- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.
|
||||
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
|
||||
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
|
||||
|
||||
## Pre-release sanity check
|
||||
|
||||
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
|
||||
@@ -131,7 +148,7 @@ Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
|
||||
> Review the diff between the latest release tag and HEAD. Focus on:
|
||||
>
|
||||
> 1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
|
||||
> 2. **Backward compatibility** — mobile apps lag behind desktop/daemon updates by days. Users will update desktop and daemon immediately but keep running the old app. Flag anything that requires both sides to update in lockstep.
|
||||
> 2. **Backward compatibility** — the important direction is old app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Flag anything that breaks old clients against new daemons or requires both sides to update in lockstep.
|
||||
> 3. **Regressions** — anything that looks like it could break existing functionality.
|
||||
>
|
||||
> Diff: `git diff <latest-release-tag>..HEAD`
|
||||
@@ -150,6 +167,8 @@ In other words, RCs are checkpoints along the way; the changelog only records th
|
||||
## Completion checklist
|
||||
|
||||
- [ ] Run the pre-release sanity check (see above) and address any findings
|
||||
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any `release:*` patch/promote command
|
||||
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any `release:*` patch/promote command
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
|
||||
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
|
||||
|
||||
68
docs/plan-approval-normalization.md
Normal file
68
docs/plan-approval-normalization.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Plan Approval Normalization
|
||||
|
||||
## Goal
|
||||
|
||||
Normalize plan approval across providers so the UI renders one consistent plan approval card and action row, while each provider keeps its own execution quirks behind the session permission interface.
|
||||
|
||||
## Compatibility Constraints
|
||||
|
||||
- Older clients must remain compatible with newer daemons.
|
||||
- All new wire fields must be optional.
|
||||
- Existing plan permissions without action metadata must still render and work.
|
||||
- Existing question permissions must keep their current behavior.
|
||||
|
||||
## Design
|
||||
|
||||
### Shared abstraction
|
||||
|
||||
Add optional permission action definitions to the shared permission request/response types.
|
||||
|
||||
- Permission requests may include `actions`.
|
||||
- Permission responses may include `selectedActionId`.
|
||||
- `kind: "plan"` remains the normalized concept for plan approval.
|
||||
- The UI renders actions from the permission request instead of hardcoding provider-specific buttons.
|
||||
|
||||
### Claude
|
||||
|
||||
Keep Claude's plan permission flow, but enrich it with explicit action definitions.
|
||||
|
||||
- Always expose `Reject`.
|
||||
- Always expose `Implement`.
|
||||
- If the agent entered plan mode from a more permissive mode like `bypassPermissions`, also expose `Implement with <previous mode>`.
|
||||
- Resolve the selected action entirely inside `respondToPermission()`.
|
||||
|
||||
### Codex
|
||||
|
||||
Synthesize a normalized `kind: "plan"` permission after a Codex plan-mode turn completes with a plan result.
|
||||
|
||||
- Emit a plan permission with `Reject` and `Implement` actions.
|
||||
- On `Implement`, disable `plan_mode`, disable `fast_mode`, and automatically start a follow-up implementation turn.
|
||||
- On `Reject`, resolve without starting a follow-up turn.
|
||||
- Keep the implementation prompt and state transitions inside the Codex provider.
|
||||
|
||||
### Manager and state sync
|
||||
|
||||
After permission resolution, refresh provider-derived state so the UI sees internal mode/feature changes without knowing provider quirks.
|
||||
|
||||
- Refresh current mode
|
||||
- Refresh pending permissions
|
||||
- Refresh runtime info
|
||||
- Refresh features
|
||||
- Persist refreshed state
|
||||
|
||||
### UI
|
||||
|
||||
Render plan permissions through the existing plan card, but generate buttons from normalized permission actions.
|
||||
|
||||
- If `actions` are absent, fall back to legacy buttons.
|
||||
- Plan cards should use `Implement` as the default primary label.
|
||||
- Do not add provider-specific rendering branches.
|
||||
|
||||
## Verification
|
||||
|
||||
1. Shared schema/type tests for optional `actions` and `selectedActionId`
|
||||
2. App tests for generic plan-action rendering
|
||||
3. Claude tests for third action when resuming from a more permissive mode
|
||||
4. Codex tests for synthetic plan approval and automatic implementation follow-up
|
||||
5. Manager tests for post-permission state refresh
|
||||
6. `npm run typecheck`
|
||||
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-ZLwOacIYQ6rUT2sYs3vNvOh7wTkwQ92cpOJDm29GLWs=";
|
||||
npmDepsHash = "sha256-eslgD6PqQaRAWCnDE2A41bTmXqoU/ZEY0oDTh+oAvh0=";
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
|
||||
38
package-lock.json
generated
38
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.51-rc.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.51-rc.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -34906,16 +34906,16 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.51-rc.1",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.48",
|
||||
"@getpaseo/highlight": "0.1.48",
|
||||
"@getpaseo/server": "0.1.48",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.51-rc.1",
|
||||
"@getpaseo/highlight": "0.1.51-rc.1",
|
||||
"@getpaseo/server": "0.1.51-rc.1",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -35032,11 +35032,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.51-rc.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.48",
|
||||
"@getpaseo/server": "0.1.48",
|
||||
"@getpaseo/relay": "0.1.51-rc.1",
|
||||
"@getpaseo/server": "0.1.51-rc.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -35077,11 +35077,11 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.51-rc.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.48",
|
||||
"@getpaseo/server": "0.1.48",
|
||||
"@getpaseo/cli": "0.1.51-rc.1",
|
||||
"@getpaseo/server": "0.1.51-rc.1",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
@@ -35115,7 +35115,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.51-rc.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
@@ -35316,7 +35316,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.51-rc.1",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -35342,7 +35342,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.51-rc.1",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -35358,14 +35358,14 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.51-rc.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.48",
|
||||
"@getpaseo/relay": "0.1.48",
|
||||
"@getpaseo/highlight": "0.1.51-rc.1",
|
||||
"@getpaseo/relay": "0.1.51-rc.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
@@ -35764,7 +35764,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.51-rc.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.51-rc.1",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
|
||||
BIN
packages/app/assets/images/editor-apps/cursor.png
Normal file
BIN
packages/app/assets/images/editor-apps/cursor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
packages/app/assets/images/editor-apps/file-explorer.png
Normal file
BIN
packages/app/assets/images/editor-apps/file-explorer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
BIN
packages/app/assets/images/editor-apps/finder.png
Normal file
BIN
packages/app/assets/images/editor-apps/finder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
packages/app/assets/images/editor-apps/vscode.png
Normal file
BIN
packages/app/assets/images/editor-apps/vscode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
BIN
packages/app/assets/images/editor-apps/webstorm.png
Normal file
BIN
packages/app/assets/images/editor-apps/webstorm.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
BIN
packages/app/assets/images/editor-apps/zed.png
Normal file
BIN
packages/app/assets/images/editor-apps/zed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.51-rc.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
@@ -31,9 +31,9 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.48",
|
||||
"@getpaseo/highlight": "0.1.48",
|
||||
"@getpaseo/server": "0.1.48",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.51-rc.1",
|
||||
"@getpaseo/highlight": "0.1.51-rc.1",
|
||||
"@getpaseo/server": "0.1.51-rc.1",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
@@ -369,10 +369,41 @@ function AppContainer({
|
||||
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
|
||||
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
|
||||
const isCompactLayout = isCompactFormFactor();
|
||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const bp = UnistylesRuntime.breakpoint;
|
||||
const screenW = UnistylesRuntime.screen.width;
|
||||
const screenH = UnistylesRuntime.screen.height;
|
||||
const isElectron = getIsElectronRuntime();
|
||||
const windowW = Platform.OS === "web" ? window.innerWidth : undefined;
|
||||
const windowH = Platform.OS === "web" ? window.innerHeight : undefined;
|
||||
const dpr = Platform.OS === "web" ? window.devicePixelRatio : undefined;
|
||||
const ua = Platform.OS === "web" ? navigator.userAgent : undefined;
|
||||
|
||||
console.log(
|
||||
"[layout-debug]",
|
||||
JSON.stringify({
|
||||
breakpoint: bp,
|
||||
isCompactLayout,
|
||||
isElectron,
|
||||
chromeEnabled,
|
||||
isFocusModeEnabled,
|
||||
agentListOpen,
|
||||
sidebarWidth,
|
||||
sidebarRenderedInRow: !isCompactLayout && chromeEnabled && !isFocusModeEnabled,
|
||||
unistylesScreen: { w: screenW, h: screenH },
|
||||
window: { w: windowW, h: windowH },
|
||||
devicePixelRatio: dpr,
|
||||
userAgent: ua,
|
||||
}),
|
||||
);
|
||||
}, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]);
|
||||
|
||||
useKeyboardShortcuts({
|
||||
enabled: chromeEnabled,
|
||||
isMobile: isCompactLayout,
|
||||
@@ -574,7 +605,7 @@ function OfferLinkListener({
|
||||
if (cancelled) return;
|
||||
const serverId = (profile as any)?.serverId;
|
||||
if (typeof serverId !== "string" || !serverId) return;
|
||||
router.replace(buildHostRootRoute(serverId) as any);
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
@@ -692,7 +723,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
if (hosts.some((host) => host.serverId === activeServerId)) {
|
||||
return;
|
||||
}
|
||||
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId) as any);
|
||||
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId));
|
||||
}, [activeServerId, hosts, pathname, router]);
|
||||
|
||||
// Parse selectedAgentKey directly from pathname
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function HostAgentReadyRoute() {
|
||||
}
|
||||
if (!client || !isConnected) {
|
||||
redirectedRef.current = true;
|
||||
router.replace(buildHostRootRoute(serverId) as any);
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
}
|
||||
}, [agentCwd, agentId, client, isConnected, router, serverId]);
|
||||
|
||||
@@ -89,14 +89,14 @@ export default function HostAgentReadyRoute() {
|
||||
);
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostRootRoute(serverId) as any);
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled || redirectedRef.current) {
|
||||
return;
|
||||
}
|
||||
redirectedRef.current = true;
|
||||
router.replace(buildHostRootRoute(serverId) as any);
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -68,11 +68,11 @@ export default function HostIndexRoute() {
|
||||
|
||||
const primaryWorkspace = visibleWorkspaces[0];
|
||||
if (primaryWorkspace?.id?.trim()) {
|
||||
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()) as any);
|
||||
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()));
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace(buildHostOpenProjectRoute(serverId) as any);
|
||||
router.replace(buildHostOpenProjectRoute(serverId));
|
||||
}, HOST_ROOT_REDIRECT_DELAY_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function Index() {
|
||||
const targetRoute = anyOnlineServerId
|
||||
? buildHostRootRoute(anyOnlineServerId)
|
||||
: WELCOME_ROUTE;
|
||||
router.replace(targetRoute as any);
|
||||
router.replace(targetRoute);
|
||||
}, [anyOnlineServerId, pathname, router, storeReady]);
|
||||
|
||||
return <StartupSplashScreen bootstrapState={bootstrapState} />;
|
||||
|
||||
@@ -174,7 +174,7 @@ export default function PairScanScreen() {
|
||||
const returnToSource = useCallback(
|
||||
(serverId: string) => {
|
||||
if (source === "onboarding") {
|
||||
router.replace(buildHostRootRoute(serverId) as any);
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
return;
|
||||
}
|
||||
if (source === "editHost" && targetServerId) {
|
||||
@@ -190,7 +190,7 @@ export default function PairScanScreen() {
|
||||
router.back();
|
||||
} catch {
|
||||
const settingsServerId = sourceServerId ?? serverId;
|
||||
router.replace(buildHostSettingsRoute(settingsServerId) as any);
|
||||
router.replace(buildHostSettingsRoute(settingsServerId));
|
||||
}
|
||||
},
|
||||
[router, source, sourceServerId, targetServerId],
|
||||
@@ -209,7 +209,7 @@ export default function PairScanScreen() {
|
||||
router.back();
|
||||
} catch {
|
||||
if (sourceServerId) {
|
||||
router.replace(buildHostSettingsRoute(sourceServerId) as any);
|
||||
router.replace(buildHostSettingsRoute(sourceServerId));
|
||||
return;
|
||||
}
|
||||
router.replace("/" as any);
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function LegacySettingsRoute() {
|
||||
if (!targetServerId) {
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostSettingsRoute(targetServerId) as any);
|
||||
router.replace(buildHostSettingsRoute(targetServerId));
|
||||
}, [router, targetServerId]);
|
||||
|
||||
if (!targetServerId) {
|
||||
|
||||
@@ -28,6 +28,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
width: "100%",
|
||||
maxWidth: 520,
|
||||
maxHeight: "85%",
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderWidth: 1,
|
||||
@@ -54,11 +56,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
desktopScroll: {
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
desktopContent: {
|
||||
padding: theme.spacing[6],
|
||||
gap: theme.spacing[4],
|
||||
flexGrow: 1,
|
||||
},
|
||||
bottomSheetHandle: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DraftAgentStatusBar,
|
||||
type DraftAgentStatusBarProps,
|
||||
} from "./agent-status-bar";
|
||||
import { ContextWindowMeter } from "./context-window-meter";
|
||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
@@ -135,6 +136,8 @@ export function AgentInputArea({
|
||||
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
||||
return {
|
||||
status: agent?.status ?? null,
|
||||
contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null,
|
||||
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -636,6 +639,25 @@ export function AgentInputArea({
|
||||
</View>
|
||||
);
|
||||
|
||||
const hasContextWindowMeter =
|
||||
typeof agentState.contextWindowMaxTokens === "number" &&
|
||||
typeof agentState.contextWindowUsedTokens === "number";
|
||||
const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null;
|
||||
const contextWindowUsedTokens = hasContextWindowMeter
|
||||
? agentState.contextWindowUsedTokens
|
||||
: null;
|
||||
|
||||
const beforeVoiceContent = (
|
||||
<View style={styles.contextWindowMeterSlot}>
|
||||
{contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
|
||||
<ContextWindowMeter
|
||||
maxTokens={contextWindowMaxTokens}
|
||||
usedTokens={contextWindowUsedTokens}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
|
||||
const leftContent =
|
||||
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
|
||||
<DraftAgentStatusBar {...statusControls} />
|
||||
@@ -715,6 +737,7 @@ export function AgentInputArea({
|
||||
disabled={isSubmitLoading}
|
||||
isInputActive={isInputActive}
|
||||
leftContent={leftContent}
|
||||
beforeVoiceContent={beforeVoiceContent}
|
||||
rightContent={rightContent}
|
||||
voiceServerId={serverId}
|
||||
voiceAgentId={agentId}
|
||||
@@ -790,6 +813,12 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
contextWindowMeterSlot: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
realtimeVoiceButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
|
||||
@@ -240,7 +240,7 @@ export function AgentList({
|
||||
target: { kind: "agent", agentId },
|
||||
pin: Boolean(agent.archivedAt),
|
||||
});
|
||||
router.navigate(route as any);
|
||||
router.navigate(route);
|
||||
},
|
||||
[isActionSheetVisible, onAgentSelect],
|
||||
);
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
} from "lucide-react-native";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import {
|
||||
buildFavoriteModelKey,
|
||||
mergeProviderPreferences,
|
||||
@@ -51,7 +51,6 @@ import {
|
||||
getStatusSelectorHint,
|
||||
resolveAgentModelSelection,
|
||||
} from "@/components/agent-status-bar.utils";
|
||||
import { isProviderModelsQueryLoading } from "@/components/agent-status-bar.model-loading";
|
||||
|
||||
type StatusOption = {
|
||||
id: string;
|
||||
@@ -75,6 +74,7 @@ type ControlledAgentStatusBarProps = {
|
||||
modelOptions?: StatusOption[];
|
||||
selectedModelId?: string;
|
||||
onSelectModel?: (modelId: string) => void;
|
||||
onSelectProviderAndModel?: (provider: string, modelId: string) => void;
|
||||
thinkingOptions?: StatusOption[];
|
||||
selectedThinkingOptionId?: string;
|
||||
onSelectThinkingOption?: (thinkingOptionId: string) => void;
|
||||
@@ -203,6 +203,7 @@ function ControlledStatusBar({
|
||||
modelOptions,
|
||||
selectedModelId,
|
||||
onSelectModel,
|
||||
onSelectProviderAndModel,
|
||||
thinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
onSelectThinkingOption,
|
||||
@@ -264,7 +265,7 @@ function ControlledStatusBar({
|
||||
return null;
|
||||
}
|
||||
|
||||
const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0;
|
||||
const modelDisabled = disabled;
|
||||
|
||||
const SEARCH_THRESHOLD = 6;
|
||||
|
||||
@@ -648,10 +649,14 @@ function ControlledStatusBar({
|
||||
selectedModel={selectedModelId ?? ""}
|
||||
canSelectProvider={canSelectProviderInModelMenu}
|
||||
onSelect={(selectedProviderId, modelId) => {
|
||||
if (selectedProviderId !== provider) {
|
||||
onSelectProvider?.(selectedProviderId);
|
||||
if (onSelectProviderAndModel) {
|
||||
onSelectProviderAndModel(selectedProviderId, modelId);
|
||||
} else {
|
||||
if (selectedProviderId !== provider) {
|
||||
onSelectProvider?.(selectedProviderId);
|
||||
}
|
||||
onSelectModel?.(modelId);
|
||||
}
|
||||
onSelectModel?.(modelId);
|
||||
}}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavorite={onToggleFavoriteModel}
|
||||
@@ -667,6 +672,7 @@ function ControlledStatusBar({
|
||||
pointerEvents="none"
|
||||
testID="agent-preferences-model"
|
||||
>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
@@ -692,6 +698,7 @@ function ControlledStatusBar({
|
||||
accessibilityLabel="Select thinking option"
|
||||
testID="agent-preferences-thinking"
|
||||
>
|
||||
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.sheetSelectText}>{displayThinking}</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
@@ -852,6 +859,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
||||
model: currentAgent.model,
|
||||
features: currentAgent.features,
|
||||
thinkingOptionId: currentAgent.thinkingOptionId,
|
||||
lastUsage: currentAgent.lastUsage,
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
@@ -863,52 +871,34 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
||||
);
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
|
||||
const modelsQuery = useQuery({
|
||||
queryKey: ["providerModels", serverId, agent?.provider ?? "__missing_provider__"],
|
||||
enabled: Boolean(client && agent?.provider),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client || !agent) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.models ?? [];
|
||||
},
|
||||
});
|
||||
const {
|
||||
entries: snapshotEntries,
|
||||
isLoading: snapshotIsLoading,
|
||||
isFetching: snapshotIsFetching,
|
||||
} = useProvidersSnapshot(serverId);
|
||||
|
||||
const snapshotModels = useMemo(() => {
|
||||
if (!snapshotEntries || !agent?.provider) {
|
||||
return null;
|
||||
}
|
||||
const entry = snapshotEntries.find((e) => e.provider === agent.provider);
|
||||
return entry?.models ?? null;
|
||||
}, [snapshotEntries, agent?.provider]);
|
||||
|
||||
const models = snapshotModels;
|
||||
|
||||
const agentProviderDefinitions = useMemo(() => {
|
||||
const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider);
|
||||
return definition ? [definition] : [];
|
||||
}, [agent?.provider]);
|
||||
|
||||
const agentProviderModelQuery = useQuery({
|
||||
queryKey: ["providerModels", serverId, agent?.provider, agent?.cwd ?? ""],
|
||||
enabled: Boolean(client && agent?.cwd && agent?.provider),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client || !agent) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.models ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
const agentProviderModels = useMemo(() => {
|
||||
const map = new Map<string, AgentModelDefinition[]>();
|
||||
if (agent?.provider && agentProviderModelQuery.data) {
|
||||
map.set(agent.provider, agentProviderModelQuery.data);
|
||||
if (agent?.provider && snapshotModels) {
|
||||
map.set(agent.provider, snapshotModels);
|
||||
}
|
||||
return map;
|
||||
}, [agent?.provider, agentProviderModelQuery.data]);
|
||||
|
||||
const models = modelsQuery.data ?? null;
|
||||
}, [agent?.provider, snapshotModels]);
|
||||
|
||||
const displayMode =
|
||||
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
|
||||
@@ -1028,7 +1018,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
||||
console.warn("[AgentStatusBar] setAgentFeature failed", error);
|
||||
});
|
||||
}}
|
||||
isModelLoading={isProviderModelsQueryLoading(modelsQuery)}
|
||||
isModelLoading={snapshotIsLoading || snapshotIsFetching}
|
||||
onDropdownClose={onDropdownClose}
|
||||
disabled={!client}
|
||||
/>
|
||||
@@ -1135,6 +1125,7 @@ export function DraftAgentStatusBar({
|
||||
modelOptions={modelOptions}
|
||||
selectedModelId={selectedModel}
|
||||
onSelectModel={(modelId) => onSelectModel(modelId)}
|
||||
onSelectProviderAndModel={onSelectProviderAndModel}
|
||||
isModelLoading={isAllModelsLoading}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavoriteModel={(provider, modelId) => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Check, ChevronDown, X } from "lucide-react-native";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import {
|
||||
AssistantMessage,
|
||||
SpeakMessage,
|
||||
UserMessage,
|
||||
ActivityLog,
|
||||
ToolCall,
|
||||
@@ -38,7 +39,10 @@ import {
|
||||
import { PlanCard } from "./plan-card";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
||||
import type {
|
||||
AgentPermissionAction,
|
||||
AgentPermissionResponse,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
@@ -178,7 +182,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
workspaceId,
|
||||
target: { kind: "file", path: normalized.file },
|
||||
});
|
||||
router.navigate(route as any);
|
||||
router.navigate(route);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -356,6 +360,21 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
if (payload.source === "agent") {
|
||||
const data = payload.data;
|
||||
|
||||
if (
|
||||
data.name === "speak" &&
|
||||
data.detail.type === "unknown" &&
|
||||
typeof data.detail.input === "string" &&
|
||||
data.detail.input.trim()
|
||||
) {
|
||||
return (
|
||||
<SpeakMessage
|
||||
message={data.detail.input}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolCall
|
||||
toolName={data.name}
|
||||
@@ -694,6 +713,29 @@ function PermissionRequestCard({
|
||||
const isPlanRequest = request.kind === "plan";
|
||||
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
|
||||
const description = request.description ?? "";
|
||||
const resolvedActions = useMemo((): AgentPermissionAction[] => {
|
||||
if (request.kind === "question") {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(request.actions) && request.actions.length > 0) {
|
||||
return request.actions;
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: "reject",
|
||||
label: "Deny",
|
||||
behavior: "deny",
|
||||
variant: "danger",
|
||||
intent: "dismiss",
|
||||
},
|
||||
{
|
||||
id: "accept",
|
||||
label: isPlanRequest ? "Implement" : "Accept",
|
||||
behavior: "allow",
|
||||
variant: "primary",
|
||||
},
|
||||
];
|
||||
}, [isPlanRequest, request]);
|
||||
|
||||
const planMarkdown = useMemo(() => {
|
||||
if (!request) {
|
||||
@@ -734,11 +776,11 @@ function PermissionRequestCard({
|
||||
isPending: isResponding,
|
||||
} = permissionMutation;
|
||||
|
||||
const [respondingAction, setRespondingAction] = useState<"accept" | "deny" | null>(null);
|
||||
const [respondingActionId, setRespondingActionId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
resetPermissionMutation();
|
||||
setRespondingAction(null);
|
||||
setRespondingActionId(null);
|
||||
}, [permission.request.id, resetPermissionMutation]);
|
||||
const handleResponse = useCallback(
|
||||
(response: AgentPermissionResponse) => {
|
||||
@@ -752,6 +794,24 @@ function PermissionRequestCard({
|
||||
},
|
||||
[permission.agentId, permission.request.id, respondToPermission],
|
||||
);
|
||||
const handleActionPress = useCallback(
|
||||
(action: AgentPermissionAction) => {
|
||||
setRespondingActionId(action.id);
|
||||
if (action.behavior === "allow") {
|
||||
handleResponse({
|
||||
behavior: "allow",
|
||||
selectedActionId: action.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleResponse({
|
||||
behavior: "deny",
|
||||
selectedActionId: action.id,
|
||||
message: "Denied by user",
|
||||
});
|
||||
},
|
||||
[handleResponse],
|
||||
);
|
||||
|
||||
if (request.kind === "question") {
|
||||
return (
|
||||
@@ -778,64 +838,48 @@ function PermissionRequestCard({
|
||||
!isMobile && permissionStyles.optionsContainerDesktop,
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
testID="permission-request-deny"
|
||||
style={({ pressed, hovered = false }) => [
|
||||
permissionStyles.optionButton,
|
||||
{
|
||||
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
pressed ? permissionStyles.optionButtonPressed : null,
|
||||
]}
|
||||
onPress={() => {
|
||||
setRespondingAction("deny");
|
||||
handleResponse({
|
||||
behavior: "deny",
|
||||
message: "Denied by user",
|
||||
});
|
||||
}}
|
||||
disabled={isResponding}
|
||||
>
|
||||
{respondingAction === "deny" ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<View style={permissionStyles.optionContent}>
|
||||
<X size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={[permissionStyles.optionText, { color: theme.colors.foregroundMuted }]}>
|
||||
Deny
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
{resolvedActions.map((action) => {
|
||||
const isDanger = action.variant === "danger" || action.behavior === "deny";
|
||||
const isPrimary = action.variant === "primary";
|
||||
const isRespondingAction = respondingActionId === action.id;
|
||||
const textColor = isPrimary ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
const iconColor = textColor;
|
||||
const Icon = action.behavior === "allow" ? Check : X;
|
||||
const testID =
|
||||
action.behavior === "deny"
|
||||
? "permission-request-deny"
|
||||
: action.id === "accept" || action.id === "implement"
|
||||
? "permission-request-accept"
|
||||
: `permission-request-action-${action.id}`;
|
||||
|
||||
<Pressable
|
||||
testID="permission-request-accept"
|
||||
style={({ pressed, hovered = false }) => [
|
||||
permissionStyles.optionButton,
|
||||
{
|
||||
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
pressed ? permissionStyles.optionButtonPressed : null,
|
||||
]}
|
||||
onPress={() => {
|
||||
setRespondingAction("accept");
|
||||
handleResponse({ behavior: "allow" });
|
||||
}}
|
||||
disabled={isResponding}
|
||||
>
|
||||
{respondingAction === "accept" ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||
) : (
|
||||
<View style={permissionStyles.optionContent}>
|
||||
<Check size={14} color={theme.colors.foreground} />
|
||||
<Text style={[permissionStyles.optionText, { color: theme.colors.foreground }]}>
|
||||
Accept
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
return (
|
||||
<Pressable
|
||||
key={action.id}
|
||||
testID={testID}
|
||||
style={({ pressed, hovered = false }) => [
|
||||
permissionStyles.optionButton,
|
||||
{
|
||||
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
||||
borderColor: isDanger ? theme.colors.borderAccent : theme.colors.borderAccent,
|
||||
},
|
||||
pressed ? permissionStyles.optionButtonPressed : null,
|
||||
]}
|
||||
onPress={() => handleActionPress(action)}
|
||||
disabled={isResponding}
|
||||
>
|
||||
{isRespondingAction ? (
|
||||
<ActivityIndicator size="small" color={textColor} />
|
||||
) : (
|
||||
<View style={permissionStyles.optionContent}>
|
||||
<Icon size={14} color={iconColor} />
|
||||
<Text style={[permissionStyles.optionText, { color: textColor }]}>
|
||||
{action.label}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,6 @@ import type { AgentProviderDefinition } from "@server/server/agent/provider-mani
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
|
||||
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import type { FavoriteModelRow } from "@/hooks/use-form-preferences";
|
||||
import {
|
||||
@@ -36,6 +35,9 @@ import {
|
||||
type SelectorModelRow,
|
||||
} from "./combined-model-selector.utils";
|
||||
|
||||
// TODO: this should be configured per provider in the provider manifest
|
||||
const PROVIDERS_WITH_MODEL_DESCRIPTIONS = new Set(["opencode", "pi"]);
|
||||
|
||||
type SelectorView =
|
||||
| { kind: "all" }
|
||||
| { kind: "provider"; providerId: string; providerLabel: string };
|
||||
@@ -161,7 +163,6 @@ function ModelRow({
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const ProviderIcon = getProviderIcon(row.provider);
|
||||
const isWeb = Platform.OS === "web";
|
||||
|
||||
const handleToggleFavorite = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
@@ -171,9 +172,13 @@ function ModelRow({
|
||||
[onToggleFavorite, row.modelId, row.provider],
|
||||
);
|
||||
|
||||
const item = (
|
||||
const showDescription =
|
||||
row.description && PROVIDERS_WITH_MODEL_DESCRIPTIONS.has(row.provider);
|
||||
|
||||
return (
|
||||
<ComboboxItem
|
||||
label={row.modelLabel}
|
||||
description={showDescription ? row.description : undefined}
|
||||
selected={isSelected}
|
||||
disabled={disabled}
|
||||
elevated={elevated}
|
||||
@@ -211,21 +216,6 @@ function ModelRow({
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!isWeb || !row.description) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<View>{item}</View>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" align="center" offset={4}>
|
||||
<Text style={styles.tooltipText}>{row.description}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function FavoritesSection({
|
||||
@@ -839,10 +829,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
favoriteButtonPressed: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
sheetLoadingState: {
|
||||
minHeight: 160,
|
||||
justifyContent: "center",
|
||||
|
||||
153
packages/app/src/components/context-window-meter.tsx
Normal file
153
packages/app/src/components/context-window-meter.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import Svg, { Circle } from "react-native-svg";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
type ContextWindowMeterProps = {
|
||||
maxTokens: number;
|
||||
usedTokens: number;
|
||||
};
|
||||
|
||||
const SVG_SIZE = 20;
|
||||
const CENTER = SVG_SIZE / 2;
|
||||
const RADIUS = 7;
|
||||
const STROKE_WIDTH = 2.25;
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||
|
||||
function isValidMaxTokens(value: number): boolean {
|
||||
return Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
function isValidUsedTokens(value: number): boolean {
|
||||
return Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
function getUsagePercentage(maxTokens: number, usedTokens: number): number | null {
|
||||
if (!isValidMaxTokens(maxTokens) || !isValidUsedTokens(usedTokens)) {
|
||||
return null;
|
||||
}
|
||||
return (usedTokens / maxTokens) * 100;
|
||||
}
|
||||
|
||||
function clampPercentage(value: number): number {
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function formatTokenCount(value: number): string {
|
||||
if (value >= 1_000_000) {
|
||||
return `${Math.round(value / 1_000_000)}m`;
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${Math.round(value / 1_000)}k`;
|
||||
}
|
||||
return Math.round(value).toString();
|
||||
}
|
||||
|
||||
function getMeterColors(
|
||||
percentage: number,
|
||||
theme: ReturnType<typeof useUnistyles>["theme"],
|
||||
): { progress: string; track: string } {
|
||||
const track = theme.colors.surface3;
|
||||
if (percentage > 90) {
|
||||
return { progress: theme.colors.destructive, track };
|
||||
}
|
||||
if (percentage >= 70) {
|
||||
return { progress: theme.colors.palette.amber[500], track };
|
||||
}
|
||||
return { progress: theme.colors.foregroundMuted, track };
|
||||
}
|
||||
|
||||
export function ContextWindowMeter({ maxTokens, usedTokens }: ContextWindowMeterProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const percentage = getUsagePercentage(maxTokens, usedTokens);
|
||||
|
||||
if (percentage === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clampedPercentage = clampPercentage(percentage);
|
||||
const roundedPercentage = Math.round(percentage);
|
||||
const dashOffset = CIRCUMFERENCE - (clampedPercentage / 100) * CIRCUMFERENCE;
|
||||
const colors = getMeterColors(clampedPercentage, theme);
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
style={styles.container}
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={`Context window ${roundedPercentage}% used`}
|
||||
>
|
||||
<Svg
|
||||
width={SVG_SIZE}
|
||||
height={SVG_SIZE}
|
||||
viewBox={`0 0 ${SVG_SIZE} ${SVG_SIZE}`}
|
||||
style={styles.svg}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
<Circle
|
||||
cx={CENTER}
|
||||
cy={CENTER}
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
stroke={colors.track}
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
/>
|
||||
<Circle
|
||||
cx={CENTER}
|
||||
cy={CENTER}
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
stroke={colors.progress}
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={CIRCUMFERENCE}
|
||||
strokeDashoffset={dashOffset}
|
||||
/>
|
||||
</Svg>
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipContent}>
|
||||
<Text style={styles.tooltipTitle}>Context window</Text>
|
||||
<Text style={styles.tooltipText}>{`${roundedPercentage}% used`}</Text>
|
||||
<Text
|
||||
style={styles.tooltipDetail}
|
||||
>{`${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens`}</Text>
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
svg: {
|
||||
transform: [{ rotate: "-90deg" }],
|
||||
},
|
||||
tooltipContent: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
tooltipTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.4,
|
||||
},
|
||||
tooltipDetail: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: theme.fontSize.xs * 1.4,
|
||||
},
|
||||
}));
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -118,7 +118,6 @@ export function FileExplorerPane({
|
||||
);
|
||||
|
||||
const {
|
||||
workspaceStateKey: actionsWorkspaceStateKey,
|
||||
requestDirectoryListing,
|
||||
requestFileDownloadToken,
|
||||
selectExplorerEntry,
|
||||
@@ -129,6 +128,16 @@ export function FileExplorerPane({
|
||||
});
|
||||
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
||||
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
||||
const expandedPathsArray = usePanelStore((state) =>
|
||||
workspaceStateKey ? state.expandedPathsByWorkspace[workspaceStateKey] : undefined,
|
||||
);
|
||||
const setExpandedPathsForWorkspace = usePanelStore(
|
||||
(state) => state.setExpandedPathsForWorkspace,
|
||||
);
|
||||
const expandedPaths = useMemo(
|
||||
() => new Set(expandedPathsArray && expandedPathsArray.length > 0 ? expandedPathsArray : ["."]),
|
||||
[expandedPathsArray],
|
||||
);
|
||||
|
||||
const directories = explorerState?.directories ?? new Map();
|
||||
const pendingRequest = explorerState?.pendingRequest ?? null;
|
||||
@@ -144,7 +153,6 @@ export function FileExplorerPane({
|
||||
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path],
|
||||
);
|
||||
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
|
||||
const treeListRef = useRef<FlatList<TreeRow>>(null);
|
||||
const scrollbar = useWebScrollViewScrollbar(treeListRef, {
|
||||
enabled: showDesktopWebScrollbar,
|
||||
@@ -154,8 +162,7 @@ export function FileExplorerPane({
|
||||
|
||||
useEffect(() => {
|
||||
hasInitializedRef.current = false;
|
||||
setExpandedPaths(new Set(["."]));
|
||||
}, [actionsWorkspaceStateKey]);
|
||||
}, [workspaceStateKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasWorkspaceScope) {
|
||||
@@ -169,23 +176,35 @@ export function FileExplorerPane({
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}, [hasWorkspaceScope, requestDirectoryListing]);
|
||||
const persistedPaths = usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
|
||||
if (persistedPaths) {
|
||||
for (const path of persistedPaths) {
|
||||
if (path !== ".") {
|
||||
void requestDirectoryListing(path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
|
||||
|
||||
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
|
||||
useEffect(() => {
|
||||
if (!selectedEntryPath || !hasWorkspaceScope) {
|
||||
if (!selectedEntryPath || !workspaceStateKey) {
|
||||
return;
|
||||
}
|
||||
const parentDir = getParentDirectory(selectedEntryPath);
|
||||
const ancestors = getAncestorDirectories(parentDir);
|
||||
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
ancestors.forEach((path) => next.add(path));
|
||||
return next;
|
||||
});
|
||||
|
||||
ancestors.forEach((path) => {
|
||||
const newPaths = ancestors.filter((path) => !expandedPaths.has(path));
|
||||
if (newPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
setExpandedPathsForWorkspace(
|
||||
workspaceStateKey,
|
||||
[...Array.from(expandedPaths), ...newPaths],
|
||||
);
|
||||
newPaths.forEach((path) => {
|
||||
if (!directories.has(path)) {
|
||||
void requestDirectoryListing(path, {
|
||||
recordHistory: false,
|
||||
@@ -193,34 +212,46 @@ export function FileExplorerPane({
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [directories, hasWorkspaceScope, requestDirectoryListing, selectedEntryPath]);
|
||||
}, [
|
||||
directories,
|
||||
workspaceStateKey,
|
||||
expandedPaths,
|
||||
requestDirectoryListing,
|
||||
selectedEntryPath,
|
||||
setExpandedPathsForWorkspace,
|
||||
]);
|
||||
|
||||
const handleToggleDirectory = useCallback(
|
||||
(entry: ExplorerEntry) => {
|
||||
if (!hasWorkspaceScope) {
|
||||
if (!workspaceStateKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isExpanded = expandedPaths.has(entry.path);
|
||||
const nextExpanded = !isExpanded;
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (isExpanded) {
|
||||
next.delete(entry.path);
|
||||
} else {
|
||||
next.add(entry.path);
|
||||
if (isExpanded) {
|
||||
setExpandedPathsForWorkspace(
|
||||
workspaceStateKey,
|
||||
Array.from(expandedPaths).filter((path) => path !== entry.path),
|
||||
);
|
||||
} else {
|
||||
setExpandedPathsForWorkspace(
|
||||
workspaceStateKey,
|
||||
[...Array.from(expandedPaths), entry.path],
|
||||
);
|
||||
if (!directories.has(entry.path)) {
|
||||
void requestDirectoryListing(entry.path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
if (nextExpanded && !directories.has(entry.path)) {
|
||||
void requestDirectoryListing(entry.path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
[directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing],
|
||||
[
|
||||
workspaceStateKey,
|
||||
expandedPaths,
|
||||
directories,
|
||||
requestDirectoryListing,
|
||||
setExpandedPathsForWorkspace,
|
||||
],
|
||||
);
|
||||
|
||||
const handleOpenFile = useCallback(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
49
packages/app/src/components/icons/editor-app-icons.tsx
Normal file
49
packages/app/src/components/icons/editor-app-icons.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { SquareTerminal } from "lucide-react-native";
|
||||
import { Image, type ImageSourcePropType } from "react-native";
|
||||
import {
|
||||
isKnownEditorTargetId,
|
||||
type EditorTargetId,
|
||||
type KnownEditorTargetId,
|
||||
} from "@server/shared/messages";
|
||||
|
||||
interface EditorAppIconProps {
|
||||
editorId: EditorTargetId;
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const EDITOR_APP_IMAGES: Record<KnownEditorTargetId, ImageSourcePropType> = {
|
||||
cursor: require("../../../assets/images/editor-apps/cursor.png"),
|
||||
vscode: require("../../../assets/images/editor-apps/vscode.png"),
|
||||
webstorm: require("../../../assets/images/editor-apps/webstorm.png"),
|
||||
zed: require("../../../assets/images/editor-apps/zed.png"),
|
||||
finder: require("../../../assets/images/editor-apps/finder.png"),
|
||||
explorer: require("../../../assets/images/editor-apps/file-explorer.png"),
|
||||
"file-manager": require("../../../assets/images/editor-apps/file-explorer.png"),
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
|
||||
export function hasBundledEditorAppIcon(
|
||||
editorId: EditorTargetId,
|
||||
): editorId is KnownEditorTargetId {
|
||||
return isKnownEditorTargetId(editorId);
|
||||
}
|
||||
|
||||
export function EditorAppIcon({
|
||||
editorId,
|
||||
size = 16,
|
||||
color,
|
||||
}: EditorAppIconProps) {
|
||||
if (!hasBundledEditorAppIcon(editorId)) {
|
||||
return <SquareTerminal size={size} color={color} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
source={EDITOR_APP_IMAGES[editorId]}
|
||||
style={{ width: size, height: size }}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -229,21 +229,21 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
return;
|
||||
}
|
||||
closeToAgent();
|
||||
router.push(buildHostSettingsRoute(activeServerId) as any);
|
||||
router.push(buildHostSettingsRoute(activeServerId));
|
||||
}, [activeServerId, closeToAgent]);
|
||||
|
||||
const handleSettingsDesktop = useCallback(() => {
|
||||
if (!activeServerId) {
|
||||
return;
|
||||
}
|
||||
router.push(buildHostSettingsRoute(activeServerId) as any);
|
||||
router.push(buildHostSettingsRoute(activeServerId));
|
||||
}, [activeServerId]);
|
||||
|
||||
const handleViewMoreNavigate = useCallback(() => {
|
||||
if (!activeServerId) {
|
||||
return;
|
||||
}
|
||||
router.push(buildHostSessionsRoute(activeServerId) as any);
|
||||
router.push(buildHostSessionsRoute(activeServerId));
|
||||
}, [activeServerId]);
|
||||
|
||||
const handleHostSelect = useCallback(
|
||||
@@ -253,7 +253,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
}
|
||||
const nextPath = mapPathnameToServer(pathname, nextServerId);
|
||||
setIsHostPickerOpen(false);
|
||||
router.push(nextPath as any);
|
||||
router.push(nextPath);
|
||||
},
|
||||
[pathname],
|
||||
);
|
||||
|
||||
@@ -79,6 +79,8 @@ export interface MessageInputProps {
|
||||
isInputActive?: boolean;
|
||||
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
|
||||
leftContent?: React.ReactNode;
|
||||
/** Content to render on the right side before the voice button (e.g., context window meter) */
|
||||
beforeVoiceContent?: React.ReactNode;
|
||||
/** Content to render on the right side after voice button (e.g., realtime button, cancel button) */
|
||||
rightContent?: React.ReactNode;
|
||||
voiceServerId?: string;
|
||||
@@ -201,6 +203,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
disabled = false,
|
||||
isInputActive = true,
|
||||
leftContent,
|
||||
beforeVoiceContent,
|
||||
rightContent,
|
||||
voiceServerId,
|
||||
voiceAgentId,
|
||||
@@ -1015,6 +1018,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
|
||||
{/* Right: voice button, contextual button (realtime/send/cancel) */}
|
||||
<View style={styles.rightButtonGroup}>
|
||||
{beforeVoiceContent}
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleVoicePress}
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
Copy,
|
||||
TriangleAlertIcon,
|
||||
Scissors,
|
||||
MicVocal,
|
||||
} from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
|
||||
import Animated, {
|
||||
@@ -913,6 +914,65 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
);
|
||||
});
|
||||
|
||||
interface SpeakMessageProps {
|
||||
message: string;
|
||||
timestamp: number;
|
||||
disableOuterSpacing?: boolean;
|
||||
}
|
||||
|
||||
const speakMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
containerSpacing: {
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
headerLabel: {
|
||||
fontFamily: Fonts.sans,
|
||||
fontSize: 12,
|
||||
fontWeight: "500",
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
text: {
|
||||
fontFamily: Fonts.sans,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 22,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
|
||||
export const SpeakMessage = memo(function SpeakMessage({
|
||||
message,
|
||||
timestamp,
|
||||
disableOuterSpacing,
|
||||
}: SpeakMessageProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||
|
||||
return (
|
||||
<View
|
||||
testID="speak-message"
|
||||
style={[
|
||||
speakMessageStylesheet.container,
|
||||
!resolvedDisableOuterSpacing && speakMessageStylesheet.containerSpacing,
|
||||
]}
|
||||
>
|
||||
<View style={speakMessageStylesheet.header}>
|
||||
<MicVocal size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={speakMessageStylesheet.headerLabel}>Spoke</Text>
|
||||
</View>
|
||||
<Text style={speakMessageStylesheet.text}>{message}</Text>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
interface ActivityLogProps {
|
||||
type: "system" | "info" | "success" | "error" | "artifact";
|
||||
message: string;
|
||||
|
||||
@@ -78,9 +78,17 @@ function createPlanMarkdownRules() {
|
||||
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
|
||||
|
||||
return (
|
||||
<View key={node.key} style={[styles.list_item, { flexShrink: 0 }]}>
|
||||
<View key={node.key} style={styles.list_item}>
|
||||
<Text style={iconStyle}>{marker}</Text>
|
||||
<Text style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</Text>
|
||||
<View style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</View>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
paragraph: (node: any, children: ReactNode[], parent: any, styles: any) => {
|
||||
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
|
||||
return (
|
||||
<View key={node.key} style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1113,7 +1113,7 @@ function WorkspaceRowWithMenu({
|
||||
serverId: workspace.serverId,
|
||||
archivedWorkspaceId: workspace.workspaceId,
|
||||
workspaces: sessionWorkspaces.values(),
|
||||
}) as any,
|
||||
}),
|
||||
);
|
||||
}, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]);
|
||||
|
||||
|
||||
@@ -313,7 +313,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
keyExtractor={(item) => item.id}
|
||||
testID="agent-chat-scroll"
|
||||
nativeID="agent-chat-scroll-native-virtualized"
|
||||
ListHeaderComponent={liveHeaderContent ? () => liveHeaderContent : undefined}
|
||||
ListHeaderComponent={liveHeaderContent ?? undefined}
|
||||
contentContainerStyle={baseListContentContainerStyle}
|
||||
style={listStyle}
|
||||
onLayout={handleListLayout}
|
||||
|
||||
@@ -203,12 +203,12 @@ export function ComboboxItem({
|
||||
]}
|
||||
>
|
||||
{leadingContent}
|
||||
<View style={styles.comboboxItemContent}>
|
||||
<View style={[styles.comboboxItemContent, description && styles.comboboxItemContentInline]}>
|
||||
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
|
||||
{label}
|
||||
</Text>
|
||||
{description ? (
|
||||
<Text numberOfLines={2} style={styles.comboboxItemDescription}>
|
||||
<Text numberOfLines={1} style={styles.comboboxItemDescription}>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -847,6 +847,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
},
|
||||
comboboxItemContentInline: {
|
||||
flexDirection: "row",
|
||||
alignItems: "baseline",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
comboboxItemLeadingSlot: {
|
||||
width: 16,
|
||||
alignItems: "center",
|
||||
@@ -857,9 +862,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
comboboxItemDescription: {
|
||||
marginTop: 2,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 1,
|
||||
},
|
||||
emptyText: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
|
||||
@@ -44,6 +44,7 @@ type TooltipContextValue = {
|
||||
setOpen: (open: boolean) => void;
|
||||
triggerRef: React.RefObject<View | null>;
|
||||
enabled: boolean;
|
||||
openOnPress: boolean;
|
||||
delayDuration: number;
|
||||
};
|
||||
|
||||
@@ -107,6 +108,18 @@ function measureElement(element: View): Promise<Rect> {
|
||||
});
|
||||
}
|
||||
|
||||
function isMobileTooltipEnvironment(): boolean {
|
||||
if (Platform.OS !== "web") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof navigator === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
|
||||
}
|
||||
|
||||
function computePosition({
|
||||
triggerRect,
|
||||
contentSize,
|
||||
@@ -214,12 +227,8 @@ export function Tooltip({
|
||||
onOpenChange,
|
||||
});
|
||||
|
||||
const isWeb = Platform.OS === "web";
|
||||
const isMobileWeb =
|
||||
isWeb &&
|
||||
typeof navigator !== "undefined" &&
|
||||
/Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
|
||||
const enabled = isWeb ? (isMobileWeb ? enabledOnMobile : enabledOnDesktop) : enabledOnMobile;
|
||||
const isMobile = isMobileTooltipEnvironment();
|
||||
const enabled = isMobile ? enabledOnMobile : enabledOnDesktop;
|
||||
|
||||
const value = useMemo<TooltipContextValue>(
|
||||
() => ({
|
||||
@@ -227,9 +236,10 @@ export function Tooltip({
|
||||
setOpen: setIsOpen,
|
||||
triggerRef,
|
||||
enabled,
|
||||
openOnPress: isMobile,
|
||||
delayDuration,
|
||||
}),
|
||||
[isOpen, setIsOpen, enabled, delayDuration],
|
||||
[isOpen, setIsOpen, enabled, isMobile, delayDuration],
|
||||
);
|
||||
|
||||
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
|
||||
@@ -323,9 +333,17 @@ export function TooltipTrigger({
|
||||
const handlePress = useCallback(
|
||||
(e: any) => {
|
||||
onPress?.(e);
|
||||
if (!ctx.enabled || disabled) {
|
||||
return;
|
||||
}
|
||||
if (ctx.openOnPress) {
|
||||
clearOpenTimer();
|
||||
ctx.setOpen(true);
|
||||
return;
|
||||
}
|
||||
close();
|
||||
},
|
||||
[close, onPress],
|
||||
[clearOpenTimer, close, ctx, disabled, onPress],
|
||||
);
|
||||
|
||||
const triggerProps = {
|
||||
@@ -492,7 +510,7 @@ export function TooltipContent({
|
||||
statusBarTranslucent={Platform.OS === "android"}
|
||||
onRequestClose={() => ctx.setOpen(false)}
|
||||
>
|
||||
<View pointerEvents="box-none" style={styles.overlay}>
|
||||
<Pressable style={styles.overlay} onPress={() => ctx.setOpen(false)}>
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
entering={FadeIn.duration(80)}
|
||||
@@ -513,7 +531,7 @@ export function TooltipContent({
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -270,12 +270,12 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
if (pendingNameHost) {
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostRootRoute(anyOnlineServerId) as any);
|
||||
router.replace(buildHostRootRoute(anyOnlineServerId));
|
||||
}, [anyOnlineServerId, pendingNameHost, router]);
|
||||
|
||||
const finishOnboarding = useCallback(
|
||||
(serverId: string) => {
|
||||
router.replace(buildHostRootRoute(serverId) as any);
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useClientActivity } from "@/hooks/use-client-activity";
|
||||
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
|
||||
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
|
||||
import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { generateMessageId, type StreamItem } from "@/types/stream";
|
||||
import {
|
||||
processTimelineResponse,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
type Agent,
|
||||
type SessionState,
|
||||
type WorkspaceDescriptor,
|
||||
mergeWorkspaceSnapshotWithExisting,
|
||||
normalizeWorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
@@ -67,6 +69,22 @@ export type {
|
||||
const HISTORY_STALE_AFTER_MS = 60_000;
|
||||
const AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS = 300;
|
||||
|
||||
function hasAgentUsageChanged(
|
||||
incomingUsage: Agent["lastUsage"] | undefined,
|
||||
currentUsage: Agent["lastUsage"] | undefined,
|
||||
): boolean {
|
||||
const keys: Array<keyof NonNullable<Agent["lastUsage"]>> = [
|
||||
"inputTokens",
|
||||
"outputTokens",
|
||||
"cachedInputTokens",
|
||||
"totalCostUsd",
|
||||
"contextWindowMaxTokens",
|
||||
"contextWindowUsedTokens",
|
||||
];
|
||||
|
||||
return keys.some((key) => incomingUsage?.[key] !== currentUsage?.[key]);
|
||||
}
|
||||
|
||||
type AudioOutputPayload = Extract<SessionOutboundMessage, { type: "audio_output" }>["payload"];
|
||||
|
||||
interface BufferedAudioChunk {
|
||||
@@ -309,6 +327,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
|
||||
const workspaces = new Map<string, WorkspaceDescriptor>();
|
||||
const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||
let cursor: string | null = null;
|
||||
let includeSubscribe = options?.subscribe ?? false;
|
||||
|
||||
@@ -324,7 +343,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
for (const entry of payload.entries) {
|
||||
const workspace = normalizeWorkspaceDescriptor(entry);
|
||||
workspaces.set(workspace.id, workspace);
|
||||
workspaces.set(
|
||||
workspace.id,
|
||||
mergeWorkspaceSnapshotWithExisting({
|
||||
incoming: workspace,
|
||||
existing: existingWorkspaces?.get(workspace.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
||||
@@ -349,6 +374,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
setAgents(serverId, (prev) => {
|
||||
const current = prev.get(agent.id);
|
||||
if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) {
|
||||
const hasUsageUpdate = hasAgentUsageChanged(agent.lastUsage, current.lastUsage);
|
||||
if (hasUsageUpdate) {
|
||||
const next = new Map(prev);
|
||||
next.set(agent.id, {
|
||||
...current,
|
||||
lastUsage: agent.lastUsage,
|
||||
});
|
||||
return next;
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
@@ -603,6 +637,34 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
updateSessionClient(serverId, client);
|
||||
}, [serverId, client, updateSessionClient]);
|
||||
|
||||
useEffect(() => {
|
||||
const serverInfo = client.getLastServerInfoMessage();
|
||||
if (!serverInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSessionServerInfo(serverId, {
|
||||
serverId: serverInfo.serverId,
|
||||
hostname: serverInfo.hostname,
|
||||
version: serverInfo.version,
|
||||
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
|
||||
...(serverInfo.features ? { features: serverInfo.features } : {}),
|
||||
});
|
||||
}, [client, serverId, updateSessionServerInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serverInfo = client.getLastServerInfoMessage();
|
||||
if (!serverInfo?.features?.providersSnapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
prefetchProvidersSnapshot(serverId, client);
|
||||
}, [client, isConnected, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!voiceRuntime) {
|
||||
return;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Image, Text, View } from "react-native";
|
||||
import { ActivityIndicator, Alert, Text, View } from "react-native";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import * as QRCode from "qrcode";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
@@ -12,7 +11,6 @@ import {
|
||||
RotateCw,
|
||||
Copy,
|
||||
FileText,
|
||||
Smartphone,
|
||||
Activity,
|
||||
} from "lucide-react-native";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
@@ -24,7 +22,6 @@ import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
|
||||
import {
|
||||
getCliDaemonStatus,
|
||||
getDesktopDaemonLogs,
|
||||
getDesktopDaemonPairing,
|
||||
getDesktopDaemonStatus,
|
||||
restartDesktopDaemon,
|
||||
shouldUseDesktopDaemon,
|
||||
@@ -32,7 +29,6 @@ import {
|
||||
stopDesktopDaemon,
|
||||
type DesktopDaemonLogs,
|
||||
type DesktopDaemonStatus,
|
||||
type DesktopPairingOffer,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
|
||||
export interface LocalDaemonSectionProps {
|
||||
@@ -52,10 +48,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
|
||||
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
|
||||
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false);
|
||||
const [isLoadingPairing, setIsLoadingPairing] = useState(false);
|
||||
const [pairingOffer, setPairingOffer] = useState<DesktopPairingOffer | null>(null);
|
||||
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
|
||||
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
|
||||
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
|
||||
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
|
||||
@@ -238,46 +230,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
setIsLogsModalOpen(true);
|
||||
}, [daemonLogs]);
|
||||
|
||||
const handleOpenPairingModal = useCallback(() => {
|
||||
if (isLoadingPairing) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPairingModalOpen(true);
|
||||
setIsLoadingPairing(true);
|
||||
setPairingStatusMessage(null);
|
||||
|
||||
void getDesktopDaemonPairing()
|
||||
.then((pairing) => {
|
||||
setPairingOffer(pairing);
|
||||
if (!pairing.relayEnabled || !pairing.url) {
|
||||
setPairingStatusMessage("Relay pairing is not available.");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setPairingOffer(null);
|
||||
setPairingStatusMessage(`Unable to load pairing offer: ${message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingPairing(false);
|
||||
});
|
||||
}, [isLoadingPairing]);
|
||||
|
||||
const handleCopyPairingLink = useCallback(() => {
|
||||
if (!pairingOffer?.url) {
|
||||
return;
|
||||
}
|
||||
void Clipboard.setStringAsync(pairingOffer.url)
|
||||
.then(() => {
|
||||
Alert.alert("Copied", "Pairing link copied.");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to copy pairing link", error);
|
||||
Alert.alert("Error", "Unable to copy pairing link.");
|
||||
});
|
||||
}, [pairingOffer?.url]);
|
||||
|
||||
const handleOpenCliStatus = useCallback(async () => {
|
||||
setIsLoadingCliStatus(true);
|
||||
try {
|
||||
@@ -418,20 +370,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Pair device</Text>
|
||||
<Text style={settingsStyles.rowHint}>Connect your phone to this computer.</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Smartphone size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleOpenPairingModal}
|
||||
>
|
||||
Pair device
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Full status</Text>
|
||||
@@ -460,20 +398,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<AdaptiveModalSheet
|
||||
visible={isPairingModalOpen}
|
||||
onClose={() => setIsPairingModalOpen(false)}
|
||||
title="Pair device"
|
||||
testID="managed-daemon-pairing-dialog"
|
||||
>
|
||||
<PairingOfferDialogContent
|
||||
isLoading={isLoadingPairing}
|
||||
pairingOffer={pairingOffer}
|
||||
statusMessage={pairingStatusMessage}
|
||||
onCopyLink={handleCopyPairingLink}
|
||||
/>
|
||||
</AdaptiveModalSheet>
|
||||
|
||||
<AdaptiveModalSheet
|
||||
visible={isLogsModalOpen}
|
||||
onClose={() => setIsLogsModalOpen(false)}
|
||||
@@ -516,107 +440,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
|
||||
const ADVANCED_DAEMON_SETTINGS_URL = "https://paseo.sh/docs/configuration";
|
||||
|
||||
function PairingOfferDialogContent(input: {
|
||||
isLoading: boolean;
|
||||
pairingOffer: DesktopPairingOffer | null;
|
||||
statusMessage: string | null;
|
||||
onCopyLink: () => void;
|
||||
}) {
|
||||
const { isLoading, pairingOffer, statusMessage, onCopyLink } = input;
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [qrError, setQrError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (!pairingOffer?.url) {
|
||||
setQrDataUrl(null);
|
||||
setQrError(null);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
setQrError(null);
|
||||
setQrDataUrl(null);
|
||||
|
||||
void QRCode.toDataURL(pairingOffer.url, {
|
||||
errorCorrectionLevel: "M",
|
||||
margin: 1,
|
||||
width: 480,
|
||||
})
|
||||
.then((dataUrl) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setQrDataUrl(dataUrl);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setQrError(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pairingOffer?.url]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.pairingState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={settingsStyles.rowHint}>Loading pairing offer…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (statusMessage) {
|
||||
return (
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={settingsStyles.rowHint}>{statusMessage}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pairingOffer?.url) {
|
||||
return (
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={settingsStyles.rowHint}>Pairing offer unavailable.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Scan this QR code in Paseo, or copy the pairing link below.
|
||||
</Text>
|
||||
<View style={styles.qrCard}>
|
||||
{qrDataUrl ? (
|
||||
<Image source={{ uri: qrDataUrl }} style={styles.qrImage} resizeMode="contain" />
|
||||
) : qrError ? (
|
||||
<Text style={settingsStyles.rowHint}>QR unavailable: {qrError}</Text>
|
||||
) : (
|
||||
<ActivityIndicator size="small" />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.linkSection}>
|
||||
<Text style={styles.linkLabel}>Pairing link</Text>
|
||||
<Text style={styles.linkText} selectable>
|
||||
{pairingOffer.url}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.modalActions}>
|
||||
<Button variant="outline" size="sm" onPress={onCopyLink}>
|
||||
Copy link
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
actionGroup: {
|
||||
flexDirection: "row",
|
||||
@@ -659,40 +482,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
pairingState: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[6],
|
||||
},
|
||||
qrCard: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
aspectRatio: 1,
|
||||
alignSelf: "stretch",
|
||||
padding: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
qrImage: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
linkSection: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
linkLabel: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
linkText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: 18,
|
||||
},
|
||||
logOutput: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
|
||||
187
packages/app/src/desktop/components/pair-device-section.tsx
Normal file
187
packages/app/src/desktop/components/pair-device-section.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useCallback } from "react";
|
||||
import { ActivityIndicator, Image, Text, TextInput, View } from "react-native";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import * as QRCode from "qrcode";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { RotateCw, Copy, Check } from "lucide-react-native";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getDesktopDaemonPairing, shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { useState } from "react";
|
||||
|
||||
export function PairDeviceSection() {
|
||||
const { theme } = useUnistyles();
|
||||
const showSection = shouldUseDesktopDaemon();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const pairingQuery = useQuery({
|
||||
queryKey: ["desktop-daemon-pairing"],
|
||||
queryFn: getDesktopDaemonPairing,
|
||||
enabled: showSection,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const qrQuery = useQuery({
|
||||
queryKey: ["desktop-daemon-pairing-qr", pairingQuery.data?.url],
|
||||
queryFn: () =>
|
||||
QRCode.toDataURL(pairingQuery.data!.url!, {
|
||||
errorCorrectionLevel: "M",
|
||||
margin: 1,
|
||||
width: 480,
|
||||
}),
|
||||
enabled: !!pairingQuery.data?.url,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const handleCopyLink = useCallback(async () => {
|
||||
if (!pairingQuery.data?.url) return;
|
||||
await Clipboard.setStringAsync(pairingQuery.data.url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [pairingQuery.data?.url]);
|
||||
|
||||
if (!showSection) return null;
|
||||
|
||||
return (
|
||||
<View style={settingsStyles.section}>
|
||||
<Text style={settingsStyles.sectionTitle}>Pair device</Text>
|
||||
<View style={settingsStyles.card}>
|
||||
{pairingQuery.isPending ? (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.hint}>Loading pairing offer…</Text>
|
||||
</View>
|
||||
) : pairingQuery.isError ? (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.hint}>
|
||||
{pairingQuery.error instanceof Error
|
||||
? pairingQuery.error.message
|
||||
: "Failed to load pairing offer."}
|
||||
</Text>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={() => void pairingQuery.refetch()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</View>
|
||||
) : !pairingQuery.data?.url ? (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.hint}>
|
||||
{pairingQuery.data?.relayEnabled === false
|
||||
? "Relay is not enabled. Enable relay to pair a device."
|
||||
: "Pairing offer unavailable."}
|
||||
</Text>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={() => void pairingQuery.refetch()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.hint}>
|
||||
Scan this QR code with Paseo on your phone, or copy the link below.
|
||||
</Text>
|
||||
<View style={styles.qrContainer}>
|
||||
{qrQuery.data ? (
|
||||
<Image source={{ uri: qrQuery.data }} style={styles.qrImage} resizeMode="contain" />
|
||||
) : qrQuery.isError ? (
|
||||
<Text style={styles.hint}>QR code unavailable.</Text>
|
||||
) : (
|
||||
<ActivityIndicator size="small" />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.linkRow}>
|
||||
<View style={styles.inputWrapper}>
|
||||
<TextInput
|
||||
style={styles.linkInput}
|
||||
value={pairingQuery.data.url}
|
||||
readOnly
|
||||
selectTextOnFocus
|
||||
selectionColor={theme.colors.accent}
|
||||
/>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={
|
||||
copied ? (
|
||||
<Check size={theme.iconSize.sm} color={theme.colors.accent} />
|
||||
) : (
|
||||
<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
)
|
||||
}
|
||||
onPress={() => void handleCopyLink()}
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
centered: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[6],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
content: {
|
||||
gap: theme.spacing[3],
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
hint: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
textAlign: "center",
|
||||
},
|
||||
qrContainer: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
alignSelf: "center",
|
||||
width: 320,
|
||||
height: 320,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
padding: theme.spacing[2],
|
||||
},
|
||||
qrImage: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
linkRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
inputWrapper: {
|
||||
flex: 1,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
linkInput: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
outlineStyle: "none",
|
||||
} as any,
|
||||
}));
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
type AgentProviderDefinition,
|
||||
@@ -11,8 +10,7 @@ import type {
|
||||
ProviderSnapshotEntry,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useSessionForServer } from "./use-session-directory";
|
||||
import { useProvidersSnapshot } from "./use-providers-snapshot";
|
||||
import {
|
||||
useFormPreferences,
|
||||
mergeProviderPreferences,
|
||||
@@ -322,10 +320,6 @@ function combineInitialValues(
|
||||
return initialValues;
|
||||
}
|
||||
|
||||
function providersSnapshotQueryKey(serverId: string | null, cwd: string | undefined) {
|
||||
return ["providersSnapshot", serverId, cwd ?? ""] as const;
|
||||
}
|
||||
|
||||
export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult {
|
||||
const {
|
||||
initialServerId = null,
|
||||
@@ -343,7 +337,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
} = useFormPreferences();
|
||||
|
||||
const daemons = useHosts();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Build a set of valid server IDs for preference validation
|
||||
const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]);
|
||||
@@ -376,95 +369,19 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
// Session state for provider model listing
|
||||
const client = useHostRuntimeClient(formState.serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(formState.serverId ?? "");
|
||||
const supportsProvidersSnapshot = useSessionForServer(
|
||||
formState.serverId,
|
||||
(session) => session?.serverInfo?.features?.providersSnapshot === true,
|
||||
);
|
||||
const {
|
||||
entries: snapshotEntries,
|
||||
isLoading: snapshotIsLoading,
|
||||
isFetching: snapshotIsFetching,
|
||||
error: snapshotError,
|
||||
refresh: refreshSnapshot,
|
||||
} = useProvidersSnapshot(formState.serverId);
|
||||
|
||||
const [debouncedCwd, setDebouncedCwd] = useState<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
const trimmed = formState.workingDir.trim();
|
||||
const next = trimmed.length > 0 ? trimmed : undefined;
|
||||
const timer = setTimeout(() => setDebouncedCwd(next), 180);
|
||||
return () => clearTimeout(timer);
|
||||
}, [formState.workingDir]);
|
||||
|
||||
const snapshotQueryKey = useMemo(
|
||||
() => providersSnapshotQueryKey(formState.serverId, debouncedCwd),
|
||||
[debouncedCwd, formState.serverId],
|
||||
);
|
||||
|
||||
const providersSnapshotQuery = useQuery({
|
||||
queryKey: snapshotQueryKey,
|
||||
enabled: Boolean(
|
||||
supportsProvidersSnapshot &&
|
||||
isVisible &&
|
||||
isTargetDaemonReady &&
|
||||
formState.serverId &&
|
||||
client &&
|
||||
isConnected,
|
||||
),
|
||||
staleTime: 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return client.getProvidersSnapshot({ cwd: debouncedCwd });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!supportsProvidersSnapshot ||
|
||||
!client ||
|
||||
!isConnected ||
|
||||
!isVisible ||
|
||||
!isTargetDaemonReady ||
|
||||
!formState.serverId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return client.on("providers_snapshot_update", (message) => {
|
||||
if (message.type !== "providers_snapshot_update") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.cwd !== undefined && message.payload.cwd !== debouncedCwd) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData(snapshotQueryKey, {
|
||||
entries: message.payload.entries,
|
||||
generatedAt: message.payload.generatedAt,
|
||||
requestId: "providers_snapshot_update",
|
||||
});
|
||||
});
|
||||
}, [
|
||||
client,
|
||||
debouncedCwd,
|
||||
formState.serverId,
|
||||
isConnected,
|
||||
isTargetDaemonReady,
|
||||
isVisible,
|
||||
queryClient,
|
||||
snapshotQueryKey,
|
||||
supportsProvidersSnapshot,
|
||||
]);
|
||||
|
||||
const snapshotEntries = providersSnapshotQuery.data?.entries ?? undefined;
|
||||
const allProviderEntries = useMemo(
|
||||
() => (supportsProvidersSnapshot ? snapshotEntries ?? [] : undefined),
|
||||
[snapshotEntries, supportsProvidersSnapshot],
|
||||
);
|
||||
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
|
||||
const snapshotProviderDefinitions = useMemo(() => {
|
||||
if (!supportsProvidersSnapshot) {
|
||||
return [];
|
||||
}
|
||||
const snapshotProviders = new Set((snapshotEntries ?? []).map((entry) => entry.provider));
|
||||
return allProviderDefinitions.filter((definition) => snapshotProviders.has(definition.id));
|
||||
}, [snapshotEntries, supportsProvidersSnapshot]);
|
||||
}, [snapshotEntries]);
|
||||
const snapshotProviderDefinitionMap = useMemo(
|
||||
() =>
|
||||
new Map<AgentProvider, AgentProviderDefinition>(
|
||||
@@ -500,157 +417,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
snapshotSelectedEntry?.modes ??
|
||||
snapshotProviderDefinitionMap.get(formState.provider)?.modes ??
|
||||
[];
|
||||
|
||||
// COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots
|
||||
const availableProvidersQuery = useQuery({
|
||||
queryKey: ["availableProviders", formState.serverId],
|
||||
enabled: Boolean(
|
||||
!supportsProvidersSnapshot &&
|
||||
isVisible &&
|
||||
isTargetDaemonReady &&
|
||||
formState.serverId &&
|
||||
client &&
|
||||
isConnected,
|
||||
),
|
||||
staleTime: 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.listAvailableProviders();
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.providers.filter((entry) => entry.available).map((entry) => entry.provider);
|
||||
},
|
||||
});
|
||||
const legacyProviderDefinitions = useMemo(() => {
|
||||
const availableProviders = availableProvidersQuery.data;
|
||||
if (!availableProviders) {
|
||||
return [];
|
||||
}
|
||||
const available = new Set(availableProviders);
|
||||
return allProviderDefinitions.filter((definition) => available.has(definition.id));
|
||||
}, [availableProvidersQuery.data]);
|
||||
const legacyProviderDefinitionMap = useMemo(
|
||||
() =>
|
||||
new Map<AgentProvider, AgentProviderDefinition>(
|
||||
legacyProviderDefinitions.map((definition) => [definition.id, definition]),
|
||||
),
|
||||
[legacyProviderDefinitions],
|
||||
);
|
||||
|
||||
// COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots
|
||||
const legacySelectedProviderModelsQuery = useQuery({
|
||||
queryKey: ["providerModels", formState.serverId, formState.provider],
|
||||
enabled: Boolean(
|
||||
!supportsProvidersSnapshot &&
|
||||
isVisible &&
|
||||
isTargetDaemonReady &&
|
||||
formState.serverId &&
|
||||
client &&
|
||||
isConnected &&
|
||||
legacyProviderDefinitionMap.has(formState.provider),
|
||||
),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.listProviderModels(formState.provider, {
|
||||
cwd: debouncedCwd,
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.models ?? [];
|
||||
},
|
||||
});
|
||||
const legacySelectedProviderModels = legacySelectedProviderModelsQuery.data ?? null;
|
||||
|
||||
// COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots
|
||||
const providerModesQuery = useQuery({
|
||||
queryKey: ["providerModes", formState.serverId, formState.provider, debouncedCwd],
|
||||
enabled: Boolean(
|
||||
!supportsProvidersSnapshot &&
|
||||
isVisible &&
|
||||
isTargetDaemonReady &&
|
||||
formState.serverId &&
|
||||
client &&
|
||||
isConnected &&
|
||||
legacyProviderDefinitionMap.has(formState.provider),
|
||||
),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.listProviderModes(formState.provider, {
|
||||
cwd: debouncedCwd,
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.modes ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
// COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots
|
||||
const allProviderModelQueries = useQueries({
|
||||
queries: (supportsProvidersSnapshot ? [] : legacyProviderDefinitions).map((def) => ({
|
||||
queryKey: ["providerModels", formState.serverId, def.id],
|
||||
enabled: Boolean(
|
||||
isVisible && isTargetDaemonReady && formState.serverId && client && isConnected,
|
||||
),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.listProviderModels(def.id as AgentProvider, {
|
||||
cwd: debouncedCwd,
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.models ?? [];
|
||||
},
|
||||
})),
|
||||
});
|
||||
const legacyAllProviderModels = useMemo(() => {
|
||||
const map = new Map<string, AgentModelDefinition[]>();
|
||||
for (let i = 0; i < legacyProviderDefinitions.length; i++) {
|
||||
const query = allProviderModelQueries[i];
|
||||
if (query?.data) {
|
||||
map.set(legacyProviderDefinitions[i]!.id, query.data);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [allProviderModelQueries, legacyProviderDefinitions]);
|
||||
const legacySelectedProviderModes =
|
||||
providerModesQuery.data ?? legacyProviderDefinitionMap.get(formState.provider)?.modes ?? [];
|
||||
|
||||
const providerDefinitions = supportsProvidersSnapshot
|
||||
? snapshotProviderDefinitions
|
||||
: legacyProviderDefinitions;
|
||||
const providerDefinitionMap = supportsProvidersSnapshot
|
||||
? snapshotProviderDefinitionMap
|
||||
: legacyProviderDefinitionMap;
|
||||
const selectableProviderDefinitionMap = supportsProvidersSnapshot
|
||||
? snapshotSelectableProviderDefinitionMap
|
||||
: legacyProviderDefinitionMap;
|
||||
const allProviderModels = supportsProvidersSnapshot
|
||||
? snapshotAllProviderModels
|
||||
: legacyAllProviderModels;
|
||||
const availableModels = supportsProvidersSnapshot
|
||||
? snapshotSelectedProviderModels
|
||||
: legacySelectedProviderModels;
|
||||
const modeOptions = supportsProvidersSnapshot
|
||||
? snapshotSelectedProviderModes
|
||||
: legacySelectedProviderModes;
|
||||
const isAllModelsLoading = supportsProvidersSnapshot
|
||||
? providersSnapshotQuery.isLoading || providersSnapshotQuery.isFetching
|
||||
: allProviderModelQueries.some((q) => q.isLoading);
|
||||
const providerDefinitions = snapshotProviderDefinitions;
|
||||
const providerDefinitionMap = snapshotProviderDefinitionMap;
|
||||
const selectableProviderDefinitionMap = snapshotSelectableProviderDefinitionMap;
|
||||
const allProviderModels = snapshotAllProviderModels;
|
||||
const availableModels = snapshotSelectedProviderModels;
|
||||
const modeOptions = snapshotSelectedProviderModes;
|
||||
const isAllModelsLoading = snapshotIsLoading || snapshotIsFetching;
|
||||
|
||||
// Combine initialValues with initialServerId for resolution
|
||||
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
|
||||
@@ -872,15 +645,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
}, []);
|
||||
|
||||
const refreshProviderModels = useCallback(() => {
|
||||
if (supportsProvidersSnapshot) {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
void client.refreshProvidersSnapshot({ cwd: debouncedCwd });
|
||||
return;
|
||||
}
|
||||
void legacySelectedProviderModelsQuery.refetch();
|
||||
}, [client, debouncedCwd, legacySelectedProviderModelsQuery, supportsProvidersSnapshot]);
|
||||
refreshSnapshot();
|
||||
}, [refreshSnapshot]);
|
||||
|
||||
const persistFormPreferences = useCallback(async () => {
|
||||
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
||||
@@ -916,17 +682,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
||||
const resolvedModelId = effectiveModel?.id ?? formState.model;
|
||||
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||
const isModelLoading = supportsProvidersSnapshot
|
||||
? providersSnapshotQuery.isLoading || providersSnapshotQuery.isFetching
|
||||
: legacySelectedProviderModelsQuery.isLoading || legacySelectedProviderModelsQuery.isFetching;
|
||||
const modelError =
|
||||
supportsProvidersSnapshot
|
||||
? providersSnapshotQuery.error instanceof Error
|
||||
? providersSnapshotQuery.error.message
|
||||
: null
|
||||
: legacySelectedProviderModelsQuery.error instanceof Error
|
||||
? legacySelectedProviderModelsQuery.error.message
|
||||
: null;
|
||||
const isModelLoading = snapshotIsLoading || snapshotIsFetching;
|
||||
const modelError = snapshotError;
|
||||
|
||||
const workingDirIsEmpty = !formState.workingDir.trim();
|
||||
|
||||
|
||||
74
packages/app/src/hooks/use-changes-preferences.test.ts
Normal file
74
packages/app/src/hooks/use-changes-preferences.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const asyncStorageMock = vi.hoisted(() => ({
|
||||
getItem: vi.fn<(_: string) => Promise<string | null>>(),
|
||||
setItem: vi.fn<(_: string, __: string) => Promise<void>>(),
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: asyncStorageMock,
|
||||
}));
|
||||
|
||||
describe("use-changes-preferences", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
asyncStorageMock.getItem.mockReset();
|
||||
asyncStorageMock.setItem.mockReset();
|
||||
});
|
||||
|
||||
it("defaults to unified layout with visible whitespace", async () => {
|
||||
asyncStorageMock.getItem.mockResolvedValue(null);
|
||||
asyncStorageMock.setItem.mockResolvedValue();
|
||||
|
||||
const mod = await import("./use-changes-preferences");
|
||||
const result = await mod.loadChangesPreferencesFromStorage();
|
||||
|
||||
expect(result).toEqual(mod.DEFAULT_CHANGES_PREFERENCES);
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
"@paseo:changes-preferences",
|
||||
JSON.stringify(mod.DEFAULT_CHANGES_PREFERENCES),
|
||||
);
|
||||
});
|
||||
|
||||
it("migrates the legacy wrap-lines toggle into the new preferences object", async () => {
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
if (key === "diff-wrap-lines") {
|
||||
return "true";
|
||||
}
|
||||
return null;
|
||||
});
|
||||
asyncStorageMock.setItem.mockResolvedValue();
|
||||
|
||||
const mod = await import("./use-changes-preferences");
|
||||
const result = await mod.loadChangesPreferencesFromStorage();
|
||||
|
||||
expect(result).toEqual({
|
||||
layout: "unified",
|
||||
wrapLines: true,
|
||||
hideWhitespace: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("loads persisted layout and whitespace preferences", async () => {
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
if (key === "@paseo:changes-preferences") {
|
||||
return JSON.stringify({
|
||||
layout: "split",
|
||||
hideWhitespace: true,
|
||||
wrapLines: false,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const mod = await import("./use-changes-preferences");
|
||||
const result = await mod.loadChangesPreferencesFromStorage();
|
||||
|
||||
expect(result).toEqual({
|
||||
layout: "split",
|
||||
hideWhitespace: true,
|
||||
wrapLines: false,
|
||||
});
|
||||
expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
89
packages/app/src/hooks/use-changes-preferences.ts
Normal file
89
packages/app/src/hooks/use-changes-preferences.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
|
||||
const CHANGES_PREFERENCES_STORAGE_KEY = "@paseo:changes-preferences";
|
||||
const LEGACY_WRAP_LINES_STORAGE_KEY = "diff-wrap-lines";
|
||||
const CHANGES_PREFERENCES_QUERY_KEY = ["changes-preferences"];
|
||||
|
||||
const changesPreferencesSchema = z.object({
|
||||
layout: z.enum(["unified", "split"]).optional(),
|
||||
wrapLines: z.boolean().optional(),
|
||||
hideWhitespace: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export interface ChangesPreferences {
|
||||
layout: "unified" | "split";
|
||||
wrapLines: boolean;
|
||||
hideWhitespace: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = {
|
||||
layout: "unified",
|
||||
wrapLines: false,
|
||||
hideWhitespace: false,
|
||||
};
|
||||
|
||||
async function loadLegacyWrapLinesPreference(): Promise<boolean | null> {
|
||||
const legacyValue = await AsyncStorage.getItem(LEGACY_WRAP_LINES_STORAGE_KEY);
|
||||
if (legacyValue === "true") {
|
||||
return true;
|
||||
}
|
||||
if (legacyValue === "false") {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function loadChangesPreferencesFromStorage(): Promise<ChangesPreferences> {
|
||||
const stored = await AsyncStorage.getItem(CHANGES_PREFERENCES_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = changesPreferencesSchema.safeParse(JSON.parse(stored));
|
||||
if (parsed.success) {
|
||||
return { ...DEFAULT_CHANGES_PREFERENCES, ...parsed.data };
|
||||
}
|
||||
}
|
||||
|
||||
const legacyWrapLines = await loadLegacyWrapLinesPreference();
|
||||
const next = {
|
||||
...DEFAULT_CHANGES_PREFERENCES,
|
||||
...(legacyWrapLines !== null ? { wrapLines: legacyWrapLines } : {}),
|
||||
} satisfies ChangesPreferences;
|
||||
await AsyncStorage.setItem(CHANGES_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
|
||||
return next;
|
||||
}
|
||||
|
||||
export interface UseChangesPreferencesReturn {
|
||||
preferences: ChangesPreferences;
|
||||
isLoading: boolean;
|
||||
updatePreferences: (updates: Partial<ChangesPreferences>) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useChangesPreferences(): UseChangesPreferencesReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isPending } = useQuery({
|
||||
queryKey: CHANGES_PREFERENCES_QUERY_KEY,
|
||||
queryFn: loadChangesPreferencesFromStorage,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
|
||||
const updatePreferences = useCallback(
|
||||
async (updates: Partial<ChangesPreferences>) => {
|
||||
const prev =
|
||||
queryClient.getQueryData<ChangesPreferences>(CHANGES_PREFERENCES_QUERY_KEY) ??
|
||||
DEFAULT_CHANGES_PREFERENCES;
|
||||
const next = { ...prev, ...updates };
|
||||
queryClient.setQueryData<ChangesPreferences>(CHANGES_PREFERENCES_QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(CHANGES_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
return {
|
||||
preferences: data ?? DEFAULT_CHANGES_PREFERENCES,
|
||||
isLoading: isPending,
|
||||
updatePreferences,
|
||||
};
|
||||
}
|
||||
@@ -13,8 +13,9 @@ function checkoutDiffQueryKey(
|
||||
cwd: string,
|
||||
mode: "uncommitted" | "base",
|
||||
baseRef?: string,
|
||||
ignoreWhitespace?: boolean,
|
||||
) {
|
||||
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? ""] as const;
|
||||
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? "", ignoreWhitespace === true] as const;
|
||||
}
|
||||
|
||||
interface UseCheckoutDiffQueryOptions {
|
||||
@@ -22,6 +23,7 @@ interface UseCheckoutDiffQueryOptions {
|
||||
cwd: string;
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -35,12 +37,16 @@ export type HighlightToken = NonNullable<DiffLine["tokens"]>[number];
|
||||
function normalizeCheckoutDiffCompare(compare: {
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
}): { mode: "uncommitted" | "base"; baseRef?: string } {
|
||||
ignoreWhitespace?: boolean;
|
||||
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
|
||||
const ignoreWhitespace = compare.ignoreWhitespace === true;
|
||||
if (compare.mode === "uncommitted") {
|
||||
return { mode: "uncommitted" };
|
||||
return { mode: "uncommitted", ignoreWhitespace };
|
||||
}
|
||||
const trimmedBaseRef = compare.baseRef?.trim();
|
||||
return trimmedBaseRef ? { mode: "base", baseRef: trimmedBaseRef } : { mode: "base" };
|
||||
return trimmedBaseRef
|
||||
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
|
||||
: { mode: "base", ignoreWhitespace };
|
||||
}
|
||||
|
||||
export function useCheckoutDiffQuery({
|
||||
@@ -48,6 +54,7 @@ export function useCheckoutDiffQuery({
|
||||
cwd,
|
||||
mode,
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
enabled = true,
|
||||
}: UseCheckoutDiffQueryOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -60,14 +67,15 @@ export function useCheckoutDiffQuery({
|
||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
const hookInstanceId = useId();
|
||||
const normalizedCompare = useMemo(
|
||||
() => normalizeCheckoutDiffCompare({ mode, baseRef }),
|
||||
[mode, baseRef],
|
||||
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
|
||||
[mode, baseRef, ignoreWhitespace],
|
||||
);
|
||||
const compareMode = normalizedCompare.mode;
|
||||
const compareBaseRef = normalizedCompare.baseRef;
|
||||
const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace;
|
||||
const queryKey = useMemo(
|
||||
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef),
|
||||
[serverId, cwd, mode, baseRef],
|
||||
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
|
||||
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
@@ -79,6 +87,7 @@ export function useCheckoutDiffQuery({
|
||||
const payload = await client.getCheckoutDiff(cwd, {
|
||||
mode: compareMode,
|
||||
baseRef: compareBaseRef,
|
||||
ignoreWhitespace: compareIgnoreWhitespace,
|
||||
});
|
||||
return {
|
||||
...payload,
|
||||
@@ -104,6 +113,7 @@ export function useCheckoutDiffQuery({
|
||||
cwd,
|
||||
compareMode,
|
||||
compareBaseRef ?? "",
|
||||
compareIgnoreWhitespace ? "ignore-ws" : "keep-ws",
|
||||
].join(":");
|
||||
let cancelled = false;
|
||||
|
||||
@@ -145,6 +155,7 @@ export function useCheckoutDiffQuery({
|
||||
{
|
||||
mode: compareMode,
|
||||
baseRef: compareBaseRef,
|
||||
ignoreWhitespace: compareIgnoreWhitespace,
|
||||
},
|
||||
{ subscriptionId },
|
||||
)
|
||||
@@ -191,6 +202,7 @@ export function useCheckoutDiffQuery({
|
||||
serverId,
|
||||
compareMode,
|
||||
compareBaseRef,
|
||||
compareIgnoreWhitespace,
|
||||
queryKey,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
@@ -165,7 +165,7 @@ export function useCommandCenter() {
|
||||
|
||||
const settingsRoute = useMemo<Href>(() => {
|
||||
const serverIdFromPath = activeServerId;
|
||||
return serverIdFromPath ? (buildHostSettingsRoute(serverIdFromPath) as Href) : "/";
|
||||
return serverIdFromPath ? buildHostSettingsRoute(serverIdFromPath) : "/";
|
||||
}, [activeServerId]);
|
||||
|
||||
const actionItems = useMemo(() => {
|
||||
@@ -220,7 +220,7 @@ export function useCommandCenter() {
|
||||
workspaceId: agent.cwd,
|
||||
target: { kind: "agent", agentId: agent.id },
|
||||
});
|
||||
router.navigate(route as any);
|
||||
router.navigate(route);
|
||||
},
|
||||
[setOpen],
|
||||
);
|
||||
|
||||
@@ -190,7 +190,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
|
||||
void runArchiveWorktree({ serverId, cwd, worktreePath })
|
||||
.then(() => {
|
||||
router.replace(buildNewAgentRoute(serverId, targetWorkingDir) as any);
|
||||
router.replace(buildNewAgentRoute(serverId, targetWorkingDir));
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to archive worktree";
|
||||
|
||||
26
packages/app/src/hooks/use-preferred-editor.test.ts
Normal file
26
packages/app/src/hooks/use-preferred-editor.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolvePreferredEditorId } from "./use-preferred-editor";
|
||||
|
||||
describe("resolvePreferredEditorId", () => {
|
||||
it("keeps the stored editor when it is still available", () => {
|
||||
expect(resolvePreferredEditorId(["cursor", "vscode"], "vscode")).toBe("vscode");
|
||||
});
|
||||
|
||||
it("falls back to the first available editor when the stored one is missing", () => {
|
||||
expect(resolvePreferredEditorId(["zed", "finder"], "cursor")).toBe("zed");
|
||||
});
|
||||
|
||||
it("falls back when a platform-specific file manager target is unavailable", () => {
|
||||
expect(resolvePreferredEditorId(["explorer", "vscode"], "finder")).toBe("explorer");
|
||||
});
|
||||
|
||||
it("keeps unknown editor ids when they are still available", () => {
|
||||
expect(resolvePreferredEditorId(["unknown-editor", "cursor"], "unknown-editor")).toBe(
|
||||
"unknown-editor",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when no editors are available", () => {
|
||||
expect(resolvePreferredEditorId([], "cursor")).toBeNull();
|
||||
});
|
||||
});
|
||||
57
packages/app/src/hooks/use-preferred-editor.ts
Normal file
57
packages/app/src/hooks/use-preferred-editor.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { EditorTargetIdSchema, type EditorTargetId } from "@server/shared/messages";
|
||||
|
||||
const PREFERRED_EDITOR_STORAGE_KEY = "@paseo:preferred-editor";
|
||||
const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"];
|
||||
|
||||
async function loadPreferredEditor(): Promise<EditorTargetId | null> {
|
||||
const stored = await AsyncStorage.getItem(PREFERRED_EDITOR_STORAGE_KEY);
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
const parsed = EditorTargetIdSchema.safeParse(stored);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
export function resolvePreferredEditorId(
|
||||
availableEditorIds: readonly EditorTargetId[],
|
||||
storedEditorId: EditorTargetId | null | undefined,
|
||||
): EditorTargetId | null {
|
||||
if (
|
||||
storedEditorId &&
|
||||
availableEditorIds.some((availableEditorId) => availableEditorId === storedEditorId)
|
||||
) {
|
||||
return storedEditorId;
|
||||
}
|
||||
return availableEditorIds[0] ?? null;
|
||||
}
|
||||
|
||||
export function usePreferredEditor() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isPending } = useQuery({
|
||||
queryKey: PREFERRED_EDITOR_QUERY_KEY,
|
||||
queryFn: loadPreferredEditor,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
|
||||
const updatePreferredEditor = useCallback(
|
||||
async (editorId: EditorTargetId | null) => {
|
||||
queryClient.setQueryData(PREFERRED_EDITOR_QUERY_KEY, editorId);
|
||||
if (editorId) {
|
||||
await AsyncStorage.setItem(PREFERRED_EDITOR_STORAGE_KEY, editorId);
|
||||
return;
|
||||
}
|
||||
await AsyncStorage.removeItem(PREFERRED_EDITOR_STORAGE_KEY);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
return {
|
||||
preferredEditorId: data ?? null,
|
||||
isLoading: isPending,
|
||||
updatePreferredEditor,
|
||||
};
|
||||
}
|
||||
86
packages/app/src/hooks/use-providers-snapshot.ts
Normal file
86
packages/app/src/hooks/use-providers-snapshot.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useSessionForServer } from "./use-session-directory";
|
||||
import { queryClient as singletonQueryClient } from "@/query/query-client";
|
||||
|
||||
export function providersSnapshotQueryKey(serverId: string | null) {
|
||||
return ["providersSnapshot", serverId] as const;
|
||||
}
|
||||
|
||||
interface UseProvidersSnapshotResult {
|
||||
entries: ProviderSnapshotEntry[] | undefined;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
error: string | null;
|
||||
supportsSnapshot: boolean;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function useProvidersSnapshot(serverId: string | null): UseProvidersSnapshotResult {
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const supportsSnapshot = useSessionForServer(
|
||||
serverId,
|
||||
(session) => session?.serverInfo?.features?.providersSnapshot === true,
|
||||
);
|
||||
|
||||
const queryKey = useMemo(() => providersSnapshotQueryKey(serverId), [serverId]);
|
||||
|
||||
const snapshotQuery = useQuery({
|
||||
queryKey,
|
||||
enabled: Boolean(supportsSnapshot && serverId && client && isConnected),
|
||||
staleTime: 60_000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return client.getProvidersSnapshot();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!supportsSnapshot || !client || !isConnected || !serverId) {
|
||||
return;
|
||||
}
|
||||
|
||||
return client.on("providers_snapshot_update", (message) => {
|
||||
if (message.type !== "providers_snapshot_update") {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData(queryKey, {
|
||||
entries: message.payload.entries,
|
||||
generatedAt: message.payload.generatedAt,
|
||||
requestId: "providers_snapshot_update",
|
||||
});
|
||||
});
|
||||
}, [client, isConnected, serverId, queryClient, queryKey, supportsSnapshot]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
void client.refreshProvidersSnapshot();
|
||||
}, [client]);
|
||||
|
||||
return {
|
||||
entries: snapshotQuery.data?.entries ?? undefined,
|
||||
isLoading: snapshotQuery.isLoading,
|
||||
isFetching: snapshotQuery.isFetching,
|
||||
error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null,
|
||||
supportsSnapshot,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
export function prefetchProvidersSnapshot(serverId: string, client: DaemonClient): void {
|
||||
const queryKey = providersSnapshotQueryKey(serverId);
|
||||
void singletonQueryClient.prefetchQuery({
|
||||
queryKey,
|
||||
staleTime: 60_000,
|
||||
queryFn: () => client.getProvidersSnapshot(),
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
|
||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
mergeWorkspaceSnapshotWithExisting,
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
@@ -356,6 +360,7 @@ export function useSidebarWorkspacesList(options?: {
|
||||
}
|
||||
void (async () => {
|
||||
const next = new Map<string, WorkspaceDescriptor>();
|
||||
const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||
let cursor: string | null = null;
|
||||
try {
|
||||
while (true) {
|
||||
@@ -365,7 +370,13 @@ export function useSidebarWorkspacesList(options?: {
|
||||
});
|
||||
for (const entry of payload.entries) {
|
||||
const workspace = toWorkspaceDescriptor(entry);
|
||||
next.set(workspace.id, workspace);
|
||||
next.set(
|
||||
workspace.id,
|
||||
mergeWorkspaceSnapshotWithExisting({
|
||||
incoming: workspace,
|
||||
existing: existingWorkspaces?.get(workspace.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
||||
break;
|
||||
|
||||
@@ -11,7 +11,7 @@ import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
*/
|
||||
export function navigateToWorkspace(serverId: string, workspaceId: string) {
|
||||
const href = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||
router.navigate(href as any);
|
||||
router.navigate(href);
|
||||
}
|
||||
|
||||
export function useWorkspaceNavigation() {
|
||||
|
||||
@@ -54,7 +54,7 @@ function SessionsScreenContent({ serverId }: { serverId: string }) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
leftIcon={ChevronLeft}
|
||||
onPress={() => router.navigate(buildHostOpenProjectRoute(serverId) as any)}
|
||||
onPress={() => router.navigate(buildHostOpenProjectRoute(serverId))}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Shield,
|
||||
Puzzle,
|
||||
Blocks,
|
||||
Smartphone,
|
||||
} from "lucide-react-native";
|
||||
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
import type { HostProfile, HostConnection } from "@/types/host-connection";
|
||||
@@ -54,6 +55,7 @@ import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-mod
|
||||
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
|
||||
import { IntegrationsSection } from "@/desktop/components/integrations-section";
|
||||
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
|
||||
import { PairDeviceSection } from "@/desktop/components/pair-device-section";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
@@ -62,12 +64,12 @@ import { settingsStyles } from "@/styles/settings";
|
||||
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
|
||||
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { isCompactFormFactor } from "@/constants/layout";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section definitions
|
||||
@@ -82,7 +84,8 @@ type SettingsSectionId =
|
||||
| "diagnostics"
|
||||
| "about"
|
||||
| "permissions"
|
||||
| "daemon";
|
||||
| "daemon"
|
||||
| "pair-device";
|
||||
|
||||
interface SettingsSectionDef {
|
||||
id: SettingsSectionId;
|
||||
@@ -101,6 +104,7 @@ function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectio
|
||||
if (context.isDesktopApp) {
|
||||
sections.push(
|
||||
{ id: "integrations", label: "Integrations", icon: Puzzle },
|
||||
{ id: "pair-device", label: "Pair device", icon: Smartphone },
|
||||
{ id: "daemon", label: "Daemon", icon: Settings },
|
||||
);
|
||||
}
|
||||
@@ -432,63 +436,70 @@ interface ProvidersSectionProps {
|
||||
|
||||
function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const client = useHostRuntimeClient(routeServerId);
|
||||
const isConnected = useHostRuntimeIsConnected(routeServerId);
|
||||
const [entries, setEntries] = useState<ProviderSnapshotEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId);
|
||||
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected) {
|
||||
setEntries([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
client
|
||||
.getProvidersSnapshot()
|
||||
.then((result) => {
|
||||
if (!cancelled) setEntries(result.entries);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setEntries([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, isConnected]);
|
||||
|
||||
const hasServer = routeServerId.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={settingsStyles.section}>
|
||||
<Text style={settingsStyles.sectionTitle}>Providers</Text>
|
||||
<View style={settingsStyles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>Providers</Text>
|
||||
{hasServer && isConnected ? (
|
||||
<Pressable
|
||||
onPress={refresh}
|
||||
disabled={isFetching}
|
||||
style={[
|
||||
settingsStyles.sectionHeaderLink,
|
||||
isFetching ? { opacity: 0.5 } : null,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: theme.colors.primary,
|
||||
fontSize: theme.fontSize.xs,
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
{!hasServer || !isConnected ? (
|
||||
<View style={[settingsStyles.card, styles.emptyCard]}>
|
||||
<Text style={styles.emptyText}>Connect to a host to see providers</Text>
|
||||
</View>
|
||||
) : loading ? (
|
||||
) : isLoading ? (
|
||||
<View style={[settingsStyles.card, styles.emptyCard]}>
|
||||
<Text style={styles.emptyText}>Loading...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[settingsStyles.card, styles.audioCard]}>
|
||||
{AGENT_PROVIDER_DEFINITIONS.map((def) => {
|
||||
const entry = entries.find((e) => e.provider === def.id);
|
||||
const entry = entries?.find((e) => e.provider === def.id);
|
||||
const status = entry?.status ?? "unavailable";
|
||||
const ProviderIcon = getProviderIcon(def.id);
|
||||
const providerError =
|
||||
status === "error" && typeof entry?.error === "string" && entry.error.trim().length > 0
|
||||
? entry.error.trim()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<View key={def.id} style={styles.audioRow}>
|
||||
<View style={[styles.audioRowContent, { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }]}>
|
||||
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
<Text style={styles.audioRowTitle}>{def.label}</Text>
|
||||
<View style={styles.audioRowContent}>
|
||||
<View
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }}
|
||||
>
|
||||
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
<Text style={styles.audioRowTitle}>{def.label}</Text>
|
||||
</View>
|
||||
{providerError ? (
|
||||
<Text style={styles.aboutErrorText} numberOfLines={3}>
|
||||
{providerError}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.providerActions}>
|
||||
<StatusBadge
|
||||
@@ -640,6 +651,8 @@ function SettingsSectionContent({
|
||||
return isDesktopApp ? <IntegrationsSection /> : null;
|
||||
case "permissions":
|
||||
return isDesktopApp ? <DesktopPermissionsSection /> : null;
|
||||
case "pair-device":
|
||||
return isDesktopApp ? <PairDeviceSection /> : null;
|
||||
case "daemon":
|
||||
return isDesktopApp ? (
|
||||
<LocalDaemonSection appVersion={appVersion} showLifecycleControls={isLocalDaemon} />
|
||||
|
||||
@@ -73,7 +73,7 @@ describe("workspace bulk close helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses one mixed closeItems RPC for agent and terminal tabs, then applies local cleanup", async () => {
|
||||
it("closes all tabs immediately and fires one mixed closeItems RPC in the background", async () => {
|
||||
const groups = classifyBulkClosableTabs([
|
||||
makeAgentTab("a1"),
|
||||
makeTerminalTab("t1"),
|
||||
@@ -91,7 +91,7 @@ describe("workspace bulk close helpers", () => {
|
||||
requestId: "req-1",
|
||||
}));
|
||||
|
||||
const result = await closeBulkWorkspaceTabs({
|
||||
await closeBulkWorkspaceTabs({
|
||||
groups,
|
||||
client: { closeItems },
|
||||
closeTab: async (tabId, action) => {
|
||||
@@ -109,23 +109,21 @@ describe("workspace bulk close helpers", () => {
|
||||
agentIds: ["a1"],
|
||||
terminalIds: ["t1", "t2"],
|
||||
});
|
||||
expect(result).toEqual({
|
||||
agents: [{ agentId: "a1", archivedAt: "2026-04-01T04:00:00.000Z" }],
|
||||
terminals: [
|
||||
{ terminalId: "t1", success: true },
|
||||
{ terminalId: "t2", success: false },
|
||||
],
|
||||
requestId: "req-1",
|
||||
});
|
||||
expect(closedTabIds).toEqual(["agent_a1", "terminal_t1", "file_/repo/README.md"]);
|
||||
expect(closedTabIds).toEqual([
|
||||
"agent_a1",
|
||||
"terminal_t1",
|
||||
"terminal_t2",
|
||||
"file_/repo/README.md",
|
||||
]);
|
||||
expect(cleanupCalls).toEqual([
|
||||
{ tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } },
|
||||
{ tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } },
|
||||
{ tabId: "terminal_t2", target: { kind: "terminal", terminalId: "t2" } },
|
||||
{ tabId: "file_/repo/README.md" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("still closes passive tabs when the mixed closeItems RPC fails", async () => {
|
||||
it("still closes all tabs when the mixed closeItems RPC fails", async () => {
|
||||
const groups = classifyBulkClosableTabs([
|
||||
makeAgentTab("a1"),
|
||||
makeTerminalTab("t1"),
|
||||
@@ -135,7 +133,7 @@ describe("workspace bulk close helpers", () => {
|
||||
const cleanupCalls: Array<{ tabId: string; target?: WorkspaceTabDescriptor["target"] }> = [];
|
||||
const warn = vi.fn();
|
||||
|
||||
const result = await closeBulkWorkspaceTabs({
|
||||
await closeBulkWorkspaceTabs({
|
||||
groups,
|
||||
client: {
|
||||
closeItems: async () => {
|
||||
@@ -153,9 +151,14 @@ describe("workspace bulk close helpers", () => {
|
||||
logLabel: "others",
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBeNull();
|
||||
expect(closedTabIds).toEqual(["file_/repo/README.md"]);
|
||||
expect(cleanupCalls).toEqual([{ tabId: "file_/repo/README.md" }]);
|
||||
expect(closedTabIds).toEqual(["agent_a1", "terminal_t1", "file_/repo/README.md"]);
|
||||
expect(cleanupCalls).toEqual([
|
||||
{ tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } },
|
||||
{ tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } },
|
||||
{ tabId: "file_/repo/README.md" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,6 @@ export type BulkClosableTabGroups = {
|
||||
otherTabs: Array<{ tabId: string }>;
|
||||
};
|
||||
|
||||
type CloseItemsPayload = Awaited<ReturnType<DaemonClient["closeItems"]>>;
|
||||
|
||||
interface CloseWorkspaceTabWithCleanupInput {
|
||||
tabId: string;
|
||||
target?: WorkspaceTabDescriptor["target"];
|
||||
@@ -68,47 +66,27 @@ export function buildBulkCloseConfirmationMessage(input: BulkClosableTabGroups):
|
||||
return `This will archive ${agentTabs.length} agent(s).`;
|
||||
}
|
||||
|
||||
function toSuccessfulAgentIds(payload: CloseItemsPayload | null): Set<string> {
|
||||
return new Set(payload?.agents.map((agent) => agent.agentId) ?? []);
|
||||
}
|
||||
|
||||
function toSuccessfulTerminalIds(payload: CloseItemsPayload | null): Set<string> {
|
||||
return new Set(
|
||||
payload?.terminals.filter((terminal) => terminal.success).map((terminal) => terminal.terminalId) ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
export async function closeBulkWorkspaceTabs(
|
||||
input: CloseBulkWorkspaceTabsInput,
|
||||
): Promise<CloseItemsPayload | null> {
|
||||
export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput): Promise<void> {
|
||||
const { client, groups, closeTab, closeWorkspaceTabWithCleanup, logLabel, warn } = input;
|
||||
const hasDestructiveTabs = groups.agentTabs.length > 0 || groups.terminalTabs.length > 0;
|
||||
let payload: CloseItemsPayload | null = null;
|
||||
|
||||
if (hasDestructiveTabs && client) {
|
||||
try {
|
||||
payload = await client.closeItems({
|
||||
void client
|
||||
.closeItems({
|
||||
agentIds: groups.agentTabs.map((tab) => tab.agentId),
|
||||
terminalIds: groups.terminalTabs.map((tab) => tab.terminalId),
|
||||
})
|
||||
.catch((error) => {
|
||||
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, { error });
|
||||
});
|
||||
} catch (error) {
|
||||
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, { error });
|
||||
}
|
||||
} else if (hasDestructiveTabs) {
|
||||
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, {
|
||||
error: new Error("Daemon client not available"),
|
||||
});
|
||||
}
|
||||
|
||||
const successfulAgentIds = toSuccessfulAgentIds(payload);
|
||||
const successfulTerminalIds = toSuccessfulTerminalIds(payload);
|
||||
|
||||
for (const { tabId, agentId } of groups.agentTabs) {
|
||||
if (!successfulAgentIds.has(agentId)) {
|
||||
continue;
|
||||
}
|
||||
await closeTab(tabId, async () => {
|
||||
void closeTab(tabId, async () => {
|
||||
closeWorkspaceTabWithCleanup({
|
||||
tabId,
|
||||
target: { kind: "agent", agentId },
|
||||
@@ -117,10 +95,7 @@ export async function closeBulkWorkspaceTabs(
|
||||
}
|
||||
|
||||
for (const { tabId, terminalId } of groups.terminalTabs) {
|
||||
if (!successfulTerminalIds.has(terminalId)) {
|
||||
continue;
|
||||
}
|
||||
await closeTab(tabId, async () => {
|
||||
void closeTab(tabId, async () => {
|
||||
closeWorkspaceTabWithCleanup({
|
||||
tabId,
|
||||
target: { kind: "terminal", terminalId },
|
||||
@@ -129,10 +104,8 @@ export async function closeBulkWorkspaceTabs(
|
||||
}
|
||||
|
||||
for (const { tabId } of groups.otherTabs) {
|
||||
await closeTab(tabId, async () => {
|
||||
void closeTab(tabId, async () => {
|
||||
closeWorkspaceTabWithCleanup({ tabId });
|
||||
});
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { ActivityIndicator, Platform, Pressable, Text, View } from "react-native";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Check, ChevronDown } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import type {
|
||||
EditorTargetDescriptorPayload,
|
||||
EditorTargetId,
|
||||
} from "@server/shared/messages";
|
||||
import { EditorAppIcon } from "@/components/icons/editor-app-icons";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import {
|
||||
resolvePreferredEditorId,
|
||||
usePreferredEditor,
|
||||
} from "@/hooks/use-preferred-editor";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
|
||||
interface WorkspaceOpenInEditorButtonProps {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export function WorkspaceOpenInEditorButton({
|
||||
serverId,
|
||||
cwd,
|
||||
}: WorkspaceOpenInEditorButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
|
||||
|
||||
const shouldLoadEditors =
|
||||
Platform.OS === "web" &&
|
||||
Boolean(client && isConnected) &&
|
||||
cwd.trim().length > 0 &&
|
||||
isAbsolutePath(cwd);
|
||||
|
||||
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
|
||||
queryKey: ["available-editors", serverId],
|
||||
enabled: shouldLoadEditors,
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const payload = await client.listAvailableEditors();
|
||||
return payload.error ? [] : payload.editors;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const availableEditors = availableEditorsQuery.data ?? [];
|
||||
const availableEditorIds = useMemo(
|
||||
() => availableEditors.map((editor: EditorTargetDescriptorPayload) => editor.id),
|
||||
[availableEditors],
|
||||
);
|
||||
const effectivePreferredEditorId = useMemo(
|
||||
() => resolvePreferredEditorId(availableEditorIds, preferredEditorId),
|
||||
[availableEditorIds, preferredEditorId],
|
||||
);
|
||||
const primaryOption =
|
||||
availableEditors.find(
|
||||
(editor: EditorTargetDescriptorPayload) => editor.id === effectivePreferredEditorId,
|
||||
) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!effectivePreferredEditorId || effectivePreferredEditorId === preferredEditorId) {
|
||||
return;
|
||||
}
|
||||
void updatePreferredEditor(effectivePreferredEditorId).catch(() => undefined);
|
||||
}, [effectivePreferredEditorId, preferredEditorId, updatePreferredEditor]);
|
||||
|
||||
const openMutation = useMutation({
|
||||
mutationFn: async (editorId: EditorTargetId) => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.openInEditor(cwd, editorId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return editorId;
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to open in editor");
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenEditor = useCallback(
|
||||
(editorId: EditorTargetId) => {
|
||||
void updatePreferredEditor(editorId).catch(() => undefined);
|
||||
openMutation.mutate(editorId);
|
||||
},
|
||||
[openMutation, updatePreferredEditor],
|
||||
);
|
||||
|
||||
if (!shouldLoadEditors || !primaryOption || availableEditors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={styles.splitButton}>
|
||||
<Pressable
|
||||
testID="workspace-open-in-editor-primary"
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.splitButtonPrimary,
|
||||
(hovered || pressed) && styles.splitButtonPrimaryHovered,
|
||||
openMutation.isPending && styles.splitButtonPrimaryDisabled,
|
||||
]}
|
||||
onPress={() => handleOpenEditor(primaryOption.id)}
|
||||
disabled={openMutation.isPending}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open workspace in ${primaryOption.label}`}
|
||||
>
|
||||
{openMutation.isPending ? (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.foreground}
|
||||
style={styles.splitButtonSpinnerOnly}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.splitButtonContent}>
|
||||
<EditorAppIcon
|
||||
editorId={primaryOption.id}
|
||||
size={16}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text style={styles.splitButtonText}>Open</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
{availableEditors.length > 1 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
testID="workspace-open-in-editor-caret"
|
||||
style={({ hovered, pressed, open }) => [
|
||||
styles.splitButtonCaret,
|
||||
(hovered || pressed || open) && styles.splitButtonCaretHovered,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Choose editor"
|
||||
>
|
||||
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
minWidth={148}
|
||||
maxWidth={176}
|
||||
testID="workspace-open-in-editor-menu"
|
||||
>
|
||||
{availableEditors.map((editor: EditorTargetDescriptorPayload) => (
|
||||
<DropdownMenuItem
|
||||
key={editor.id}
|
||||
testID={`workspace-open-in-editor-item-${editor.id}`}
|
||||
leading={
|
||||
<EditorAppIcon
|
||||
editorId={editor.id}
|
||||
size={16}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
}
|
||||
trailing={
|
||||
editor.id === effectivePreferredEditorId
|
||||
? <Check size={16} color={theme.colors.foregroundMuted} />
|
||||
: undefined
|
||||
}
|
||||
onSelect={() => handleOpenEditor(editor.id)}
|
||||
>
|
||||
{editor.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
flexShrink: 0,
|
||||
},
|
||||
splitButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "stretch",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
overflow: "hidden",
|
||||
},
|
||||
splitButtonPrimary: {
|
||||
paddingLeft: theme.spacing[3],
|
||||
paddingRight: 10,
|
||||
paddingVertical: theme.spacing[1],
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
},
|
||||
splitButtonPrimaryHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
splitButtonPrimaryDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
splitButtonText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.5,
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
splitButtonContent: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
splitButtonSpinnerOnly: {
|
||||
transform: [{ scale: 0.8 }],
|
||||
},
|
||||
splitButtonCaret: {
|
||||
width: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderLeftWidth: theme.borderWidth[1],
|
||||
borderLeftColor: theme.colors.borderAccent,
|
||||
},
|
||||
splitButtonCaretHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
@@ -48,6 +48,7 @@ import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
||||
import { SplitContainer } from "@/components/split-container";
|
||||
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
|
||||
import { WorkspaceGitActions } from "@/screens/workspace/workspace-git-actions";
|
||||
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
|
||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||
@@ -78,7 +79,7 @@ import {
|
||||
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { applyArchivedAgentCloseResults, useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
@@ -1213,10 +1214,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
return;
|
||||
}
|
||||
|
||||
await killTerminalAsync(terminalId);
|
||||
setHoveredTabKey((current) => (current === tabId ? null : current));
|
||||
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
|
||||
|
||||
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
@@ -1226,13 +1223,18 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
|
||||
};
|
||||
});
|
||||
|
||||
setHoveredTabKey((current) => (current === tabId ? null : current));
|
||||
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
|
||||
if (persistenceKey) {
|
||||
closeWorkspaceTabWithCleanup({
|
||||
tabId,
|
||||
target: { kind: "terminal", terminalId },
|
||||
});
|
||||
}
|
||||
|
||||
void killTerminalAsync(terminalId).catch(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
|
||||
});
|
||||
});
|
||||
},
|
||||
[
|
||||
@@ -1253,18 +1255,22 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Archive agent?",
|
||||
message: "This closes the tab and archives the agent.",
|
||||
confirmLabel: "Archive",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
const agent =
|
||||
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
|
||||
|
||||
if (agent?.status !== "idle") {
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Archive agent?",
|
||||
message: "This closes the tab and archives the agent.",
|
||||
confirmLabel: "Archive",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await archiveAgent({ serverId: normalizedServerId, agentId });
|
||||
setHoveredTabKey((current) => (current === tabId ? null : current));
|
||||
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
|
||||
if (persistenceKey) {
|
||||
@@ -1273,6 +1279,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
target: { kind: "agent", agentId },
|
||||
});
|
||||
}
|
||||
|
||||
void archiveAgent({ serverId: normalizedServerId, agentId });
|
||||
});
|
||||
},
|
||||
[archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey],
|
||||
@@ -1417,7 +1425,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
return;
|
||||
}
|
||||
|
||||
const closeItemsPayload = await closeBulkWorkspaceTabs({
|
||||
await closeBulkWorkspaceTabs({
|
||||
client,
|
||||
groups,
|
||||
closeTab,
|
||||
@@ -1433,31 +1441,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
},
|
||||
});
|
||||
|
||||
if (closeItemsPayload) {
|
||||
for (const terminal of closeItemsPayload.terminals) {
|
||||
if (!terminal.success) {
|
||||
continue;
|
||||
}
|
||||
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
terminals: current.terminals.filter((entry) => entry.id !== terminal.terminalId),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedServerId) {
|
||||
applyArchivedAgentCloseResults({
|
||||
queryClient,
|
||||
serverId: normalizedServerId,
|
||||
results: closeItemsPayload.agents,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const closedKeys = new Set(tabsToClose.map((tab) => tab.key));
|
||||
setHoveredTabKey((current) => (current && closedKeys.has(current) ? null : current));
|
||||
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
|
||||
@@ -1466,10 +1449,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
client,
|
||||
closeTab,
|
||||
closeWorkspaceTabWithCleanup,
|
||||
normalizedServerId,
|
||||
persistenceKey,
|
||||
queryClient,
|
||||
terminalsQueryKey,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2045,6 +2025,12 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
}
|
||||
right={
|
||||
<View style={styles.headerRight}>
|
||||
{!isMobile ? (
|
||||
<WorkspaceOpenInEditorButton
|
||||
serverId={normalizedServerId}
|
||||
cwd={normalizedWorkspaceId}
|
||||
/>
|
||||
) : null}
|
||||
{!isMobile && isGitCheckout ? (
|
||||
<>
|
||||
<WorkspaceGitActions
|
||||
|
||||
@@ -39,10 +39,10 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
|
||||
"Copy resume command",
|
||||
"Copy agent id",
|
||||
"Reload agent",
|
||||
"Close to the left",
|
||||
"Close to the right",
|
||||
"Close other tabs",
|
||||
"Reload agent",
|
||||
"Close",
|
||||
]);
|
||||
});
|
||||
@@ -66,10 +66,10 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
|
||||
"Copy resume command",
|
||||
"Copy agent id",
|
||||
"Reload agent",
|
||||
"Close tabs above",
|
||||
"Close tabs below",
|
||||
"Close other tabs",
|
||||
"Reload agent",
|
||||
"Close",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -135,17 +135,6 @@ export function buildWorkspaceTabMenuEntries(
|
||||
void onCopyAgentId(agentId);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "reload-agent",
|
||||
label: "Reload agent",
|
||||
icon: "rotate-cw",
|
||||
tooltip: "Reload agent to update skills, MCPs or login status.",
|
||||
testID: `${menuTestIDBase}-reload-agent`,
|
||||
onSelect: () => {
|
||||
void onReloadAgent(agentId);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
kind: "separator",
|
||||
key: "copy-separator",
|
||||
@@ -185,6 +174,20 @@ export function buildWorkspaceTabMenuEntries(
|
||||
void onCloseOtherTabs(tab.tabId);
|
||||
},
|
||||
});
|
||||
if (tab.target.kind === "agent") {
|
||||
const { agentId } = tab.target;
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "reload-agent",
|
||||
label: "Reload agent",
|
||||
icon: "rotate-cw",
|
||||
tooltip: "Reload agent to update skills, MCPs or login status.",
|
||||
testID: `${menuTestIDBase}-reload-agent`,
|
||||
onSelect: () => {
|
||||
void onReloadAgent(agentId);
|
||||
},
|
||||
});
|
||||
}
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "close",
|
||||
|
||||
@@ -66,6 +66,8 @@ interface PanelState {
|
||||
// File explorer settings (shared between mobile/desktop)
|
||||
explorerTab: ExplorerTab;
|
||||
explorerTabByCheckout: Record<string, ExplorerTab>;
|
||||
expandedPathsByWorkspace: Record<string, string[]>;
|
||||
diffExpandedPathsByWorkspace: Record<string, string[]>;
|
||||
activeExplorerCheckout: ExplorerCheckoutContext | null;
|
||||
sidebarWidth: number;
|
||||
explorerWidth: number;
|
||||
@@ -85,6 +87,8 @@ interface PanelState {
|
||||
// File explorer settings actions
|
||||
setExplorerTab: (tab: ExplorerTab) => void;
|
||||
setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void;
|
||||
setExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void;
|
||||
setDiffExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void;
|
||||
activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void;
|
||||
setActiveExplorerCheckout: (checkout: ExplorerCheckoutContext | null) => void;
|
||||
setSidebarWidth: (width: number) => void;
|
||||
@@ -142,6 +146,8 @@ export const usePanelStore = create<PanelState>()(
|
||||
// File explorer defaults
|
||||
explorerTab: "changes",
|
||||
explorerTabByCheckout: {},
|
||||
expandedPathsByWorkspace: {},
|
||||
diffExpandedPathsByWorkspace: {},
|
||||
activeExplorerCheckout: null,
|
||||
sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
|
||||
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
|
||||
@@ -261,6 +267,17 @@ export const usePanelStore = create<PanelState>()(
|
||||
}
|
||||
return nextState;
|
||||
}),
|
||||
setExpandedPathsForWorkspace: (workspaceKey, paths) =>
|
||||
set((state) => ({
|
||||
expandedPathsByWorkspace: { ...state.expandedPathsByWorkspace, [workspaceKey]: paths },
|
||||
})),
|
||||
setDiffExpandedPathsForWorkspace: (workspaceKey, paths) =>
|
||||
set((state) => ({
|
||||
diffExpandedPathsByWorkspace: {
|
||||
...state.diffExpandedPathsByWorkspace,
|
||||
[workspaceKey]: paths,
|
||||
},
|
||||
})),
|
||||
activateExplorerTabForCheckout: (checkout) =>
|
||||
set((state) => ({
|
||||
activeExplorerCheckout: checkout,
|
||||
@@ -295,7 +312,7 @@ export const usePanelStore = create<PanelState>()(
|
||||
}),
|
||||
{
|
||||
name: "panel-state",
|
||||
version: 8,
|
||||
version: 10,
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
migrate: (persistedState, version) => {
|
||||
const state = persistedState as Partial<PanelState> & Record<string, unknown>;
|
||||
@@ -371,6 +388,22 @@ export const usePanelStore = create<PanelState>()(
|
||||
state.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
|
||||
}
|
||||
|
||||
if (
|
||||
version < 9 ||
|
||||
typeof state.expandedPathsByWorkspace !== "object" ||
|
||||
!state.expandedPathsByWorkspace
|
||||
) {
|
||||
state.expandedPathsByWorkspace = {};
|
||||
}
|
||||
|
||||
if (
|
||||
version < 10 ||
|
||||
typeof state.diffExpandedPathsByWorkspace !== "object" ||
|
||||
!state.diffExpandedPathsByWorkspace
|
||||
) {
|
||||
state.diffExpandedPathsByWorkspace = {};
|
||||
}
|
||||
|
||||
state.activeExplorerCheckout = null;
|
||||
|
||||
return state as PanelState;
|
||||
@@ -380,6 +413,8 @@ export const usePanelStore = create<PanelState>()(
|
||||
desktop: state.desktop,
|
||||
explorerTab: state.explorerTab,
|
||||
explorerTabByCheckout: state.explorerTabByCheckout,
|
||||
expandedPathsByWorkspace: state.expandedPathsByWorkspace,
|
||||
diffExpandedPathsByWorkspace: state.diffExpandedPathsByWorkspace,
|
||||
sidebarWidth: state.sidebarWidth,
|
||||
explorerWidth: state.explorerWidth,
|
||||
explorerSortOption: state.explorerSortOption,
|
||||
|
||||
53
packages/app/src/stores/session-store.test.ts
Normal file
53
packages/app/src/stores/session-store.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
mergeWorkspaceSnapshotWithExisting,
|
||||
type WorkspaceDescriptor,
|
||||
} from "./session-store";
|
||||
|
||||
function createWorkspace(
|
||||
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
||||
): WorkspaceDescriptor {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectId ?? "remote:github.com/getpaseo/paseo",
|
||||
projectDisplayName: input.projectDisplayName ?? "getpaseo/paseo",
|
||||
projectRootPath: input.projectRootPath ?? "/tmp/repo",
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "local_checkout",
|
||||
name: input.name ?? "main",
|
||||
status: input.status ?? "done",
|
||||
activityAt: input.activityAt ?? null,
|
||||
diffStat: input.diffStat ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("mergeWorkspaceSnapshotWithExisting", () => {
|
||||
it("preserves the last known diff stat when a snapshot only has baseline null data", () => {
|
||||
const existing = createWorkspace({
|
||||
id: "/tmp/repo",
|
||||
diffStat: { additions: 4, deletions: 2 },
|
||||
});
|
||||
const incoming = createWorkspace({
|
||||
id: "/tmp/repo",
|
||||
diffStat: null,
|
||||
});
|
||||
|
||||
expect(mergeWorkspaceSnapshotWithExisting({ incoming, existing })).toEqual({
|
||||
...incoming,
|
||||
diffStat: { additions: 4, deletions: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the incoming diff stat when the server provides a known value", () => {
|
||||
const existing = createWorkspace({
|
||||
id: "/tmp/repo",
|
||||
diffStat: { additions: 4, deletions: 2 },
|
||||
});
|
||||
const incoming = createWorkspace({
|
||||
id: "/tmp/repo",
|
||||
diffStat: { additions: 0, deletions: 0 },
|
||||
});
|
||||
|
||||
expect(mergeWorkspaceSnapshotWithExisting({ incoming, existing })).toEqual(incoming);
|
||||
});
|
||||
});
|
||||
@@ -142,6 +142,21 @@ export function normalizeWorkspaceDescriptor(
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeWorkspaceSnapshotWithExisting(input: {
|
||||
incoming: WorkspaceDescriptor;
|
||||
existing?: WorkspaceDescriptor | null;
|
||||
}): WorkspaceDescriptor {
|
||||
const { incoming, existing } = input;
|
||||
if (!existing || existing.id !== incoming.id) {
|
||||
return incoming;
|
||||
}
|
||||
|
||||
return {
|
||||
...incoming,
|
||||
diffStat: incoming.diffStat ?? existing.diffStat,
|
||||
};
|
||||
}
|
||||
|
||||
export type ExplorerEntryKind = "file" | "directory";
|
||||
export type ExplorerFileKind = "text" | "image" | "binary";
|
||||
export type ExplorerEncoding = "utf-8" | "base64" | "none";
|
||||
|
||||
@@ -111,6 +111,7 @@ const lightSemanticColors = {
|
||||
surface2: "#f4f4f5", // Elevated: badges, inputs, sheets (was zinc-200, now zinc-100)
|
||||
surface3: "#e4e4e7", // Highest elevation (was zinc-300, now zinc-200)
|
||||
surface4: "#d4d4d8", // Extra emphasis (was zinc-400, now zinc-300)
|
||||
surfaceDiffEmpty: "#f6f6f6", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2
|
||||
surfaceSidebar: "#f4f4f5", // Sidebar background (darker than main)
|
||||
surfaceWorkspace: "#ffffff", // Workspace main background
|
||||
|
||||
@@ -185,6 +186,7 @@ const darkSemanticColors = {
|
||||
surface2: "#272A29", // Elevated: badges, inputs, sheets
|
||||
surface3: "#434645", // Highest elevation
|
||||
surface4: "#595B5B", // Extra emphasis
|
||||
surfaceDiffEmpty: "#252827", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2
|
||||
surfaceSidebar: "#141716", // Sidebar background (darker than main)
|
||||
surfaceWorkspace: "#1E2120", // Workspace main background (surface1)
|
||||
|
||||
@@ -279,6 +281,10 @@ const commonTheme = {
|
||||
"4xl": 34,
|
||||
},
|
||||
|
||||
lineHeight: {
|
||||
diff: 22,
|
||||
},
|
||||
|
||||
iconSize: {
|
||||
xs: 12,
|
||||
sm: 14,
|
||||
|
||||
81
packages/app/src/utils/diff-layout.test.ts
Normal file
81
packages/app/src/utils/diff-layout.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSplitDiffRows } from "./diff-layout";
|
||||
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
|
||||
|
||||
function makeFile(lines: ParsedDiffFile["hunks"][number]["lines"]): ParsedDiffFile {
|
||||
return {
|
||||
path: "example.ts",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: lines.filter((line) => line.type === "add").length,
|
||||
deletions: lines.filter((line) => line.type === "remove").length,
|
||||
status: "ok",
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 10,
|
||||
oldCount: 4,
|
||||
newStart: 10,
|
||||
newCount: 5,
|
||||
lines,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildSplitDiffRows", () => {
|
||||
it("pairs replacement runs by index", () => {
|
||||
const rows = buildSplitDiffRows(
|
||||
makeFile([
|
||||
{ type: "header", content: "@@ -10,2 +10,2 @@" },
|
||||
{ type: "remove", content: "before one" },
|
||||
{ type: "remove", content: "before two" },
|
||||
{ type: "add", content: "after one" },
|
||||
{ type: "add", content: "after two" },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows[1]).toMatchObject({
|
||||
kind: "pair",
|
||||
left: { type: "remove", content: "before one", lineNumber: 10 },
|
||||
right: { type: "add", content: "after one", lineNumber: 10 },
|
||||
});
|
||||
expect(rows[2]).toMatchObject({
|
||||
kind: "pair",
|
||||
left: { type: "remove", content: "before two", lineNumber: 11 },
|
||||
right: { type: "add", content: "after two", lineNumber: 11 },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unmatched additions on the right side only", () => {
|
||||
const rows = buildSplitDiffRows(
|
||||
makeFile([
|
||||
{ type: "header", content: "@@ -10,1 +10,2 @@" },
|
||||
{ type: "remove", content: "before" },
|
||||
{ type: "add", content: "after one" },
|
||||
{ type: "add", content: "after two" },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(rows[2]).toMatchObject({
|
||||
kind: "pair",
|
||||
left: null,
|
||||
right: { type: "add", content: "after two", lineNumber: 11 },
|
||||
});
|
||||
});
|
||||
|
||||
it("duplicates context rows on both sides", () => {
|
||||
const rows = buildSplitDiffRows(
|
||||
makeFile([
|
||||
{ type: "header", content: "@@ -10,1 +10,1 @@" },
|
||||
{ type: "context", content: "same line" },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(rows[1]).toMatchObject({
|
||||
kind: "pair",
|
||||
left: { type: "context", content: "same line", lineNumber: 10 },
|
||||
right: { type: "context", content: "same line", lineNumber: 10 },
|
||||
});
|
||||
});
|
||||
});
|
||||
147
packages/app/src/utils/diff-layout.ts
Normal file
147
packages/app/src/utils/diff-layout.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { DiffLine, ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
|
||||
|
||||
export interface SplitDiffDisplayLine {
|
||||
type: DiffLine["type"];
|
||||
content: string;
|
||||
tokens?: DiffLine["tokens"];
|
||||
lineNumber: number | null;
|
||||
}
|
||||
|
||||
export type SplitDiffRow =
|
||||
| {
|
||||
kind: "header";
|
||||
content: string;
|
||||
}
|
||||
| {
|
||||
kind: "pair";
|
||||
left: SplitDiffDisplayLine | null;
|
||||
right: SplitDiffDisplayLine | null;
|
||||
};
|
||||
|
||||
function toDisplayLine(input: {
|
||||
line: DiffLine;
|
||||
oldLineNumber: number | null;
|
||||
newLineNumber: number | null;
|
||||
side: "left" | "right";
|
||||
}): SplitDiffDisplayLine | null {
|
||||
const { line, oldLineNumber, newLineNumber, side } = input;
|
||||
if (line.type === "header") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (line.type === "remove") {
|
||||
if (side !== "left") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "remove",
|
||||
content: line.content,
|
||||
tokens: line.tokens,
|
||||
lineNumber: oldLineNumber,
|
||||
};
|
||||
}
|
||||
|
||||
if (line.type === "add") {
|
||||
if (side !== "right") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "add",
|
||||
content: line.content,
|
||||
tokens: line.tokens,
|
||||
lineNumber: newLineNumber,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "context",
|
||||
content: line.content,
|
||||
tokens: line.tokens,
|
||||
lineNumber: side === "left" ? oldLineNumber : newLineNumber,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] {
|
||||
const rows: SplitDiffRow[] = [];
|
||||
|
||||
for (const hunk of file.hunks) {
|
||||
let oldLineNo = hunk.oldStart;
|
||||
let newLineNo = hunk.newStart;
|
||||
rows.push({
|
||||
kind: "header",
|
||||
content: hunk.lines[0]?.type === "header" ? hunk.lines[0].content : "@@",
|
||||
});
|
||||
|
||||
let pendingRemovals: Array<{ line: DiffLine; oldLineNumber: number }> = [];
|
||||
let pendingAdditions: Array<{ line: DiffLine; newLineNumber: number }> = [];
|
||||
|
||||
const flushPendingRows = () => {
|
||||
const pairCount = Math.max(pendingRemovals.length, pendingAdditions.length);
|
||||
for (let index = 0; index < pairCount; index += 1) {
|
||||
const removal = pendingRemovals[index] ?? null;
|
||||
const addition = pendingAdditions[index] ?? null;
|
||||
rows.push({
|
||||
kind: "pair",
|
||||
left: removal
|
||||
? toDisplayLine({
|
||||
line: removal.line,
|
||||
oldLineNumber: removal.oldLineNumber,
|
||||
newLineNumber: null,
|
||||
side: "left",
|
||||
})
|
||||
: null,
|
||||
right: addition
|
||||
? toDisplayLine({
|
||||
line: addition.line,
|
||||
oldLineNumber: null,
|
||||
newLineNumber: addition.newLineNumber,
|
||||
side: "right",
|
||||
})
|
||||
: null,
|
||||
});
|
||||
}
|
||||
pendingRemovals = [];
|
||||
pendingAdditions = [];
|
||||
};
|
||||
|
||||
for (const line of hunk.lines.slice(1)) {
|
||||
if (line.type === "remove") {
|
||||
pendingRemovals.push({ line, oldLineNumber: oldLineNo });
|
||||
oldLineNo += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.type === "add") {
|
||||
pendingAdditions.push({ line, newLineNumber: newLineNo });
|
||||
newLineNo += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
flushPendingRows();
|
||||
|
||||
if (line.type === "context") {
|
||||
rows.push({
|
||||
kind: "pair",
|
||||
left: toDisplayLine({
|
||||
line,
|
||||
oldLineNumber: oldLineNo,
|
||||
newLineNumber: newLineNo,
|
||||
side: "left",
|
||||
}),
|
||||
right: toDisplayLine({
|
||||
line,
|
||||
oldLineNumber: oldLineNo,
|
||||
newLineNumber: newLineNo,
|
||||
side: "right",
|
||||
}),
|
||||
});
|
||||
oldLineNo += 1;
|
||||
newLineNo += 1;
|
||||
}
|
||||
}
|
||||
|
||||
flushPendingRows();
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
@@ -268,99 +268,99 @@ export function parseHostWorkspaceRouteFromPathname(
|
||||
return { serverId, workspaceId };
|
||||
}
|
||||
|
||||
export function buildHostWorkspaceRoute(serverId: string, workspaceId: string): string {
|
||||
export function buildHostWorkspaceRoute(serverId: string, workspaceId: string) {
|
||||
const normalizedServerId = trimNonEmpty(serverId);
|
||||
const normalizedWorkspaceId = trimNonEmpty(workspaceId);
|
||||
if (!normalizedServerId || !normalizedWorkspaceId) {
|
||||
return "/";
|
||||
return "/" as const;
|
||||
}
|
||||
const encodedWorkspaceId = encodeWorkspaceIdForPathSegment(normalizedWorkspaceId);
|
||||
if (!encodedWorkspaceId) {
|
||||
return "/";
|
||||
return "/" as const;
|
||||
}
|
||||
return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(encodedWorkspaceId)}`;
|
||||
return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(encodedWorkspaceId)}` as const;
|
||||
}
|
||||
|
||||
export function buildHostAgentDetailRoute(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
workspaceId?: string,
|
||||
): string {
|
||||
) {
|
||||
const normalizedWorkspaceId = trimNonEmpty(workspaceId);
|
||||
if (normalizedWorkspaceId) {
|
||||
const normalizedAgentId = trimNonEmpty(agentId);
|
||||
if (!normalizedAgentId) {
|
||||
return "/";
|
||||
return "/" as const;
|
||||
}
|
||||
const base = buildHostWorkspaceRoute(serverId, normalizedWorkspaceId);
|
||||
if (base === "/") {
|
||||
return "/";
|
||||
return "/" as const;
|
||||
}
|
||||
return `${base}?open=${encodeURIComponent(`agent:${normalizedAgentId}`)}`;
|
||||
return `${base}?open=${encodeURIComponent(`agent:${normalizedAgentId}`)}` as const;
|
||||
}
|
||||
const normalizedServerId = trimNonEmpty(serverId);
|
||||
const normalizedAgentId = trimNonEmpty(agentId);
|
||||
if (!normalizedServerId || !normalizedAgentId) {
|
||||
return "/";
|
||||
return "/" as const;
|
||||
}
|
||||
return `${buildHostRootRoute(normalizedServerId)}/agent/${encodeSegment(normalizedAgentId)}`;
|
||||
return `${buildHostRootRoute(normalizedServerId)}/agent/${encodeSegment(normalizedAgentId)}` as const;
|
||||
}
|
||||
|
||||
export function buildHostRootRoute(serverId: string): string {
|
||||
export function buildHostRootRoute(serverId: string) {
|
||||
const normalized = trimNonEmpty(serverId);
|
||||
if (!normalized) {
|
||||
return "/";
|
||||
return "/" as const;
|
||||
}
|
||||
return `/h/${encodeSegment(normalized)}`;
|
||||
return `/h/${encodeSegment(normalized)}` as const;
|
||||
}
|
||||
|
||||
export function buildHostSessionsRoute(serverId: string): string {
|
||||
export function buildHostSessionsRoute(serverId: string) {
|
||||
const base = buildHostRootRoute(serverId);
|
||||
if (base === "/") {
|
||||
return "/";
|
||||
return "/" as const;
|
||||
}
|
||||
return `${base}/sessions`;
|
||||
return `${base}/sessions` as const;
|
||||
}
|
||||
|
||||
export function buildHostOpenProjectRoute(serverId: string): string {
|
||||
export function buildHostOpenProjectRoute(serverId: string) {
|
||||
const base = buildHostRootRoute(serverId);
|
||||
if (base === "/") {
|
||||
return "/";
|
||||
return "/" as const;
|
||||
}
|
||||
return `${base}/open-project`;
|
||||
return `${base}/open-project` as const;
|
||||
}
|
||||
|
||||
export function buildHostSettingsRoute(serverId: string): string {
|
||||
export function buildHostSettingsRoute(serverId: string) {
|
||||
const base = buildHostRootRoute(serverId);
|
||||
if (base === "/") {
|
||||
return "/";
|
||||
return "/" as const;
|
||||
}
|
||||
return `${base}/settings`;
|
||||
return `${base}/settings` as const;
|
||||
}
|
||||
|
||||
export function mapPathnameToServer(pathname: string, nextServerId: string): string {
|
||||
export function mapPathnameToServer(pathname: string, nextServerId: string) {
|
||||
const normalized = trimNonEmpty(nextServerId);
|
||||
if (!normalized) {
|
||||
return "/";
|
||||
return "/" as const;
|
||||
}
|
||||
|
||||
const suffix = pathname.replace(/^\/h\/[^/]+\/?/, "");
|
||||
const base = buildHostRootRoute(normalized);
|
||||
if (suffix.startsWith("settings")) {
|
||||
return `${base}/settings`;
|
||||
return `${base}/settings` as const;
|
||||
}
|
||||
if (suffix.startsWith("sessions")) {
|
||||
return `${base}/sessions`;
|
||||
return `${base}/sessions` as const;
|
||||
}
|
||||
if (suffix.startsWith("open-project")) {
|
||||
return `${base}/open-project`;
|
||||
return `${base}/open-project` as const;
|
||||
}
|
||||
const workspaceRoute = parseHostWorkspaceRouteFromPathname(pathname);
|
||||
if (workspaceRoute) {
|
||||
return buildHostWorkspaceRoute(normalized, workspaceRoute.workspaceId);
|
||||
}
|
||||
if (suffix.startsWith("agent/")) {
|
||||
return `${base}/${suffix}`;
|
||||
return `${base}/${suffix}` as const;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function resolveNewAgentWorkingDir(
|
||||
return inferMainRepoRootFromPaseoWorktreePath(cwd) ?? cwd;
|
||||
}
|
||||
|
||||
export function buildNewAgentRoute(serverId: string, workingDir?: string | null): string {
|
||||
export function buildNewAgentRoute(serverId: string, workingDir?: string | null) {
|
||||
const trimmedWorkingDir = workingDir?.trim();
|
||||
return buildHostWorkspaceRoute(serverId, trimmedWorkingDir || ".");
|
||||
}
|
||||
|
||||
@@ -27,17 +27,17 @@ export function resolveNotificationTarget(data: NotificationData): {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNotificationRoute(data: NotificationData): string {
|
||||
export function buildNotificationRoute(data: NotificationData) {
|
||||
const { serverId, agentId, workspaceId } = resolveNotificationTarget(data);
|
||||
if (serverId && agentId) {
|
||||
if (workspaceId) {
|
||||
const base = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||
return `${base}?open=${encodeURIComponent(`agent:${agentId}`)}`;
|
||||
return `${base}?open=${encodeURIComponent(`agent:${agentId}`)}` as const;
|
||||
}
|
||||
return buildHostAgentDetailRoute(serverId, agentId);
|
||||
}
|
||||
if (serverId) {
|
||||
return buildHostRootRoute(serverId);
|
||||
}
|
||||
return "/";
|
||||
return "/" as const;
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export function buildWorkspaceArchiveRedirectRoute(input: {
|
||||
serverId: string;
|
||||
archivedWorkspaceId: string;
|
||||
workspaces: Iterable<WorkspaceDescriptor>;
|
||||
}): string {
|
||||
}) {
|
||||
const redirectWorkspaceId = resolveWorkspaceArchiveRedirectWorkspaceId({
|
||||
archivedWorkspaceId: input.archivedWorkspaceId,
|
||||
workspaces: input.workspaces,
|
||||
|
||||
@@ -20,7 +20,7 @@ function getPreparedTarget(target: WorkspaceTabTarget): WorkspaceTabTarget {
|
||||
return { kind: "draft", draftId: generateDraftId() };
|
||||
}
|
||||
|
||||
export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput): string {
|
||||
export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput) {
|
||||
const target = getPreparedTarget(input.target);
|
||||
const key =
|
||||
buildWorkspaceTabPersistenceKey({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.51-rc.1",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -24,8 +24,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.48",
|
||||
"@getpaseo/server": "0.1.48",
|
||||
"@getpaseo/relay": "0.1.51-rc.1",
|
||||
"@getpaseo/server": "0.1.51-rc.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { Command } from "commander";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { promisify } from "node:util";
|
||||
import {
|
||||
getOrCreateServerId,
|
||||
findExecutable,
|
||||
quoteWindowsCommand,
|
||||
applyProviderEnv,
|
||||
} from "@getpaseo/server";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
import { tryConnectToDaemon } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
|
||||
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
|
||||
@@ -171,31 +173,33 @@ const PROVIDER_BINARIES: { label: string; binary: string }[] = [
|
||||
{ label: "OpenCode", binary: "opencode" },
|
||||
];
|
||||
|
||||
function checkProviderBinary(binary: string): { path: string | null; version: string | null } {
|
||||
const binaryPath = findExecutable(binary);
|
||||
async function checkProviderBinary(binary: string): Promise<{ path: string | null; version: string | null }> {
|
||||
const binaryPath = await findExecutable(binary);
|
||||
if (!binaryPath) {
|
||||
return { path: null, version: null };
|
||||
}
|
||||
const env = applyProviderEnv(process.env);
|
||||
try {
|
||||
const output = execFileSync(quoteWindowsCommand(binaryPath), ["--version"], {
|
||||
const { stdout } = await execFileAsync(binaryPath, ["--version"], {
|
||||
encoding: "utf8",
|
||||
timeout: 5000,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
shell: process.platform === "win32",
|
||||
}).trim();
|
||||
return { path: binaryPath, version: output || null };
|
||||
windowsHide: true,
|
||||
});
|
||||
return { path: binaryPath, version: stdout.trim() || null };
|
||||
} catch {
|
||||
return { path: binaryPath, version: null };
|
||||
}
|
||||
}
|
||||
|
||||
function checkProviderBinaries(): ProviderBinaryStatus[] {
|
||||
return PROVIDER_BINARIES.map(({ label, binary }) => {
|
||||
const result = checkProviderBinary(binary);
|
||||
return { label, ...result };
|
||||
});
|
||||
async function checkProviderBinaries(): Promise<ProviderBinaryStatus[]> {
|
||||
const results = await Promise.all(
|
||||
PROVIDER_BINARIES.map(async ({ label, binary }) => {
|
||||
const result = await checkProviderBinary(binary);
|
||||
return { label, ...result };
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
function resolveOwnerLabel(uid: number | undefined, hostname: string | undefined): string | null {
|
||||
@@ -295,7 +299,7 @@ export async function runStatusCommand(
|
||||
note = appendNote(note, `serverId unavailable: ${shortenMessage(normalizeError(error))}`);
|
||||
}
|
||||
|
||||
const providers = checkProviderBinaries();
|
||||
const providers = await checkProviderBinaries();
|
||||
|
||||
const daemonStatus: DaemonStatus = {
|
||||
serverId,
|
||||
|
||||
72
packages/cli/tests/e2e/opencode-invalid-model.test.ts
Normal file
72
packages/cli/tests/e2e/opencode-invalid-model.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from "node:assert";
|
||||
import { createE2ETestContext, type TestDaemonContext } from "../helpers/test-daemon.ts";
|
||||
|
||||
interface E2EContext extends TestDaemonContext {
|
||||
paseo: (
|
||||
args: string[],
|
||||
opts?: { timeout?: number; cwd?: string },
|
||||
) => Promise<{
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
let ctx: E2EContext;
|
||||
|
||||
async function setup(): Promise<void> {
|
||||
ctx = await createE2ETestContext({ timeout: 45_000 });
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
if (ctx) {
|
||||
await ctx.stop();
|
||||
}
|
||||
}
|
||||
|
||||
async function test_invalid_opencode_model_does_not_report_completed_while_still_running() {
|
||||
const result = await ctx.paseo(
|
||||
["run", "--provider", "opencode/adklasldkdas", "hello"],
|
||||
{ timeout: 45_000 },
|
||||
);
|
||||
|
||||
const output = `${result.stdout}\n${result.stderr}`;
|
||||
const agentId = output.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)?.[0];
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
assert(
|
||||
output.toLowerCase().includes("error") || output.toLowerCase().includes("failed"),
|
||||
`expected invalid model failure output\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
assert(agentId, `expected run output to include an agent id\nstdout:\n${result.stdout}`);
|
||||
|
||||
const inspect = await ctx.paseo(["inspect", agentId], { timeout: 15_000 });
|
||||
assert.strictEqual(inspect.exitCode, 0, `inspect failed\nstdout:\n${inspect.stdout}\nstderr:\n${inspect.stderr}`);
|
||||
|
||||
const runReportedCompleted = result.stdout.includes("completed");
|
||||
const inspectStillRunning = inspect.stdout.includes("Status running");
|
||||
|
||||
assert(
|
||||
!(runReportedCompleted && inspectStillRunning),
|
||||
`run reported completed while inspect still showed running\nrun stdout:\n${result.stdout}\ninspect stdout:\n${inspect.stdout}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
await setup();
|
||||
await test_invalid_opencode_model_does_not_report_completed_while_still_running();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.51-rc.1",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"main": "dist/main.js",
|
||||
@@ -12,8 +12,8 @@
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.48",
|
||||
"@getpaseo/server": "0.1.48",
|
||||
"@getpaseo/cli": "0.1.51-rc.1",
|
||||
"@getpaseo/server": "0.1.51-rc.1",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { type ChildProcess } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { app, ipcMain } from "electron";
|
||||
import log from "electron-log/main";
|
||||
import { resolvePaseoHome } from "@getpaseo/server";
|
||||
import { resolvePaseoHome, spawnProcess } from "@getpaseo/server";
|
||||
import {
|
||||
copyAttachmentFileToManagedStorage,
|
||||
deleteManagedAttachmentFile,
|
||||
@@ -266,15 +266,11 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||
args: invocation.args,
|
||||
});
|
||||
|
||||
const child: ChildProcess = spawn(
|
||||
invocation.command,
|
||||
invocation.args,
|
||||
{
|
||||
detached: true,
|
||||
env: { ...invocation.env, PASEO_DESKTOP_MANAGED: "1" },
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
},
|
||||
);
|
||||
const child: ChildProcess = spawnProcess(invocation.command, invocation.args, {
|
||||
detached: true,
|
||||
env: { ...invocation.env, PASEO_DESKTOP_MANAGED: "1" },
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
});
|
||||
|
||||
logDesktopDaemonLifecycle("detached spawn returned", {
|
||||
childPid: child.pid ?? null,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { spawnProcess } from "@getpaseo/server";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { app } from "electron";
|
||||
@@ -234,6 +235,7 @@ export function runCliPassthroughCommand(args: string[]): number {
|
||||
const result = spawnSync(invocation.command, invocation.args, {
|
||||
env: invocation.env,
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
@@ -252,7 +254,7 @@ function spawnAsync(
|
||||
options: { env: NodeJS.ProcessEnv },
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
const child = spawnProcess(command, args, {
|
||||
env: options.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
@@ -260,10 +262,10 @@ function spawnAsync(
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout.on("data", (data: Buffer) => {
|
||||
child.stdout!.on("data", (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
child.stderr.on("data", (data: Buffer) => {
|
||||
child.stderr!.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
|
||||
@@ -200,7 +200,20 @@ export async function installCli(): Promise<InstallStatus> {
|
||||
if (await pathOrSymlinkExists(targetPath)) {
|
||||
await fs.unlink(targetPath);
|
||||
}
|
||||
await fs.copyFile(installSourcePath, targetPath);
|
||||
// Generate a thin .cmd trampoline that delegates to the bundled shim.
|
||||
// Only the app install path is baked in — internal details (asar layout,
|
||||
// entrypoint scripts) live in the bundled shim and update with the app.
|
||||
const cmdContent = [
|
||||
"@echo off",
|
||||
`set "BUNDLED_CLI=${shimPath}"`,
|
||||
`if not exist "%BUNDLED_CLI%" (`,
|
||||
` echo Paseo CLI not found at %BUNDLED_CLI% — is Paseo installed? 1>&2`,
|
||||
` exit /b 1`,
|
||||
`)`,
|
||||
`call "%BUNDLED_CLI%" %*`,
|
||||
`exit /b %errorlevel%`,
|
||||
].join("\r\n");
|
||||
await fs.writeFile(targetPath, cmdContent, "utf-8");
|
||||
} else {
|
||||
if (await pathOrSymlinkExists(targetPath)) {
|
||||
await fs.unlink(targetPath);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import log from "electron-log/main";
|
||||
log.transports.console.level = "info";
|
||||
log.initialize({ spyRendererConsole: true });
|
||||
|
||||
import { inheritLoginShellEnv } from "./login-shell-env.js";
|
||||
@@ -37,6 +38,18 @@ const APP_SCHEME = "paseo";
|
||||
const OPEN_PROJECT_EVENT = "paseo:event:open-project";
|
||||
app.setName("Paseo");
|
||||
|
||||
// Allow users to pass Chromium flags via PASEO_ELECTRON_FLAGS for debugging
|
||||
// rendering issues (e.g. "--disable-gpu --ozone-platform=x11").
|
||||
// Must run before app.whenReady().
|
||||
const electronFlags = process.env.PASEO_ELECTRON_FLAGS?.trim();
|
||||
if (electronFlags) {
|
||||
for (const token of electronFlags.split(/\s+/)) {
|
||||
const [key, ...rest] = token.replace(/^--/, "").split("=");
|
||||
app.commandLine.appendSwitch(key, rest.join("=") || undefined);
|
||||
}
|
||||
log.info("[electron-flags]", electronFlags);
|
||||
}
|
||||
|
||||
let pendingOpenProjectPath = parseOpenProjectPathFromArgv({
|
||||
argv: process.argv,
|
||||
isDefaultApp: process.defaultApp,
|
||||
@@ -134,6 +147,7 @@ async function createMainWindow(): Promise<void> {
|
||||
setupWindowResizeEvents(mainWindow);
|
||||
setupDefaultContextMenu(mainWindow);
|
||||
setupDragDropPrevention(mainWindow);
|
||||
|
||||
mainWindow.once("ready-to-show", () => {
|
||||
mainWindow.show();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.51-rc.1",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.51-rc.1",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.51-rc.1",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.51-rc.1",
|
||||
"description": "Paseo backend server",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -64,8 +64,8 @@
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.48",
|
||||
"@getpaseo/relay": "0.1.48",
|
||||
"@getpaseo/highlight": "0.1.51-rc.1",
|
||||
"@getpaseo/relay": "0.1.51-rc.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
|
||||
@@ -34,6 +34,8 @@ import type {
|
||||
PaseoWorktreeListResponse,
|
||||
PaseoWorktreeArchiveResponse,
|
||||
ProjectIconResponse,
|
||||
ListAvailableEditorsResponseMessage,
|
||||
OpenInEditorResponseMessage,
|
||||
OpenProjectResponseMessage,
|
||||
ArchiveWorkspaceResponseMessage,
|
||||
ListCommandsResponse,
|
||||
@@ -54,6 +56,7 @@ import type {
|
||||
TerminalInput,
|
||||
SessionInboundMessage,
|
||||
SessionOutboundMessage,
|
||||
EditorTargetId,
|
||||
} from "../shared/messages.js";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
@@ -472,8 +475,11 @@ export type InspectScheduleOptions = {
|
||||
id: string;
|
||||
requestId?: string;
|
||||
};
|
||||
type ListAvailableEditorsPayload = ListAvailableEditorsResponseMessage["payload"];
|
||||
type OpenInEditorPayload = OpenInEditorResponseMessage["payload"];
|
||||
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
|
||||
type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"];
|
||||
export type EditorTargetDescriptor = ListAvailableEditorsPayload["editors"][number];
|
||||
|
||||
export type FetchAgentResult = {
|
||||
agent: AgentSnapshotPayload;
|
||||
@@ -611,7 +617,10 @@ export class DaemonClient {
|
||||
private connectionState: ConnectionState = { status: "idle" };
|
||||
private checkoutDiffSubscriptions = new Map<
|
||||
string,
|
||||
{ cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } }
|
||||
{
|
||||
cwd: string;
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
|
||||
}
|
||||
>();
|
||||
private terminalDirectorySubscriptions = new Set<string>();
|
||||
private terminalSlots = new Map<string, number>();
|
||||
@@ -1315,6 +1324,34 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listAvailableEditors(requestId?: string): Promise<ListAvailableEditorsPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "list_available_editors_request",
|
||||
},
|
||||
responseType: "list_available_editors_response",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async openInEditor(
|
||||
path: string,
|
||||
editorId: EditorTargetId,
|
||||
requestId?: string,
|
||||
): Promise<OpenInEditorPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "open_in_editor_request",
|
||||
path,
|
||||
editorId,
|
||||
},
|
||||
responseType: "open_in_editor_response",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async archiveWorkspace(
|
||||
workspaceId: string,
|
||||
requestId?: string,
|
||||
@@ -2146,20 +2183,22 @@ export class DaemonClient {
|
||||
private normalizeCheckoutDiffCompare(compare: {
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
}): { mode: "uncommitted" | "base"; baseRef?: string } {
|
||||
ignoreWhitespace?: boolean;
|
||||
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
|
||||
const ignoreWhitespace = compare.ignoreWhitespace === true;
|
||||
if (compare.mode === "uncommitted") {
|
||||
return { mode: "uncommitted" };
|
||||
return { mode: "uncommitted", ignoreWhitespace };
|
||||
}
|
||||
const trimmedBaseRef = compare.baseRef?.trim();
|
||||
if (!trimmedBaseRef) {
|
||||
return { mode: "base" };
|
||||
return { mode: "base", ignoreWhitespace };
|
||||
}
|
||||
return { mode: "base", baseRef: trimmedBaseRef };
|
||||
return { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace };
|
||||
}
|
||||
|
||||
async getCheckoutDiff(
|
||||
cwd: string,
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string },
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
|
||||
requestId?: string,
|
||||
): Promise<CheckoutDiffPayload> {
|
||||
const oneShotSubscriptionId = `oneshot-checkout-diff:${crypto.randomUUID()}`;
|
||||
@@ -2185,7 +2224,7 @@ export class DaemonClient {
|
||||
|
||||
async subscribeCheckoutDiff(
|
||||
cwd: string,
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string },
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
|
||||
options?: { subscriptionId?: string; requestId?: string },
|
||||
): Promise<SubscribeCheckoutDiffPayload> {
|
||||
const subscriptionId = options?.subscriptionId ?? crypto.randomUUID();
|
||||
|
||||
@@ -20,11 +20,11 @@ import {
|
||||
query,
|
||||
type SDKUserMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { isCommandAvailable } from "../utils/executable.js";
|
||||
import { isCommandAvailableSync } from "../utils/executable.js";
|
||||
|
||||
const hasClaudeCredentials =
|
||||
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
|
||||
const canRunClaudeIntegration = isCommandAvailable("claude") && hasClaudeCredentials;
|
||||
const canRunClaudeIntegration = isCommandAvailableSync("claude") && hasClaudeCredentials;
|
||||
|
||||
// Pattern from claude-agent.ts listModels():
|
||||
// Use an empty async generator when you just need control methods
|
||||
|
||||
@@ -171,6 +171,9 @@ function sanitizePermissionRequest(
|
||||
if (sanitized.suggestions === undefined) {
|
||||
delete sanitized.suggestions;
|
||||
}
|
||||
if (sanitized.actions === undefined) {
|
||||
delete sanitized.actions;
|
||||
}
|
||||
if (sanitized.metadata === undefined) {
|
||||
delete sanitized.metadata;
|
||||
}
|
||||
|
||||
@@ -2890,6 +2890,213 @@ describe("AgentManager", () => {
|
||||
expect(updatedAgent?.currentModeId).toBe("acceptEdits");
|
||||
});
|
||||
|
||||
test("respondToPermission refreshes features and runtime info after provider-managed plan approval", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class RefreshingPermissionSession extends TestAgentSession {
|
||||
private featureState: AgentFeature[] = [
|
||||
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
||||
createFeature({ id: "plan_mode", label: "Plan", value: true }),
|
||||
];
|
||||
private modeId = "auto";
|
||||
private pending = [
|
||||
{
|
||||
id: "perm-plan-1",
|
||||
provider: "codex" as const,
|
||||
name: "CodexPlanApproval",
|
||||
kind: "plan" as const,
|
||||
input: { plan: "- Implement the feature" },
|
||||
},
|
||||
];
|
||||
|
||||
get features(): AgentFeature[] {
|
||||
return this.featureState;
|
||||
}
|
||||
|
||||
override async getRuntimeInfo() {
|
||||
return {
|
||||
provider: this.provider,
|
||||
sessionId: this.id,
|
||||
model: "gpt-5.4",
|
||||
modeId: this.modeId,
|
||||
extra: { collaborationMode: this.features[1]?.value ? "Plan" : "Code" },
|
||||
};
|
||||
}
|
||||
|
||||
override async getCurrentMode() {
|
||||
return this.modeId;
|
||||
}
|
||||
|
||||
override getPendingPermissions() {
|
||||
return this.pending;
|
||||
}
|
||||
|
||||
override async respondToPermission(): Promise<void> {
|
||||
this.modeId = "auto";
|
||||
this.pending = [];
|
||||
this.featureState = [
|
||||
createFeature({ id: "fast_mode", label: "Fast", value: false }),
|
||||
createFeature({ id: "plan_mode", label: "Plan", value: false }),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class RefreshingPermissionClient extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new RefreshingPermissionSession(config);
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new RefreshingPermissionClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000133",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
const agent = manager.getAgent(snapshot.id);
|
||||
if (!agent) {
|
||||
throw new Error("Expected managed agent");
|
||||
}
|
||||
agent.pendingPermissions.set("perm-plan-1", {
|
||||
id: "perm-plan-1",
|
||||
provider: "codex",
|
||||
name: "CodexPlanApproval",
|
||||
kind: "plan",
|
||||
input: { plan: "- Implement the feature" },
|
||||
});
|
||||
|
||||
await manager.respondToPermission(snapshot.id, "perm-plan-1", {
|
||||
behavior: "allow",
|
||||
selectedActionId: "implement",
|
||||
});
|
||||
|
||||
const updated = manager.getAgent(snapshot.id);
|
||||
expect(updated?.pendingPermissions.size).toBe(0);
|
||||
expect(updated?.features).toEqual([
|
||||
createFeature({ id: "fast_mode", label: "Fast", value: false }),
|
||||
createFeature({ id: "plan_mode", label: "Plan", value: false }),
|
||||
]);
|
||||
expect(updated?.runtimeInfo).toMatchObject({
|
||||
model: "gpt-5.4",
|
||||
extra: { collaborationMode: "Code" },
|
||||
});
|
||||
|
||||
const persisted = await storage.get(snapshot.id);
|
||||
expect(persisted?.features).toEqual([
|
||||
createFeature({ id: "fast_mode", label: "Fast", value: false }),
|
||||
createFeature({ id: "plan_mode", label: "Plan", value: false }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("respondToPermission emits refreshed state before permission_resolved", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-permission-order-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class OrderedPermissionSession extends TestAgentSession {
|
||||
private featureState: AgentFeature[] = [
|
||||
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
||||
];
|
||||
private modeId = "plan";
|
||||
private pending = [
|
||||
{
|
||||
id: "perm-order-1",
|
||||
provider: "codex" as const,
|
||||
name: "ExitPlanMode",
|
||||
kind: "plan" as const,
|
||||
input: { plan: "- Do the work" },
|
||||
},
|
||||
];
|
||||
|
||||
get features(): AgentFeature[] {
|
||||
return this.featureState;
|
||||
}
|
||||
|
||||
override async getRuntimeInfo() {
|
||||
return {
|
||||
provider: this.provider,
|
||||
sessionId: this.id,
|
||||
model: "gpt-5.4",
|
||||
modeId: this.modeId,
|
||||
};
|
||||
}
|
||||
|
||||
override async getCurrentMode() {
|
||||
return this.modeId;
|
||||
}
|
||||
|
||||
override getPendingPermissions() {
|
||||
return this.pending;
|
||||
}
|
||||
|
||||
override async respondToPermission(): Promise<void> {
|
||||
this.pushEvent({
|
||||
type: "permission_resolved",
|
||||
provider: this.provider,
|
||||
requestId: "perm-order-1",
|
||||
resolution: { behavior: "allow" },
|
||||
});
|
||||
this.modeId = "acceptEdits";
|
||||
this.featureState = [createFeature({ id: "fast_mode", label: "Fast", value: false })];
|
||||
this.pending = [];
|
||||
}
|
||||
}
|
||||
|
||||
class OrderedPermissionClient extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new OrderedPermissionSession(config);
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new OrderedPermissionClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000134",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
const seen: string[] = [];
|
||||
manager.subscribe((event) => {
|
||||
if ("agentId" in event && event.agentId !== snapshot.id) {
|
||||
return;
|
||||
}
|
||||
if (event.type === "agent_state" && event.agent.id === snapshot.id) {
|
||||
const fastMode = event.agent.features?.find((feature) => feature.id === "fast_mode");
|
||||
seen.push(`state:${event.agent.currentModeId}:${String(fastMode?.type === "toggle" ? fastMode.value : null)}`);
|
||||
return;
|
||||
}
|
||||
if (event.type === "agent_stream" && event.event.type === "permission_resolved") {
|
||||
seen.push(`resolved:${event.event.requestId}`);
|
||||
}
|
||||
});
|
||||
|
||||
await manager.respondToPermission(snapshot.id, "perm-order-1", {
|
||||
behavior: "allow",
|
||||
});
|
||||
|
||||
const refreshedStateIndex = seen.findIndex((entry) => entry === "state:acceptEdits:false");
|
||||
const resolvedIndex = seen.findIndex((entry) => entry === "resolved:perm-order-1");
|
||||
expect(refreshedStateIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(resolvedIndex).toBeGreaterThan(refreshedStateIndex);
|
||||
});
|
||||
|
||||
test("close during in-flight stream does not clear persistence sessionId", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
AgentMode,
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentPermissionResult,
|
||||
AgentPersistenceHandle,
|
||||
AgentPromptInput,
|
||||
AgentProvider,
|
||||
@@ -172,6 +173,11 @@ type ManagedAgentBase = {
|
||||
features?: AgentFeature[];
|
||||
currentModeId: string | null;
|
||||
pendingPermissions: Map<string, AgentPermissionRequest>;
|
||||
bufferedPermissionResolutions: Map<
|
||||
string,
|
||||
Extract<AgentStreamEvent, { type: "permission_resolved" }>
|
||||
>;
|
||||
inFlightPermissionResponses: Set<string>;
|
||||
pendingReplacement: boolean;
|
||||
timeline: AgentTimelineItem[];
|
||||
timelineRows: AgentTimelineRow[];
|
||||
@@ -1151,11 +1157,10 @@ export class AgentManager {
|
||||
agent.lastError = undefined;
|
||||
|
||||
const self = this;
|
||||
const pendingRun = self.createPendingForegroundRun();
|
||||
self.pendingForegroundRuns.set(agentId, pendingRun);
|
||||
|
||||
const streamForwarder = (async function* streamForwarder() {
|
||||
const pendingRun = self.createPendingForegroundRun();
|
||||
self.pendingForegroundRuns.set(agentId, pendingRun);
|
||||
|
||||
let turnId: string;
|
||||
let waiter: ForegroundTurnWaiter | null = null;
|
||||
try {
|
||||
@@ -1435,20 +1440,35 @@ export class AgentManager {
|
||||
agentId: string,
|
||||
requestId: string,
|
||||
response: AgentPermissionResponse,
|
||||
): Promise<void> {
|
||||
): Promise<AgentPermissionResult | void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
await agent.session.respondToPermission(requestId, response);
|
||||
agent.pendingPermissions.delete(requestId);
|
||||
agent.inFlightPermissionResponses.add(requestId);
|
||||
|
||||
// Update currentModeId - the session may have changed mode internally
|
||||
// (e.g., plan approval changes mode from "plan" to "acceptEdits")
|
||||
try {
|
||||
agent.currentModeId = await agent.session.getCurrentMode();
|
||||
} catch {
|
||||
// Ignore errors from getCurrentMode - mode tracking is best effort
|
||||
}
|
||||
const result = await agent.session.respondToPermission(requestId, response);
|
||||
agent.pendingPermissions.delete(requestId);
|
||||
|
||||
this.emitState(agent);
|
||||
try {
|
||||
await this.refreshSessionState(agent);
|
||||
} catch {
|
||||
// Ignore refresh errors - state sync after permission approval is best effort.
|
||||
}
|
||||
|
||||
this.touchUpdatedAt(agent);
|
||||
await this.persistSnapshot(agent);
|
||||
this.emitState(agent);
|
||||
|
||||
const bufferedResolution = agent.bufferedPermissionResolutions.get(requestId);
|
||||
if (bufferedResolution) {
|
||||
agent.bufferedPermissionResolutions.delete(requestId);
|
||||
this.dispatchStream(agent.id, bufferedResolution);
|
||||
}
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
agent.inFlightPermissionResponses.delete(requestId);
|
||||
agent.bufferedPermissionResolutions.delete(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
async cancelAgentRun(agentId: string): Promise<boolean> {
|
||||
@@ -1607,8 +1627,9 @@ export class AgentManager {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
const pendingForegroundRun = this.getPendingForegroundRun(agentId);
|
||||
const hasForegroundTurn =
|
||||
Boolean(snapshot.activeForegroundTurnId) || this.hasPendingForegroundRun(agentId);
|
||||
Boolean(snapshot.activeForegroundTurnId) || Boolean(pendingForegroundRun);
|
||||
|
||||
const immediatePermission = this.peekPendingPermission(snapshot);
|
||||
if (immediatePermission) {
|
||||
@@ -1650,7 +1671,10 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
let currentStatus: AgentLifecycleStatus = initialStatus;
|
||||
let hasStarted = initialBusy || hasForegroundTurn;
|
||||
let hasStarted =
|
||||
isAgentBusy(initialStatus) ||
|
||||
Boolean(snapshot.activeForegroundTurnId) ||
|
||||
Boolean(pendingForegroundRun?.started);
|
||||
let terminalStatusOverride: AgentLifecycleStatus | null = null;
|
||||
|
||||
// Bug #3 Fix: Declare unsubscribe and abortHandler upfront so cleanup can reference them
|
||||
@@ -1799,6 +1823,8 @@ export class AgentManager {
|
||||
availableModes: [],
|
||||
currentModeId: null,
|
||||
pendingPermissions: new Map(),
|
||||
bufferedPermissionResolutions: new Map(),
|
||||
inFlightPermissionResponses: new Set(),
|
||||
pendingReplacement: false,
|
||||
activeForegroundTurnId: null,
|
||||
foregroundTurnWaiters: new Set(),
|
||||
@@ -2011,6 +2037,7 @@ export class AgentManager {
|
||||
agent.pendingPermissions.clear();
|
||||
}
|
||||
|
||||
this.syncFeaturesFromSession(agent);
|
||||
await this.refreshRuntimeInfo(agent);
|
||||
}
|
||||
|
||||
@@ -2087,6 +2114,7 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
let timelineRow: AgentTimelineRow | null = null;
|
||||
let shouldDispatchEvent = true;
|
||||
|
||||
switch (event.type) {
|
||||
case "thread_started":
|
||||
@@ -2102,6 +2130,10 @@ export class AgentManager {
|
||||
void this.refreshRuntimeInfo(agent);
|
||||
}
|
||||
break;
|
||||
case "usage_updated":
|
||||
agent.lastUsage = event.usage;
|
||||
this.emitState(agent);
|
||||
break;
|
||||
case "timeline":
|
||||
// Skip provider-replayed user_message items during history hydration.
|
||||
if (options?.fromHistory && event.item.type === "user_message") {
|
||||
@@ -2259,6 +2291,11 @@ export class AgentManager {
|
||||
break;
|
||||
case "permission_resolved":
|
||||
agent.pendingPermissions.delete(event.requestId);
|
||||
if (!options?.fromHistory && agent.inFlightPermissionResponses.has(event.requestId)) {
|
||||
agent.bufferedPermissionResolutions.set(event.requestId, event);
|
||||
shouldDispatchEvent = false;
|
||||
break;
|
||||
}
|
||||
this.emitState(agent);
|
||||
break;
|
||||
default:
|
||||
@@ -2270,7 +2307,7 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
// Skip dispatching individual stream events during history replay.
|
||||
if (!options?.fromHistory) {
|
||||
if (!options?.fromHistory && shouldDispatchEvent) {
|
||||
this.dispatchStream(
|
||||
agent.id,
|
||||
event,
|
||||
@@ -2365,9 +2402,7 @@ export class AgentManager {
|
||||
// Keep attention as an edge-triggered unread signal, not a level signal.
|
||||
this.checkAndSetAttention(agent);
|
||||
|
||||
if (agent.session?.features) {
|
||||
agent.features = agent.session.features;
|
||||
}
|
||||
this.syncFeaturesFromSession(agent);
|
||||
|
||||
this.dispatch({
|
||||
type: "agent_state",
|
||||
@@ -2375,6 +2410,12 @@ export class AgentManager {
|
||||
});
|
||||
}
|
||||
|
||||
private syncFeaturesFromSession(agent: ManagedAgent): void {
|
||||
if ("session" in agent && agent.session?.features) {
|
||||
agent.features = agent.session.features;
|
||||
}
|
||||
}
|
||||
|
||||
private checkAndSetAttention(agent: ManagedAgent): void {
|
||||
const previousStatus = this.previousStatuses.get(agent.id);
|
||||
const currentStatus = agent.lifecycle;
|
||||
|
||||
@@ -260,6 +260,34 @@ describe("toAgentPayload", () => {
|
||||
expect(permissionA.title).toBe("Run command");
|
||||
});
|
||||
|
||||
it("omits usage when any numeric usage field is NaN", () => {
|
||||
const fields = [
|
||||
"inputTokens",
|
||||
"cachedInputTokens",
|
||||
"outputTokens",
|
||||
"totalCostUsd",
|
||||
"contextWindowMaxTokens",
|
||||
"contextWindowUsedTokens",
|
||||
] as const;
|
||||
|
||||
for (const field of fields) {
|
||||
const agent = createManagedAgent({
|
||||
lastUsage: {
|
||||
inputTokens: 10,
|
||||
cachedInputTokens: 5,
|
||||
outputTokens: 20,
|
||||
totalCostUsd: 0.5,
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 100_000,
|
||||
[field]: Number.NaN,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = toAgentPayload(agent);
|
||||
expect(payload.lastUsage).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("produces null title and current mode even without overrides", () => {
|
||||
const agent = createManagedAgent({ currentModeId: null, lastUserMessageAt: null });
|
||||
const payload = toAgentPayload(agent);
|
||||
@@ -303,6 +331,56 @@ describe("toAgentPayload", () => {
|
||||
expect(payload).not.toHaveProperty("lastUsage");
|
||||
});
|
||||
|
||||
it("preserves context window usage fields when they are valid numbers", () => {
|
||||
const agent = createManagedAgent({
|
||||
lastUsage: {
|
||||
inputTokens: 10,
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 42_000,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = toAgentPayload(agent);
|
||||
|
||||
expect(payload.lastUsage).toEqual({
|
||||
inputTokens: 10,
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 42_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("omits lastUsage when context window usage fields are invalid", () => {
|
||||
const agent = createManagedAgent({
|
||||
lastUsage: {
|
||||
inputTokens: 10,
|
||||
contextWindowMaxTokens: "200000" as unknown as number,
|
||||
contextWindowUsedTokens: NaN,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = toAgentPayload(agent);
|
||||
|
||||
expect(payload).not.toHaveProperty("lastUsage");
|
||||
});
|
||||
|
||||
it("keeps existing lastUsage behavior when context window fields are absent", () => {
|
||||
const agent = createManagedAgent({
|
||||
lastUsage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
totalCostUsd: 1.25,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = toAgentPayload(agent);
|
||||
|
||||
expect(payload.lastUsage).toEqual({
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
totalCostUsd: 1.25,
|
||||
});
|
||||
});
|
||||
|
||||
it("includes features in the snapshot payload", () => {
|
||||
const features = [createFeature()];
|
||||
const agent = createManagedAgent({ features });
|
||||
|
||||
@@ -61,6 +61,7 @@ export function toStoredAgentRecord(
|
||||
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
|
||||
config: config ?? null,
|
||||
runtimeInfo,
|
||||
features: agent.features,
|
||||
persistence,
|
||||
requiresAttention: agent.attention.requiresAttention,
|
||||
attentionReason: agent.attention.requiresAttention ? agent.attention.attentionReason : null,
|
||||
@@ -166,6 +167,7 @@ function sanitizePendingPermissions(
|
||||
...request,
|
||||
input: sanitizeMetadata(request.input),
|
||||
suggestions: sanitizeMetadataArray(request.suggestions),
|
||||
actions: request.actions?.map((action) => ({ ...action })),
|
||||
metadata: sanitizeMetadata(request.metadata),
|
||||
}));
|
||||
}
|
||||
@@ -259,29 +261,41 @@ function sanitizeUsage(value: unknown): AgentUsage | undefined {
|
||||
}
|
||||
const result: AgentUsage = {};
|
||||
const inputTokens = sanitized.inputTokens;
|
||||
if (typeof inputTokens === "number") {
|
||||
if (typeof inputTokens === "number" && Number.isFinite(inputTokens)) {
|
||||
result.inputTokens = inputTokens;
|
||||
} else if (inputTokens !== undefined && inputTokens !== null) {
|
||||
return undefined;
|
||||
}
|
||||
const cachedInputTokens = sanitized.cachedInputTokens;
|
||||
if (typeof cachedInputTokens === "number") {
|
||||
if (typeof cachedInputTokens === "number" && Number.isFinite(cachedInputTokens)) {
|
||||
result.cachedInputTokens = cachedInputTokens;
|
||||
} else if (cachedInputTokens !== undefined && cachedInputTokens !== null) {
|
||||
return undefined;
|
||||
}
|
||||
const outputTokens = sanitized.outputTokens;
|
||||
if (typeof outputTokens === "number") {
|
||||
if (typeof outputTokens === "number" && Number.isFinite(outputTokens)) {
|
||||
result.outputTokens = outputTokens;
|
||||
} else if (outputTokens !== undefined && outputTokens !== null) {
|
||||
return undefined;
|
||||
}
|
||||
const totalCostUsd = sanitized.totalCostUsd;
|
||||
if (typeof totalCostUsd === "number") {
|
||||
if (typeof totalCostUsd === "number" && Number.isFinite(totalCostUsd)) {
|
||||
result.totalCostUsd = totalCostUsd;
|
||||
} else if (totalCostUsd !== undefined && totalCostUsd !== null) {
|
||||
return undefined;
|
||||
}
|
||||
const contextWindowMaxTokens = sanitized.contextWindowMaxTokens;
|
||||
if (typeof contextWindowMaxTokens === "number" && Number.isFinite(contextWindowMaxTokens)) {
|
||||
result.contextWindowMaxTokens = contextWindowMaxTokens;
|
||||
} else if (contextWindowMaxTokens !== undefined && contextWindowMaxTokens !== null) {
|
||||
return undefined;
|
||||
}
|
||||
const contextWindowUsedTokens = sanitized.contextWindowUsedTokens;
|
||||
if (typeof contextWindowUsedTokens === "number" && Number.isFinite(contextWindowUsedTokens)) {
|
||||
result.contextWindowUsedTokens = contextWindowUsedTokens;
|
||||
} else if (contextWindowUsedTokens !== undefined && contextWindowUsedTokens !== null) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.keys(result).length ? result : undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,8 @@ export type AgentUsage = {
|
||||
cachedInputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalCostUsd?: number;
|
||||
contextWindowMaxTokens?: number;
|
||||
contextWindowUsedTokens?: number;
|
||||
};
|
||||
|
||||
export const TOOL_CALL_ICON_NAMES = [
|
||||
@@ -299,6 +301,7 @@ export type AgentStreamEvent =
|
||||
| { type: "thread_started"; sessionId: string; provider: AgentProvider }
|
||||
| { type: "turn_started"; provider: AgentProvider; turnId?: string }
|
||||
| { type: "turn_completed"; provider: AgentProvider; usage?: AgentUsage; turnId?: string }
|
||||
| { type: "usage_updated"; provider: AgentProvider; usage: AgentUsage; turnId?: string }
|
||||
| {
|
||||
type: "turn_failed";
|
||||
provider: AgentProvider;
|
||||
@@ -328,6 +331,14 @@ export type AgentPermissionRequestKind = "tool" | "plan" | "question" | "mode" |
|
||||
|
||||
export type AgentPermissionUpdate = AgentMetadata;
|
||||
|
||||
export type AgentPermissionAction = {
|
||||
id: string;
|
||||
label: string;
|
||||
behavior: "allow" | "deny";
|
||||
variant?: "primary" | "secondary" | "danger";
|
||||
intent?: "implement" | "implement_resume" | "dismiss";
|
||||
};
|
||||
|
||||
export type AgentPermissionRequest = {
|
||||
id: string;
|
||||
provider: AgentProvider;
|
||||
@@ -338,17 +349,20 @@ export type AgentPermissionRequest = {
|
||||
input?: AgentMetadata;
|
||||
detail?: ToolCallDetail;
|
||||
suggestions?: AgentPermissionUpdate[];
|
||||
actions?: AgentPermissionAction[];
|
||||
metadata?: AgentMetadata;
|
||||
};
|
||||
|
||||
export type AgentPermissionResponse =
|
||||
| {
|
||||
behavior: "allow";
|
||||
selectedActionId?: string;
|
||||
updatedInput?: AgentMetadata;
|
||||
updatedPermissions?: AgentPermissionUpdate[];
|
||||
}
|
||||
| {
|
||||
behavior: "deny";
|
||||
selectedActionId?: string;
|
||||
message?: string;
|
||||
interrupt?: boolean;
|
||||
};
|
||||
@@ -427,6 +441,14 @@ export interface AgentLaunchContext {
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned by respondToPermission when the permission resolution requires
|
||||
* a follow-up turn (e.g. Codex plan approval → implementation).
|
||||
*/
|
||||
export interface AgentPermissionResult {
|
||||
followUpPrompt?: AgentPromptInput;
|
||||
}
|
||||
|
||||
export interface AgentSession {
|
||||
readonly provider: AgentProvider;
|
||||
readonly id: string | null;
|
||||
@@ -441,7 +463,10 @@ export interface AgentSession {
|
||||
getCurrentMode(): Promise<string | null>;
|
||||
setMode(modeId: string): Promise<void>;
|
||||
getPendingPermissions(): AgentPermissionRequest[];
|
||||
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
|
||||
respondToPermission(
|
||||
requestId: string,
|
||||
response: AgentPermissionResponse,
|
||||
): Promise<AgentPermissionResult | void>;
|
||||
describePersistence(): AgentPersistenceHandle | null;
|
||||
interrupt(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { AgentStatusSchema } from "../messages.js";
|
||||
import { AgentFeatureSchema, AgentStatusSchema } from "../messages.js";
|
||||
import { toStoredAgentRecord } from "./agent-projections.js";
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentSessionConfig } from "./agent-sdk-types.js";
|
||||
@@ -56,6 +56,7 @@ const STORED_AGENT_SCHEMA = z.object({
|
||||
extra: z.record(z.unknown()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
features: z.array(AgentFeatureSchema).optional(),
|
||||
persistence: PERSISTENCE_HANDLE_SCHEMA,
|
||||
requiresAttention: z.boolean().optional(),
|
||||
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
|
||||
|
||||
@@ -233,6 +233,9 @@ function sanitizePermissionRequest(
|
||||
if (sanitized.suggestions === undefined) {
|
||||
delete sanitized.suggestions;
|
||||
}
|
||||
if (sanitized.actions === undefined) {
|
||||
delete sanitized.actions;
|
||||
}
|
||||
if (sanitized.metadata === undefined) {
|
||||
delete sanitized.metadata;
|
||||
}
|
||||
|
||||
@@ -7,19 +7,19 @@ import {
|
||||
} from "./provider-launch-config.js";
|
||||
|
||||
describe("resolveProviderCommandPrefix", () => {
|
||||
test("uses resolved default command in default mode", () => {
|
||||
test("uses resolved default command in default mode", async () => {
|
||||
const resolveDefault = vi.fn(() => "/usr/local/bin/claude");
|
||||
|
||||
const resolved = resolveProviderCommandPrefix(undefined, resolveDefault);
|
||||
const resolved = await resolveProviderCommandPrefix(undefined, resolveDefault);
|
||||
|
||||
expect(resolveDefault).toHaveBeenCalledTimes(1);
|
||||
expect(resolved).toEqual({ command: "/usr/local/bin/claude", args: [] });
|
||||
});
|
||||
|
||||
test("appends args in append mode", () => {
|
||||
test("appends args in append mode", async () => {
|
||||
const resolveDefault = vi.fn(() => "/usr/local/bin/claude");
|
||||
|
||||
const resolved = resolveProviderCommandPrefix(
|
||||
const resolved = await resolveProviderCommandPrefix(
|
||||
{
|
||||
mode: "append",
|
||||
args: ["--chrome"],
|
||||
@@ -34,10 +34,10 @@ describe("resolveProviderCommandPrefix", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("replaces command in replace mode without resolving default", () => {
|
||||
test("replaces command in replace mode without resolving default", async () => {
|
||||
const resolveDefault = vi.fn(() => "/usr/local/bin/claude");
|
||||
|
||||
const resolved = resolveProviderCommandPrefix(
|
||||
const resolved = await resolveProviderCommandPrefix(
|
||||
{
|
||||
mode: "replace",
|
||||
argv: ["docker", "run", "--rm", "my-wrapper"],
|
||||
|
||||
@@ -53,20 +53,20 @@ export type ProviderCommandPrefix = {
|
||||
args: string[];
|
||||
};
|
||||
|
||||
export function resolveProviderCommandPrefix(
|
||||
export async function resolveProviderCommandPrefix(
|
||||
commandConfig: ProviderCommand | undefined,
|
||||
resolveDefaultCommand: () => string,
|
||||
): ProviderCommandPrefix {
|
||||
resolveDefaultCommand: () => string | Promise<string>,
|
||||
): Promise<ProviderCommandPrefix> {
|
||||
if (!commandConfig || commandConfig.mode === "default") {
|
||||
return {
|
||||
command: resolveDefaultCommand(),
|
||||
command: await resolveDefaultCommand(),
|
||||
args: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (commandConfig.mode === "append") {
|
||||
return {
|
||||
command: resolveDefaultCommand(),
|
||||
command: await resolveDefaultCommand(),
|
||||
args: [...(commandConfig.args ?? [])],
|
||||
};
|
||||
}
|
||||
@@ -102,12 +102,12 @@ export function applyProviderEnv(
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function isProviderCommandAvailable(
|
||||
export async function isProviderCommandAvailable(
|
||||
commandConfig: ProviderCommand | undefined,
|
||||
resolveDefaultCommand: () => string,
|
||||
): boolean {
|
||||
resolveDefaultCommand: () => string | Promise<string>,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const prefix = resolveProviderCommandPrefix(commandConfig, resolveDefaultCommand);
|
||||
const prefix = await resolveProviderCommandPrefix(commandConfig, resolveDefaultCommand);
|
||||
return isCommandAvailable(prefix.command);
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -251,6 +251,51 @@ describe("ProviderSnapshotManager", () => {
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("refresh during an in-flight refresh is a no-op", async () => {
|
||||
const fetchModels = deferred<AgentModelDefinition[]>();
|
||||
const fetchModes = deferred<AgentMode[]>();
|
||||
const { registry, handles } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
fetchModels: async () => fetchModels.promise,
|
||||
fetchModes: async () => fetchModes.promise,
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||
const changes: ProviderSnapshotEntry[][] = [];
|
||||
manager.on("change", (entries) => changes.push(entries));
|
||||
|
||||
manager.refresh("/tmp/project");
|
||||
|
||||
expect(manager.getSnapshot("/tmp/project")).toEqual([
|
||||
{ provider: "codex", status: "loading" },
|
||||
]);
|
||||
|
||||
manager.refresh("/tmp/project");
|
||||
manager.refresh("/tmp/project");
|
||||
manager.refresh("/tmp/project");
|
||||
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1);
|
||||
|
||||
fetchModels.resolve([createModel("codex", "gpt-5.2")]);
|
||||
fetchModes.resolve([createMode("auto")]);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")).toMatchObject({
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
models: [createModel("codex", "gpt-5.2")],
|
||||
modes: [createMode("auto")],
|
||||
});
|
||||
});
|
||||
|
||||
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1);
|
||||
expect(handles.codex?.fetchModes).toHaveBeenCalledTimes(1);
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("multiple getSnapshot calls for same cwd do not trigger multiple warmUps", async () => {
|
||||
const codexModels = deferred<AgentModelDefinition[]>();
|
||||
const { registry, handles } = createRegistry([
|
||||
|
||||
@@ -32,8 +32,7 @@ export class ProviderSnapshotManager {
|
||||
const cwdKey = normalizeCwdKey(cwd);
|
||||
const entries = this.snapshots.get(cwdKey);
|
||||
if (!entries) {
|
||||
const loadingEntries = this.createLoadingEntries();
|
||||
this.snapshots.set(cwdKey, loadingEntries);
|
||||
const loadingEntries = this.resetSnapshotToLoading(cwdKey);
|
||||
void this.warmUp(cwd);
|
||||
return entriesToArray(loadingEntries);
|
||||
}
|
||||
@@ -42,7 +41,11 @@ export class ProviderSnapshotManager {
|
||||
|
||||
refresh(cwd?: string): void {
|
||||
const cwdKey = normalizeCwdKey(cwd);
|
||||
this.snapshots.set(cwdKey, this.createLoadingEntries());
|
||||
if (this.warmUps.has(cwdKey)) {
|
||||
return;
|
||||
}
|
||||
this.resetSnapshotToLoading(cwdKey);
|
||||
this.emitChange(cwdKey);
|
||||
void this.warmUp(cwd);
|
||||
}
|
||||
|
||||
@@ -170,6 +173,15 @@ export class ProviderSnapshotManager {
|
||||
return created;
|
||||
}
|
||||
|
||||
private resetSnapshotToLoading(cwdKey: string): Map<AgentProvider, ProviderSnapshotEntry> {
|
||||
const snapshot = this.getOrCreateSnapshot(cwdKey);
|
||||
snapshot.clear();
|
||||
for (const [provider, entry] of this.createLoadingEntries()) {
|
||||
snapshot.set(provider, entry);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private getProviderIds(): AgentProvider[] {
|
||||
return AGENT_PROVIDER_IDS.filter((provider) => this.providerRegistry[provider]);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import { isCommandAvailable } from "../../../../utils/executable.js";
|
||||
import { isCommandAvailableSync } from "../../../../utils/executable.js";
|
||||
import { ClaudeAgentClient } from "../claude-agent.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -28,7 +28,7 @@ const logger = pino({ level: "silent" });
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const hasClaudeCredentials =
|
||||
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
|
||||
const canRun = isCommandAvailable("claude") && hasClaudeCredentials;
|
||||
const canRun = isCommandAvailableSync("claude") && hasClaudeCredentials;
|
||||
|
||||
function tmpCwd(prefix: string): string {
|
||||
return mkdtempSync(path.join(tmpdir(), prefix));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user