Merge remote-tracking branch 'origin/main' into merge/main-into-dev
# Conflicts: # CLAUDE.md # nix/package.nix # packages/app/src/components/combined-model-selector.tsx # packages/app/src/hooks/use-sidebar-workspaces-list.ts # packages/app/src/screens/workspace/workspace-screen.tsx # packages/app/src/stores/session-store.test.ts # packages/server/src/client/daemon-client.ts # packages/server/src/server/agent/agent-manager.ts # packages/server/src/server/session.ts # packages/server/src/server/session.workspace-git-watch.test.ts # packages/server/src/server/session.workspaces.test.ts # packages/server/src/shared/messages.ts
This commit is contained in:
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.49 - 2026-04-07
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Models and providers now load reliably on first app connect instead of requiring a second status refresh.
|
||||||
|
- Model picker on running agents now only shows models from the agent's own provider, not every provider on the server.
|
||||||
|
- Model data is now prefetched consistently regardless of which screen you open first.
|
||||||
|
- Draft and running-agent flows now share the same provider data path, eliminating stale model lists from legacy fallbacks.
|
||||||
|
|
||||||
## 0.1.48 - 2026-04-05
|
## 0.1.48 - 2026-04-05
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,3 +1,58 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
See [AGENTS.md](./AGENTS.md) for the full development guide.
|
Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket. Connects directly to your actual development environment — your code stays on your machine.
|
||||||
|
|
||||||
|
**Supported agents:** Claude Code, Codex, and OpenCode.
|
||||||
|
|
||||||
|
## Repository map
|
||||||
|
|
||||||
|
This is an npm workspace monorepo:
|
||||||
|
|
||||||
|
- `packages/server` — Daemon: agent lifecycle, WebSocket API, MCP server
|
||||||
|
- `packages/app` — Mobile + web client (Expo)
|
||||||
|
- `packages/cli` — Docker-style CLI (`paseo run/ls/logs/wait`)
|
||||||
|
- `packages/relay` — E2E encrypted relay for remote access
|
||||||
|
- `packages/desktop` — Electron desktop wrapper
|
||||||
|
- `packages/website` — Marketing site (paseo.sh)
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
| Doc | What's in it |
|
||||||
|
|---|---|
|
||||||
|
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
||||||
|
| [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
||||||
|
| [docs/TESTING.md](docs/TESTING.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||||
|
| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||||
|
| [docs/RELEASE.md](docs/RELEASE.md) | Release playbook, draft releases, completion checklist |
|
||||||
|
| [docs/ANDROID.md](docs/ANDROID.md) | App variants, local/cloud builds, EAS workflows |
|
||||||
|
| [docs/DESIGN.md](docs/DESIGN.md) | How to design features before implementation |
|
||||||
|
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Start daemon + Expo in Tmux
|
||||||
|
npm run cli -- ls -a -g # List all agents
|
||||||
|
npm run cli -- daemon status # Check daemon status
|
||||||
|
npm run typecheck # Always run after changes
|
||||||
|
```
|
||||||
|
|
||||||
|
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
|
||||||
|
|
||||||
|
## Critical rules
|
||||||
|
|
||||||
|
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
|
||||||
|
- **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 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).
|
||||||
|
- Never narrow a field's type (e.g. `string` → `enum`, `nullable` → non-null).
|
||||||
|
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
|
||||||
|
Find the complete daemon logs and traces in the $PASEO_HOME/daemon.log
|
||||||
|
|||||||
+10
-1
@@ -11,6 +11,12 @@ There are two supported ways to ship from `main`:
|
|||||||
|
|
||||||
## Standard release (patch)
|
## 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
|
```bash
|
||||||
npm run release:patch
|
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
|
## Manual step-by-step
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm run typecheck # Verify the exact commit you intend to release
|
||||||
npm run release:check # Typecheck, build, dry-run pack
|
npm run release:check # Typecheck, build, dry-run pack
|
||||||
npm run version:all:patch # Bump version, create commit + tag
|
npm run version:all:patch # Bump version, create commit + tag
|
||||||
npm run release:publish # Publish to npm
|
npm run release:publish # Publish to npm
|
||||||
@@ -131,7 +138,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:
|
> 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.
|
> 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.
|
> 3. **Regressions** — anything that looks like it could break existing functionality.
|
||||||
>
|
>
|
||||||
> Diff: `git diff <latest-release-tag>..HEAD`
|
> Diff: `git diff <latest-release-tag>..HEAD`
|
||||||
@@ -150,6 +157,8 @@ In other words, RCs are checkpoints along the way; the changelog only records th
|
|||||||
## Completion checklist
|
## Completion checklist
|
||||||
|
|
||||||
- [ ] Run the pre-release sanity check (see above) and address any findings
|
- [ ] 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)
|
- [ ] 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
|
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||||
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
|
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
|
||||||
|
|||||||
@@ -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`
|
||||||
+1
-1
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
|||||||
|
|
||||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||||
npmDepsHash = "sha256-cAc6WSFXCB82Z/iKsNsXjGNg0EhBBVs7qn6suqR+zdE=";
|
npmDepsHash = "sha256-7ZBAwyC3CGqA9bK7bDW8YvlQyYhw1bsfSRd/LrdfA/E=";
|
||||||
|
|
||||||
# Prevent onnxruntime-node's install script from running during automatic
|
# 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).
|
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||||
|
|||||||
Generated
+19
-19
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.48",
|
"version": "0.1.49",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.48",
|
"version": "0.1.49",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -36221,16 +36221,16 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.48",
|
"version": "0.1.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/expo-two-way-audio": "0.1.48",
|
"@getpaseo/expo-two-way-audio": "0.1.49",
|
||||||
"@getpaseo/highlight": "0.1.48",
|
"@getpaseo/highlight": "0.1.49",
|
||||||
"@getpaseo/server": "0.1.48",
|
"@getpaseo/server": "0.1.49",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
@@ -36347,11 +36347,11 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.48",
|
"version": "0.1.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/relay": "0.1.48",
|
"@getpaseo/relay": "0.1.49",
|
||||||
"@getpaseo/server": "0.1.48",
|
"@getpaseo/server": "0.1.49",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -36392,11 +36392,11 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.48",
|
"version": "0.1.49",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "0.1.48",
|
"@getpaseo/cli": "0.1.49",
|
||||||
"@getpaseo/server": "0.1.48",
|
"@getpaseo/server": "0.1.49",
|
||||||
"electron-log": "^5.4.3",
|
"electron-log": "^5.4.3",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
@@ -36430,7 +36430,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.1.48",
|
"version": "0.1.49",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.9.4",
|
"@biomejs/biome": "1.9.4",
|
||||||
@@ -36631,7 +36631,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.48",
|
"version": "0.1.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lezer/common": "^1.5.0",
|
"@lezer/common": "^1.5.0",
|
||||||
"@lezer/cpp": "^1.1.5",
|
"@lezer/cpp": "^1.1.5",
|
||||||
@@ -36657,7 +36657,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.48",
|
"version": "0.1.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -36673,14 +36673,14 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.48",
|
"version": "0.1.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@ai-sdk/openai": "2.0.52",
|
"@ai-sdk/openai": "2.0.52",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||||
"@deepgram/sdk": "^3.4.0",
|
"@deepgram/sdk": "^3.4.0",
|
||||||
"@getpaseo/highlight": "0.1.48",
|
"@getpaseo/highlight": "0.1.49",
|
||||||
"@getpaseo/relay": "0.1.48",
|
"@getpaseo/relay": "0.1.49",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@opencode-ai/sdk": "1.2.6",
|
"@opencode-ai/sdk": "1.2.6",
|
||||||
@@ -37083,7 +37083,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.1.48",
|
"version": "0.1.49",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.20.3",
|
"@cloudflare/vite-plugin": "^1.20.3",
|
||||||
"@cloudflare/workers-types": "^4.20260114.0",
|
"@cloudflare/workers-types": "^4.20260114.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.48",
|
"version": "0.1.49",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/expo-two-way-audio",
|
"packages/expo-two-way-audio",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"version": "0.1.48",
|
"version": "0.1.49",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
@@ -31,9 +31,9 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/expo-two-way-audio": "0.1.48",
|
"@getpaseo/expo-two-way-audio": "0.1.49",
|
||||||
"@getpaseo/highlight": "0.1.48",
|
"@getpaseo/highlight": "0.1.49",
|
||||||
"@getpaseo/server": "0.1.48",
|
"@getpaseo/server": "0.1.49",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: 520,
|
maxWidth: 520,
|
||||||
maxHeight: "85%",
|
maxHeight: "85%",
|
||||||
|
flexShrink: 1,
|
||||||
|
minHeight: 0,
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
borderRadius: theme.borderRadius.xl,
|
borderRadius: theme.borderRadius.xl,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
@@ -61,11 +63,13 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
desktopScroll: {
|
desktopScroll: {
|
||||||
flex: 1,
|
flexShrink: 1,
|
||||||
|
minHeight: 0,
|
||||||
},
|
},
|
||||||
desktopContent: {
|
desktopContent: {
|
||||||
padding: theme.spacing[6],
|
padding: theme.spacing[6],
|
||||||
gap: theme.spacing[4],
|
gap: theme.spacing[4],
|
||||||
|
flexGrow: 1,
|
||||||
},
|
},
|
||||||
bottomSheetHandle: {
|
bottomSheetHandle: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import {
|
|||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
import {
|
import {
|
||||||
buildFavoriteModelKey,
|
buildFavoriteModelKey,
|
||||||
mergeProviderPreferences,
|
mergeProviderPreferences,
|
||||||
@@ -51,7 +51,6 @@ import {
|
|||||||
getStatusSelectorHint,
|
getStatusSelectorHint,
|
||||||
resolveAgentModelSelection,
|
resolveAgentModelSelection,
|
||||||
} from "@/components/agent-status-bar.utils";
|
} from "@/components/agent-status-bar.utils";
|
||||||
import { isProviderModelsQueryLoading } from "@/components/agent-status-bar.model-loading";
|
|
||||||
|
|
||||||
type StatusOption = {
|
type StatusOption = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -75,6 +74,7 @@ type ControlledAgentStatusBarProps = {
|
|||||||
modelOptions?: StatusOption[];
|
modelOptions?: StatusOption[];
|
||||||
selectedModelId?: string;
|
selectedModelId?: string;
|
||||||
onSelectModel?: (modelId: string) => void;
|
onSelectModel?: (modelId: string) => void;
|
||||||
|
onSelectProviderAndModel?: (provider: string, modelId: string) => void;
|
||||||
thinkingOptions?: StatusOption[];
|
thinkingOptions?: StatusOption[];
|
||||||
selectedThinkingOptionId?: string;
|
selectedThinkingOptionId?: string;
|
||||||
onSelectThinkingOption?: (thinkingOptionId: string) => void;
|
onSelectThinkingOption?: (thinkingOptionId: string) => void;
|
||||||
@@ -203,6 +203,7 @@ function ControlledStatusBar({
|
|||||||
modelOptions,
|
modelOptions,
|
||||||
selectedModelId,
|
selectedModelId,
|
||||||
onSelectModel,
|
onSelectModel,
|
||||||
|
onSelectProviderAndModel,
|
||||||
thinkingOptions,
|
thinkingOptions,
|
||||||
selectedThinkingOptionId,
|
selectedThinkingOptionId,
|
||||||
onSelectThinkingOption,
|
onSelectThinkingOption,
|
||||||
@@ -264,7 +265,7 @@ function ControlledStatusBar({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0;
|
const modelDisabled = disabled;
|
||||||
|
|
||||||
const SEARCH_THRESHOLD = 6;
|
const SEARCH_THRESHOLD = 6;
|
||||||
|
|
||||||
@@ -648,10 +649,14 @@ function ControlledStatusBar({
|
|||||||
selectedModel={selectedModelId ?? ""}
|
selectedModel={selectedModelId ?? ""}
|
||||||
canSelectProvider={canSelectProviderInModelMenu}
|
canSelectProvider={canSelectProviderInModelMenu}
|
||||||
onSelect={(selectedProviderId, modelId) => {
|
onSelect={(selectedProviderId, modelId) => {
|
||||||
if (selectedProviderId !== provider) {
|
if (onSelectProviderAndModel) {
|
||||||
onSelectProvider?.(selectedProviderId);
|
onSelectProviderAndModel(selectedProviderId, modelId);
|
||||||
|
} else {
|
||||||
|
if (selectedProviderId !== provider) {
|
||||||
|
onSelectProvider?.(selectedProviderId);
|
||||||
|
}
|
||||||
|
onSelectModel?.(modelId);
|
||||||
}
|
}
|
||||||
onSelectModel?.(modelId);
|
|
||||||
}}
|
}}
|
||||||
favoriteKeys={favoriteKeys}
|
favoriteKeys={favoriteKeys}
|
||||||
onToggleFavorite={onToggleFavoriteModel}
|
onToggleFavorite={onToggleFavoriteModel}
|
||||||
@@ -667,6 +672,7 @@ function ControlledStatusBar({
|
|||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
testID="agent-preferences-model"
|
testID="agent-preferences-model"
|
||||||
>
|
>
|
||||||
|
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
|
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
|
||||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
</View>
|
</View>
|
||||||
@@ -692,6 +698,7 @@ function ControlledStatusBar({
|
|||||||
accessibilityLabel="Select thinking option"
|
accessibilityLabel="Select thinking option"
|
||||||
testID="agent-preferences-thinking"
|
testID="agent-preferences-thinking"
|
||||||
>
|
>
|
||||||
|
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.sheetSelectText}>{displayThinking}</Text>
|
<Text style={styles.sheetSelectText}>{displayThinking}</Text>
|
||||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -852,6 +859,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
|||||||
model: currentAgent.model,
|
model: currentAgent.model,
|
||||||
features: currentAgent.features,
|
features: currentAgent.features,
|
||||||
thinkingOptionId: currentAgent.thinkingOptionId,
|
thinkingOptionId: currentAgent.thinkingOptionId,
|
||||||
|
lastUsage: currentAgent.lastUsage,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
}),
|
}),
|
||||||
@@ -863,52 +871,34 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
|||||||
);
|
);
|
||||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||||
|
|
||||||
const modelsQuery = useQuery({
|
const {
|
||||||
queryKey: ["providerModels", serverId, agent?.provider ?? "__missing_provider__"],
|
entries: snapshotEntries,
|
||||||
enabled: Boolean(client && agent?.provider),
|
isLoading: snapshotIsLoading,
|
||||||
staleTime: 5 * 60 * 1000,
|
isFetching: snapshotIsFetching,
|
||||||
queryFn: async () => {
|
} = useProvidersSnapshot(serverId);
|
||||||
if (!client || !agent) {
|
|
||||||
throw new Error("Daemon client unavailable");
|
const snapshotModels = useMemo(() => {
|
||||||
}
|
if (!snapshotEntries || !agent?.provider) {
|
||||||
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
|
return null;
|
||||||
if (payload.error) {
|
}
|
||||||
throw new Error(payload.error);
|
const entry = snapshotEntries.find((e) => e.provider === agent.provider);
|
||||||
}
|
return entry?.models ?? null;
|
||||||
return payload.models ?? [];
|
}, [snapshotEntries, agent?.provider]);
|
||||||
},
|
|
||||||
});
|
const models = snapshotModels;
|
||||||
|
|
||||||
const agentProviderDefinitions = useMemo(() => {
|
const agentProviderDefinitions = useMemo(() => {
|
||||||
const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider);
|
const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider);
|
||||||
return definition ? [definition] : [];
|
return definition ? [definition] : [];
|
||||||
}, [agent?.provider]);
|
}, [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 agentProviderModels = useMemo(() => {
|
||||||
const map = new Map<string, AgentModelDefinition[]>();
|
const map = new Map<string, AgentModelDefinition[]>();
|
||||||
if (agent?.provider && agentProviderModelQuery.data) {
|
if (agent?.provider && snapshotModels) {
|
||||||
map.set(agent.provider, agentProviderModelQuery.data);
|
map.set(agent.provider, snapshotModels);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [agent?.provider, agentProviderModelQuery.data]);
|
}, [agent?.provider, snapshotModels]);
|
||||||
|
|
||||||
const models = modelsQuery.data ?? null;
|
|
||||||
|
|
||||||
const displayMode =
|
const displayMode =
|
||||||
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
|
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);
|
console.warn("[AgentStatusBar] setAgentFeature failed", error);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
isModelLoading={isProviderModelsQueryLoading(modelsQuery)}
|
isModelLoading={snapshotIsLoading || snapshotIsFetching}
|
||||||
onDropdownClose={onDropdownClose}
|
onDropdownClose={onDropdownClose}
|
||||||
disabled={!client}
|
disabled={!client}
|
||||||
/>
|
/>
|
||||||
@@ -1135,6 +1125,7 @@ export function DraftAgentStatusBar({
|
|||||||
modelOptions={modelOptions}
|
modelOptions={modelOptions}
|
||||||
selectedModelId={selectedModel}
|
selectedModelId={selectedModel}
|
||||||
onSelectModel={(modelId) => onSelectModel(modelId)}
|
onSelectModel={(modelId) => onSelectModel(modelId)}
|
||||||
|
onSelectProviderAndModel={onSelectProviderAndModel}
|
||||||
isModelLoading={isAllModelsLoading}
|
isModelLoading={isAllModelsLoading}
|
||||||
favoriteKeys={favoriteKeys}
|
favoriteKeys={favoriteKeys}
|
||||||
onToggleFavoriteModel={(provider, modelId) => {
|
onToggleFavoriteModel={(provider, modelId) => {
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ import {
|
|||||||
import { PlanCard } from "./plan-card";
|
import { PlanCard } from "./plan-card";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import type { PendingPermission } from "@/types/shared";
|
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 type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||||
@@ -700,6 +703,29 @@ function PermissionRequestCard({
|
|||||||
const isPlanRequest = request.kind === "plan";
|
const isPlanRequest = request.kind === "plan";
|
||||||
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
|
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
|
||||||
const description = request.description ?? "";
|
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(() => {
|
const planMarkdown = useMemo(() => {
|
||||||
if (!request) {
|
if (!request) {
|
||||||
@@ -740,11 +766,11 @@ function PermissionRequestCard({
|
|||||||
isPending: isResponding,
|
isPending: isResponding,
|
||||||
} = permissionMutation;
|
} = permissionMutation;
|
||||||
|
|
||||||
const [respondingAction, setRespondingAction] = useState<"accept" | "deny" | null>(null);
|
const [respondingActionId, setRespondingActionId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resetPermissionMutation();
|
resetPermissionMutation();
|
||||||
setRespondingAction(null);
|
setRespondingActionId(null);
|
||||||
}, [permission.request.id, resetPermissionMutation]);
|
}, [permission.request.id, resetPermissionMutation]);
|
||||||
const handleResponse = useCallback(
|
const handleResponse = useCallback(
|
||||||
(response: AgentPermissionResponse) => {
|
(response: AgentPermissionResponse) => {
|
||||||
@@ -758,6 +784,24 @@ function PermissionRequestCard({
|
|||||||
},
|
},
|
||||||
[permission.agentId, permission.request.id, respondToPermission],
|
[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") {
|
if (request.kind === "question") {
|
||||||
return (
|
return (
|
||||||
@@ -784,64 +828,48 @@ function PermissionRequestCard({
|
|||||||
!isMobile && permissionStyles.optionsContainerDesktop,
|
!isMobile && permissionStyles.optionsContainerDesktop,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Pressable
|
{resolvedActions.map((action) => {
|
||||||
testID="permission-request-deny"
|
const isDanger = action.variant === "danger" || action.behavior === "deny";
|
||||||
style={({ pressed, hovered = false }) => [
|
const isPrimary = action.variant === "primary";
|
||||||
permissionStyles.optionButton,
|
const isRespondingAction = respondingActionId === action.id;
|
||||||
{
|
const textColor = isPrimary ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||||
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
const iconColor = textColor;
|
||||||
borderColor: theme.colors.borderAccent,
|
const Icon = action.behavior === "allow" ? Check : X;
|
||||||
},
|
const testID =
|
||||||
pressed ? permissionStyles.optionButtonPressed : null,
|
action.behavior === "deny"
|
||||||
]}
|
? "permission-request-deny"
|
||||||
onPress={() => {
|
: action.id === "accept" || action.id === "implement"
|
||||||
setRespondingAction("deny");
|
? "permission-request-accept"
|
||||||
handleResponse({
|
: `permission-request-action-${action.id}`;
|
||||||
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>
|
|
||||||
|
|
||||||
<Pressable
|
return (
|
||||||
testID="permission-request-accept"
|
<Pressable
|
||||||
style={({ pressed, hovered = false }) => [
|
key={action.id}
|
||||||
permissionStyles.optionButton,
|
testID={testID}
|
||||||
{
|
style={({ pressed, hovered = false }) => [
|
||||||
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
permissionStyles.optionButton,
|
||||||
borderColor: theme.colors.borderAccent,
|
{
|
||||||
},
|
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
||||||
pressed ? permissionStyles.optionButtonPressed : null,
|
borderColor: isDanger ? theme.colors.borderAccent : theme.colors.borderAccent,
|
||||||
]}
|
},
|
||||||
onPress={() => {
|
pressed ? permissionStyles.optionButtonPressed : null,
|
||||||
setRespondingAction("accept");
|
]}
|
||||||
handleResponse({ behavior: "allow" });
|
onPress={() => handleActionPress(action)}
|
||||||
}}
|
disabled={isResponding}
|
||||||
disabled={isResponding}
|
>
|
||||||
>
|
{isRespondingAction ? (
|
||||||
{respondingAction === "accept" ? (
|
<ActivityIndicator size="small" color={textColor} />
|
||||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
) : (
|
||||||
) : (
|
<View style={permissionStyles.optionContent}>
|
||||||
<View style={permissionStyles.optionContent}>
|
<Icon size={14} color={iconColor} />
|
||||||
<Check size={14} color={theme.colors.foreground} />
|
<Text style={[permissionStyles.optionText, { color: textColor }]}>
|
||||||
<Text style={[permissionStyles.optionText, { color: theme.colors.foreground }]}>
|
{action.label}
|
||||||
Accept
|
</Text>
|
||||||
</Text>
|
</View>
|
||||||
</View>
|
)}
|
||||||
)}
|
</Pressable>
|
||||||
</Pressable>
|
);
|
||||||
|
})}
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
DraftAgentStatusBar,
|
DraftAgentStatusBar,
|
||||||
type DraftAgentStatusBarProps,
|
type DraftAgentStatusBarProps,
|
||||||
} from "./agent-status-bar";
|
} from "./agent-status-bar";
|
||||||
|
import { ContextWindowMeter } from "./context-window-meter";
|
||||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
@@ -140,6 +141,8 @@ export function Composer({
|
|||||||
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
||||||
return {
|
return {
|
||||||
status: agent?.status ?? null,
|
status: agent?.status ?? null,
|
||||||
|
contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null,
|
||||||
|
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -649,6 +652,25 @@ export function Composer({
|
|||||||
</View>
|
</View>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
|
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 =
|
const leftContent =
|
||||||
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
|
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
|
||||||
<DraftAgentStatusBar {...statusControls} />
|
<DraftAgentStatusBar {...statusControls} />
|
||||||
@@ -729,6 +751,7 @@ export function Composer({
|
|||||||
disabled={isSubmitLoading}
|
disabled={isSubmitLoading}
|
||||||
isInputActive={isInputActive}
|
isInputActive={isInputActive}
|
||||||
leftContent={leftContent}
|
leftContent={leftContent}
|
||||||
|
beforeVoiceContent={beforeVoiceContent}
|
||||||
rightContent={rightContent}
|
rightContent={rightContent}
|
||||||
voiceServerId={serverId}
|
voiceServerId={serverId}
|
||||||
voiceAgentId={agentId}
|
voiceAgentId={agentId}
|
||||||
@@ -805,6 +828,12 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
|
contextWindowMeterSlot: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
realtimeVoiceButton: {
|
realtimeVoiceButton: {
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
|
|||||||
@@ -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 { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
@@ -118,7 +118,6 @@ export function FileExplorerPane({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
workspaceStateKey: actionsWorkspaceStateKey,
|
|
||||||
requestDirectoryListing,
|
requestDirectoryListing,
|
||||||
requestFileDownloadToken,
|
requestFileDownloadToken,
|
||||||
selectExplorerEntry,
|
selectExplorerEntry,
|
||||||
@@ -129,6 +128,16 @@ export function FileExplorerPane({
|
|||||||
});
|
});
|
||||||
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
||||||
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
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 directories = explorerState?.directories ?? new Map();
|
||||||
const pendingRequest = explorerState?.pendingRequest ?? null;
|
const pendingRequest = explorerState?.pendingRequest ?? null;
|
||||||
@@ -144,7 +153,6 @@ export function FileExplorerPane({
|
|||||||
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path],
|
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
|
|
||||||
const treeListRef = useRef<FlatList<TreeRow>>(null);
|
const treeListRef = useRef<FlatList<TreeRow>>(null);
|
||||||
const scrollbar = useWebScrollViewScrollbar(treeListRef, {
|
const scrollbar = useWebScrollViewScrollbar(treeListRef, {
|
||||||
enabled: showDesktopWebScrollbar,
|
enabled: showDesktopWebScrollbar,
|
||||||
@@ -154,8 +162,7 @@ export function FileExplorerPane({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
hasInitializedRef.current = false;
|
hasInitializedRef.current = false;
|
||||||
setExpandedPaths(new Set(["."]));
|
}, [workspaceStateKey]);
|
||||||
}, [actionsWorkspaceStateKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasWorkspaceScope) {
|
if (!hasWorkspaceScope) {
|
||||||
@@ -169,23 +176,35 @@ export function FileExplorerPane({
|
|||||||
recordHistory: false,
|
recordHistory: false,
|
||||||
setCurrentPath: 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)
|
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedEntryPath || !hasWorkspaceScope) {
|
if (!selectedEntryPath || !workspaceStateKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const parentDir = getParentDirectory(selectedEntryPath);
|
const parentDir = getParentDirectory(selectedEntryPath);
|
||||||
const ancestors = getAncestorDirectories(parentDir);
|
const ancestors = getAncestorDirectories(parentDir);
|
||||||
|
const newPaths = ancestors.filter((path) => !expandedPaths.has(path));
|
||||||
setExpandedPaths((prev) => {
|
if (newPaths.length === 0) {
|
||||||
const next = new Set(prev);
|
return;
|
||||||
ancestors.forEach((path) => next.add(path));
|
}
|
||||||
return next;
|
setExpandedPathsForWorkspace(
|
||||||
});
|
workspaceStateKey,
|
||||||
|
[...Array.from(expandedPaths), ...newPaths],
|
||||||
ancestors.forEach((path) => {
|
);
|
||||||
|
newPaths.forEach((path) => {
|
||||||
if (!directories.has(path)) {
|
if (!directories.has(path)) {
|
||||||
void requestDirectoryListing(path, {
|
void requestDirectoryListing(path, {
|
||||||
recordHistory: false,
|
recordHistory: false,
|
||||||
@@ -193,34 +212,46 @@ export function FileExplorerPane({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [directories, hasWorkspaceScope, requestDirectoryListing, selectedEntryPath]);
|
}, [
|
||||||
|
directories,
|
||||||
|
workspaceStateKey,
|
||||||
|
expandedPaths,
|
||||||
|
requestDirectoryListing,
|
||||||
|
selectedEntryPath,
|
||||||
|
setExpandedPathsForWorkspace,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleToggleDirectory = useCallback(
|
const handleToggleDirectory = useCallback(
|
||||||
(entry: ExplorerEntry) => {
|
(entry: ExplorerEntry) => {
|
||||||
if (!hasWorkspaceScope) {
|
if (!workspaceStateKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isExpanded = expandedPaths.has(entry.path);
|
const isExpanded = expandedPaths.has(entry.path);
|
||||||
const nextExpanded = !isExpanded;
|
if (isExpanded) {
|
||||||
setExpandedPaths((prev) => {
|
setExpandedPathsForWorkspace(
|
||||||
const next = new Set(prev);
|
workspaceStateKey,
|
||||||
if (isExpanded) {
|
Array.from(expandedPaths).filter((path) => path !== entry.path),
|
||||||
next.delete(entry.path);
|
);
|
||||||
} else {
|
} else {
|
||||||
next.add(entry.path);
|
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(
|
const handleOpenFile = useCallback(
|
||||||
|
|||||||
@@ -18,17 +18,21 @@ import {
|
|||||||
type LayoutChangeEvent,
|
type LayoutChangeEvent,
|
||||||
type NativeSyntheticEvent,
|
type NativeSyntheticEvent,
|
||||||
type NativeScrollEvent,
|
type NativeScrollEvent,
|
||||||
|
TextStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import {
|
import {
|
||||||
|
AlignJustify,
|
||||||
Archive,
|
Archive,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Columns2,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
GitCommitHorizontal,
|
GitCommitHorizontal,
|
||||||
GitMerge,
|
GitMerge,
|
||||||
ListChevronsDownUp,
|
ListChevronsDownUp,
|
||||||
ListChevronsUpDown,
|
ListChevronsUpDown,
|
||||||
|
Pilcrow,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
Upload,
|
Upload,
|
||||||
WrapText,
|
WrapText,
|
||||||
@@ -42,6 +46,7 @@ import {
|
|||||||
} from "@/hooks/use-checkout-diff-query";
|
} from "@/hooks/use-checkout-diff-query";
|
||||||
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||||
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
|
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
|
||||||
|
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||||
import { DiffScroll } from "./diff-scroll";
|
import { DiffScroll } from "./diff-scroll";
|
||||||
import {
|
import {
|
||||||
darkHighlightColors,
|
darkHighlightColors,
|
||||||
@@ -51,6 +56,7 @@ import {
|
|||||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
|
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
|
||||||
|
import { buildSplitDiffRows, type SplitDiffDisplayLine } from "@/utils/diff-layout";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -66,6 +72,8 @@ import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
|||||||
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
|
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
|
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
|
||||||
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
|
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
||||||
|
|
||||||
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
|
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
|
||||||
|
|
||||||
@@ -77,13 +85,27 @@ type HighlightStyle = NonNullable<HighlightToken["style"]>;
|
|||||||
|
|
||||||
interface HighlightedTextProps {
|
interface HighlightedTextProps {
|
||||||
tokens: HighlightToken[];
|
tokens: HighlightToken[];
|
||||||
baseStyle: HighlightStyle | null;
|
wrapLines?: boolean;
|
||||||
lineType: "add" | "remove" | "context" | "header";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
|
type WrappedWebTextStyle = TextStyle & {
|
||||||
|
whiteSpace?: "pre" | "pre-wrap";
|
||||||
|
overflowWrap?: "normal" | "anywhere";
|
||||||
|
};
|
||||||
|
|
||||||
|
function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined {
|
||||||
|
if (Platform.OS !== "web") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return wrapLines
|
||||||
|
? { whiteSpace: "pre-wrap", overflowWrap: "anywhere" }
|
||||||
|
: { whiteSpace: "pre", overflowWrap: "normal" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isDark = theme.colorScheme === "dark";
|
const isDark = theme.colorScheme === "dark";
|
||||||
|
const lineHeight = theme.lineHeight.diff;
|
||||||
|
|
||||||
const getTokenColor = (style: HighlightStyle | null): string => {
|
const getTokenColor = (style: HighlightStyle | null): string => {
|
||||||
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
||||||
@@ -93,9 +115,14 @@ function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text style={styles.diffLineText}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.diffLineText,
|
||||||
|
{ lineHeight, ...getWrappedTextStyle(wrapLines) },
|
||||||
|
]}
|
||||||
|
>
|
||||||
{tokens.map((token, index) => (
|
{tokens.map((token, index) => (
|
||||||
<Text key={index} style={{ color: getTokenColor(token.style) }}>
|
<Text key={index} style={{ color: getTokenColor(token.style), lineHeight }}>
|
||||||
{token.text}
|
{token.text}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
@@ -115,10 +142,12 @@ function DiffLineView({
|
|||||||
line,
|
line,
|
||||||
lineNumber,
|
lineNumber,
|
||||||
gutterWidth,
|
gutterWidth,
|
||||||
|
wrapLines,
|
||||||
}: {
|
}: {
|
||||||
line: DiffLine;
|
line: DiffLine;
|
||||||
lineNumber: number | null;
|
lineNumber: number | null;
|
||||||
gutterWidth: number;
|
gutterWidth: number;
|
||||||
|
wrapLines: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -142,11 +171,12 @@ function DiffLineView({
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{line.tokens && line.type !== "header" ? (
|
{line.tokens && line.type !== "header" ? (
|
||||||
<HighlightedText tokens={line.tokens} baseStyle={null} lineType={line.type} />
|
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
|
||||||
) : (
|
) : (
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.diffLineText,
|
styles.diffLineText,
|
||||||
|
getWrappedTextStyle(wrapLines),
|
||||||
line.type === "add" && styles.addLineText,
|
line.type === "add" && styles.addLineText,
|
||||||
line.type === "remove" && styles.removeLineText,
|
line.type === "remove" && styles.removeLineText,
|
||||||
line.type === "header" && styles.headerLineText,
|
line.type === "header" && styles.headerLineText,
|
||||||
@@ -160,6 +190,59 @@ function DiffLineView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SplitDiffCell({
|
||||||
|
line,
|
||||||
|
gutterWidth,
|
||||||
|
wrapLines,
|
||||||
|
showDivider = false,
|
||||||
|
}: {
|
||||||
|
line: SplitDiffDisplayLine | null;
|
||||||
|
gutterWidth: number;
|
||||||
|
wrapLines: boolean;
|
||||||
|
showDivider?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.splitCell,
|
||||||
|
showDivider && styles.splitCellWithDivider,
|
||||||
|
!line && styles.emptySplitCell,
|
||||||
|
line?.type === "add" && styles.addLineContainer,
|
||||||
|
line?.type === "remove" && styles.removeLineContainer,
|
||||||
|
line?.type === "context" && styles.contextLineContainer,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={[styles.lineNumberGutter, { width: gutterWidth }]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.lineNumberText,
|
||||||
|
line?.type === "add" && styles.addLineNumberText,
|
||||||
|
line?.type === "remove" && styles.removeLineNumberText,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{line?.lineNumber != null ? String(line.lineNumber) : ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{line?.tokens ? (
|
||||||
|
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.diffLineText,
|
||||||
|
getWrappedTextStyle(wrapLines) ,
|
||||||
|
line?.type === "add" && styles.addLineText,
|
||||||
|
line?.type === "remove" && styles.removeLineText,
|
||||||
|
line?.type === "context" && styles.contextLineText,
|
||||||
|
!line && styles.emptySplitCellText,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{line?.content ?? ""}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const DiffFileHeader = memo(function DiffFileHeader({
|
const DiffFileHeader = memo(function DiffFileHeader({
|
||||||
file,
|
file,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
@@ -245,21 +328,25 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
|||||||
|
|
||||||
function DiffFileBody({
|
function DiffFileBody({
|
||||||
file,
|
file,
|
||||||
|
layout,
|
||||||
wrapLines,
|
wrapLines,
|
||||||
onBodyHeightChange,
|
onBodyHeightChange,
|
||||||
testID,
|
testID,
|
||||||
}: {
|
}: {
|
||||||
file: ParsedDiffFile;
|
file: ParsedDiffFile;
|
||||||
|
layout: "unified" | "split";
|
||||||
wrapLines: boolean;
|
wrapLines: boolean;
|
||||||
onBodyHeightChange?: (path: string, height: number) => void;
|
onBodyHeightChange?: (path: string, height: number) => void;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
}) {
|
}) {
|
||||||
const [scrollViewWidth, setScrollViewWidth] = useState(0);
|
const [scrollViewWidth, setScrollViewWidth] = useState(0);
|
||||||
|
const [bodyWidth, setBodyWidth] = useState(0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
|
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
|
||||||
onLayout={(event) => {
|
onLayout={(event) => {
|
||||||
|
setBodyWidth(event.nativeEvent.layout.width);
|
||||||
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
|
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
|
||||||
}}
|
}}
|
||||||
testID={testID}
|
testID={testID}
|
||||||
@@ -275,50 +362,80 @@ function DiffFileBody({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const linesContent = (() => {
|
let maxLineNo = 0;
|
||||||
let maxLineNo = 0;
|
for (const hunk of file.hunks) {
|
||||||
for (const hunk of file.hunks) {
|
maxLineNo = Math.max(maxLineNo, hunk.oldStart + hunk.oldCount, hunk.newStart + hunk.newCount);
|
||||||
maxLineNo = Math.max(
|
}
|
||||||
maxLineNo,
|
const gutterWidth = lineNumberGutterWidth(maxLineNo);
|
||||||
hunk.oldStart + hunk.oldCount,
|
|
||||||
hunk.newStart + hunk.newCount,
|
const linesContent =
|
||||||
);
|
layout === "split"
|
||||||
}
|
? buildSplitDiffRows(file).map((row, rowIndex) => {
|
||||||
const gutterWidth = lineNumberGutterWidth(maxLineNo);
|
if (row.kind === "header") {
|
||||||
return file.hunks.map((hunk, hunkIndex) => {
|
return (
|
||||||
let oldLineNo = hunk.oldStart;
|
<View key={`header-${rowIndex}`} style={styles.splitHeaderRow}>
|
||||||
let newLineNo = hunk.newStart;
|
<Text style={[styles.diffLineText, styles.headerLineText]}>{row.content}</Text>
|
||||||
return hunk.lines.map((line, lineIndex) => {
|
</View>
|
||||||
let lineNumber: number | null = null;
|
);
|
||||||
if (line.type === "remove") {
|
}
|
||||||
lineNumber = oldLineNo;
|
|
||||||
oldLineNo++;
|
return (
|
||||||
} else if (line.type === "add") {
|
<View key={`pair-${rowIndex}`} style={styles.splitRow}>
|
||||||
lineNumber = newLineNo;
|
<SplitDiffCell line={row.left} gutterWidth={gutterWidth} wrapLines={wrapLines} />
|
||||||
newLineNo++;
|
<SplitDiffCell
|
||||||
} else if (line.type === "context") {
|
line={row.right}
|
||||||
lineNumber = newLineNo;
|
gutterWidth={gutterWidth}
|
||||||
oldLineNo++;
|
wrapLines={wrapLines}
|
||||||
newLineNo++;
|
showDivider
|
||||||
}
|
/>
|
||||||
return (
|
</View>
|
||||||
<DiffLineView
|
);
|
||||||
key={`${hunkIndex}-${lineIndex}`}
|
})
|
||||||
line={line}
|
: file.hunks.map((hunk, hunkIndex) => {
|
||||||
lineNumber={lineNumber}
|
let oldLineNo = hunk.oldStart;
|
||||||
gutterWidth={gutterWidth}
|
let newLineNo = hunk.newStart;
|
||||||
/>
|
return hunk.lines.map((line, lineIndex) => {
|
||||||
);
|
let lineNumber: number | null = null;
|
||||||
});
|
if (line.type === "remove") {
|
||||||
});
|
lineNumber = oldLineNo;
|
||||||
})();
|
oldLineNo++;
|
||||||
|
} else if (line.type === "add") {
|
||||||
|
lineNumber = newLineNo;
|
||||||
|
newLineNo++;
|
||||||
|
} else if (line.type === "context") {
|
||||||
|
lineNumber = newLineNo;
|
||||||
|
oldLineNo++;
|
||||||
|
newLineNo++;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<DiffLineView
|
||||||
|
key={`${hunkIndex}-${lineIndex}`}
|
||||||
|
line={line}
|
||||||
|
lineNumber={lineNumber}
|
||||||
|
gutterWidth={gutterWidth}
|
||||||
|
wrapLines={wrapLines}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableWidth = bodyWidth > 0 ? bodyWidth : scrollViewWidth;
|
||||||
|
const contentContainer = (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
layout === "split" ? styles.splitLinesContainer : styles.linesContainer,
|
||||||
|
availableWidth > 0 &&
|
||||||
|
(layout === "split"
|
||||||
|
? { width: availableWidth, minWidth: availableWidth, maxWidth: availableWidth }
|
||||||
|
: { minWidth: availableWidth }),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{linesContent}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
if (wrapLines) {
|
if (wrapLines) {
|
||||||
return (
|
return <View style={styles.diffContent}>{contentContainer}</View>;
|
||||||
<View style={styles.diffContent}>
|
|
||||||
<View style={styles.linesContainer}>{linesContent}</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -328,11 +445,7 @@ function DiffFileBody({
|
|||||||
style={styles.diffContent}
|
style={styles.diffContent}
|
||||||
contentContainerStyle={styles.diffContentInner}
|
contentContainerStyle={styles.diffContentInner}
|
||||||
>
|
>
|
||||||
<View
|
{contentContainer}
|
||||||
style={[styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }]}
|
|
||||||
>
|
|
||||||
{linesContent}
|
|
||||||
</View>
|
|
||||||
</DiffScroll>
|
</DiffScroll>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@@ -355,26 +468,31 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||||
|
const canUseSplitLayout = Platform.OS === "web" && !isMobile;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
|
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
||||||
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
||||||
const [wrapLines, setWrapLines] = useState(false);
|
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
|
||||||
|
useChangesPreferences();
|
||||||
useEffect(() => {
|
const wrapLines = changesPreferences.wrapLines;
|
||||||
AsyncStorage.getItem("diff-wrap-lines").then((value) => {
|
const effectiveLayout = canUseSplitLayout ? changesPreferences.layout : "unified";
|
||||||
if (value === "true") setWrapLines(true);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleToggleWrapLines = useCallback(() => {
|
const handleToggleWrapLines = useCallback(() => {
|
||||||
setWrapLines((prev) => {
|
void updateChangesPreferences({ wrapLines: !wrapLines });
|
||||||
const next = !prev;
|
}, [updateChangesPreferences, wrapLines]);
|
||||||
AsyncStorage.setItem("diff-wrap-lines", String(next));
|
|
||||||
return next;
|
const handleLayoutChange = useCallback(
|
||||||
});
|
(nextLayout: "unified" | "split") => {
|
||||||
}, []);
|
void updateChangesPreferences({ layout: nextLayout });
|
||||||
|
},
|
||||||
|
[updateChangesPreferences],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleHideWhitespace = useCallback(() => {
|
||||||
|
void updateChangesPreferences({ hideWhitespace: !changesPreferences.hideWhitespace });
|
||||||
|
}, [changesPreferences.hideWhitespace, updateChangesPreferences]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
status,
|
status,
|
||||||
@@ -410,6 +528,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
cwd,
|
cwd,
|
||||||
mode: diffMode,
|
mode: diffMode,
|
||||||
baseRef,
|
baseRef,
|
||||||
|
ignoreWhitespace: changesPreferences.hideWhitespace,
|
||||||
enabled: isGit,
|
enabled: isGit,
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
@@ -424,7 +543,25 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
});
|
});
|
||||||
// Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches
|
// Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches
|
||||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||||
const [expandedByPath, setExpandedByPath] = useState<Record<string, boolean>>({});
|
const normalizedWorkspaceRoot = useMemo(() => cwd.trim(), [cwd]);
|
||||||
|
const workspaceStateKey = useMemo(
|
||||||
|
() =>
|
||||||
|
buildWorkspaceExplorerStateKey({
|
||||||
|
workspaceId,
|
||||||
|
workspaceRoot: normalizedWorkspaceRoot,
|
||||||
|
}),
|
||||||
|
[normalizedWorkspaceRoot, workspaceId],
|
||||||
|
);
|
||||||
|
const expandedPathsArray = usePanelStore((state) =>
|
||||||
|
workspaceStateKey ? state.diffExpandedPathsByWorkspace[workspaceStateKey] : undefined,
|
||||||
|
);
|
||||||
|
const setDiffExpandedPathsForWorkspace = usePanelStore(
|
||||||
|
(state) => state.setDiffExpandedPathsForWorkspace,
|
||||||
|
);
|
||||||
|
const expandedPaths = useMemo(
|
||||||
|
() => new Set(expandedPathsArray ?? []),
|
||||||
|
[expandedPathsArray],
|
||||||
|
);
|
||||||
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
|
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
|
||||||
const scrollbar = useWebScrollViewScrollbar(diffListRef, {
|
const scrollbar = useWebScrollViewScrollbar(diffListRef, {
|
||||||
enabled: showDesktopWebScrollbar,
|
enabled: showDesktopWebScrollbar,
|
||||||
@@ -484,7 +621,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
const stickyIndices: number[] = [];
|
const stickyIndices: number[] = [];
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
const file = files[i];
|
const file = files[i];
|
||||||
const isExpanded = expandedByPath[file.path] ?? false;
|
const isExpanded = expandedPaths.has(file.path);
|
||||||
items.push({ type: "header", file, fileIndex: i, isExpanded });
|
items.push({ type: "header", file, fileIndex: i, isExpanded });
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
stickyIndices.push(items.length - 1);
|
stickyIndices.push(items.length - 1);
|
||||||
@@ -494,7 +631,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { flatItems: items, stickyHeaderIndices: stickyIndices };
|
return { flatItems: items, stickyHeaderIndices: stickyIndices };
|
||||||
}, [files, expandedByPath]);
|
}, [expandedPaths, files]);
|
||||||
|
|
||||||
const handleHeaderHeightChange = useCallback((path: string, height: number) => {
|
const handleHeaderHeightChange = useCallback((path: string, height: number) => {
|
||||||
if (!Number.isFinite(height) || height <= 0) {
|
if (!Number.isFinite(height) || height <= 0) {
|
||||||
@@ -540,18 +677,21 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
offset += headerHeightByPathRef.current[file.path] ?? defaultHeaderHeight;
|
offset += headerHeightByPathRef.current[file.path] ?? defaultHeaderHeight;
|
||||||
if (expandedByPath[file.path]) {
|
if (expandedPaths.has(file.path)) {
|
||||||
offset += bodyHeightByPathRef.current[file.path] ?? 0;
|
offset += bodyHeightByPathRef.current[file.path] ?? 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Math.max(0, offset);
|
return Math.max(0, offset);
|
||||||
},
|
},
|
||||||
[expandedByPath, files],
|
[expandedPaths, files],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleToggleExpanded = useCallback(
|
const handleToggleExpanded = useCallback(
|
||||||
(path: string) => {
|
(path: string) => {
|
||||||
const isCurrentlyExpanded = expandedByPath[path] ?? false;
|
if (!workspaceStateKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isCurrentlyExpanded = expandedPaths.has(path);
|
||||||
const nextExpanded = !isCurrentlyExpanded;
|
const nextExpanded = !isCurrentlyExpanded;
|
||||||
const targetOffset = isCurrentlyExpanded ? computeHeaderOffset(path) : null;
|
const targetOffset = isCurrentlyExpanded ? computeHeaderOffset(path) : null;
|
||||||
const headerHeight = headerHeightByPathRef.current[path] ?? defaultHeaderHeightRef.current;
|
const headerHeight = headerHeightByPathRef.current[path] ?? defaultHeaderHeightRef.current;
|
||||||
@@ -573,32 +713,32 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setExpandedByPath((prev) => ({
|
const nextPaths = nextExpanded
|
||||||
...prev,
|
? [...expandedPaths, path]
|
||||||
// Use a deterministic target value (instead of toggling from prev) so duplicate
|
: Array.from(expandedPaths).filter((expandedPath) => expandedPath !== path);
|
||||||
// onPress events from sticky headers on Android can't flip back immediately.
|
setDiffExpandedPathsForWorkspace(workspaceStateKey, nextPaths);
|
||||||
[path]: nextExpanded,
|
|
||||||
}));
|
|
||||||
},
|
},
|
||||||
[computeHeaderOffset, expandedByPath],
|
[computeHeaderOffset, expandedPaths, setDiffExpandedPathsForWorkspace, workspaceStateKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
const allExpanded = useMemo(() => {
|
const allExpanded = useMemo(() => {
|
||||||
if (files.length === 0) return false;
|
if (files.length === 0) return false;
|
||||||
return files.every((file) => expandedByPath[file.path]);
|
return files.every((file) => expandedPaths.has(file.path));
|
||||||
}, [files, expandedByPath]);
|
}, [expandedPaths, files]);
|
||||||
|
|
||||||
const handleToggleExpandAll = useCallback(() => {
|
const handleToggleExpandAll = useCallback(() => {
|
||||||
if (allExpanded) {
|
if (!workspaceStateKey) {
|
||||||
setExpandedByPath({});
|
return;
|
||||||
} else {
|
|
||||||
const newExpanded: Record<string, boolean> = {};
|
|
||||||
for (const file of files) {
|
|
||||||
newExpanded[file.path] = true;
|
|
||||||
}
|
|
||||||
setExpandedByPath(newExpanded);
|
|
||||||
}
|
}
|
||||||
}, [allExpanded, files]);
|
if (allExpanded) {
|
||||||
|
setDiffExpandedPathsForWorkspace(workspaceStateKey, []);
|
||||||
|
} else {
|
||||||
|
setDiffExpandedPathsForWorkspace(
|
||||||
|
workspaceStateKey,
|
||||||
|
files.map((file) => file.path),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [allExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
|
||||||
|
|
||||||
// Reset manual refresh flag when fetch completes
|
// Reset manual refresh flag when fetch completes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -726,13 +866,20 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
return (
|
return (
|
||||||
<DiffFileBody
|
<DiffFileBody
|
||||||
file={item.file}
|
file={item.file}
|
||||||
|
layout={effectiveLayout}
|
||||||
wrapLines={wrapLines}
|
wrapLines={wrapLines}
|
||||||
onBodyHeightChange={handleBodyHeightChange}
|
onBodyHeightChange={handleBodyHeightChange}
|
||||||
testID={`diff-file-${item.fileIndex}-body`}
|
testID={`diff-file-${item.fileIndex}-body`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded, wrapLines],
|
[
|
||||||
|
effectiveLayout,
|
||||||
|
handleBodyHeightChange,
|
||||||
|
handleHeaderHeightChange,
|
||||||
|
handleToggleExpanded,
|
||||||
|
wrapLines,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const flatKeyExtractor = useCallback(
|
const flatKeyExtractor = useCallback(
|
||||||
@@ -821,7 +968,11 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
bodyContent = (
|
bodyContent = (
|
||||||
<View style={styles.emptyContainer}>
|
<View style={styles.emptyContainer}>
|
||||||
<Text style={styles.emptyText}>
|
<Text style={styles.emptyText}>
|
||||||
{diffMode === "uncommitted" ? "No uncommitted changes" : `No changes vs ${baseRefLabel}`}
|
{changesPreferences.hideWhitespace
|
||||||
|
? "No visible changes after hiding whitespace"
|
||||||
|
: diffMode === "uncommitted"
|
||||||
|
? "No uncommitted changes"
|
||||||
|
: `No changes vs ${baseRefLabel}`}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -833,7 +984,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
renderItem={renderFlatItem}
|
renderItem={renderFlatItem}
|
||||||
keyExtractor={flatKeyExtractor}
|
keyExtractor={flatKeyExtractor}
|
||||||
stickyHeaderIndices={stickyHeaderIndices}
|
stickyHeaderIndices={stickyHeaderIndices}
|
||||||
extraData={expandedByPath}
|
extraData={{ expandedPathsArray, effectiveLayout, wrapLines }}
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
contentContainerStyle={styles.contentContainer}
|
contentContainerStyle={styles.contentContainer}
|
||||||
testID="git-diff-scroll"
|
testID="git-diff-scroll"
|
||||||
@@ -1019,56 +1170,149 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
{files.length > 0 ? (
|
<View style={styles.diffStatusButtons}>
|
||||||
<View style={styles.diffStatusButtons}>
|
{canUseSplitLayout ? (
|
||||||
<Tooltip delayDuration={300}>
|
<View style={styles.toggleButtonGroup}>
|
||||||
<TooltipTrigger asChild>
|
<Tooltip delayDuration={300}>
|
||||||
<Pressable
|
<TooltipTrigger asChild>
|
||||||
style={({ hovered, pressed }) => [
|
<Pressable
|
||||||
styles.expandAllButton,
|
accessibilityRole="button"
|
||||||
(hovered || pressed) && styles.diffStatusRowHovered,
|
accessibilityLabel="Unified diff"
|
||||||
]}
|
testID="changes-layout-unified"
|
||||||
onPress={handleToggleWrapLines}
|
onPress={() => handleLayoutChange("unified")}
|
||||||
>
|
style={({ hovered, pressed }) => [
|
||||||
<WrapText size={isMobile ? 18 : 14} color={theme.colors.foregroundMuted} />
|
styles.toggleButton,
|
||||||
</Pressable>
|
styles.toggleButtonGroupStart,
|
||||||
</TooltipTrigger>
|
changesPreferences.layout === "unified" && styles.toggleButtonSelected,
|
||||||
<TooltipContent side="bottom">
|
(hovered || pressed) && styles.diffStatusRowHovered,
|
||||||
<Text style={styles.tooltipText}>
|
]}
|
||||||
{wrapLines ? "Scroll long lines" : "Wrap long lines"}
|
>
|
||||||
</Text>
|
<AlignJustify
|
||||||
</TooltipContent>
|
size={14}
|
||||||
</Tooltip>
|
color={
|
||||||
<Tooltip delayDuration={300}>
|
changesPreferences.layout === "unified"
|
||||||
<TooltipTrigger asChild>
|
? theme.colors.foreground
|
||||||
<Pressable
|
: theme.colors.foregroundMuted
|
||||||
style={({ hovered, pressed }) => [
|
}
|
||||||
styles.expandAllButton,
|
|
||||||
(hovered || pressed) && styles.diffStatusRowHovered,
|
|
||||||
]}
|
|
||||||
onPress={handleToggleExpandAll}
|
|
||||||
>
|
|
||||||
{allExpanded ? (
|
|
||||||
<ListChevronsDownUp
|
|
||||||
size={isMobile ? 18 : 14}
|
|
||||||
color={theme.colors.foregroundMuted}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
</Pressable>
|
||||||
<ListChevronsUpDown
|
</TooltipTrigger>
|
||||||
size={isMobile ? 18 : 14}
|
<TooltipContent side="bottom">
|
||||||
color={theme.colors.foregroundMuted}
|
<Text style={styles.tooltipText}>Unified diff</Text>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip delayDuration={300}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Side-by-side diff"
|
||||||
|
testID="changes-layout-split"
|
||||||
|
onPress={() => handleLayoutChange("split")}
|
||||||
|
style={({ hovered, pressed }) => [
|
||||||
|
styles.toggleButton,
|
||||||
|
styles.toggleButtonGroupEnd,
|
||||||
|
changesPreferences.layout === "split" && styles.toggleButtonSelected,
|
||||||
|
(hovered || pressed) && styles.diffStatusRowHovered,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Columns2
|
||||||
|
size={14}
|
||||||
|
color={
|
||||||
|
changesPreferences.layout === "split"
|
||||||
|
? theme.colors.foreground
|
||||||
|
: theme.colors.foregroundMuted
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
</Pressable>
|
||||||
</Pressable>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<TooltipContent side="bottom">
|
||||||
<TooltipContent side="bottom">
|
<Text style={styles.tooltipText}>Side-by-side diff</Text>
|
||||||
<Text style={styles.tooltipText}>
|
</TooltipContent>
|
||||||
{allExpanded ? "Collapse all files" : "Expand all files"}
|
</Tooltip>
|
||||||
</Text>
|
</View>
|
||||||
</TooltipContent>
|
) : null}
|
||||||
</Tooltip>
|
<Tooltip delayDuration={300}>
|
||||||
</View>
|
<TooltipTrigger asChild>
|
||||||
) : null}
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Hide whitespace"
|
||||||
|
testID="changes-toggle-whitespace"
|
||||||
|
style={({ hovered, pressed }) => [
|
||||||
|
styles.expandAllButton,
|
||||||
|
changesPreferences.hideWhitespace && styles.toggleButtonSelected,
|
||||||
|
(hovered || pressed) && styles.diffStatusRowHovered,
|
||||||
|
]}
|
||||||
|
onPress={handleToggleHideWhitespace}
|
||||||
|
>
|
||||||
|
<Pilcrow
|
||||||
|
size={isMobile ? 18 : 14}
|
||||||
|
color={
|
||||||
|
changesPreferences.hideWhitespace
|
||||||
|
? theme.colors.foreground
|
||||||
|
: theme.colors.foregroundMuted
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
<Text style={styles.tooltipText}>Hide whitespace</Text>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
{files.length > 0 ? (
|
||||||
|
<View style={styles.diffStatusButtons}>
|
||||||
|
<Tooltip delayDuration={300}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Pressable
|
||||||
|
style={({ hovered, pressed }) => [
|
||||||
|
styles.expandAllButton,
|
||||||
|
wrapLines && styles.toggleButtonSelected,
|
||||||
|
(hovered || pressed) && styles.diffStatusRowHovered,
|
||||||
|
]}
|
||||||
|
onPress={handleToggleWrapLines}
|
||||||
|
>
|
||||||
|
<WrapText
|
||||||
|
size={isMobile ? 18 : 14}
|
||||||
|
color={wrapLines ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
<Text style={styles.tooltipText}>
|
||||||
|
{wrapLines ? "Scroll long lines" : "Wrap long lines"}
|
||||||
|
</Text>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip delayDuration={300}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Pressable
|
||||||
|
style={({ hovered, pressed }) => [
|
||||||
|
styles.expandAllButton,
|
||||||
|
(hovered || pressed) && styles.diffStatusRowHovered,
|
||||||
|
]}
|
||||||
|
onPress={handleToggleExpandAll}
|
||||||
|
>
|
||||||
|
{allExpanded ? (
|
||||||
|
<ListChevronsDownUp
|
||||||
|
size={isMobile ? 18 : 14}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ListChevronsUpDown
|
||||||
|
size={isMobile ? 18 : 14}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
<Text style={styles.tooltipText}>
|
||||||
|
{allExpanded ? "Collapse all files" : "Expand all files"}
|
||||||
|
</Text>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1160,11 +1404,42 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
diffStatusButtons: {
|
diffStatusButtons: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: {
|
gap: theme.spacing[1],
|
||||||
xs: theme.spacing[1],
|
flexWrap: "wrap",
|
||||||
sm: theme.spacing[1],
|
},
|
||||||
md: 0,
|
toggleButtonGroup: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
toggleButton: {
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
minWidth: {
|
||||||
|
xs: 32,
|
||||||
|
sm: 32,
|
||||||
|
md: 24,
|
||||||
},
|
},
|
||||||
|
height: {
|
||||||
|
xs: 32,
|
||||||
|
sm: 32,
|
||||||
|
md: 24,
|
||||||
|
},
|
||||||
|
paddingHorizontal: {
|
||||||
|
xs: theme.spacing[2],
|
||||||
|
sm: theme.spacing[2],
|
||||||
|
md: theme.spacing[1],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
toggleButtonGroupStart: {
|
||||||
|
borderTopLeftRadius: theme.borderRadius.base,
|
||||||
|
borderBottomLeftRadius: theme.borderRadius.base,
|
||||||
|
},
|
||||||
|
toggleButtonGroupEnd: {
|
||||||
|
borderTopRightRadius: theme.borderRadius.base,
|
||||||
|
borderBottomRightRadius: theme.borderRadius.base,
|
||||||
|
},
|
||||||
|
toggleButtonSelected: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
expandAllButton: {
|
expandAllButton: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -1343,6 +1618,36 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
linesContainer: {
|
linesContainer: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
|
splitLinesContainer: {
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
minWidth: 760,
|
||||||
|
},
|
||||||
|
splitRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "stretch",
|
||||||
|
width: "100%",
|
||||||
|
minWidth: "100%",
|
||||||
|
},
|
||||||
|
splitHeaderRow: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
},
|
||||||
|
splitCell: {
|
||||||
|
flex: 1,
|
||||||
|
flexBasis: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "stretch",
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
emptySplitCell: {
|
||||||
|
backgroundColor: theme.colors.surfaceDiffEmpty,
|
||||||
|
},
|
||||||
|
splitCellWithDivider: {
|
||||||
|
borderLeftWidth: theme.borderWidth[1],
|
||||||
|
borderLeftColor: theme.colors.border,
|
||||||
|
},
|
||||||
diffLineContainer: {
|
diffLineContainer: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "stretch",
|
alignItems: "stretch",
|
||||||
@@ -1352,13 +1657,13 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderRightColor: theme.colors.border,
|
borderRightColor: theme.colors.border,
|
||||||
marginRight: theme.spacing[2],
|
marginRight: theme.spacing[2],
|
||||||
alignSelf: "stretch",
|
alignSelf: "stretch",
|
||||||
justifyContent: "center",
|
justifyContent: "flex-start",
|
||||||
},
|
},
|
||||||
lineNumberText: {
|
lineNumberText: {
|
||||||
textAlign: "right",
|
textAlign: "right",
|
||||||
paddingRight: theme.spacing[2],
|
paddingRight: theme.spacing[2],
|
||||||
paddingVertical: theme.spacing[1],
|
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
|
lineHeight: theme.lineHeight.diff,
|
||||||
fontFamily: Fonts.mono,
|
fontFamily: Fonts.mono,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
@@ -1372,8 +1677,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
diffLineText: {
|
diffLineText: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingRight: theme.spacing[3],
|
paddingRight: theme.spacing[3],
|
||||||
paddingVertical: theme.spacing[1],
|
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
|
lineHeight: theme.lineHeight.diff,
|
||||||
fontFamily: Fonts.mono,
|
fontFamily: Fonts.mono,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
userSelect: "text",
|
userSelect: "text",
|
||||||
@@ -1402,6 +1707,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
contextLineText: {
|
contextLineText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
|
emptySplitCellText: {
|
||||||
|
color: "transparent",
|
||||||
|
},
|
||||||
statusMessageContainer: {
|
statusMessageContainer: {
|
||||||
borderTopWidth: theme.borderWidth[1],
|
borderTopWidth: theme.borderWidth[1],
|
||||||
borderTopColor: theme.colors.border,
|
borderTopColor: theme.colors.border,
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Image, type ImageSourcePropType } from "react-native";
|
||||||
|
import type { EditorTargetId } from "@server/shared/messages";
|
||||||
|
|
||||||
|
interface EditorAppIconProps {
|
||||||
|
editorId: EditorTargetId;
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
|
const EDITOR_APP_IMAGES: Record<EditorTargetId, ImageSourcePropType> = {
|
||||||
|
cursor: require("../../../assets/images/editor-apps/cursor.png"),
|
||||||
|
vscode: require("../../../assets/images/editor-apps/vscode.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 EditorAppIcon({
|
||||||
|
editorId,
|
||||||
|
size = 16,
|
||||||
|
}: EditorAppIconProps) {
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
source={EDITOR_APP_IMAGES[editorId]}
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -80,6 +80,8 @@ export interface MessageInputProps {
|
|||||||
isInputActive?: boolean;
|
isInputActive?: boolean;
|
||||||
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
|
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
|
||||||
leftContent?: React.ReactNode;
|
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) */
|
/** Content to render on the right side after voice button (e.g., realtime button, cancel button) */
|
||||||
rightContent?: React.ReactNode;
|
rightContent?: React.ReactNode;
|
||||||
voiceServerId?: string;
|
voiceServerId?: string;
|
||||||
@@ -207,6 +209,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
isInputActive = true,
|
isInputActive = true,
|
||||||
leftContent,
|
leftContent,
|
||||||
|
beforeVoiceContent,
|
||||||
rightContent,
|
rightContent,
|
||||||
voiceServerId,
|
voiceServerId,
|
||||||
voiceAgentId,
|
voiceAgentId,
|
||||||
@@ -1024,6 +1027,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
|
|
||||||
{/* Right: voice button, contextual button (realtime/send/cancel) */}
|
{/* Right: voice button, contextual button (realtime/send/cancel) */}
|
||||||
<View style={styles.rightButtonGroup}>
|
<View style={styles.rightButtonGroup}>
|
||||||
|
{beforeVoiceContent}
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
onPress={handleVoicePress}
|
onPress={handleVoicePress}
|
||||||
|
|||||||
@@ -78,9 +78,17 @@ function createPlanMarkdownRules() {
|
|||||||
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
|
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
|
||||||
|
|
||||||
return (
|
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={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>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -203,12 +203,12 @@ export function ComboboxItem({
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{leadingContent}
|
{leadingContent}
|
||||||
<View style={styles.comboboxItemContent}>
|
<View style={[styles.comboboxItemContent, description && styles.comboboxItemContentInline]}>
|
||||||
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
|
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
{description ? (
|
{description ? (
|
||||||
<Text numberOfLines={2} style={styles.comboboxItemDescription}>
|
<Text numberOfLines={1} style={styles.comboboxItemDescription}>
|
||||||
{description}
|
{description}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -838,6 +838,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
},
|
},
|
||||||
|
comboboxItemContentInline: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "baseline",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
comboboxItemLeadingSlot: {
|
comboboxItemLeadingSlot: {
|
||||||
width: 16,
|
width: 16,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -848,9 +853,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
},
|
},
|
||||||
comboboxItemDescription: {
|
comboboxItemDescription: {
|
||||||
marginTop: 2,
|
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
|
flexShrink: 1,
|
||||||
},
|
},
|
||||||
emptyText: {
|
emptyText: {
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ type TooltipContextValue = {
|
|||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
triggerRef: React.RefObject<View | null>;
|
triggerRef: React.RefObject<View | null>;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
openOnPress: boolean;
|
||||||
delayDuration: number;
|
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({
|
function computePosition({
|
||||||
triggerRect,
|
triggerRect,
|
||||||
contentSize,
|
contentSize,
|
||||||
@@ -214,12 +227,8 @@ export function Tooltip({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isWeb = Platform.OS === "web";
|
const isMobile = isMobileTooltipEnvironment();
|
||||||
const isMobileWeb =
|
const enabled = isMobile ? enabledOnMobile : enabledOnDesktop;
|
||||||
isWeb &&
|
|
||||||
typeof navigator !== "undefined" &&
|
|
||||||
/Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
|
|
||||||
const enabled = isWeb ? (isMobileWeb ? enabledOnMobile : enabledOnDesktop) : enabledOnMobile;
|
|
||||||
|
|
||||||
const value = useMemo<TooltipContextValue>(
|
const value = useMemo<TooltipContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -227,9 +236,10 @@ export function Tooltip({
|
|||||||
setOpen: setIsOpen,
|
setOpen: setIsOpen,
|
||||||
triggerRef,
|
triggerRef,
|
||||||
enabled,
|
enabled,
|
||||||
|
openOnPress: isMobile,
|
||||||
delayDuration,
|
delayDuration,
|
||||||
}),
|
}),
|
||||||
[isOpen, setIsOpen, enabled, delayDuration],
|
[isOpen, setIsOpen, enabled, isMobile, delayDuration],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
|
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
|
||||||
@@ -322,9 +332,17 @@ export function TooltipTrigger({
|
|||||||
const handlePress = useCallback(
|
const handlePress = useCallback(
|
||||||
(e: any) => {
|
(e: any) => {
|
||||||
onPress?.(e);
|
onPress?.(e);
|
||||||
|
if (!ctx.enabled || disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ctx.openOnPress) {
|
||||||
|
clearOpenTimer();
|
||||||
|
ctx.setOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
close();
|
close();
|
||||||
},
|
},
|
||||||
[close, onPress],
|
[clearOpenTimer, close, ctx, disabled, onPress],
|
||||||
);
|
);
|
||||||
|
|
||||||
const triggerProps = {
|
const triggerProps = {
|
||||||
@@ -491,7 +509,7 @@ export function TooltipContent({
|
|||||||
statusBarTranslucent={Platform.OS === "android"}
|
statusBarTranslucent={Platform.OS === "android"}
|
||||||
onRequestClose={() => ctx.setOpen(false)}
|
onRequestClose={() => ctx.setOpen(false)}
|
||||||
>
|
>
|
||||||
<View pointerEvents="box-none" style={styles.overlay}>
|
<Pressable style={styles.overlay} onPress={() => ctx.setOpen(false)}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
entering={FadeIn.duration(80)}
|
entering={FadeIn.duration(80)}
|
||||||
@@ -512,7 +530,7 @@ export function TooltipContent({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</Pressable>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useClientActivity } from "@/hooks/use-client-activity";
|
import { useClientActivity } from "@/hooks/use-client-activity";
|
||||||
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
|
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
|
||||||
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
|
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
|
||||||
|
import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
import { generateMessageId, type StreamItem } from "@/types/stream";
|
import { generateMessageId, type StreamItem } from "@/types/stream";
|
||||||
import {
|
import {
|
||||||
processTimelineResponse,
|
processTimelineResponse,
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
type Agent,
|
type Agent,
|
||||||
type SessionState,
|
type SessionState,
|
||||||
type WorkspaceDescriptor,
|
type WorkspaceDescriptor,
|
||||||
|
mergeWorkspaceSnapshotWithExisting,
|
||||||
normalizeWorkspaceDescriptor,
|
normalizeWorkspaceDescriptor,
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
import { useDraftStore } from "@/stores/draft-store";
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
@@ -69,6 +71,22 @@ export type {
|
|||||||
const HISTORY_STALE_AFTER_MS = 60_000;
|
const HISTORY_STALE_AFTER_MS = 60_000;
|
||||||
const AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS = 300;
|
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"];
|
type AudioOutputPayload = Extract<SessionOutboundMessage, { type: "audio_output" }>["payload"];
|
||||||
|
|
||||||
interface BufferedAudioChunk {
|
interface BufferedAudioChunk {
|
||||||
@@ -318,6 +336,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
const workspaces = new Map<string, WorkspaceDescriptor>();
|
const workspaces = new Map<string, WorkspaceDescriptor>();
|
||||||
|
const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||||
let cursor: string | null = null;
|
let cursor: string | null = null;
|
||||||
let includeSubscribe = options?.subscribe ?? false;
|
let includeSubscribe = options?.subscribe ?? false;
|
||||||
|
|
||||||
@@ -333,7 +352,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
|
|
||||||
for (const entry of payload.entries) {
|
for (const entry of payload.entries) {
|
||||||
const workspace = normalizeWorkspaceDescriptor(entry);
|
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) {
|
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
||||||
@@ -358,6 +383,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
setAgents(serverId, (prev) => {
|
setAgents(serverId, (prev) => {
|
||||||
const current = prev.get(agent.id);
|
const current = prev.get(agent.id);
|
||||||
if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) {
|
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;
|
return prev;
|
||||||
}
|
}
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
@@ -611,6 +645,34 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
updateSessionClient(serverId, client);
|
updateSessionClient(serverId, client);
|
||||||
}, [serverId, client, updateSessionClient]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!voiceRuntime) {
|
if (!voiceRuntime) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useQuery, useQueries, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
import {
|
||||||
AGENT_PROVIDER_DEFINITIONS,
|
AGENT_PROVIDER_DEFINITIONS,
|
||||||
type AgentProviderDefinition,
|
type AgentProviderDefinition,
|
||||||
@@ -11,8 +10,7 @@ import type {
|
|||||||
ProviderSnapshotEntry,
|
ProviderSnapshotEntry,
|
||||||
} from "@server/server/agent/agent-sdk-types";
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useProvidersSnapshot } from "./use-providers-snapshot";
|
||||||
import { useSessionForServer } from "./use-session-directory";
|
|
||||||
import {
|
import {
|
||||||
useFormPreferences,
|
useFormPreferences,
|
||||||
mergeProviderPreferences,
|
mergeProviderPreferences,
|
||||||
@@ -320,10 +318,6 @@ function combineInitialValues(
|
|||||||
return initialValues;
|
return initialValues;
|
||||||
}
|
}
|
||||||
|
|
||||||
function providersSnapshotQueryKey(serverId: string | null, cwd: string | undefined) {
|
|
||||||
return ["providersSnapshot", serverId, cwd ?? ""] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult {
|
export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult {
|
||||||
const {
|
const {
|
||||||
initialServerId = null,
|
initialServerId = null,
|
||||||
@@ -341,7 +335,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
} = useFormPreferences();
|
} = useFormPreferences();
|
||||||
|
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
// Build a set of valid server IDs for preference validation
|
// Build a set of valid server IDs for preference validation
|
||||||
const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]);
|
const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]);
|
||||||
@@ -374,95 +367,19 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
}
|
}
|
||||||
}, [isVisible]);
|
}, [isVisible]);
|
||||||
|
|
||||||
// Session state for provider model listing
|
const {
|
||||||
const client = useHostRuntimeClient(formState.serverId ?? "");
|
entries: snapshotEntries,
|
||||||
const isConnected = useHostRuntimeIsConnected(formState.serverId ?? "");
|
isLoading: snapshotIsLoading,
|
||||||
const supportsProvidersSnapshot = useSessionForServer(
|
isFetching: snapshotIsFetching,
|
||||||
formState.serverId,
|
error: snapshotError,
|
||||||
(session) => session?.serverInfo?.features?.providersSnapshot === true,
|
refresh: refreshSnapshot,
|
||||||
);
|
} = useProvidersSnapshot(formState.serverId);
|
||||||
|
|
||||||
const [debouncedCwd, setDebouncedCwd] = useState<string | undefined>(undefined);
|
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
|
||||||
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 snapshotProviderDefinitions = useMemo(() => {
|
const snapshotProviderDefinitions = useMemo(() => {
|
||||||
if (!supportsProvidersSnapshot) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const snapshotProviders = new Set((snapshotEntries ?? []).map((entry) => entry.provider));
|
const snapshotProviders = new Set((snapshotEntries ?? []).map((entry) => entry.provider));
|
||||||
return allProviderDefinitions.filter((definition) => snapshotProviders.has(definition.id));
|
return allProviderDefinitions.filter((definition) => snapshotProviders.has(definition.id));
|
||||||
}, [snapshotEntries, supportsProvidersSnapshot]);
|
}, [snapshotEntries]);
|
||||||
const snapshotProviderDefinitionMap = useMemo(
|
const snapshotProviderDefinitionMap = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new Map<AgentProvider, AgentProviderDefinition>(
|
new Map<AgentProvider, AgentProviderDefinition>(
|
||||||
@@ -498,157 +415,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
snapshotSelectedEntry?.modes ??
|
snapshotSelectedEntry?.modes ??
|
||||||
snapshotProviderDefinitionMap.get(formState.provider)?.modes ??
|
snapshotProviderDefinitionMap.get(formState.provider)?.modes ??
|
||||||
[];
|
[];
|
||||||
|
const providerDefinitions = snapshotProviderDefinitions;
|
||||||
// COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots
|
const providerDefinitionMap = snapshotProviderDefinitionMap;
|
||||||
const availableProvidersQuery = useQuery({
|
const selectableProviderDefinitionMap = snapshotSelectableProviderDefinitionMap;
|
||||||
queryKey: ["availableProviders", formState.serverId],
|
const allProviderModels = snapshotAllProviderModels;
|
||||||
enabled: Boolean(
|
const availableModels = snapshotSelectedProviderModels;
|
||||||
!supportsProvidersSnapshot &&
|
const modeOptions = snapshotSelectedProviderModes;
|
||||||
isVisible &&
|
const isAllModelsLoading = snapshotIsLoading || snapshotIsFetching;
|
||||||
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);
|
|
||||||
|
|
||||||
// Combine initialValues with initialServerId for resolution
|
// Combine initialValues with initialServerId for resolution
|
||||||
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
|
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
|
||||||
@@ -870,15 +643,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refreshProviderModels = useCallback(() => {
|
const refreshProviderModels = useCallback(() => {
|
||||||
if (supportsProvidersSnapshot) {
|
refreshSnapshot();
|
||||||
if (!client) {
|
}, [refreshSnapshot]);
|
||||||
return;
|
|
||||||
}
|
|
||||||
void client.refreshProvidersSnapshot({ cwd: debouncedCwd });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void legacySelectedProviderModelsQuery.refetch();
|
|
||||||
}, [client, debouncedCwd, legacySelectedProviderModelsQuery, supportsProvidersSnapshot]);
|
|
||||||
|
|
||||||
const persistFormPreferences = useCallback(async () => {
|
const persistFormPreferences = useCallback(async () => {
|
||||||
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
||||||
@@ -914,17 +680,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
||||||
const resolvedModelId = effectiveModel?.id ?? formState.model;
|
const resolvedModelId = effectiveModel?.id ?? formState.model;
|
||||||
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||||
const isModelLoading = supportsProvidersSnapshot
|
const isModelLoading = snapshotIsLoading || snapshotIsFetching;
|
||||||
? providersSnapshotQuery.isLoading || providersSnapshotQuery.isFetching
|
const modelError = snapshotError;
|
||||||
: legacySelectedProviderModelsQuery.isLoading || legacySelectedProviderModelsQuery.isFetching;
|
|
||||||
const modelError =
|
|
||||||
supportsProvidersSnapshot
|
|
||||||
? providersSnapshotQuery.error instanceof Error
|
|
||||||
? providersSnapshotQuery.error.message
|
|
||||||
: null
|
|
||||||
: legacySelectedProviderModelsQuery.error instanceof Error
|
|
||||||
? legacySelectedProviderModelsQuery.error.message
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const workingDirIsEmpty = !formState.workingDir.trim();
|
const workingDirIsEmpty = !formState.workingDir.trim();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
cwd: string,
|
||||||
mode: "uncommitted" | "base",
|
mode: "uncommitted" | "base",
|
||||||
baseRef?: string,
|
baseRef?: string,
|
||||||
|
ignoreWhitespace?: boolean,
|
||||||
) {
|
) {
|
||||||
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? ""] as const;
|
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? "", ignoreWhitespace === true] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseCheckoutDiffQueryOptions {
|
interface UseCheckoutDiffQueryOptions {
|
||||||
@@ -22,6 +23,7 @@ interface UseCheckoutDiffQueryOptions {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
mode: "uncommitted" | "base";
|
mode: "uncommitted" | "base";
|
||||||
baseRef?: string;
|
baseRef?: string;
|
||||||
|
ignoreWhitespace?: boolean;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,12 +37,16 @@ export type HighlightToken = NonNullable<DiffLine["tokens"]>[number];
|
|||||||
function normalizeCheckoutDiffCompare(compare: {
|
function normalizeCheckoutDiffCompare(compare: {
|
||||||
mode: "uncommitted" | "base";
|
mode: "uncommitted" | "base";
|
||||||
baseRef?: string;
|
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") {
|
if (compare.mode === "uncommitted") {
|
||||||
return { mode: "uncommitted" };
|
return { mode: "uncommitted", ignoreWhitespace };
|
||||||
}
|
}
|
||||||
const trimmedBaseRef = compare.baseRef?.trim();
|
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({
|
export function useCheckoutDiffQuery({
|
||||||
@@ -48,6 +54,7 @@ export function useCheckoutDiffQuery({
|
|||||||
cwd,
|
cwd,
|
||||||
mode,
|
mode,
|
||||||
baseRef,
|
baseRef,
|
||||||
|
ignoreWhitespace,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
}: UseCheckoutDiffQueryOptions) {
|
}: UseCheckoutDiffQueryOptions) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -60,14 +67,15 @@ export function useCheckoutDiffQuery({
|
|||||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||||
const hookInstanceId = useId();
|
const hookInstanceId = useId();
|
||||||
const normalizedCompare = useMemo(
|
const normalizedCompare = useMemo(
|
||||||
() => normalizeCheckoutDiffCompare({ mode, baseRef }),
|
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
|
||||||
[mode, baseRef],
|
[mode, baseRef, ignoreWhitespace],
|
||||||
);
|
);
|
||||||
const compareMode = normalizedCompare.mode;
|
const compareMode = normalizedCompare.mode;
|
||||||
const compareBaseRef = normalizedCompare.baseRef;
|
const compareBaseRef = normalizedCompare.baseRef;
|
||||||
|
const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace;
|
||||||
const queryKey = useMemo(
|
const queryKey = useMemo(
|
||||||
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef),
|
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
|
||||||
[serverId, cwd, mode, baseRef],
|
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
|
||||||
);
|
);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
@@ -79,6 +87,7 @@ export function useCheckoutDiffQuery({
|
|||||||
const payload = await client.getCheckoutDiff(cwd, {
|
const payload = await client.getCheckoutDiff(cwd, {
|
||||||
mode: compareMode,
|
mode: compareMode,
|
||||||
baseRef: compareBaseRef,
|
baseRef: compareBaseRef,
|
||||||
|
ignoreWhitespace: compareIgnoreWhitespace,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
...payload,
|
...payload,
|
||||||
@@ -104,6 +113,7 @@ export function useCheckoutDiffQuery({
|
|||||||
cwd,
|
cwd,
|
||||||
compareMode,
|
compareMode,
|
||||||
compareBaseRef ?? "",
|
compareBaseRef ?? "",
|
||||||
|
compareIgnoreWhitespace ? "ignore-ws" : "keep-ws",
|
||||||
].join(":");
|
].join(":");
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -145,6 +155,7 @@ export function useCheckoutDiffQuery({
|
|||||||
{
|
{
|
||||||
mode: compareMode,
|
mode: compareMode,
|
||||||
baseRef: compareBaseRef,
|
baseRef: compareBaseRef,
|
||||||
|
ignoreWhitespace: compareIgnoreWhitespace,
|
||||||
},
|
},
|
||||||
{ subscriptionId },
|
{ subscriptionId },
|
||||||
)
|
)
|
||||||
@@ -191,6 +202,7 @@ export function useCheckoutDiffQuery({
|
|||||||
serverId,
|
serverId,
|
||||||
compareMode,
|
compareMode,
|
||||||
compareBaseRef,
|
compareBaseRef,
|
||||||
|
compareIgnoreWhitespace,
|
||||||
queryKey,
|
queryKey,
|
||||||
queryClient,
|
queryClient,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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("returns null when no editors are available", () => {
|
||||||
|
expect(resolvePreferredEditorId([], "cursor")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,6 +1,10 @@
|
|||||||
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
|
||||||
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
|
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
|
||||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
import {
|
||||||
|
mergeWorkspaceSnapshotWithExisting,
|
||||||
|
normalizeWorkspaceDescriptor,
|
||||||
|
useSessionStore,
|
||||||
|
} from "@/stores/session-store";
|
||||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
@@ -372,6 +376,7 @@ export function useSidebarWorkspacesList(options?: {
|
|||||||
}
|
}
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const next = new Map<string, WorkspaceDescriptor>();
|
const next = new Map<string, WorkspaceDescriptor>();
|
||||||
|
const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||||
let cursor: string | null = null;
|
let cursor: string | null = null;
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -381,7 +386,13 @@ export function useSidebarWorkspacesList(options?: {
|
|||||||
});
|
});
|
||||||
for (const entry of payload.entries) {
|
for (const entry of payload.entries) {
|
||||||
const workspace = toWorkspaceDescriptor(entry);
|
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) {
|
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -62,12 +62,12 @@ import { settingsStyles } from "@/styles/settings";
|
|||||||
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
|
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
|
||||||
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
||||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||||
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
import { isCompactFormFactor } from "@/constants/layout";
|
import { isCompactFormFactor } from "@/constants/layout";
|
||||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
||||||
import { StatusBadge } from "@/components/ui/status-badge";
|
import { StatusBadge } from "@/components/ui/status-badge";
|
||||||
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Section definitions
|
// Section definitions
|
||||||
@@ -431,55 +431,49 @@ interface ProvidersSectionProps {
|
|||||||
|
|
||||||
function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const client = useHostRuntimeClient(routeServerId);
|
|
||||||
const isConnected = useHostRuntimeIsConnected(routeServerId);
|
const isConnected = useHostRuntimeIsConnected(routeServerId);
|
||||||
const [entries, setEntries] = useState<ProviderSnapshotEntry[]>([]);
|
const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null);
|
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;
|
const hasServer = routeServerId.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<View style={settingsStyles.section}>
|
<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 ? (
|
{!hasServer || !isConnected ? (
|
||||||
<View style={[settingsStyles.card, styles.emptyCard]}>
|
<View style={[settingsStyles.card, styles.emptyCard]}>
|
||||||
<Text style={styles.emptyText}>Connect to a host to see providers</Text>
|
<Text style={styles.emptyText}>Connect to a host to see providers</Text>
|
||||||
</View>
|
</View>
|
||||||
) : loading ? (
|
) : isLoading ? (
|
||||||
<View style={[settingsStyles.card, styles.emptyCard]}>
|
<View style={[settingsStyles.card, styles.emptyCard]}>
|
||||||
<Text style={styles.emptyText}>Loading...</Text>
|
<Text style={styles.emptyText}>Loading...</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={[settingsStyles.card, styles.audioCard]}>
|
<View style={[settingsStyles.card, styles.audioCard]}>
|
||||||
{AGENT_PROVIDER_DEFINITIONS.map((def) => {
|
{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 status = entry?.status ?? "unavailable";
|
||||||
const ProviderIcon = getProviderIcon(def.id);
|
const ProviderIcon = getProviderIcon(def.id);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -49,6 +49,7 @@ import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
|||||||
import { SplitContainer } from "@/components/split-container";
|
import { SplitContainer } from "@/components/split-container";
|
||||||
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
|
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
|
||||||
import { WorkspaceGitActions } from "@/screens/workspace/workspace-git-actions";
|
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 { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||||
@@ -2098,6 +2099,12 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
}
|
}
|
||||||
right={
|
right={
|
||||||
<View style={styles.headerRight}>
|
<View style={styles.headerRight}>
|
||||||
|
{!isMobile ? (
|
||||||
|
<WorkspaceOpenInEditorButton
|
||||||
|
serverId={normalizedServerId}
|
||||||
|
cwd={normalizedWorkspaceId}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{!isMobile && isGitCheckout ? (
|
{!isMobile && isGitCheckout ? (
|
||||||
<>
|
<>
|
||||||
{workspaceDirectory ? (
|
{workspaceDirectory ? (
|
||||||
|
|||||||
@@ -39,10 +39,10 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
|||||||
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
|
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
|
||||||
"Copy resume command",
|
"Copy resume command",
|
||||||
"Copy agent id",
|
"Copy agent id",
|
||||||
"Reload agent",
|
|
||||||
"Close to the left",
|
"Close to the left",
|
||||||
"Close to the right",
|
"Close to the right",
|
||||||
"Close other tabs",
|
"Close other tabs",
|
||||||
|
"Reload agent",
|
||||||
"Close",
|
"Close",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -66,10 +66,10 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
|||||||
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
|
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
|
||||||
"Copy resume command",
|
"Copy resume command",
|
||||||
"Copy agent id",
|
"Copy agent id",
|
||||||
"Reload agent",
|
|
||||||
"Close tabs above",
|
"Close tabs above",
|
||||||
"Close tabs below",
|
"Close tabs below",
|
||||||
"Close other tabs",
|
"Close other tabs",
|
||||||
|
"Reload agent",
|
||||||
"Close",
|
"Close",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -138,17 +138,6 @@ export function buildWorkspaceTabMenuEntries(
|
|||||||
void onCopyAgentId(agentId);
|
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({
|
entries.push({
|
||||||
kind: "separator",
|
kind: "separator",
|
||||||
key: "copy-separator",
|
key: "copy-separator",
|
||||||
@@ -188,6 +177,20 @@ export function buildWorkspaceTabMenuEntries(
|
|||||||
void onCloseOtherTabs(tab.tabId);
|
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({
|
entries.push({
|
||||||
kind: "item",
|
kind: "item",
|
||||||
key: "close",
|
key: "close",
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ interface PanelState {
|
|||||||
// File explorer settings (shared between mobile/desktop)
|
// File explorer settings (shared between mobile/desktop)
|
||||||
explorerTab: ExplorerTab;
|
explorerTab: ExplorerTab;
|
||||||
explorerTabByCheckout: Record<string, ExplorerTab>;
|
explorerTabByCheckout: Record<string, ExplorerTab>;
|
||||||
|
expandedPathsByWorkspace: Record<string, string[]>;
|
||||||
|
diffExpandedPathsByWorkspace: Record<string, string[]>;
|
||||||
activeExplorerCheckout: ExplorerCheckoutContext | null;
|
activeExplorerCheckout: ExplorerCheckoutContext | null;
|
||||||
sidebarWidth: number;
|
sidebarWidth: number;
|
||||||
explorerWidth: number;
|
explorerWidth: number;
|
||||||
@@ -85,6 +87,8 @@ interface PanelState {
|
|||||||
// File explorer settings actions
|
// File explorer settings actions
|
||||||
setExplorerTab: (tab: ExplorerTab) => void;
|
setExplorerTab: (tab: ExplorerTab) => void;
|
||||||
setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { 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;
|
activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void;
|
||||||
setActiveExplorerCheckout: (checkout: ExplorerCheckoutContext | null) => void;
|
setActiveExplorerCheckout: (checkout: ExplorerCheckoutContext | null) => void;
|
||||||
setSidebarWidth: (width: number) => void;
|
setSidebarWidth: (width: number) => void;
|
||||||
@@ -142,6 +146,8 @@ export const usePanelStore = create<PanelState>()(
|
|||||||
// File explorer defaults
|
// File explorer defaults
|
||||||
explorerTab: "changes",
|
explorerTab: "changes",
|
||||||
explorerTabByCheckout: {},
|
explorerTabByCheckout: {},
|
||||||
|
expandedPathsByWorkspace: {},
|
||||||
|
diffExpandedPathsByWorkspace: {},
|
||||||
activeExplorerCheckout: null,
|
activeExplorerCheckout: null,
|
||||||
sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
|
sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
|
||||||
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
|
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
|
||||||
@@ -261,6 +267,17 @@ export const usePanelStore = create<PanelState>()(
|
|||||||
}
|
}
|
||||||
return nextState;
|
return nextState;
|
||||||
}),
|
}),
|
||||||
|
setExpandedPathsForWorkspace: (workspaceKey, paths) =>
|
||||||
|
set((state) => ({
|
||||||
|
expandedPathsByWorkspace: { ...state.expandedPathsByWorkspace, [workspaceKey]: paths },
|
||||||
|
})),
|
||||||
|
setDiffExpandedPathsForWorkspace: (workspaceKey, paths) =>
|
||||||
|
set((state) => ({
|
||||||
|
diffExpandedPathsByWorkspace: {
|
||||||
|
...state.diffExpandedPathsByWorkspace,
|
||||||
|
[workspaceKey]: paths,
|
||||||
|
},
|
||||||
|
})),
|
||||||
activateExplorerTabForCheckout: (checkout) =>
|
activateExplorerTabForCheckout: (checkout) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
activeExplorerCheckout: checkout,
|
activeExplorerCheckout: checkout,
|
||||||
@@ -295,7 +312,7 @@ export const usePanelStore = create<PanelState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "panel-state",
|
name: "panel-state",
|
||||||
version: 8,
|
version: 10,
|
||||||
storage: createJSONStorage(() => AsyncStorage),
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
migrate: (persistedState, version) => {
|
migrate: (persistedState, version) => {
|
||||||
const state = persistedState as Partial<PanelState> & Record<string, unknown>;
|
const state = persistedState as Partial<PanelState> & Record<string, unknown>;
|
||||||
@@ -371,6 +388,22 @@ export const usePanelStore = create<PanelState>()(
|
|||||||
state.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
|
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;
|
state.activeExplorerCheckout = null;
|
||||||
|
|
||||||
return state as PanelState;
|
return state as PanelState;
|
||||||
@@ -380,6 +413,8 @@ export const usePanelStore = create<PanelState>()(
|
|||||||
desktop: state.desktop,
|
desktop: state.desktop,
|
||||||
explorerTab: state.explorerTab,
|
explorerTab: state.explorerTab,
|
||||||
explorerTabByCheckout: state.explorerTabByCheckout,
|
explorerTabByCheckout: state.explorerTabByCheckout,
|
||||||
|
expandedPathsByWorkspace: state.expandedPathsByWorkspace,
|
||||||
|
diffExpandedPathsByWorkspace: state.diffExpandedPathsByWorkspace,
|
||||||
sidebarWidth: state.sidebarWidth,
|
sidebarWidth: state.sidebarWidth,
|
||||||
explorerWidth: state.explorerWidth,
|
explorerWidth: state.explorerWidth,
|
||||||
explorerSortOption: state.explorerSortOption,
|
explorerSortOption: state.explorerSortOption,
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
|
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
mergeWorkspaceSnapshotWithExisting,
|
||||||
normalizeWorkspaceDescriptor,
|
normalizeWorkspaceDescriptor,
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
type WorkspaceDescriptor,
|
type WorkspaceDescriptor,
|
||||||
} from "./session-store";
|
} from "./session-store";
|
||||||
|
|
||||||
function workspace(
|
function createWorkspace(
|
||||||
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
||||||
): WorkspaceDescriptor {
|
): WorkspaceDescriptor {
|
||||||
return {
|
return {
|
||||||
@@ -17,7 +20,7 @@ function workspace(
|
|||||||
projectRootPath: input.projectRootPath ?? "/repo",
|
projectRootPath: input.projectRootPath ?? "/repo",
|
||||||
workspaceDirectory: input.workspaceDirectory ?? "/repo",
|
workspaceDirectory: input.workspaceDirectory ?? "/repo",
|
||||||
projectKind: input.projectKind ?? "git",
|
projectKind: input.projectKind ?? "git",
|
||||||
workspaceKind: input.workspaceKind ?? "checkout",
|
workspaceKind: input.workspaceKind ?? "local_checkout",
|
||||||
name: input.name ?? "main",
|
name: input.name ?? "main",
|
||||||
status: input.status ?? "done",
|
status: input.status ?? "done",
|
||||||
activityAt: input.activityAt ?? null,
|
activityAt: input.activityAt ?? null,
|
||||||
@@ -99,11 +102,11 @@ describe("mergeWorkspaces", () => {
|
|||||||
store.initializeSession("test-server", null as unknown as DaemonClient);
|
store.initializeSession("test-server", null as unknown as DaemonClient);
|
||||||
store.setWorkspaces(
|
store.setWorkspaces(
|
||||||
"test-server",
|
"test-server",
|
||||||
new Map([["/repo/main", workspace({ id: "/repo/main", services: [] })]]),
|
new Map([["/repo/main", createWorkspace({ id: "/repo/main", services: [] })]]),
|
||||||
);
|
);
|
||||||
|
|
||||||
store.mergeWorkspaces("test-server", [
|
store.mergeWorkspaces("test-server", [
|
||||||
workspace({
|
createWorkspace({
|
||||||
id: "/repo/main",
|
id: "/repo/main",
|
||||||
services: [
|
services: [
|
||||||
{
|
{
|
||||||
@@ -130,3 +133,34 @@ describe("mergeWorkspaces", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -147,6 +147,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 ExplorerEntryKind = "file" | "directory";
|
||||||
export type ExplorerFileKind = "text" | "image" | "binary";
|
export type ExplorerFileKind = "text" | "image" | "binary";
|
||||||
export type ExplorerEncoding = "utf-8" | "base64" | "none";
|
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)
|
surface2: "#f4f4f5", // Elevated: badges, inputs, sheets (was zinc-200, now zinc-100)
|
||||||
surface3: "#e4e4e7", // Highest elevation (was zinc-300, now zinc-200)
|
surface3: "#e4e4e7", // Highest elevation (was zinc-300, now zinc-200)
|
||||||
surface4: "#d4d4d8", // Extra emphasis (was zinc-400, now zinc-300)
|
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)
|
surfaceSidebar: "#f4f4f5", // Sidebar background (darker than main)
|
||||||
surfaceWorkspace: "#ffffff", // Workspace main background
|
surfaceWorkspace: "#ffffff", // Workspace main background
|
||||||
|
|
||||||
@@ -185,6 +186,7 @@ const darkSemanticColors = {
|
|||||||
surface2: "#272A29", // Elevated: badges, inputs, sheets
|
surface2: "#272A29", // Elevated: badges, inputs, sheets
|
||||||
surface3: "#434645", // Highest elevation
|
surface3: "#434645", // Highest elevation
|
||||||
surface4: "#595B5B", // Extra emphasis
|
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)
|
surfaceSidebar: "#141716", // Sidebar background (darker than main)
|
||||||
surfaceWorkspace: "#1E2120", // Workspace main background (surface1)
|
surfaceWorkspace: "#1E2120", // Workspace main background (surface1)
|
||||||
|
|
||||||
@@ -279,6 +281,10 @@ const commonTheme = {
|
|||||||
"4xl": 34,
|
"4xl": 34,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
lineHeight: {
|
||||||
|
diff: 22,
|
||||||
|
},
|
||||||
|
|
||||||
iconSize: {
|
iconSize: {
|
||||||
xs: 12,
|
xs: 12,
|
||||||
sm: 14,
|
sm: 14,
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.48",
|
"version": "0.1.49",
|
||||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
@@ -24,8 +24,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/relay": "0.1.48",
|
"@getpaseo/relay": "0.1.49",
|
||||||
"@getpaseo/server": "0.1.48",
|
"@getpaseo/server": "0.1.49",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.48",
|
"version": "0.1.49",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo desktop app (Electron wrapper)",
|
"description": "Paseo desktop app (Electron wrapper)",
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "0.1.48",
|
"@getpaseo/cli": "0.1.49",
|
||||||
"@getpaseo/server": "0.1.48",
|
"@getpaseo/server": "0.1.49",
|
||||||
"electron-log": "^5.4.3",
|
"electron-log": "^5.4.3",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.1.48",
|
"version": "0.1.49",
|
||||||
"description": "Native module for two way audio streaming",
|
"description": "Native module for two way audio streaming",
|
||||||
"main": "build/index.js",
|
"main": "build/index.js",
|
||||||
"types": "build/index.d.ts",
|
"types": "build/index.d.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.48",
|
"version": "0.1.49",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.48",
|
"version": "0.1.49",
|
||||||
"description": "Paseo relay for bridging daemon and client connections",
|
"description": "Paseo relay for bridging daemon and client connections",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.48",
|
"version": "0.1.49",
|
||||||
"description": "Paseo backend server",
|
"description": "Paseo backend server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
@@ -61,8 +61,8 @@
|
|||||||
"@ai-sdk/openai": "2.0.52",
|
"@ai-sdk/openai": "2.0.52",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||||
"@deepgram/sdk": "^3.4.0",
|
"@deepgram/sdk": "^3.4.0",
|
||||||
"@getpaseo/highlight": "0.1.48",
|
"@getpaseo/highlight": "0.1.49",
|
||||||
"@getpaseo/relay": "0.1.48",
|
"@getpaseo/relay": "0.1.49",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@opencode-ai/sdk": "1.2.6",
|
"@opencode-ai/sdk": "1.2.6",
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import type {
|
|||||||
PaseoWorktreeListResponse,
|
PaseoWorktreeListResponse,
|
||||||
PaseoWorktreeArchiveResponse,
|
PaseoWorktreeArchiveResponse,
|
||||||
ProjectIconResponse,
|
ProjectIconResponse,
|
||||||
|
ListAvailableEditorsResponseMessage,
|
||||||
|
OpenInEditorResponseMessage,
|
||||||
OpenProjectResponseMessage,
|
OpenProjectResponseMessage,
|
||||||
ArchiveWorkspaceResponseMessage,
|
ArchiveWorkspaceResponseMessage,
|
||||||
WorkspaceSetupStatusResponseMessage,
|
WorkspaceSetupStatusResponseMessage,
|
||||||
@@ -55,6 +57,7 @@ import type {
|
|||||||
TerminalInput,
|
TerminalInput,
|
||||||
SessionInboundMessage,
|
SessionInboundMessage,
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
|
EditorTargetId,
|
||||||
} from "../shared/messages.js";
|
} from "../shared/messages.js";
|
||||||
import type {
|
import type {
|
||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
@@ -478,9 +481,12 @@ export type InspectScheduleOptions = {
|
|||||||
id: string;
|
id: string;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
};
|
};
|
||||||
|
type ListAvailableEditorsPayload = ListAvailableEditorsResponseMessage["payload"];
|
||||||
|
type OpenInEditorPayload = OpenInEditorResponseMessage["payload"];
|
||||||
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
|
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
|
||||||
type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"];
|
type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"];
|
||||||
type WorkspaceSetupStatusPayload = WorkspaceSetupStatusResponseMessage["payload"];
|
type WorkspaceSetupStatusPayload = WorkspaceSetupStatusResponseMessage["payload"];
|
||||||
|
export type EditorTargetDescriptor = ListAvailableEditorsPayload["editors"][number];
|
||||||
|
|
||||||
export type FetchAgentResult = {
|
export type FetchAgentResult = {
|
||||||
agent: AgentSnapshotPayload;
|
agent: AgentSnapshotPayload;
|
||||||
@@ -618,7 +624,10 @@ export class DaemonClient {
|
|||||||
private connectionState: ConnectionState = { status: "idle" };
|
private connectionState: ConnectionState = { status: "idle" };
|
||||||
private checkoutDiffSubscriptions = new Map<
|
private checkoutDiffSubscriptions = new Map<
|
||||||
string,
|
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 terminalDirectorySubscriptions = new Set<string>();
|
||||||
private terminalSlots = new Map<string, number>();
|
private terminalSlots = new Map<string, number>();
|
||||||
@@ -1339,8 +1348,36 @@ 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(
|
async archiveWorkspace(
|
||||||
workspaceId: number,
|
workspaceId: string | number,
|
||||||
requestId?: string,
|
requestId?: string,
|
||||||
): Promise<ArchiveWorkspacePayload> {
|
): Promise<ArchiveWorkspacePayload> {
|
||||||
return this.sendCorrelatedSessionRequest({
|
return this.sendCorrelatedSessionRequest({
|
||||||
@@ -2185,20 +2222,22 @@ export class DaemonClient {
|
|||||||
private normalizeCheckoutDiffCompare(compare: {
|
private normalizeCheckoutDiffCompare(compare: {
|
||||||
mode: "uncommitted" | "base";
|
mode: "uncommitted" | "base";
|
||||||
baseRef?: string;
|
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") {
|
if (compare.mode === "uncommitted") {
|
||||||
return { mode: "uncommitted" };
|
return { mode: "uncommitted", ignoreWhitespace };
|
||||||
}
|
}
|
||||||
const trimmedBaseRef = compare.baseRef?.trim();
|
const trimmedBaseRef = compare.baseRef?.trim();
|
||||||
if (!trimmedBaseRef) {
|
if (!trimmedBaseRef) {
|
||||||
return { mode: "base" };
|
return { mode: "base", ignoreWhitespace };
|
||||||
}
|
}
|
||||||
return { mode: "base", baseRef: trimmedBaseRef };
|
return { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCheckoutDiff(
|
async getCheckoutDiff(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
compare: { mode: "uncommitted" | "base"; baseRef?: string },
|
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
|
||||||
requestId?: string,
|
requestId?: string,
|
||||||
): Promise<CheckoutDiffPayload> {
|
): Promise<CheckoutDiffPayload> {
|
||||||
const oneShotSubscriptionId = `oneshot-checkout-diff:${crypto.randomUUID()}`;
|
const oneShotSubscriptionId = `oneshot-checkout-diff:${crypto.randomUUID()}`;
|
||||||
@@ -2224,7 +2263,7 @@ export class DaemonClient {
|
|||||||
|
|
||||||
async subscribeCheckoutDiff(
|
async subscribeCheckoutDiff(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
compare: { mode: "uncommitted" | "base"; baseRef?: string },
|
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
|
||||||
options?: { subscriptionId?: string; requestId?: string },
|
options?: { subscriptionId?: string; requestId?: string },
|
||||||
): Promise<SubscribeCheckoutDiffPayload> {
|
): Promise<SubscribeCheckoutDiffPayload> {
|
||||||
const subscriptionId = options?.subscriptionId ?? crypto.randomUUID();
|
const subscriptionId = options?.subscriptionId ?? crypto.randomUUID();
|
||||||
|
|||||||
@@ -174,6 +174,9 @@ function sanitizePermissionRequest(
|
|||||||
if (sanitized.suggestions === undefined) {
|
if (sanitized.suggestions === undefined) {
|
||||||
delete sanitized.suggestions;
|
delete sanitized.suggestions;
|
||||||
}
|
}
|
||||||
|
if (sanitized.actions === undefined) {
|
||||||
|
delete sanitized.actions;
|
||||||
|
}
|
||||||
if (sanitized.metadata === undefined) {
|
if (sanitized.metadata === undefined) {
|
||||||
delete sanitized.metadata;
|
delete sanitized.metadata;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3459,6 +3459,213 @@ describe("AgentManager", () => {
|
|||||||
expect(persisted?.lastModeId).toBe("acceptEdits");
|
expect(persisted?.lastModeId).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 () => {
|
test("close during in-flight stream does not clear persistence sessionId", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
|
|||||||
@@ -150,6 +150,11 @@ type ManagedAgentBase = {
|
|||||||
features?: AgentFeature[];
|
features?: AgentFeature[];
|
||||||
currentModeId: string | null;
|
currentModeId: string | null;
|
||||||
pendingPermissions: Map<string, AgentPermissionRequest>;
|
pendingPermissions: Map<string, AgentPermissionRequest>;
|
||||||
|
bufferedPermissionResolutions: Map<
|
||||||
|
string,
|
||||||
|
Extract<AgentStreamEvent, { type: "permission_resolved" }>
|
||||||
|
>;
|
||||||
|
inFlightPermissionResponses: Set<string>;
|
||||||
pendingReplacement: boolean;
|
pendingReplacement: boolean;
|
||||||
persistence: AgentPersistenceHandle | null;
|
persistence: AgentPersistenceHandle | null;
|
||||||
historyPrimed: boolean;
|
historyPrimed: boolean;
|
||||||
@@ -1378,25 +1383,32 @@ export class AgentManager {
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
response: AgentPermissionResponse,
|
response: AgentPermissionResponse,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const agent = this.requireSessionAgent(agentId);
|
const agent = this.requireAgent(agentId);
|
||||||
await agent.session.respondToPermission(requestId, response);
|
agent.inFlightPermissionResponses.add(requestId);
|
||||||
agent.pendingPermissions.delete(requestId);
|
|
||||||
|
|
||||||
// Update currentModeId - the session may have changed mode internally
|
|
||||||
// (e.g., plan approval changes mode from "plan" to "acceptEdits")
|
|
||||||
try {
|
try {
|
||||||
agent.currentModeId = await agent.session.getCurrentMode();
|
await agent.session.respondToPermission(requestId, response);
|
||||||
if (agent.runtimeInfo) {
|
agent.pendingPermissions.delete(requestId);
|
||||||
agent.runtimeInfo = {
|
|
||||||
...agent.runtimeInfo,
|
|
||||||
modeId: agent.currentModeId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore errors from getCurrentMode - mode tracking is best effort
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
agent.inFlightPermissionResponses.delete(requestId);
|
||||||
|
agent.bufferedPermissionResolutions.delete(requestId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancelAgentRun(agentId: string): Promise<boolean> {
|
async cancelAgentRun(agentId: string): Promise<boolean> {
|
||||||
@@ -1840,6 +1852,8 @@ export class AgentManager {
|
|||||||
availableModes: [],
|
availableModes: [],
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
pendingPermissions: new Map<string, AgentPermissionRequest>(),
|
pendingPermissions: new Map<string, AgentPermissionRequest>(),
|
||||||
|
bufferedPermissionResolutions: new Map(),
|
||||||
|
inFlightPermissionResponses: new Set(),
|
||||||
pendingReplacement: false,
|
pendingReplacement: false,
|
||||||
activeForegroundTurnId: null,
|
activeForegroundTurnId: null,
|
||||||
foregroundTurnWaiters: new Set<ForegroundTurnWaiter>(),
|
foregroundTurnWaiters: new Set<ForegroundTurnWaiter>(),
|
||||||
@@ -2097,6 +2111,7 @@ export class AgentManager {
|
|||||||
agent.pendingPermissions.clear();
|
agent.pendingPermissions.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.syncFeaturesFromSession(agent);
|
||||||
await this.refreshRuntimeInfo(agent);
|
await this.refreshRuntimeInfo(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2174,6 +2189,7 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let timelineRow: AgentTimelineRow | null = null;
|
let timelineRow: AgentTimelineRow | null = null;
|
||||||
|
let shouldDispatchEvent = true;
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "thread_started":
|
case "thread_started":
|
||||||
@@ -2344,6 +2360,11 @@ export class AgentManager {
|
|||||||
break;
|
break;
|
||||||
case "permission_resolved":
|
case "permission_resolved":
|
||||||
agent.pendingPermissions.delete(event.requestId);
|
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);
|
this.emitState(agent);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@@ -2355,7 +2376,7 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Skip dispatching individual stream events during history replay.
|
// Skip dispatching individual stream events during history replay.
|
||||||
if (!options?.fromHistory) {
|
if (!options?.fromHistory && shouldDispatchEvent) {
|
||||||
this.dispatchStream(
|
this.dispatchStream(
|
||||||
agent.id,
|
agent.id,
|
||||||
event,
|
event,
|
||||||
@@ -2436,9 +2457,7 @@ export class AgentManager {
|
|||||||
this.enqueueBackgroundPersist(agent);
|
this.enqueueBackgroundPersist(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (agent.session?.features) {
|
this.syncFeaturesFromSession(agent);
|
||||||
agent.features = agent.session.features;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.dispatch({
|
this.dispatch({
|
||||||
type: "agent_state",
|
type: "agent_state",
|
||||||
@@ -2446,6 +2465,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 {
|
private checkAndSetAttention(agent: ManagedAgent): void {
|
||||||
const previousStatus = this.previousStatuses.get(agent.id);
|
const previousStatus = this.previousStatuses.get(agent.id);
|
||||||
const currentStatus = agent.lifecycle;
|
const currentStatus = agent.lifecycle;
|
||||||
|
|||||||
@@ -260,6 +260,34 @@ describe("toAgentPayload", () => {
|
|||||||
expect(permissionA.title).toBe("Run command");
|
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", () => {
|
it("produces null title and current mode even without overrides", () => {
|
||||||
const agent = createManagedAgent({ currentModeId: null, lastUserMessageAt: null });
|
const agent = createManagedAgent({ currentModeId: null, lastUserMessageAt: null });
|
||||||
const payload = toAgentPayload(agent);
|
const payload = toAgentPayload(agent);
|
||||||
@@ -303,6 +331,56 @@ describe("toAgentPayload", () => {
|
|||||||
expect(payload).not.toHaveProperty("lastUsage");
|
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", () => {
|
it("includes features in the snapshot payload", () => {
|
||||||
const features = [createFeature()];
|
const features = [createFeature()];
|
||||||
const agent = createManagedAgent({ features });
|
const agent = createManagedAgent({ features });
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export function toStoredAgentRecord(
|
|||||||
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
|
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
|
||||||
config: config ?? null,
|
config: config ?? null,
|
||||||
runtimeInfo,
|
runtimeInfo,
|
||||||
|
features: agent.features,
|
||||||
persistence,
|
persistence,
|
||||||
lastError: agent.lastError ?? undefined,
|
lastError: agent.lastError ?? undefined,
|
||||||
requiresAttention: agent.attention.requiresAttention,
|
requiresAttention: agent.attention.requiresAttention,
|
||||||
@@ -167,6 +168,7 @@ function sanitizePendingPermissions(
|
|||||||
...request,
|
...request,
|
||||||
input: sanitizeMetadata(request.input),
|
input: sanitizeMetadata(request.input),
|
||||||
suggestions: sanitizeMetadataArray(request.suggestions),
|
suggestions: sanitizeMetadataArray(request.suggestions),
|
||||||
|
actions: request.actions?.map((action) => ({ ...action })),
|
||||||
metadata: sanitizeMetadata(request.metadata),
|
metadata: sanitizeMetadata(request.metadata),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -260,29 +262,41 @@ function sanitizeUsage(value: unknown): AgentUsage | undefined {
|
|||||||
}
|
}
|
||||||
const result: AgentUsage = {};
|
const result: AgentUsage = {};
|
||||||
const inputTokens = sanitized.inputTokens;
|
const inputTokens = sanitized.inputTokens;
|
||||||
if (typeof inputTokens === "number") {
|
if (typeof inputTokens === "number" && Number.isFinite(inputTokens)) {
|
||||||
result.inputTokens = inputTokens;
|
result.inputTokens = inputTokens;
|
||||||
} else if (inputTokens !== undefined && inputTokens !== null) {
|
} else if (inputTokens !== undefined && inputTokens !== null) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const cachedInputTokens = sanitized.cachedInputTokens;
|
const cachedInputTokens = sanitized.cachedInputTokens;
|
||||||
if (typeof cachedInputTokens === "number") {
|
if (typeof cachedInputTokens === "number" && Number.isFinite(cachedInputTokens)) {
|
||||||
result.cachedInputTokens = cachedInputTokens;
|
result.cachedInputTokens = cachedInputTokens;
|
||||||
} else if (cachedInputTokens !== undefined && cachedInputTokens !== null) {
|
} else if (cachedInputTokens !== undefined && cachedInputTokens !== null) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const outputTokens = sanitized.outputTokens;
|
const outputTokens = sanitized.outputTokens;
|
||||||
if (typeof outputTokens === "number") {
|
if (typeof outputTokens === "number" && Number.isFinite(outputTokens)) {
|
||||||
result.outputTokens = outputTokens;
|
result.outputTokens = outputTokens;
|
||||||
} else if (outputTokens !== undefined && outputTokens !== null) {
|
} else if (outputTokens !== undefined && outputTokens !== null) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const totalCostUsd = sanitized.totalCostUsd;
|
const totalCostUsd = sanitized.totalCostUsd;
|
||||||
if (typeof totalCostUsd === "number") {
|
if (typeof totalCostUsd === "number" && Number.isFinite(totalCostUsd)) {
|
||||||
result.totalCostUsd = totalCostUsd;
|
result.totalCostUsd = totalCostUsd;
|
||||||
} else if (totalCostUsd !== undefined && totalCostUsd !== null) {
|
} else if (totalCostUsd !== undefined && totalCostUsd !== null) {
|
||||||
return undefined;
|
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;
|
return Object.keys(result).length ? result : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ export type AgentUsage = {
|
|||||||
cachedInputTokens?: number;
|
cachedInputTokens?: number;
|
||||||
outputTokens?: number;
|
outputTokens?: number;
|
||||||
totalCostUsd?: number;
|
totalCostUsd?: number;
|
||||||
|
contextWindowMaxTokens?: number;
|
||||||
|
contextWindowUsedTokens?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TOOL_CALL_ICON_NAMES = [
|
export const TOOL_CALL_ICON_NAMES = [
|
||||||
@@ -329,6 +331,14 @@ export type AgentPermissionRequestKind = "tool" | "plan" | "question" | "mode" |
|
|||||||
|
|
||||||
export type AgentPermissionUpdate = AgentMetadata;
|
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 = {
|
export type AgentPermissionRequest = {
|
||||||
id: string;
|
id: string;
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
@@ -339,17 +349,20 @@ export type AgentPermissionRequest = {
|
|||||||
input?: AgentMetadata;
|
input?: AgentMetadata;
|
||||||
detail?: ToolCallDetail;
|
detail?: ToolCallDetail;
|
||||||
suggestions?: AgentPermissionUpdate[];
|
suggestions?: AgentPermissionUpdate[];
|
||||||
|
actions?: AgentPermissionAction[];
|
||||||
metadata?: AgentMetadata;
|
metadata?: AgentMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AgentPermissionResponse =
|
export type AgentPermissionResponse =
|
||||||
| {
|
| {
|
||||||
behavior: "allow";
|
behavior: "allow";
|
||||||
|
selectedActionId?: string;
|
||||||
updatedInput?: AgentMetadata;
|
updatedInput?: AgentMetadata;
|
||||||
updatedPermissions?: AgentPermissionUpdate[];
|
updatedPermissions?: AgentPermissionUpdate[];
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
behavior: "deny";
|
behavior: "deny";
|
||||||
|
selectedActionId?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
interrupt?: boolean;
|
interrupt?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "node:path";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
import { AgentStatusSchema } from "../messages.js";
|
import { AgentFeatureSchema, AgentStatusSchema } from "../messages.js";
|
||||||
import { toStoredAgentRecord } from "./agent-projections.js";
|
import { toStoredAgentRecord } from "./agent-projections.js";
|
||||||
import type { ManagedAgent } from "./agent-manager.js";
|
import type { ManagedAgent } from "./agent-manager.js";
|
||||||
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
|
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
|
||||||
@@ -57,6 +57,7 @@ const STORED_AGENT_SCHEMA = z.object({
|
|||||||
extra: z.record(z.unknown()).optional(),
|
extra: z.record(z.unknown()).optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
features: z.array(AgentFeatureSchema).optional(),
|
||||||
persistence: PERSISTENCE_HANDLE_SCHEMA,
|
persistence: PERSISTENCE_HANDLE_SCHEMA,
|
||||||
lastError: z.string().nullable().optional(),
|
lastError: z.string().nullable().optional(),
|
||||||
requiresAttention: z.boolean().optional(),
|
requiresAttention: z.boolean().optional(),
|
||||||
|
|||||||
@@ -236,6 +236,9 @@ function sanitizePermissionRequest(
|
|||||||
if (sanitized.suggestions === undefined) {
|
if (sanitized.suggestions === undefined) {
|
||||||
delete sanitized.suggestions;
|
delete sanitized.suggestions;
|
||||||
}
|
}
|
||||||
|
if (sanitized.actions === undefined) {
|
||||||
|
delete sanitized.actions;
|
||||||
|
}
|
||||||
if (sanitized.metadata === undefined) {
|
if (sanitized.metadata === undefined) {
|
||||||
delete sanitized.metadata;
|
delete sanitized.metadata;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -747,4 +747,55 @@ describe("ACPAgentSession", () => {
|
|||||||
});
|
});
|
||||||
expect((session as any).activeForegroundTurnId).toBeNull();
|
expect((session as any).activeForegroundTurnId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("auto-approves Copilot ACP permissions in autopilot mode without emitting prompt events", async () => {
|
||||||
|
const session = new ACPAgentSession(
|
||||||
|
{
|
||||||
|
provider: "copilot",
|
||||||
|
cwd: "/tmp/paseo-acp-test",
|
||||||
|
modeId: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "copilot",
|
||||||
|
logger: createTestLogger(),
|
||||||
|
defaultCommand: ["copilot", "--acp"],
|
||||||
|
defaultModes: [],
|
||||||
|
capabilities: {
|
||||||
|
supportsStreaming: true,
|
||||||
|
supportsSessionPersistence: true,
|
||||||
|
supportsDynamicModes: true,
|
||||||
|
supportsMcpServers: true,
|
||||||
|
supportsReasoningStream: true,
|
||||||
|
supportsToolInvocations: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const events: Array<{ type: string }> = [];
|
||||||
|
session.subscribe((event) => {
|
||||||
|
events.push(event as { type: string });
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await session.requestPermission({
|
||||||
|
toolCall: {
|
||||||
|
toolCallId: "tool-1",
|
||||||
|
title: "Edit file",
|
||||||
|
kind: "edit",
|
||||||
|
status: "pending",
|
||||||
|
} as any,
|
||||||
|
options: [
|
||||||
|
{ optionId: "allow-once", name: "Allow Once", kind: "allow_once" },
|
||||||
|
{ optionId: "reject-once", name: "Reject Once", kind: "reject_once" },
|
||||||
|
],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(response).toEqual({
|
||||||
|
outcome: {
|
||||||
|
outcome: "selected",
|
||||||
|
optionId: "allow-once",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(session.getPendingPermissions()).toEqual([]);
|
||||||
|
expect(events.find((event) => event.type === "permission_requested")).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ const ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = {
|
|||||||
terminal: true,
|
terminal: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const COPILOT_AUTOPILOT_MODE =
|
||||||
|
"https://agentclientprotocol.com/protocol/session-modes#autopilot";
|
||||||
|
|
||||||
type ACPAgentClientOptions = {
|
type ACPAgentClientOptions = {
|
||||||
provider: string;
|
provider: string;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
@@ -1084,6 +1087,18 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
async requestPermission(
|
async requestPermission(
|
||||||
params: RequestPermissionRequest,
|
params: RequestPermissionRequest,
|
||||||
): Promise<RequestPermissionResponse> {
|
): Promise<RequestPermissionResponse> {
|
||||||
|
if (shouldAutoApprovePermissionRequest(this.provider, this.currentMode)) {
|
||||||
|
const selectedOption = selectPermissionOption(params.options, { behavior: "allow" });
|
||||||
|
return selectedOption
|
||||||
|
? {
|
||||||
|
outcome: {
|
||||||
|
outcome: "selected",
|
||||||
|
optionId: selectedOption.optionId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { outcome: { outcome: "cancelled" } };
|
||||||
|
}
|
||||||
|
|
||||||
const requestId = randomUUID();
|
const requestId = randomUUID();
|
||||||
let toolSnapshot =
|
let toolSnapshot =
|
||||||
this.toolCalls.get(params.toolCall.toolCallId) ??
|
this.toolCalls.get(params.toolCall.toolCallId) ??
|
||||||
@@ -1939,6 +1954,10 @@ function mapPermissionRequest(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldAutoApprovePermissionRequest(provider: string, currentMode: string | null): boolean {
|
||||||
|
return provider === "copilot" && currentMode === COPILOT_AUTOPILOT_MODE;
|
||||||
|
}
|
||||||
|
|
||||||
function selectPermissionOption(
|
function selectPermissionOption(
|
||||||
options: PermissionOption[],
|
options: PermissionOption[],
|
||||||
response: AgentPermissionResponse,
|
response: AgentPermissionResponse,
|
||||||
|
|||||||
@@ -835,6 +835,82 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("plan approval exposes a resume-bypass action and can return to bypassPermissions", async () => {
|
||||||
|
const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined })));
|
||||||
|
sdkQueryFactory.mockImplementation(() => queryMock);
|
||||||
|
|
||||||
|
const session = await createSession();
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await session.setMode("bypassPermissions");
|
||||||
|
await session.setMode("plan");
|
||||||
|
|
||||||
|
const internal = session as unknown as {
|
||||||
|
handlePermissionRequest: (
|
||||||
|
toolName: string,
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
options: Record<string, unknown>,
|
||||||
|
) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingResolution = internal.handlePermissionRequest(
|
||||||
|
"ExitPlanMode",
|
||||||
|
{ plan: "- Implement the approved plan" },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const requestEvent = events.find(
|
||||||
|
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
|
||||||
|
event.type === "permission_requested" && event.request.kind === "plan",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(requestEvent).toBeDefined();
|
||||||
|
expect(requestEvent?.request.actions).toEqual([
|
||||||
|
{
|
||||||
|
id: "reject",
|
||||||
|
label: "Reject",
|
||||||
|
behavior: "deny",
|
||||||
|
variant: "danger",
|
||||||
|
intent: "dismiss",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "implement",
|
||||||
|
label: "Implement",
|
||||||
|
behavior: "allow",
|
||||||
|
variant: "primary",
|
||||||
|
intent: "implement",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "implement_resume",
|
||||||
|
label: "Implement with Bypass",
|
||||||
|
behavior: "allow",
|
||||||
|
variant: "secondary",
|
||||||
|
intent: "implement_resume",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!requestEvent) {
|
||||||
|
throw new Error("Expected plan permission request");
|
||||||
|
}
|
||||||
|
|
||||||
|
await session.respondToPermission(requestEvent.request.id, {
|
||||||
|
behavior: "allow",
|
||||||
|
selectedActionId: "implement_resume",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(pendingResolution).resolves.toMatchObject({
|
||||||
|
behavior: "allow",
|
||||||
|
updatedInput: { plan: "- Implement the approved plan" },
|
||||||
|
});
|
||||||
|
expect(queryMock.setPermissionMode).toHaveBeenLastCalledWith("bypassPermissions");
|
||||||
|
expect(await session.getCurrentMode()).toBe("bypassPermissions");
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("reuses one autonomous run for unbound stream_event bursts with no foreground run", async () => {
|
test("reuses one autonomous run for unbound stream_event bursts with no foreground run", async () => {
|
||||||
const session = await createSession();
|
const session = await createSession();
|
||||||
const internal = session as unknown as {
|
const internal = session as unknown as {
|
||||||
|
|||||||
@@ -112,6 +112,28 @@ describe("convertClaudeHistoryEntry", () => {
|
|||||||
expect(mapBlocks).not.toHaveBeenCalled();
|
expect(mapBlocks).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("skips meta user entries from Claude skill loading", () => {
|
||||||
|
const entry = {
|
||||||
|
type: "user",
|
||||||
|
isMeta: true,
|
||||||
|
message: {
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Base directory for this skill: /tmp/skill\n\n# Orchestrate\n\nYou are an end-to-end implementation orchestrator.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapBlocks = vi.fn().mockReturnValue([]);
|
||||||
|
const result = convertClaudeHistoryEntry(entry, mapBlocks);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(mapBlocks).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
test("skips interrupt placeholder transcript noise", () => {
|
test("skips interrupt placeholder transcript noise", () => {
|
||||||
const interruptEntry = {
|
const interruptEntry = {
|
||||||
type: "user",
|
type: "user",
|
||||||
@@ -261,3 +283,356 @@ describe("ClaudeAgentClient.listModels", () => {
|
|||||||
expect(defaultModel?.id).toBe("claude-opus-4-6");
|
expect(defaultModel?.id).toBe("claude-opus-4-6");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ClaudeAgentSession context window usage", () => {
|
||||||
|
const logger = createTestLogger();
|
||||||
|
|
||||||
|
async function createSessionForTest(): Promise<any> {
|
||||||
|
const client = new ClaudeAgentClient({ logger });
|
||||||
|
return client.createSession({
|
||||||
|
provider: "claude",
|
||||||
|
cwd: process.cwd(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createQueryFactoryForTurns(turns: Array<Array<Record<string, unknown>>>) {
|
||||||
|
return vi.fn(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||||
|
const queuedMessages: Array<Record<string, unknown>> = [];
|
||||||
|
const waiters: Array<() => void> = [];
|
||||||
|
let turnIndex = 0;
|
||||||
|
let closed = false;
|
||||||
|
|
||||||
|
function wakeNextWaiter() {
|
||||||
|
const waiter = waiters.shift();
|
||||||
|
waiter?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueue(message: Record<string, unknown>) {
|
||||||
|
queuedMessages.push(message);
|
||||||
|
wakeNextWaiter();
|
||||||
|
}
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
for await (const _prompt of prompt) {
|
||||||
|
const turnMessages = turns[turnIndex] ?? [];
|
||||||
|
turnIndex += 1;
|
||||||
|
for (const message of turnMessages) {
|
||||||
|
enqueue(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closed = true;
|
||||||
|
wakeNextWaiter();
|
||||||
|
})();
|
||||||
|
|
||||||
|
return {
|
||||||
|
next: vi.fn(async () => {
|
||||||
|
while (queuedMessages.length === 0 && !closed) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
waiters.push(resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (queuedMessages.length === 0) {
|
||||||
|
return { done: true, value: undefined };
|
||||||
|
}
|
||||||
|
return { done: false, value: queuedMessages.shift() };
|
||||||
|
}),
|
||||||
|
interrupt: vi.fn(async () => undefined),
|
||||||
|
return: vi.fn(async () => {
|
||||||
|
closed = true;
|
||||||
|
wakeNextWaiter();
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
close: vi.fn(() => {
|
||||||
|
closed = true;
|
||||||
|
wakeNextWaiter();
|
||||||
|
}),
|
||||||
|
setPermissionMode: vi.fn(async () => undefined),
|
||||||
|
setModel: vi.fn(async () => undefined),
|
||||||
|
supportedModels: vi.fn(async () => []),
|
||||||
|
supportedCommands: vi.fn(async () => []),
|
||||||
|
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||||
|
[Symbol.asyncIterator]() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("convertUsage includes contextWindowMaxTokens and derives used tokens from result usage", async () => {
|
||||||
|
const session = await createSessionForTest();
|
||||||
|
|
||||||
|
const usage = session.convertUsage(
|
||||||
|
{
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
usage: {
|
||||||
|
input_tokens: 10,
|
||||||
|
cache_read_input_tokens: 5,
|
||||||
|
output_tokens: 7,
|
||||||
|
},
|
||||||
|
total_cost_usd: 0.12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claude-sonnet-4-6": { contextWindow: 200_000 },
|
||||||
|
"claude-opus-4-6": { contextWindow: 1_000_000 },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(usage).toEqual({
|
||||||
|
inputTokens: 10,
|
||||||
|
cachedInputTokens: 5,
|
||||||
|
outputTokens: 7,
|
||||||
|
totalCostUsd: 0.12,
|
||||||
|
contextWindowMaxTokens: 1_000_000,
|
||||||
|
contextWindowUsedTokens: 22,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contextWindowUsedTokens is derived from result usage when task_progress is missing", async () => {
|
||||||
|
const session = await createSessionForTest();
|
||||||
|
|
||||||
|
const usage = session.convertUsage({
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
usage: {
|
||||||
|
input_tokens: 10,
|
||||||
|
cache_creation_input_tokens: 3,
|
||||||
|
cache_read_input_tokens: 5,
|
||||||
|
output_tokens: 7,
|
||||||
|
},
|
||||||
|
total_cost_usd: 0.12,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(usage).toEqual({
|
||||||
|
inputTokens: 10,
|
||||||
|
cachedInputTokens: 5,
|
||||||
|
outputTokens: 7,
|
||||||
|
totalCostUsd: 0.12,
|
||||||
|
contextWindowUsedTokens: 25,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contextWindowUsedTokens is populated from task_progress usage data", async () => {
|
||||||
|
const session = await createSessionForTest();
|
||||||
|
|
||||||
|
session.translateMessageToEvents({
|
||||||
|
type: "system",
|
||||||
|
subtype: "task_progress",
|
||||||
|
task_id: "task-1",
|
||||||
|
description: "Processing",
|
||||||
|
usage: {
|
||||||
|
total_tokens: 999,
|
||||||
|
tool_uses: 1,
|
||||||
|
duration_ms: 50,
|
||||||
|
input_tokens: 345,
|
||||||
|
cache_read_input_tokens: 55,
|
||||||
|
},
|
||||||
|
uuid: "task-progress-1",
|
||||||
|
session_id: "session-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = session.translateMessageToEvents({
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
duration_ms: 100,
|
||||||
|
duration_api_ms: 75,
|
||||||
|
is_error: false,
|
||||||
|
num_turns: 1,
|
||||||
|
result: "done",
|
||||||
|
stop_reason: null,
|
||||||
|
total_cost_usd: 0.25,
|
||||||
|
usage: {
|
||||||
|
input_tokens: 10,
|
||||||
|
cache_read_input_tokens: 5,
|
||||||
|
output_tokens: 7,
|
||||||
|
},
|
||||||
|
modelUsage: {
|
||||||
|
"claude-sonnet-4-6": { contextWindow: 200_000 },
|
||||||
|
},
|
||||||
|
permission_denials: [],
|
||||||
|
uuid: "result-1",
|
||||||
|
session_id: "session-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toContainEqual({
|
||||||
|
type: "turn_completed",
|
||||||
|
provider: "claude",
|
||||||
|
usage: {
|
||||||
|
inputTokens: 10,
|
||||||
|
cachedInputTokens: 5,
|
||||||
|
outputTokens: 7,
|
||||||
|
totalCostUsd: 0.25,
|
||||||
|
contextWindowMaxTokens: 200_000,
|
||||||
|
contextWindowUsedTokens: 999,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("task_progress usage takes priority over derived result usage", async () => {
|
||||||
|
const session = await createSessionForTest();
|
||||||
|
|
||||||
|
session.translateMessageToEvents({
|
||||||
|
type: "system",
|
||||||
|
subtype: "task_progress",
|
||||||
|
task_id: "task-1",
|
||||||
|
description: "Processing",
|
||||||
|
usage: {
|
||||||
|
total_tokens: 999,
|
||||||
|
tool_uses: 1,
|
||||||
|
duration_ms: 50,
|
||||||
|
input_tokens: 345,
|
||||||
|
cache_read_input_tokens: 55,
|
||||||
|
},
|
||||||
|
uuid: "task-progress-1",
|
||||||
|
session_id: "session-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const usage = session.convertUsage({
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
usage: {
|
||||||
|
input_tokens: 10,
|
||||||
|
cache_creation_input_tokens: 3,
|
||||||
|
cache_read_input_tokens: 5,
|
||||||
|
output_tokens: 7,
|
||||||
|
},
|
||||||
|
total_cost_usd: 0.12,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(usage).toEqual({
|
||||||
|
inputTokens: 10,
|
||||||
|
cachedInputTokens: 5,
|
||||||
|
outputTokens: 7,
|
||||||
|
totalCostUsd: 0.12,
|
||||||
|
contextWindowUsedTokens: 999,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contextWindowUsedTokens resets between turns and falls back to result usage", async () => {
|
||||||
|
const queryFactory = createQueryFactoryForTurns([
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "system",
|
||||||
|
subtype: "init",
|
||||||
|
session_id: "session-1",
|
||||||
|
permissionMode: "default",
|
||||||
|
model: "claude-sonnet-4-6",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "system",
|
||||||
|
subtype: "task_progress",
|
||||||
|
task_id: "task-1",
|
||||||
|
description: "Processing",
|
||||||
|
usage: {
|
||||||
|
total_tokens: 999,
|
||||||
|
tool_uses: 1,
|
||||||
|
duration_ms: 50,
|
||||||
|
input_tokens: 345,
|
||||||
|
cache_read_input_tokens: 55,
|
||||||
|
},
|
||||||
|
uuid: "task-progress-1",
|
||||||
|
session_id: "session-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
duration_ms: 100,
|
||||||
|
duration_api_ms: 75,
|
||||||
|
is_error: false,
|
||||||
|
num_turns: 1,
|
||||||
|
result: "done",
|
||||||
|
stop_reason: null,
|
||||||
|
total_cost_usd: 0.25,
|
||||||
|
usage: {
|
||||||
|
input_tokens: 10,
|
||||||
|
cache_read_input_tokens: 5,
|
||||||
|
output_tokens: 7,
|
||||||
|
},
|
||||||
|
modelUsage: {
|
||||||
|
"claude-sonnet-4-6": { contextWindow: 200_000 },
|
||||||
|
},
|
||||||
|
permission_denials: [],
|
||||||
|
uuid: "result-1",
|
||||||
|
session_id: "session-1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
duration_ms: 110,
|
||||||
|
duration_api_ms: 80,
|
||||||
|
is_error: false,
|
||||||
|
num_turns: 1,
|
||||||
|
result: "still done",
|
||||||
|
stop_reason: null,
|
||||||
|
total_cost_usd: 0.1,
|
||||||
|
usage: {
|
||||||
|
input_tokens: 11,
|
||||||
|
cache_creation_input_tokens: 3,
|
||||||
|
cache_read_input_tokens: 6,
|
||||||
|
output_tokens: 8,
|
||||||
|
},
|
||||||
|
modelUsage: {
|
||||||
|
"claude-sonnet-4-6": { contextWindow: 200_000 },
|
||||||
|
},
|
||||||
|
permission_denials: [],
|
||||||
|
uuid: "result-2",
|
||||||
|
session_id: "session-1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
const client = new ClaudeAgentClient({ logger, queryFactory });
|
||||||
|
const session = await client.createSession({
|
||||||
|
provider: "claude",
|
||||||
|
cwd: process.cwd(),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firstTurn = await session.run("turn 1");
|
||||||
|
const secondTurn = await session.run("turn 2");
|
||||||
|
|
||||||
|
expect(firstTurn.usage).toEqual({
|
||||||
|
inputTokens: 10,
|
||||||
|
cachedInputTokens: 5,
|
||||||
|
outputTokens: 7,
|
||||||
|
totalCostUsd: 0.25,
|
||||||
|
contextWindowMaxTokens: 200_000,
|
||||||
|
contextWindowUsedTokens: 999,
|
||||||
|
});
|
||||||
|
expect(secondTurn.usage).toEqual({
|
||||||
|
inputTokens: 11,
|
||||||
|
cachedInputTokens: 6,
|
||||||
|
outputTokens: 8,
|
||||||
|
totalCostUsd: 0.1,
|
||||||
|
contextWindowMaxTokens: 200_000,
|
||||||
|
contextWindowUsedTokens: 28,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("convertUsage derives used tokens even when modelUsage is missing", async () => {
|
||||||
|
const session = await createSessionForTest();
|
||||||
|
|
||||||
|
const usage = session.convertUsage({
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
usage: {
|
||||||
|
input_tokens: 10,
|
||||||
|
cache_read_input_tokens: 5,
|
||||||
|
output_tokens: 7,
|
||||||
|
},
|
||||||
|
total_cost_usd: 0.12,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(usage).toEqual({
|
||||||
|
inputTokens: 10,
|
||||||
|
cachedInputTokens: 5,
|
||||||
|
outputTokens: 7,
|
||||||
|
totalCostUsd: 0.12,
|
||||||
|
contextWindowUsedTokens: 22,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
type SpawnOptions,
|
type SpawnOptions,
|
||||||
type SDKMessage,
|
type SDKMessage,
|
||||||
type SDKPartialAssistantMessage,
|
type SDKPartialAssistantMessage,
|
||||||
|
type SDKTaskProgressMessage,
|
||||||
type SDKResultMessage,
|
type SDKResultMessage,
|
||||||
type SDKSystemMessage,
|
type SDKSystemMessage,
|
||||||
type SDKUserMessage,
|
type SDKUserMessage,
|
||||||
@@ -47,6 +48,7 @@ import {
|
|||||||
} from "./diagnostic-utils.js";
|
} from "./diagnostic-utils.js";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
AgentPermissionAction,
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
AgentClient,
|
AgentClient,
|
||||||
AgentLaunchContext,
|
AgentLaunchContext,
|
||||||
@@ -669,6 +671,43 @@ function resolvePermissionKind(
|
|||||||
return "tool";
|
return "tool";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getClaudeModeLabel(modeId: PermissionMode): string {
|
||||||
|
return DEFAULT_MODES.find((mode) => mode.id === modeId)?.label ?? modeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClaudePlanPermissionActions(
|
||||||
|
resumeMode: PermissionMode | null,
|
||||||
|
): AgentPermissionAction[] {
|
||||||
|
const actions: AgentPermissionAction[] = [
|
||||||
|
{
|
||||||
|
id: "reject",
|
||||||
|
label: "Reject",
|
||||||
|
behavior: "deny",
|
||||||
|
variant: "danger",
|
||||||
|
intent: "dismiss",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "implement",
|
||||||
|
label: "Implement",
|
||||||
|
behavior: "allow",
|
||||||
|
variant: "primary",
|
||||||
|
intent: "implement",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (resumeMode === "bypassPermissions") {
|
||||||
|
actions.push({
|
||||||
|
id: "implement_resume",
|
||||||
|
label: `Implement with ${getClaudeModeLabel(resumeMode)}`,
|
||||||
|
behavior: "allow",
|
||||||
|
variant: "secondary",
|
||||||
|
intent: "implement_resume",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
type TimelineFragment = {
|
type TimelineFragment = {
|
||||||
kind: "assistant" | "reasoning";
|
kind: "assistant" | "reasoning";
|
||||||
text: string;
|
text: string;
|
||||||
@@ -967,7 +1006,8 @@ function isSyntheticUserEntry(entry: unknown): boolean {
|
|||||||
if (!entry || typeof entry !== "object") {
|
if (!entry || typeof entry !== "object") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return (entry as { isSynthetic?: unknown }).isSynthetic === true;
|
const candidate = entry as { isSynthetic?: unknown; isMeta?: unknown };
|
||||||
|
return candidate.isSynthetic === true || candidate.isMeta === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readEventIdentifiers(message: SDKMessage): EventIdentifiers {
|
export function readEventIdentifiers(message: SDKMessage): EventIdentifiers {
|
||||||
@@ -1160,6 +1200,40 @@ function resolveClaudeVersion(runtimeSettings?: ProviderRuntimeSettings): string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractContextWindowSize(modelUsage: unknown): number | undefined {
|
||||||
|
if (!modelUsage || typeof modelUsage !== "object") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let maxContextWindow: number | undefined;
|
||||||
|
for (const value of Object.values(modelUsage as Record<string, unknown>)) {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const contextWindow = (value as { contextWindow?: unknown }).contextWindow;
|
||||||
|
if (
|
||||||
|
typeof contextWindow !== "number" ||
|
||||||
|
!Number.isFinite(contextWindow) ||
|
||||||
|
contextWindow <= 0
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
maxContextWindow = Math.max(maxContextWindow ?? 0, contextWindow);
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxContextWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readContextWindowUsedTokensFromTaskProgress(
|
||||||
|
message: SDKTaskProgressMessage,
|
||||||
|
): number | undefined {
|
||||||
|
const totalTokens = message.usage?.total_tokens;
|
||||||
|
if (typeof totalTokens !== "number" || !Number.isFinite(totalTokens) || totalTokens < 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return totalTokens;
|
||||||
|
}
|
||||||
|
|
||||||
class ClaudeAgentSession implements AgentSession {
|
class ClaudeAgentSession implements AgentSession {
|
||||||
readonly provider: "claude" = "claude";
|
readonly provider: "claude" = "claude";
|
||||||
readonly capabilities = CLAUDE_CAPABILITIES;
|
readonly capabilities = CLAUDE_CAPABILITIES;
|
||||||
@@ -1175,6 +1249,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private claudeSessionId: string | null;
|
private claudeSessionId: string | null;
|
||||||
private persistence: AgentPersistenceHandle | null;
|
private persistence: AgentPersistenceHandle | null;
|
||||||
private currentMode: PermissionMode;
|
private currentMode: PermissionMode;
|
||||||
|
private planResumeMode: PermissionMode | null = null;
|
||||||
private availableModes: AgentMode[] = DEFAULT_MODES;
|
private availableModes: AgentMode[] = DEFAULT_MODES;
|
||||||
private toolUseCache = new Map<string, ToolUseCacheEntry>();
|
private toolUseCache = new Map<string, ToolUseCacheEntry>();
|
||||||
private toolUseIndexToId = new Map<number, string>();
|
private toolUseIndexToId = new Map<number, string>();
|
||||||
@@ -1201,6 +1276,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private pendingInterruptAbort = false;
|
private pendingInterruptAbort = false;
|
||||||
private lastForegroundPromptText: string | null = null;
|
private lastForegroundPromptText: string | null = null;
|
||||||
private foregroundHasVisibleActivity = false;
|
private foregroundHasVisibleActivity = false;
|
||||||
|
private lastContextWindowUsedTokens: number | undefined;
|
||||||
private userMessageIds: string[] = [];
|
private userMessageIds: string[] = [];
|
||||||
private recentStderr = "";
|
private recentStderr = "";
|
||||||
private closed = false;
|
private closed = false;
|
||||||
@@ -1235,6 +1311,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.currentMode = isPermissionMode(config.modeId) ? config.modeId : "default";
|
this.currentMode = isPermissionMode(config.modeId) ? config.modeId : "default";
|
||||||
|
if (this.currentMode !== "plan") {
|
||||||
|
this.planResumeMode = this.currentMode;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get id(): string | null {
|
get id(): string | null {
|
||||||
@@ -1380,6 +1459,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
|
|
||||||
const sdkMessage = this.toSdkUserMessage(prompt);
|
const sdkMessage = this.toSdkUserMessage(prompt);
|
||||||
this.lastForegroundPromptText = this.extractPromptText(prompt);
|
this.lastForegroundPromptText = this.extractPromptText(prompt);
|
||||||
|
this.lastContextWindowUsedTokens = undefined;
|
||||||
const turnId = this.createTurnId("foreground");
|
const turnId = this.createTurnId("foreground");
|
||||||
this.activeForegroundTurnId = turnId;
|
this.activeForegroundTurnId = turnId;
|
||||||
this.foregroundHasVisibleActivity = false;
|
this.foregroundHasVisibleActivity = false;
|
||||||
@@ -1476,8 +1556,16 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalized = isPermissionMode(modeId) ? modeId : "default";
|
const normalized = isPermissionMode(modeId) ? modeId : "default";
|
||||||
|
const previousMode = this.currentMode;
|
||||||
const query = await this.ensureQuery();
|
const query = await this.ensureQuery();
|
||||||
await query.setPermissionMode(normalized);
|
await query.setPermissionMode(normalized);
|
||||||
|
if (normalized === "plan") {
|
||||||
|
if (previousMode !== "plan") {
|
||||||
|
this.planResumeMode = previousMode;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.planResumeMode = normalized;
|
||||||
|
}
|
||||||
this.currentMode = normalized;
|
this.currentMode = normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1524,13 +1612,22 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
|
|
||||||
if (response.behavior === "allow") {
|
if (response.behavior === "allow") {
|
||||||
if (pending.request.kind === "plan") {
|
if (pending.request.kind === "plan") {
|
||||||
await this.setMode("acceptEdits");
|
const selectedActionId = response.selectedActionId;
|
||||||
|
const shouldResumePriorMode =
|
||||||
|
selectedActionId === "implement_resume" && this.planResumeMode === "bypassPermissions";
|
||||||
|
const targetMode: PermissionMode = shouldResumePriorMode
|
||||||
|
? "bypassPermissions"
|
||||||
|
: "acceptEdits";
|
||||||
|
await this.setMode(targetMode);
|
||||||
this.pushToolCall(
|
this.pushToolCall(
|
||||||
mapClaudeCompletedToolCall({
|
mapClaudeCompletedToolCall({
|
||||||
name: "plan_approval",
|
name: "plan_approval",
|
||||||
callId: pending.request.id,
|
callId: pending.request.id,
|
||||||
input: pending.request.input ?? null,
|
input: pending.request.input ?? null,
|
||||||
output: { approved: true },
|
output: {
|
||||||
|
approved: true,
|
||||||
|
actionId: selectedActionId ?? "implement",
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1847,6 +1944,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.input = null;
|
this.input = null;
|
||||||
this.queryPumpPromise = null;
|
this.queryPumpPromise = null;
|
||||||
this.queryRestartNeeded = false;
|
this.queryRestartNeeded = false;
|
||||||
|
// Reset session identity for explicit restarts so the new query starts
|
||||||
|
// a fresh session rather than resuming the previous one.
|
||||||
|
this.claudeSessionId = null;
|
||||||
oldInput?.end();
|
oldInput?.end();
|
||||||
oldQuery.close?.();
|
oldQuery.close?.();
|
||||||
try {
|
try {
|
||||||
@@ -1856,6 +1956,12 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When the pump died unexpectedly (query became null, e.g. after a session
|
||||||
|
// ID overwrite error), preserve claudeSessionId so buildOptions() passes
|
||||||
|
// resume: sessionId and the new query auto-resumes the previous session.
|
||||||
|
// For explicit restarts above, claudeSessionId was already cleared.
|
||||||
|
this.persistence = null;
|
||||||
|
|
||||||
const input = createAsyncMessageInput<SDKUserMessage>();
|
const input = createAsyncMessageInput<SDKUserMessage>();
|
||||||
const options = this.buildOptions();
|
const options = this.buildOptions();
|
||||||
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
|
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
|
||||||
@@ -2197,6 +2303,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.notifySubscribers(event);
|
this.notifySubscribers(event);
|
||||||
this.activeForegroundTurnId = null;
|
this.activeForegroundTurnId = null;
|
||||||
this.lastForegroundPromptText = null;
|
this.lastForegroundPromptText = null;
|
||||||
|
this.lastContextWindowUsedTokens = undefined;
|
||||||
this.cancelCurrentTurn = null;
|
this.cancelCurrentTurn = null;
|
||||||
this.syncTurnState("foreground turn terminal");
|
this.syncTurnState("foreground turn terminal");
|
||||||
}
|
}
|
||||||
@@ -2212,10 +2319,12 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
if (this.activeForegroundTurnId) {
|
if (this.activeForegroundTurnId) {
|
||||||
this.activeForegroundTurnId = null;
|
this.activeForegroundTurnId = null;
|
||||||
this.lastForegroundPromptText = null;
|
this.lastForegroundPromptText = null;
|
||||||
|
this.lastContextWindowUsedTokens = undefined;
|
||||||
this.cancelCurrentTurn = null;
|
this.cancelCurrentTurn = null;
|
||||||
this.syncTurnState("foreground turn terminal");
|
this.syncTurnState("foreground turn terminal");
|
||||||
} else if (this.autonomousTurn) {
|
} else if (this.autonomousTurn) {
|
||||||
this.autonomousTurn = null;
|
this.autonomousTurn = null;
|
||||||
|
this.lastContextWindowUsedTokens = undefined;
|
||||||
this.syncTurnState("autonomous turn terminal");
|
this.syncTurnState("autonomous turn terminal");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2228,6 +2337,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.autonomousTurn = {
|
this.autonomousTurn = {
|
||||||
id: this.createTurnId("autonomous"),
|
id: this.createTurnId("autonomous"),
|
||||||
};
|
};
|
||||||
|
this.lastContextWindowUsedTokens = undefined;
|
||||||
this.notifySubscribers({ type: "turn_started", provider: "claude" });
|
this.notifySubscribers({ type: "turn_started", provider: "claude" });
|
||||||
this.syncTurnState("autonomous turn started");
|
this.syncTurnState("autonomous turn started");
|
||||||
}
|
}
|
||||||
@@ -2238,6 +2348,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
this.notifySubscribers({ type: "turn_completed", provider: "claude" });
|
this.notifySubscribers({ type: "turn_completed", provider: "claude" });
|
||||||
this.autonomousTurn = null;
|
this.autonomousTurn = null;
|
||||||
|
this.lastContextWindowUsedTokens = undefined;
|
||||||
this.syncTurnState("autonomous turn completed");
|
this.syncTurnState("autonomous turn completed");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2566,6 +2677,10 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
provider: "claude",
|
provider: "claude",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (message.subtype === "task_progress") {
|
||||||
|
this.lastContextWindowUsedTokens =
|
||||||
|
readContextWindowUsedTokensFromTaskProgress(message) ??
|
||||||
|
this.lastContextWindowUsedTokens;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "user": {
|
case "user": {
|
||||||
@@ -2643,7 +2758,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "result": {
|
case "result": {
|
||||||
const usage = this.convertUsage(message);
|
const usage = this.convertUsage(message, message.modelUsage);
|
||||||
if (message.subtype === "success") {
|
if (message.subtype === "success") {
|
||||||
events.push({ type: "turn_completed", provider: "claude", usage });
|
events.push({ type: "turn_completed", provider: "claude", usage });
|
||||||
} else {
|
} else {
|
||||||
@@ -2688,11 +2803,16 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
if (this.claudeSessionId === sessionId) {
|
if (this.claudeSessionId === sessionId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
throw new Error(
|
// Session ID changed mid-stream (e.g. a hook caused Claude to restart
|
||||||
`CRITICAL: Claude session ID overwrite detected! ` +
|
// with a new session). Accept the new ID and continue — the turn should
|
||||||
`Existing: ${this.claudeSessionId}, New: ${sessionId}. ` +
|
// not be failed just because the underlying subprocess cycled.
|
||||||
`This indicates a session identity corruption bug.`,
|
this.logger.warn(
|
||||||
|
{ existingSessionId: this.claudeSessionId, newSessionId: sessionId },
|
||||||
|
"Claude session ID changed in message; accepting new session",
|
||||||
);
|
);
|
||||||
|
this.claudeSessionId = sessionId;
|
||||||
|
this.persistence = null;
|
||||||
|
return sessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleSystemMessage(message: SDKSystemMessage): string | null {
|
private handleSystemMessage(message: SDKSystemMessage): string | null {
|
||||||
@@ -2727,14 +2847,20 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
} else if (existingSessionId === newSessionId) {
|
} else if (existingSessionId === newSessionId) {
|
||||||
this.logger.debug({ sessionId: newSessionId }, "Claude session ID unchanged (same value)");
|
this.logger.debug({ sessionId: newSessionId }, "Claude session ID unchanged (same value)");
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
// Session ID changed in an init message (e.g. a hook restarted Claude
|
||||||
`CRITICAL: Claude session ID overwrite detected! ` +
|
// with a new session mid-turn). Accept the new ID and continue.
|
||||||
`Existing: ${existingSessionId}, New: ${newSessionId}. ` +
|
this.logger.warn(
|
||||||
`This indicates a session identity corruption bug.`,
|
{ existingSessionId, newSessionId },
|
||||||
|
"Claude session ID changed in init message; accepting new session",
|
||||||
);
|
);
|
||||||
|
this.claudeSessionId = newSessionId;
|
||||||
|
threadStartedSessionId = newSessionId;
|
||||||
}
|
}
|
||||||
this.availableModes = DEFAULT_MODES;
|
this.availableModes = DEFAULT_MODES;
|
||||||
this.currentMode = message.permissionMode;
|
this.currentMode = message.permissionMode;
|
||||||
|
if (this.currentMode !== "plan") {
|
||||||
|
this.planResumeMode = this.currentMode;
|
||||||
|
}
|
||||||
this.persistence = null;
|
this.persistence = null;
|
||||||
if (message.model) {
|
if (message.model) {
|
||||||
const normalizedRuntimeModel = normalizeClaudeRuntimeModelId(message.model);
|
const normalizedRuntimeModel = normalizeClaudeRuntimeModelId(message.model);
|
||||||
@@ -2776,16 +2902,38 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private convertUsage(message: SDKResultMessage): AgentUsage | undefined {
|
private convertUsage(message: SDKResultMessage, modelUsage?: unknown): AgentUsage | undefined {
|
||||||
if (!message.usage) {
|
if (!message.usage) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return {
|
const usage: AgentUsage = {
|
||||||
inputTokens: message.usage.input_tokens,
|
inputTokens: message.usage.input_tokens,
|
||||||
cachedInputTokens: message.usage.cache_read_input_tokens,
|
cachedInputTokens: message.usage.cache_read_input_tokens,
|
||||||
outputTokens: message.usage.output_tokens,
|
outputTokens: message.usage.output_tokens,
|
||||||
totalCostUsd: message.total_cost_usd,
|
totalCostUsd: message.total_cost_usd,
|
||||||
};
|
};
|
||||||
|
const contextWindowMaxTokens = extractContextWindowSize(
|
||||||
|
modelUsage ?? message.modelUsage,
|
||||||
|
);
|
||||||
|
if (contextWindowMaxTokens !== undefined) {
|
||||||
|
usage.contextWindowMaxTokens = contextWindowMaxTokens;
|
||||||
|
}
|
||||||
|
if (typeof this.lastContextWindowUsedTokens === "number") {
|
||||||
|
usage.contextWindowUsedTokens = this.lastContextWindowUsedTokens;
|
||||||
|
} else if (message.usage) {
|
||||||
|
const usageWithCacheCreation = message.usage as typeof message.usage & {
|
||||||
|
cache_creation_input_tokens?: number;
|
||||||
|
};
|
||||||
|
const derived =
|
||||||
|
(message.usage.input_tokens ?? 0) +
|
||||||
|
(usageWithCacheCreation.cache_creation_input_tokens ?? 0) +
|
||||||
|
(message.usage.cache_read_input_tokens ?? 0) +
|
||||||
|
(message.usage.output_tokens ?? 0);
|
||||||
|
if (Number.isFinite(derived) && derived > 0) {
|
||||||
|
usage.contextWindowUsedTokens = derived;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return usage;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handlePermissionRequest: CanUseTool = async (
|
private handlePermissionRequest: CanUseTool = async (
|
||||||
@@ -2820,6 +2968,8 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
input,
|
input,
|
||||||
detail: toolDetail,
|
detail: toolDetail,
|
||||||
suggestions: options.suggestions?.map((suggestion) => ({ ...suggestion })),
|
suggestions: options.suggestions?.map((suggestion) => ({ ...suggestion })),
|
||||||
|
actions:
|
||||||
|
kind === "plan" ? buildClaudePlanPermissionActions(this.planResumeMode) : undefined,
|
||||||
metadata: Object.keys(metadata).length ? metadata : undefined,
|
metadata: Object.keys(metadata).length ? metadata : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import { existsSync, rmSync } from "node:fs";
|
import { existsSync, rmSync } from "node:fs";
|
||||||
|
|
||||||
import type { AgentLaunchContext, AgentSession, AgentSessionConfig, AgentStreamEvent } from "../agent-sdk-types.js";
|
import type { AgentLaunchContext, AgentSession, AgentSessionConfig, AgentStreamEvent } from "../agent-sdk-types.js";
|
||||||
@@ -40,6 +40,183 @@ function createSession(configOverrides: Partial<AgentSessionConfig> = {}) {
|
|||||||
describe("Codex app-server provider", () => {
|
describe("Codex app-server provider", () => {
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
|
|
||||||
|
test("extracts context window usage from snake_case token payloads", () => {
|
||||||
|
expect(
|
||||||
|
__codexAppServerInternals.toAgentUsage({
|
||||||
|
model_context_window: 200000,
|
||||||
|
last: {
|
||||||
|
total_tokens: 50000,
|
||||||
|
inputTokens: 30000,
|
||||||
|
cachedInputTokens: 5000,
|
||||||
|
outputTokens: 15000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
inputTokens: 30000,
|
||||||
|
cachedInputTokens: 5000,
|
||||||
|
outputTokens: 15000,
|
||||||
|
contextWindowMaxTokens: 200000,
|
||||||
|
contextWindowUsedTokens: 50000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extracts context window usage from camelCase token payloads", () => {
|
||||||
|
expect(
|
||||||
|
__codexAppServerInternals.toAgentUsage({
|
||||||
|
modelContextWindow: 200000,
|
||||||
|
last: {
|
||||||
|
totalTokens: 50000,
|
||||||
|
inputTokens: 30000,
|
||||||
|
cachedInputTokens: 5000,
|
||||||
|
outputTokens: 15000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
inputTokens: 30000,
|
||||||
|
cachedInputTokens: 5000,
|
||||||
|
outputTokens: 15000,
|
||||||
|
contextWindowMaxTokens: 200000,
|
||||||
|
contextWindowUsedTokens: 50000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps existing usage behavior when context window fields are missing", () => {
|
||||||
|
expect(
|
||||||
|
__codexAppServerInternals.toAgentUsage({
|
||||||
|
last: {
|
||||||
|
inputTokens: 30000,
|
||||||
|
cachedInputTokens: 5000,
|
||||||
|
outputTokens: 15000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
inputTokens: 30000,
|
||||||
|
cachedInputTokens: 5000,
|
||||||
|
outputTokens: 15000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("excludes invalid context window values", () => {
|
||||||
|
expect(
|
||||||
|
__codexAppServerInternals.toAgentUsage({
|
||||||
|
model_context_window: Number.NaN,
|
||||||
|
modelContextWindow: "200000",
|
||||||
|
last: {
|
||||||
|
total_tokens: Number.NaN,
|
||||||
|
totalTokens: "50000",
|
||||||
|
inputTokens: 30000,
|
||||||
|
cachedInputTokens: 5000,
|
||||||
|
outputTokens: 15000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
inputTokens: 30000,
|
||||||
|
cachedInputTokens: 5000,
|
||||||
|
outputTokens: 15000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes raw output schemas for Codex structured outputs", () => {
|
||||||
|
const input = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
findings: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
severity: { type: "string" },
|
||||||
|
summary: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["severity"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overall: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["overall"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalized = __codexAppServerInternals.normalizeCodexOutputSchema(input);
|
||||||
|
|
||||||
|
expect(normalized).toEqual({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
findings: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
severity: { type: "string" },
|
||||||
|
summary: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["severity", "summary"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overall: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["overall", "findings"],
|
||||||
|
additionalProperties: false,
|
||||||
|
});
|
||||||
|
expect(input).toEqual({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
findings: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
severity: { type: "string" },
|
||||||
|
summary: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["severity"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overall: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["overall"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes a normalized output schema to turn/start", async () => {
|
||||||
|
const session = createSession();
|
||||||
|
const request = vi.fn(async (method: string) => {
|
||||||
|
if (method === "thread/loaded/list") {
|
||||||
|
return { data: ["test-thread"] };
|
||||||
|
}
|
||||||
|
if (method === "turn/start") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected request: ${method}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
session.activeForegroundTurnId = null;
|
||||||
|
session.client = { request } as any;
|
||||||
|
|
||||||
|
await session.startTurn("Return JSON", {
|
||||||
|
outputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
summary: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const turnStartCall = request.mock.calls.find(([method]) => method === "turn/start");
|
||||||
|
expect(turnStartCall?.[1]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
outputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
summary: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["summary"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("maps image prompt blocks to Codex localImage input", async () => {
|
test("maps image prompt blocks to Codex localImage input", async () => {
|
||||||
const input = await codexAppServerTurnInputFromPrompt(
|
const input = await codexAppServerTurnInputFromPrompt(
|
||||||
[
|
[
|
||||||
@@ -345,4 +522,165 @@ describe("Codex app-server provider", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("emits a synthetic plan approval permission after a successful Codex plan turn", () => {
|
||||||
|
const session = createSession({
|
||||||
|
featureValues: { plan_mode: true, fast_mode: true },
|
||||||
|
});
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
|
||||||
|
(session as any).handleNotification("turn/started", {
|
||||||
|
turn: { id: "turn-plan-1" },
|
||||||
|
});
|
||||||
|
(session as any).handleNotification("turn/plan/updated", {
|
||||||
|
plan: [
|
||||||
|
{ step: "Inspect the existing auth flow", status: "completed" },
|
||||||
|
{ step: "Implement the button behavior", status: "pending" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
(session as any).handleNotification("turn/completed", {
|
||||||
|
turn: { status: "completed", error: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events.at(-2)).toEqual({
|
||||||
|
type: "permission_requested",
|
||||||
|
provider: "codex",
|
||||||
|
turnId: "test-turn",
|
||||||
|
request: expect.objectContaining({
|
||||||
|
provider: "codex",
|
||||||
|
name: "CodexPlanApproval",
|
||||||
|
kind: "plan",
|
||||||
|
title: "Plan",
|
||||||
|
input: {
|
||||||
|
plan: "- Inspect the existing auth flow\n- Implement the button behavior",
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "reject",
|
||||||
|
label: "Reject",
|
||||||
|
behavior: "deny",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "implement",
|
||||||
|
label: "Implement",
|
||||||
|
behavior: "allow",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(events.at(-1)).toEqual({
|
||||||
|
type: "turn_completed",
|
||||||
|
provider: "codex",
|
||||||
|
turnId: "test-turn",
|
||||||
|
usage: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("approving a synthetic Codex plan permission disables plan and fast mode and starts implementation", async () => {
|
||||||
|
const session = createSession({
|
||||||
|
featureValues: { plan_mode: true, fast_mode: true },
|
||||||
|
});
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
|
||||||
|
const startTurnSpy = vi
|
||||||
|
.spyOn(session, "startTurn")
|
||||||
|
.mockResolvedValue({ turnId: "follow-up-turn" });
|
||||||
|
|
||||||
|
(session as any).handleNotification("turn/started", {
|
||||||
|
turn: { id: "turn-plan-2" },
|
||||||
|
});
|
||||||
|
(session as any).handleNotification("turn/plan/updated", {
|
||||||
|
plan: [{ step: "Implement the new flow", status: "pending" }],
|
||||||
|
});
|
||||||
|
(session as any).handleNotification("turn/completed", {
|
||||||
|
turn: { status: "completed", error: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = events.find(
|
||||||
|
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
|
||||||
|
event.type === "permission_requested" && event.request.kind === "plan",
|
||||||
|
);
|
||||||
|
expect(request).toBeDefined();
|
||||||
|
if (!request) {
|
||||||
|
throw new Error("Expected synthetic plan approval permission");
|
||||||
|
}
|
||||||
|
|
||||||
|
await session.respondToPermission(request.request.id, {
|
||||||
|
behavior: "allow",
|
||||||
|
selectedActionId: "implement",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((session as any).serviceTier).toBeNull();
|
||||||
|
expect((session as any).planModeEnabled).toBe(false);
|
||||||
|
expect((session as any).config.featureValues).toEqual({
|
||||||
|
plan_mode: false,
|
||||||
|
fast_mode: false,
|
||||||
|
});
|
||||||
|
expect(startTurnSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("The user approved the plan. Implement it now."),
|
||||||
|
);
|
||||||
|
expect(events.at(-1)).toEqual({
|
||||||
|
type: "permission_resolved",
|
||||||
|
provider: "codex",
|
||||||
|
requestId: request.request.id,
|
||||||
|
resolution: {
|
||||||
|
behavior: "allow",
|
||||||
|
selectedActionId: "implement",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed synthetic Codex plan implementation keeps the permission pending for retry", async () => {
|
||||||
|
const session = createSession({
|
||||||
|
featureValues: { plan_mode: true, fast_mode: true },
|
||||||
|
});
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
|
||||||
|
const startTurnSpy = vi
|
||||||
|
.spyOn(session, "startTurn")
|
||||||
|
.mockRejectedValueOnce(new Error("follow-up failed"));
|
||||||
|
|
||||||
|
(session as any).handleNotification("turn/started", {
|
||||||
|
turn: { id: "turn-plan-retry" },
|
||||||
|
});
|
||||||
|
(session as any).handleNotification("turn/plan/updated", {
|
||||||
|
plan: [{ step: "Implement the retriable flow", status: "pending" }],
|
||||||
|
});
|
||||||
|
(session as any).handleNotification("turn/completed", {
|
||||||
|
turn: { status: "completed", error: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = events.find(
|
||||||
|
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
|
||||||
|
event.type === "permission_requested" && event.request.kind === "plan",
|
||||||
|
);
|
||||||
|
expect(request).toBeDefined();
|
||||||
|
if (!request) {
|
||||||
|
throw new Error("Expected synthetic plan approval permission");
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
session.respondToPermission(request.request.id, {
|
||||||
|
behavior: "allow",
|
||||||
|
selectedActionId: "implement",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("follow-up failed");
|
||||||
|
|
||||||
|
expect(startTurnSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect((session as any).planModeEnabled).toBe(true);
|
||||||
|
expect((session as any).config.featureValues).toEqual({
|
||||||
|
plan_mode: true,
|
||||||
|
fast_mode: true,
|
||||||
|
});
|
||||||
|
expect(session.getPendingPermissions()).toEqual([request.request]);
|
||||||
|
expect(
|
||||||
|
events.some(
|
||||||
|
(event) =>
|
||||||
|
event.type === "permission_resolved" && event.requestId === request.request.id,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AgentPermissionAction,
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
AgentClient,
|
AgentClient,
|
||||||
AgentFeature,
|
AgentFeature,
|
||||||
@@ -64,6 +65,8 @@ const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
|
|||||||
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
||||||
const CODEX_PROVIDER = "codex" as const;
|
const CODEX_PROVIDER = "codex" as const;
|
||||||
const CODEX_IMAGE_ATTACHMENT_DIR = "paseo-attachments";
|
const CODEX_IMAGE_ATTACHMENT_DIR = "paseo-attachments";
|
||||||
|
const CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX =
|
||||||
|
"The user approved the plan. Implement it now. Do not restate or revise the plan unless blocked.";
|
||||||
|
|
||||||
const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = {
|
const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
supportsStreaming: true,
|
supportsStreaming: true,
|
||||||
@@ -143,6 +146,73 @@ function normalizeCodexModelLabel(displayName: string): string {
|
|||||||
return displayName.replace(/\bgpt\b/gi, "GPT");
|
return displayName.replace(/\bgpt\b/gi, "GPT");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSchemaRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObjectSchemaNode(schema: Record<string, unknown>): boolean {
|
||||||
|
const type = schema.type;
|
||||||
|
return (
|
||||||
|
isSchemaRecord(schema.properties) ||
|
||||||
|
type === "object" ||
|
||||||
|
(Array.isArray(type) && type.includes("object"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCodexOutputSchemaNode(
|
||||||
|
schema: unknown,
|
||||||
|
path: string,
|
||||||
|
): unknown {
|
||||||
|
if (Array.isArray(schema)) {
|
||||||
|
return schema.map((entry, index) => normalizeCodexOutputSchemaNode(entry, `${path}[${index}]`));
|
||||||
|
}
|
||||||
|
if (!isSchemaRecord(schema)) {
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(schema)) {
|
||||||
|
normalized[key] = normalizeCodexOutputSchemaNode(value, `${path}.${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isObjectSchemaNode(normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.additionalProperties === undefined) {
|
||||||
|
normalized.additionalProperties = false;
|
||||||
|
} else if (normalized.additionalProperties !== false) {
|
||||||
|
throw new Error(
|
||||||
|
`Codex structured outputs require ${path} to set additionalProperties to false for object schemas.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const properties = isSchemaRecord(normalized.properties) ? normalized.properties : null;
|
||||||
|
if (!properties) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
const propertyKeys = Object.keys(properties);
|
||||||
|
const existingRequired = Array.isArray(normalized.required)
|
||||||
|
? normalized.required.filter((entry): entry is string => typeof entry === "string")
|
||||||
|
: [];
|
||||||
|
normalized.required = Array.from(new Set([...existingRequired, ...propertyKeys]));
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCodexOutputSchema(schema: unknown): Record<string, unknown> {
|
||||||
|
if (!isSchemaRecord(schema)) {
|
||||||
|
throw new Error("Codex structured outputs require a JSON object schema.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeCodexOutputSchemaNode(schema, "$");
|
||||||
|
if (!isSchemaRecord(normalized) || !isObjectSchemaNode(normalized)) {
|
||||||
|
throw new Error("Codex structured outputs require a root object schema.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
type CodexConfiguredDefaults = {
|
type CodexConfiguredDefaults = {
|
||||||
model?: string;
|
model?: string;
|
||||||
thinkingOptionId?: string;
|
thinkingOptionId?: string;
|
||||||
@@ -677,12 +747,42 @@ function terminateChildProcessTree(child: ChildProcessWithoutNullStreams): void
|
|||||||
function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined {
|
function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined {
|
||||||
if (!tokenUsage || typeof tokenUsage !== "object") return undefined;
|
if (!tokenUsage || typeof tokenUsage !== "object") return undefined;
|
||||||
const usage = tokenUsage as {
|
const usage = tokenUsage as {
|
||||||
last?: { inputTokens?: number; cachedInputTokens?: number; outputTokens?: number };
|
model_context_window?: number;
|
||||||
|
modelContextWindow?: number;
|
||||||
|
last?: {
|
||||||
|
inputTokens?: number;
|
||||||
|
cachedInputTokens?: number;
|
||||||
|
outputTokens?: number;
|
||||||
|
total_tokens?: number;
|
||||||
|
totalTokens?: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
const contextWindowMaxTokens =
|
||||||
|
typeof usage.model_context_window === "number" &&
|
||||||
|
Number.isFinite(usage.model_context_window) &&
|
||||||
|
usage.model_context_window > 0
|
||||||
|
? usage.model_context_window
|
||||||
|
: typeof usage.modelContextWindow === "number" &&
|
||||||
|
Number.isFinite(usage.modelContextWindow) &&
|
||||||
|
usage.modelContextWindow > 0
|
||||||
|
? usage.modelContextWindow
|
||||||
|
: undefined;
|
||||||
|
const contextWindowUsedTokens =
|
||||||
|
typeof usage.last?.total_tokens === "number" &&
|
||||||
|
Number.isFinite(usage.last.total_tokens) &&
|
||||||
|
usage.last.total_tokens > 0
|
||||||
|
? usage.last.total_tokens
|
||||||
|
: typeof usage.last?.totalTokens === "number" &&
|
||||||
|
Number.isFinite(usage.last.totalTokens) &&
|
||||||
|
usage.last.totalTokens > 0
|
||||||
|
? usage.last.totalTokens
|
||||||
|
: undefined;
|
||||||
return {
|
return {
|
||||||
inputTokens: usage.last?.inputTokens,
|
inputTokens: usage.last?.inputTokens,
|
||||||
cachedInputTokens: usage.last?.cachedInputTokens,
|
cachedInputTokens: usage.last?.cachedInputTokens,
|
||||||
outputTokens: usage.last?.outputTokens,
|
outputTokens: usage.last?.outputTokens,
|
||||||
|
...(contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {}),
|
||||||
|
...(contextWindowUsedTokens !== undefined ? { contextWindowUsedTokens } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -739,6 +839,53 @@ function mapCodexPlanToToolCall(params: { callId: string; text: string }): ToolC
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPlanPermissionActions(
|
||||||
|
options?: { includeResumeAction?: boolean; resumeLabel?: string },
|
||||||
|
): AgentPermissionAction[] {
|
||||||
|
const actions: AgentPermissionAction[] = [
|
||||||
|
{
|
||||||
|
id: "reject",
|
||||||
|
label: "Reject",
|
||||||
|
behavior: "deny",
|
||||||
|
variant: "danger",
|
||||||
|
intent: "dismiss",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "implement",
|
||||||
|
label: "Implement",
|
||||||
|
behavior: "allow",
|
||||||
|
variant: "primary",
|
||||||
|
intent: "implement",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options?.includeResumeAction && options.resumeLabel) {
|
||||||
|
actions.push({
|
||||||
|
id: "implement_resume",
|
||||||
|
label: options.resumeLabel,
|
||||||
|
behavior: "allow",
|
||||||
|
variant: "secondary",
|
||||||
|
intent: "implement_resume",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCodexPlanImplementationPrompt(planText: string): string {
|
||||||
|
const normalizedPlan = normalizePlanMarkdown(planText);
|
||||||
|
if (!normalizedPlan) {
|
||||||
|
return `${CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX} Make the required code changes and verify them.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX,
|
||||||
|
"Approved plan:",
|
||||||
|
normalizedPlan,
|
||||||
|
"Carry out the work, make the necessary code changes, and verify the result.",
|
||||||
|
].join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
type CodexQuestionOption = {
|
type CodexQuestionOption = {
|
||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -2269,8 +2416,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
resolve: (value: unknown) => void;
|
resolve: (value: unknown) => void;
|
||||||
kind: "command" | "file" | "question";
|
kind: "command" | "file" | "question" | "plan";
|
||||||
questions?: CodexQuestionPrompt[];
|
questions?: CodexQuestionPrompt[];
|
||||||
|
planText?: string;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
private resolvedPermissionRequests = new Set<string>();
|
private resolvedPermissionRequests = new Set<string>();
|
||||||
@@ -2289,6 +2437,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private warnedInvalidNotificationPayloads = new Set<string>();
|
private warnedInvalidNotificationPayloads = new Set<string>();
|
||||||
private warnedIncompleteEditToolCallIds = new Set<string>();
|
private warnedIncompleteEditToolCallIds = new Set<string>();
|
||||||
private latestUsage: AgentUsage | undefined;
|
private latestUsage: AgentUsage | undefined;
|
||||||
|
private latestPlanResult: { callId: string; text: string; turnId: string | null } | null = null;
|
||||||
private connected = false;
|
private connected = false;
|
||||||
private collaborationModes: Array<{
|
private collaborationModes: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
@@ -2472,6 +2621,79 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
this.resolvedCollaborationMode = this.resolveCollaborationMode();
|
this.resolvedCollaborationMode = this.resolveCollaborationMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private applyFeatureValue(featureId: "fast_mode" | "plan_mode", value: boolean): void {
|
||||||
|
this.config.featureValues = {
|
||||||
|
...(this.config.featureValues ?? {}),
|
||||||
|
[featureId]: value,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (featureId === "fast_mode") {
|
||||||
|
this.serviceTier = value ? "fast" : null;
|
||||||
|
this.cachedRuntimeInfo = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.planModeEnabled = value;
|
||||||
|
this.refreshResolvedCollaborationMode();
|
||||||
|
this.cachedRuntimeInfo = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private rememberPlanResult(item: ToolCallTimelineItem): void {
|
||||||
|
if (item.detail.type !== "plan") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.latestPlanResult = {
|
||||||
|
callId: item.callId,
|
||||||
|
text: item.detail.text,
|
||||||
|
turnId: this.currentTurnId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitSyntheticPlanApprovalRequest(planText: string): void {
|
||||||
|
const requestId = `permission-${randomUUID()}`;
|
||||||
|
const request: AgentPermissionRequest = {
|
||||||
|
id: requestId,
|
||||||
|
provider: CODEX_PROVIDER,
|
||||||
|
name: "CodexPlanApproval",
|
||||||
|
kind: "plan",
|
||||||
|
title: "Plan",
|
||||||
|
description: "Review the proposed plan before implementation starts.",
|
||||||
|
input: { plan: planText },
|
||||||
|
actions: buildPlanPermissionActions(),
|
||||||
|
metadata: {
|
||||||
|
planText,
|
||||||
|
source: "codex_plan_approval",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.pendingPermissions.set(requestId, request);
|
||||||
|
this.pendingPermissionHandlers.set(requestId, {
|
||||||
|
resolve: () => undefined,
|
||||||
|
kind: "plan",
|
||||||
|
planText,
|
||||||
|
});
|
||||||
|
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleApprovedPlanPermission(params: { planText?: unknown }): Promise<void> {
|
||||||
|
const planText =
|
||||||
|
typeof params.planText === "string" ? normalizePlanMarkdown(params.planText) : "";
|
||||||
|
const previousPlanMode = this.planModeEnabled;
|
||||||
|
const previousFastMode = this.serviceTier === "fast";
|
||||||
|
|
||||||
|
this.applyFeatureValue("plan_mode", false);
|
||||||
|
this.applyFeatureValue("fast_mode", false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.startTurn(buildCodexPlanImplementationPrompt(planText));
|
||||||
|
} catch (error) {
|
||||||
|
this.applyFeatureValue("plan_mode", previousPlanMode);
|
||||||
|
this.applyFeatureValue("fast_mode", previousFastMode);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private registerRequestHandlers(): void {
|
private registerRequestHandlers(): void {
|
||||||
if (!this.client) return;
|
if (!this.client) return;
|
||||||
|
|
||||||
@@ -2774,7 +2996,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
params.cwd = this.config.cwd;
|
params.cwd = this.config.cwd;
|
||||||
}
|
}
|
||||||
if (options?.outputSchema) {
|
if (options?.outputSchema) {
|
||||||
params.outputSchema = options.outputSchema;
|
params.outputSchema = normalizeCodexOutputSchema(options.outputSchema);
|
||||||
}
|
}
|
||||||
if (this.config.systemPrompt?.trim()) {
|
if (this.config.systemPrompt?.trim()) {
|
||||||
params.developerInstructions = this.config.systemPrompt.trim();
|
params.developerInstructions = this.config.systemPrompt.trim();
|
||||||
@@ -2869,14 +3091,11 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
|
|
||||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||||
if (featureId === "fast_mode") {
|
if (featureId === "fast_mode") {
|
||||||
this.serviceTier = value ? "fast" : null;
|
this.applyFeatureValue("fast_mode", Boolean(value));
|
||||||
this.cachedRuntimeInfo = null;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (featureId === "plan_mode") {
|
if (featureId === "plan_mode") {
|
||||||
this.planModeEnabled = Boolean(value);
|
this.applyFeatureValue("plan_mode", Boolean(value));
|
||||||
this.refreshResolvedCollaborationMode();
|
|
||||||
this.cachedRuntimeInfo = null;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new Error(`Unknown Codex feature: ${featureId}`);
|
throw new Error(`Unknown Codex feature: ${featureId}`);
|
||||||
@@ -2892,6 +3111,26 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
throw new Error(`No pending Codex app-server permission request with id '${requestId}'`);
|
throw new Error(`No pending Codex app-server permission request with id '${requestId}'`);
|
||||||
}
|
}
|
||||||
const pendingRequest = this.pendingPermissions.get(requestId) ?? null;
|
const pendingRequest = this.pendingPermissions.get(requestId) ?? null;
|
||||||
|
|
||||||
|
if (pending.kind === "plan") {
|
||||||
|
if (response.behavior === "allow") {
|
||||||
|
await this.handleApprovedPlanPermission({
|
||||||
|
planText: pending.planText ?? pendingRequest?.metadata?.planText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingPermissionHandlers.delete(requestId);
|
||||||
|
this.pendingPermissions.delete(requestId);
|
||||||
|
this.resolvedPermissionRequests.add(requestId);
|
||||||
|
this.emitEvent({
|
||||||
|
type: "permission_resolved",
|
||||||
|
provider: CODEX_PROVIDER,
|
||||||
|
requestId,
|
||||||
|
resolution: response,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.pendingPermissionHandlers.delete(requestId);
|
this.pendingPermissionHandlers.delete(requestId);
|
||||||
this.pendingPermissions.delete(requestId);
|
this.pendingPermissions.delete(requestId);
|
||||||
this.resolvedPermissionRequests.add(requestId);
|
this.resolvedPermissionRequests.add(requestId);
|
||||||
@@ -3193,6 +3432,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
|
|
||||||
if (parsed.kind === "turn_started") {
|
if (parsed.kind === "turn_started") {
|
||||||
this.currentTurnId = parsed.turnId;
|
this.currentTurnId = parsed.turnId;
|
||||||
|
this.latestPlanResult = null;
|
||||||
this.emittedItemStartedIds.clear();
|
this.emittedItemStartedIds.clear();
|
||||||
this.emittedItemCompletedIds.clear();
|
this.emittedItemCompletedIds.clear();
|
||||||
this.emittedExecCommandStartedCallIds.clear();
|
this.emittedExecCommandStartedCallIds.clear();
|
||||||
@@ -3214,6 +3454,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
} else if (parsed.status === "interrupted") {
|
} else if (parsed.status === "interrupted") {
|
||||||
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
|
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
|
||||||
} else {
|
} else {
|
||||||
|
if (this.planModeEnabled && this.latestPlanResult?.text) {
|
||||||
|
this.emitSyntheticPlanApprovalRequest(this.latestPlanResult.text);
|
||||||
|
}
|
||||||
this.emitEvent({
|
this.emitEvent({
|
||||||
type: "turn_completed",
|
type: "turn_completed",
|
||||||
provider: CODEX_PROVIDER,
|
provider: CODEX_PROVIDER,
|
||||||
@@ -3221,6 +3464,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.activeForegroundTurnId = null;
|
this.activeForegroundTurnId = null;
|
||||||
|
this.latestPlanResult = null;
|
||||||
this.emittedItemStartedIds.clear();
|
this.emittedItemStartedIds.clear();
|
||||||
this.emittedItemCompletedIds.clear();
|
this.emittedItemCompletedIds.clear();
|
||||||
this.emittedExecCommandStartedCallIds.clear();
|
this.emittedExecCommandStartedCallIds.clear();
|
||||||
@@ -3242,6 +3486,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
if (timelineItem) {
|
if (timelineItem) {
|
||||||
|
this.rememberPlanResult(timelineItem);
|
||||||
this.emitEvent({
|
this.emitEvent({
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: CODEX_PROVIDER,
|
provider: CODEX_PROVIDER,
|
||||||
@@ -3435,6 +3680,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (timelineItem.type === "tool_call") {
|
if (timelineItem.type === "tool_call") {
|
||||||
|
if (timelineItem.detail.type === "plan") {
|
||||||
|
this.rememberPlanResult(timelineItem);
|
||||||
|
}
|
||||||
this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item);
|
this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item);
|
||||||
}
|
}
|
||||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||||
@@ -4008,6 +4256,8 @@ export const __codexAppServerInternals = {
|
|||||||
mapCodexPatchNotificationToToolCall,
|
mapCodexPatchNotificationToToolCall,
|
||||||
planStepsToMarkdown,
|
planStepsToMarkdown,
|
||||||
mapCodexPlanToToolCall,
|
mapCodexPlanToToolCall,
|
||||||
|
normalizeCodexOutputSchema,
|
||||||
normalizeCodexQuestionPrompts,
|
normalizeCodexQuestionPrompts,
|
||||||
|
toAgentUsage,
|
||||||
threadItemToTimeline,
|
threadItemToTimeline,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import path from "node:path";
|
|||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
|
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
import {
|
||||||
|
__openCodeInternals,
|
||||||
|
OpenCodeAgentClient,
|
||||||
|
translateOpenCodeEvent,
|
||||||
|
} from "./opencode-agent.js";
|
||||||
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
||||||
import type {
|
import type {
|
||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
@@ -202,6 +206,11 @@ const hasOpenCode = isBinaryInstalled("opencode");
|
|||||||
|
|
||||||
// HARD ASSERT: Model ID contains provider prefix (format: providerId/modelId)
|
// HARD ASSERT: Model ID contains provider prefix (format: providerId/modelId)
|
||||||
expect(model.id).toContain("/");
|
expect(model.id).toContain("/");
|
||||||
|
expect(model.metadata).toMatchObject({
|
||||||
|
providerId: expect.any(String),
|
||||||
|
modelId: expect.any(String),
|
||||||
|
contextWindowMaxTokens: expect.any(Number),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
@@ -303,3 +312,149 @@ const hasOpenCode = isBinaryInstalled("opencode");
|
|||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
}, 180_000);
|
}, 180_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("OpenCode adapter context-window normalization", () => {
|
||||||
|
test("preserves provider catalog context limit in model metadata", () => {
|
||||||
|
const definition = __openCodeInternals.buildOpenCodeModelDefinition(
|
||||||
|
{ id: "openai", name: "OpenAI" },
|
||||||
|
"gpt-5",
|
||||||
|
{
|
||||||
|
name: "GPT-5",
|
||||||
|
family: "gpt",
|
||||||
|
limit: {
|
||||||
|
context: 400_000,
|
||||||
|
input: 200_000,
|
||||||
|
output: 16_384,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(definition.metadata).toMatchObject({
|
||||||
|
providerId: "openai",
|
||||||
|
modelId: "gpt-5",
|
||||||
|
contextWindowMaxTokens: 400_000,
|
||||||
|
limit: {
|
||||||
|
context: 400_000,
|
||||||
|
input: 200_000,
|
||||||
|
output: 16_384,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves selected model context window from connected provider catalog data", () => {
|
||||||
|
expect(
|
||||||
|
__openCodeInternals.resolveOpenCodeSelectedModelContextWindow(
|
||||||
|
{
|
||||||
|
connected: ["openai"],
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "openai",
|
||||||
|
models: {
|
||||||
|
"gpt-5": {
|
||||||
|
limit: {
|
||||||
|
context: 400_000,
|
||||||
|
output: 16_384,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "anthropic",
|
||||||
|
models: {
|
||||||
|
"claude-opus": {
|
||||||
|
limit: {
|
||||||
|
context: 1_000_000,
|
||||||
|
output: 8_192,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"openai/gpt-5",
|
||||||
|
),
|
||||||
|
).toBe(400_000);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
__openCodeInternals.resolveOpenCodeSelectedModelContextWindow(
|
||||||
|
{
|
||||||
|
connected: ["openai"],
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "anthropic",
|
||||||
|
models: {
|
||||||
|
"claude-opus": {
|
||||||
|
limit: {
|
||||||
|
context: 1_000_000,
|
||||||
|
output: 8_192,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"anthropic/claude-opus",
|
||||||
|
),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes step-finish usage into AgentUsage context window fields", () => {
|
||||||
|
const usage = { contextWindowMaxTokens: 400_000 };
|
||||||
|
|
||||||
|
__openCodeInternals.mergeOpenCodeStepFinishUsage(usage, {
|
||||||
|
cost: 0.25,
|
||||||
|
tokens: {
|
||||||
|
total: 999_999,
|
||||||
|
input: 30_000,
|
||||||
|
output: 12_000,
|
||||||
|
reasoning: 10_000,
|
||||||
|
cache: {
|
||||||
|
read: 2_000,
|
||||||
|
write: 1_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(usage).toEqual({
|
||||||
|
contextWindowMaxTokens: 400_000,
|
||||||
|
contextWindowUsedTokens: 55_000,
|
||||||
|
cachedInputTokens: 2_000,
|
||||||
|
inputTokens: 30_000,
|
||||||
|
outputTokens: 12_000,
|
||||||
|
totalCostUsd: 0.25,
|
||||||
|
});
|
||||||
|
expect(__openCodeInternals.hasNormalizedOpenCodeUsage(usage)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves context window max tokens from assistant message metadata", () => {
|
||||||
|
const usage = {};
|
||||||
|
const onAssistantModelContextWindowResolved = vi.fn();
|
||||||
|
|
||||||
|
translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "message.updated",
|
||||||
|
properties: {
|
||||||
|
info: {
|
||||||
|
id: "message-1",
|
||||||
|
sessionID: "session-1",
|
||||||
|
role: "assistant",
|
||||||
|
providerID: "openai",
|
||||||
|
modelID: "gpt-5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sessionId: "session-1",
|
||||||
|
messageRoles: new Map(),
|
||||||
|
accumulatedUsage: usage,
|
||||||
|
streamedPartKeys: new Set(),
|
||||||
|
emittedStructuredMessageIds: new Set(),
|
||||||
|
partTypes: new Map(),
|
||||||
|
modelContextWindowsByModelKey: new Map([["openai/gpt-5", 400_000]]),
|
||||||
|
onAssistantModelContextWindowResolved,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onAssistantModelContextWindowResolved).toHaveBeenCalledWith(400_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { spawn, type ChildProcess } from "node:child_process";
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client";
|
import {
|
||||||
|
createOpencodeClient,
|
||||||
|
type OpencodeClient,
|
||||||
|
} from "@opencode-ai/sdk/v2/client";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -268,6 +271,224 @@ function sortOpenCodeModes(modes: AgentMode[]): AgentMode[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readPositiveFiniteNumber(value: unknown): number | undefined {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOpenCodeModelLookupKey(providerId: string, modelId: string): string {
|
||||||
|
return `${providerId}/${modelId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOpenCodeModelLookupKey(modelId: string | null | undefined): string | undefined {
|
||||||
|
if (typeof modelId !== "string" || modelId.trim().length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slashIndex = modelId.indexOf("/");
|
||||||
|
if (slashIndex <= 0 || slashIndex === modelId.length - 1) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerId = modelId.slice(0, slashIndex).trim();
|
||||||
|
const providerModelId = modelId.slice(slashIndex + 1).trim();
|
||||||
|
if (!providerId || !providerModelId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildOpenCodeModelLookupKey(providerId, providerModelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractOpenCodeModelContextWindow(model: unknown): number | undefined {
|
||||||
|
if (!model || typeof model !== "object") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const limit = (model as { limit?: { context?: unknown } }).limit;
|
||||||
|
return readPositiveFiniteNumber(limit?.context);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOpenCodeModelDefinition(
|
||||||
|
provider: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
},
|
||||||
|
modelId: string,
|
||||||
|
model: {
|
||||||
|
name: string;
|
||||||
|
family?: string;
|
||||||
|
release_date?: string;
|
||||||
|
attachment?: boolean;
|
||||||
|
reasoning?: boolean;
|
||||||
|
tool_call?: boolean;
|
||||||
|
cost?: unknown;
|
||||||
|
limit?: { context?: number; input?: number; output?: number };
|
||||||
|
variants?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
): AgentModelDefinition {
|
||||||
|
const rawVariants = model.variants ? Object.keys(model.variants) : [];
|
||||||
|
const thinkingOptions = rawVariants.map((id, index) => ({
|
||||||
|
id,
|
||||||
|
label: id,
|
||||||
|
isDefault: index === 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: "opencode",
|
||||||
|
id: `${provider.id}/${modelId}`,
|
||||||
|
label: model.name,
|
||||||
|
description: `${provider.name} - ${model.family ?? ""}`.trim(),
|
||||||
|
thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined,
|
||||||
|
defaultThinkingOptionId: thinkingOptions[0]?.id,
|
||||||
|
metadata: {
|
||||||
|
providerId: provider.id,
|
||||||
|
providerName: provider.name,
|
||||||
|
modelId,
|
||||||
|
family: model.family,
|
||||||
|
releaseDate: model.release_date,
|
||||||
|
supportsAttachments: model.attachment,
|
||||||
|
supportsReasoning: model.reasoning,
|
||||||
|
supportsToolCall: model.tool_call,
|
||||||
|
cost: model.cost,
|
||||||
|
contextWindowMaxTokens: extractOpenCodeModelContextWindow(model),
|
||||||
|
...(model.limit ? { limit: model.limit } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOpenCodeSelectedModelContextWindow(
|
||||||
|
providers: {
|
||||||
|
connected?: string[];
|
||||||
|
all?: Array<{
|
||||||
|
id: string;
|
||||||
|
models?: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
|
} | null | undefined,
|
||||||
|
modelId: string | null | undefined,
|
||||||
|
): number | undefined {
|
||||||
|
if (!providers) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const modelLookupKey = parseOpenCodeModelLookupKey(modelId);
|
||||||
|
if (!modelLookupKey) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const lookup = buildOpenCodeModelContextWindowLookup(providers);
|
||||||
|
return lookup.get(modelLookupKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOpenCodeModelContextWindowLookup(providers: {
|
||||||
|
connected?: string[];
|
||||||
|
all?: Array<{
|
||||||
|
id: string;
|
||||||
|
models?: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
|
} | null | undefined): Map<string, number> {
|
||||||
|
const lookup = new Map<string, number>();
|
||||||
|
if (!providers) {
|
||||||
|
return lookup;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectedProviderIds = new Set(providers.connected ?? []);
|
||||||
|
for (const provider of providers.all ?? []) {
|
||||||
|
if (!connectedProviderIds.has(provider.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const [modelId, modelDefinition] of Object.entries(provider.models ?? {})) {
|
||||||
|
const contextWindow = extractOpenCodeModelContextWindow(modelDefinition);
|
||||||
|
if (contextWindow === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lookup.set(buildOpenCodeModelLookupKey(provider.id, modelId), contextWindow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lookup;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOpenCodeModelLookupKeyFromAssistantMessageInfo(
|
||||||
|
info: AgentMetadata | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!info || info.role !== "assistant") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerId = readNonEmptyString(info.providerID);
|
||||||
|
const modelId = readNonEmptyString(info.modelID);
|
||||||
|
if (!providerId || !modelId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildOpenCodeModelLookupKey(providerId, modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeOpenCodeStepFinishUsage(
|
||||||
|
usage: AgentUsage,
|
||||||
|
part: {
|
||||||
|
cost?: unknown;
|
||||||
|
tokens?: {
|
||||||
|
input?: unknown;
|
||||||
|
output?: unknown;
|
||||||
|
reasoning?: unknown;
|
||||||
|
total?: unknown;
|
||||||
|
cache?: {
|
||||||
|
read?: unknown;
|
||||||
|
write?: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
},
|
||||||
|
): void {
|
||||||
|
const inputTokens = readPositiveFiniteNumber(part.tokens?.input);
|
||||||
|
const outputTokens = readPositiveFiniteNumber(part.tokens?.output);
|
||||||
|
const reasoningTokens = readPositiveFiniteNumber(part.tokens?.reasoning);
|
||||||
|
const cacheReadTokens = readPositiveFiniteNumber(part.tokens?.cache?.read);
|
||||||
|
const cacheWriteTokens = readPositiveFiniteNumber(part.tokens?.cache?.write);
|
||||||
|
const totalTokens =
|
||||||
|
(inputTokens ?? 0) +
|
||||||
|
(outputTokens ?? 0) +
|
||||||
|
(reasoningTokens ?? 0) +
|
||||||
|
(cacheReadTokens ?? 0) +
|
||||||
|
(cacheWriteTokens ?? 0);
|
||||||
|
const cost = readPositiveFiniteNumber(part.cost);
|
||||||
|
|
||||||
|
if (inputTokens !== undefined) {
|
||||||
|
usage.inputTokens = (usage.inputTokens ?? 0) + inputTokens;
|
||||||
|
}
|
||||||
|
if (cacheReadTokens !== undefined) {
|
||||||
|
usage.cachedInputTokens = (usage.cachedInputTokens ?? 0) + cacheReadTokens;
|
||||||
|
}
|
||||||
|
if (outputTokens !== undefined) {
|
||||||
|
usage.outputTokens = (usage.outputTokens ?? 0) + outputTokens;
|
||||||
|
}
|
||||||
|
if (totalTokens > 0) {
|
||||||
|
usage.contextWindowUsedTokens = (usage.contextWindowUsedTokens ?? 0) + totalTokens;
|
||||||
|
}
|
||||||
|
if (cost !== undefined) {
|
||||||
|
usage.totalCostUsd = (usage.totalCostUsd ?? 0) + cost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasNormalizedOpenCodeUsage(usage: AgentUsage): boolean {
|
||||||
|
return [
|
||||||
|
usage.inputTokens,
|
||||||
|
usage.cachedInputTokens,
|
||||||
|
usage.outputTokens,
|
||||||
|
usage.totalCostUsd,
|
||||||
|
usage.contextWindowMaxTokens,
|
||||||
|
usage.contextWindowUsedTokens,
|
||||||
|
].some((value) => typeof value === "number" && Number.isFinite(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const __openCodeInternals = {
|
||||||
|
buildOpenCodeModelContextWindowLookup,
|
||||||
|
buildOpenCodeModelDefinition,
|
||||||
|
buildOpenCodeModelLookupKey,
|
||||||
|
extractOpenCodeModelContextWindow,
|
||||||
|
hasNormalizedOpenCodeUsage,
|
||||||
|
mergeOpenCodeStepFinishUsage,
|
||||||
|
parseOpenCodeModelLookupKey,
|
||||||
|
resolveOpenCodeModelLookupKeyFromAssistantMessageInfo,
|
||||||
|
resolveOpenCodeSelectedModelContextWindow,
|
||||||
|
};
|
||||||
|
|
||||||
export class OpenCodeServerManager {
|
export class OpenCodeServerManager {
|
||||||
private static instance: OpenCodeServerManager | null = null;
|
private static instance: OpenCodeServerManager | null = null;
|
||||||
private static exitHandlerRegistered = false;
|
private static exitHandlerRegistered = false;
|
||||||
@@ -423,6 +644,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
private readonly serverManager: OpenCodeServerManager;
|
private readonly serverManager: OpenCodeServerManager;
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||||
|
private readonly modelContextWindows = new Map<string, number>();
|
||||||
|
|
||||||
constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) {
|
constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) {
|
||||||
this.logger = logger.child({ module: "agent", provider: "opencode" });
|
this.logger = logger.child({ module: "agent", provider: "opencode" });
|
||||||
@@ -460,7 +682,15 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
throw new Error("OpenCode session creation returned no data");
|
throw new Error("OpenCode session creation returned no data");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new OpenCodeAgentSession(openCodeConfig, client, session.id, this.logger);
|
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
|
||||||
|
|
||||||
|
return new OpenCodeAgentSession(
|
||||||
|
openCodeConfig,
|
||||||
|
client,
|
||||||
|
session.id,
|
||||||
|
this.logger,
|
||||||
|
new Map(this.modelContextWindows),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async resumeSession(
|
async resumeSession(
|
||||||
@@ -485,7 +715,15 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
directory: openCodeConfig.cwd,
|
directory: openCodeConfig.cwd,
|
||||||
});
|
});
|
||||||
|
|
||||||
return new OpenCodeAgentSession(openCodeConfig, client, handle.sessionId, this.logger);
|
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
|
||||||
|
|
||||||
|
return new OpenCodeAgentSession(
|
||||||
|
openCodeConfig,
|
||||||
|
client,
|
||||||
|
handle.sessionId,
|
||||||
|
this.logger,
|
||||||
|
new Map(this.modelContextWindows),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||||
@@ -533,6 +771,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const models: AgentModelDefinition[] = [];
|
const models: AgentModelDefinition[] = [];
|
||||||
|
this.modelContextWindows.clear();
|
||||||
for (const provider of providers.all) {
|
for (const provider of providers.all) {
|
||||||
// Skip providers that aren't connected/configured
|
// Skip providers that aren't connected/configured
|
||||||
if (!connectedProviderIds.has(provider.id)) {
|
if (!connectedProviderIds.has(provider.id)) {
|
||||||
@@ -540,32 +779,15 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const [modelId, model] of Object.entries(provider.models)) {
|
for (const [modelId, model] of Object.entries(provider.models)) {
|
||||||
const rawVariants = model.variants ? Object.keys(model.variants) : [];
|
const definition = buildOpenCodeModelDefinition(provider, modelId, model);
|
||||||
const thinkingOptions = rawVariants.map((id, index) => ({
|
const contextWindowMaxTokens = extractOpenCodeModelContextWindow(model);
|
||||||
id,
|
if (contextWindowMaxTokens !== undefined) {
|
||||||
label: id,
|
this.modelContextWindows.set(
|
||||||
isDefault: index === 0,
|
buildOpenCodeModelLookupKey(provider.id, modelId),
|
||||||
}));
|
contextWindowMaxTokens,
|
||||||
|
);
|
||||||
models.push({
|
}
|
||||||
provider: "opencode",
|
models.push(definition);
|
||||||
id: `${provider.id}/${modelId}`,
|
|
||||||
label: model.name,
|
|
||||||
description: `${provider.name} - ${model.family ?? ""}`.trim(),
|
|
||||||
thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined,
|
|
||||||
defaultThinkingOptionId: thinkingOptions[0]?.id,
|
|
||||||
metadata: {
|
|
||||||
providerId: provider.id,
|
|
||||||
providerName: provider.name,
|
|
||||||
modelId,
|
|
||||||
family: model.family,
|
|
||||||
releaseDate: model.release_date,
|
|
||||||
supportsAttachments: model.attachment,
|
|
||||||
supportsReasoning: model.reasoning,
|
|
||||||
supportsToolCall: model.tool_call,
|
|
||||||
cost: model.cost,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,6 +907,22 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
}
|
}
|
||||||
return { ...config, provider: "opencode" };
|
return { ...config, provider: "opencode" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async populateModelContextWindowCache(
|
||||||
|
client: OpencodeClient,
|
||||||
|
cwd: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await client.provider.list({ directory: cwd });
|
||||||
|
if (response.error || !response.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lookup = buildOpenCodeModelContextWindowLookup(response.data);
|
||||||
|
this.modelContextWindows.clear();
|
||||||
|
for (const [modelLookupKey, contextWindowMaxTokens] of lookup.entries()) {
|
||||||
|
this.modelContextWindows.set(modelLookupKey, contextWindowMaxTokens);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OpenCodeEventTranslationState = {
|
export type OpenCodeEventTranslationState = {
|
||||||
@@ -695,6 +933,8 @@ export type OpenCodeEventTranslationState = {
|
|||||||
emittedStructuredMessageIds: Set<string>;
|
emittedStructuredMessageIds: Set<string>;
|
||||||
/** Tracks the type of each part by ID, learned from message.part.updated events. */
|
/** Tracks the type of each part by ID, learned from message.part.updated events. */
|
||||||
partTypes: Map<string, string>;
|
partTypes: Map<string, string>;
|
||||||
|
modelContextWindowsByModelKey?: ReadonlyMap<string, number>;
|
||||||
|
onAssistantModelContextWindowResolved?: (contextWindowMaxTokens: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function stringifyStructuredAssistantMessage(value: unknown): string | null {
|
function stringifyStructuredAssistantMessage(value: unknown): string | null {
|
||||||
@@ -787,6 +1027,15 @@ export function translateOpenCodeEvent(
|
|||||||
|
|
||||||
if (messageId && messageSessionId === state.sessionId && role) {
|
if (messageId && messageSessionId === state.sessionId && role) {
|
||||||
state.messageRoles.set(messageId, role);
|
state.messageRoles.set(messageId, role);
|
||||||
|
if (role === "assistant") {
|
||||||
|
const modelLookupKey = resolveOpenCodeModelLookupKeyFromAssistantMessageInfo(info);
|
||||||
|
if (modelLookupKey) {
|
||||||
|
const contextWindowMaxTokens = state.modelContextWindowsByModelKey?.get(modelLookupKey);
|
||||||
|
if (contextWindowMaxTokens !== undefined) {
|
||||||
|
state.onAssistantModelContextWindowResolved?.(contextWindowMaxTokens);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
role === "assistant" &&
|
role === "assistant" &&
|
||||||
!state.emittedStructuredMessageIds.has(messageId) &&
|
!state.emittedStructuredMessageIds.has(messageId) &&
|
||||||
@@ -894,20 +1143,7 @@ export function translateOpenCodeEvent(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (partType === "step-finish") {
|
} else if (partType === "step-finish") {
|
||||||
const tokens = part.tokens as
|
mergeOpenCodeStepFinishUsage(state.accumulatedUsage, part);
|
||||||
| { input?: number; output?: number; reasoning?: number }
|
|
||||||
| undefined;
|
|
||||||
const cost = part.cost as number | undefined;
|
|
||||||
|
|
||||||
if (tokens) {
|
|
||||||
state.accumulatedUsage.inputTokens =
|
|
||||||
(state.accumulatedUsage.inputTokens ?? 0) + (tokens.input ?? 0);
|
|
||||||
state.accumulatedUsage.outputTokens =
|
|
||||||
(state.accumulatedUsage.outputTokens ?? 0) + (tokens.output ?? 0);
|
|
||||||
}
|
|
||||||
if (cost !== undefined) {
|
|
||||||
state.accumulatedUsage.totalCostUsd = (state.accumulatedUsage.totalCostUsd ?? 0) + cost;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1083,6 +1319,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
private readonly client: OpencodeClient;
|
private readonly client: OpencodeClient;
|
||||||
private readonly sessionId: string;
|
private readonly sessionId: string;
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
|
private readonly modelContextWindowsByModelKey: ReadonlyMap<string, number>;
|
||||||
private currentMode: string = "default";
|
private currentMode: string = "default";
|
||||||
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
||||||
private abortController: AbortController | null = null;
|
private abortController: AbortController | null = null;
|
||||||
@@ -1102,18 +1339,23 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
private nextTurnOrdinal = 0;
|
private nextTurnOrdinal = 0;
|
||||||
private activeForegroundTurnId: string | null = null;
|
private activeForegroundTurnId: string | null = null;
|
||||||
private readonly runningToolCalls = new Map<string, ToolCallTimelineItem>();
|
private readonly runningToolCalls = new Map<string, ToolCallTimelineItem>();
|
||||||
|
private selectedModelContextWindowMaxTokens: number | undefined;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: OpenCodeAgentConfig,
|
config: OpenCodeAgentConfig,
|
||||||
client: OpencodeClient,
|
client: OpencodeClient,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
|
modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(),
|
||||||
) {
|
) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.sessionId = sessionId;
|
this.sessionId = sessionId;
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
|
this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
|
||||||
this.currentMode = normalizeOpenCodeModeId(config.modeId);
|
this.currentMode = normalizeOpenCodeModeId(config.modeId);
|
||||||
|
this.selectedModelContextWindowMaxTokens =
|
||||||
|
this.resolveConfiguredModelContextWindowMaxTokens(config.model);
|
||||||
}
|
}
|
||||||
|
|
||||||
get id(): string | null {
|
get id(): string | null {
|
||||||
@@ -1133,6 +1375,8 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
const normalizedModelId =
|
const normalizedModelId =
|
||||||
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
|
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
|
||||||
this.config.model = normalizedModelId ?? undefined;
|
this.config.model = normalizedModelId ?? undefined;
|
||||||
|
this.selectedModelContextWindowMaxTokens =
|
||||||
|
this.resolveConfiguredModelContextWindowMaxTokens(this.config.model);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
|
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
|
||||||
@@ -1246,6 +1490,9 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
const turnAbortController = new AbortController();
|
const turnAbortController = new AbortController();
|
||||||
this.abortController = turnAbortController;
|
this.abortController = turnAbortController;
|
||||||
await this.ensureMcpServersConfigured();
|
await this.ensureMcpServersConfigured();
|
||||||
|
const contextWindowMaxTokens = this.resolveSelectedModelContextWindowMaxTokens();
|
||||||
|
this.accumulatedUsage =
|
||||||
|
contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
|
||||||
|
|
||||||
const parts = this.buildPromptParts(prompt);
|
const parts = this.buildPromptParts(prompt);
|
||||||
const model = this.parseModel(this.config.model);
|
const model = this.parseModel(this.config.model);
|
||||||
@@ -1781,28 +2028,35 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
streamedPartKeys: this.streamedPartKeys,
|
streamedPartKeys: this.streamedPartKeys,
|
||||||
emittedStructuredMessageIds: this.emittedStructuredMessageIds,
|
emittedStructuredMessageIds: this.emittedStructuredMessageIds,
|
||||||
partTypes: this.partTypes,
|
partTypes: this.partTypes,
|
||||||
|
modelContextWindowsByModelKey: this.modelContextWindowsByModelKey,
|
||||||
|
onAssistantModelContextWindowResolved: (contextWindowMaxTokens) => {
|
||||||
|
this.accumulatedUsage.contextWindowMaxTokens = contextWindowMaxTokens;
|
||||||
|
if (!this.config.model) {
|
||||||
|
this.selectedModelContextWindowMaxTokens = contextWindowMaxTokens;
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const translatedEvent of translated) {
|
for (const translatedEvent of translated) {
|
||||||
if (translatedEvent.type === "permission_requested") {
|
if (translatedEvent.type === "permission_requested") {
|
||||||
this.pendingPermissions.set(translatedEvent.request.id, translatedEvent.request);
|
this.pendingPermissions.set(translatedEvent.request.id, translatedEvent.request);
|
||||||
}
|
}
|
||||||
if (translatedEvent.type === "turn_completed") {
|
|
||||||
translatedEvent.usage = this.extractAndResetUsage();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return translated;
|
return translated;
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractAndResetUsage(): AgentUsage | undefined {
|
private resolveSelectedModelContextWindowMaxTokens(): number | undefined {
|
||||||
const usage = this.accumulatedUsage;
|
return this.selectedModelContextWindowMaxTokens;
|
||||||
this.accumulatedUsage = {};
|
}
|
||||||
|
|
||||||
if (!usage.inputTokens && !usage.outputTokens && !usage.totalCostUsd) {
|
private resolveConfiguredModelContextWindowMaxTokens(
|
||||||
|
modelId: string | undefined,
|
||||||
|
): number | undefined {
|
||||||
|
const modelLookupKey = parseOpenCodeModelLookupKey(modelId);
|
||||||
|
if (!modelLookupKey) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
return this.modelContextWindowsByModelKey.get(modelLookupKey);
|
||||||
return usage;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,33 @@ function createState(sessionId = "session-1"): OpenCodeEventTranslationState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("translateOpenCodeEvent", () => {
|
describe("translateOpenCodeEvent", () => {
|
||||||
|
it("resolves context window max tokens from assistant message.updated model metadata", () => {
|
||||||
|
const resolvedContextWindowMaxTokens: number[] = [];
|
||||||
|
const state = createState();
|
||||||
|
state.modelContextWindowsByModelKey = new Map([["anthropic/claude-sonnet-4", 200_000]]);
|
||||||
|
state.onAssistantModelContextWindowResolved = (contextWindowMaxTokens) => {
|
||||||
|
resolvedContextWindowMaxTokens.push(contextWindowMaxTokens);
|
||||||
|
};
|
||||||
|
|
||||||
|
translateOpenCodeEvent(
|
||||||
|
{
|
||||||
|
type: "message.updated",
|
||||||
|
properties: {
|
||||||
|
info: {
|
||||||
|
id: "message-model-1",
|
||||||
|
sessionID: "session-1",
|
||||||
|
role: "assistant",
|
||||||
|
providerID: "anthropic",
|
||||||
|
modelID: "claude-sonnet-4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolvedContextWindowMaxTokens).toEqual([200_000]);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not duplicate assistant output when completed part echoes streamed delta", () => {
|
it("does not duplicate assistant output when completed part echoes streamed delta", () => {
|
||||||
const state = createState();
|
const state = createState();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
const execFileMock = vi.hoisted(() =>
|
||||||
|
vi.fn(
|
||||||
|
(
|
||||||
|
_file: string,
|
||||||
|
_args: string[],
|
||||||
|
_options: unknown,
|
||||||
|
callback: (error: Error | null, stdout?: string, stderr?: string) => void,
|
||||||
|
) => {
|
||||||
|
callback(null, "", "");
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock("node:child_process", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
execFile: execFileMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js";
|
||||||
|
|
||||||
|
async function flushPromises(): Promise<void> {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLogger() {
|
||||||
|
const logger = {
|
||||||
|
child: () => logger,
|
||||||
|
debug: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
};
|
||||||
|
return logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("BackgroundGitFetchManager", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
execFileMock.mockReset();
|
||||||
|
execFileMock.mockImplementation(
|
||||||
|
(
|
||||||
|
_file: string,
|
||||||
|
_args: string[],
|
||||||
|
_options: unknown,
|
||||||
|
callback: (error: Error | null, stdout?: string, stderr?: string) => void,
|
||||||
|
) => {
|
||||||
|
callback(null, "", "");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates a fetch timer for a repo with an origin remote", async () => {
|
||||||
|
const logger = createLogger();
|
||||||
|
const manager = new BackgroundGitFetchManager({ logger: logger as any });
|
||||||
|
|
||||||
|
const subscription = await manager.subscribe(
|
||||||
|
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
|
||||||
|
vi.fn(),
|
||||||
|
);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const managerAny = manager as any;
|
||||||
|
const target = managerAny.targets.get("/tmp/repo/.git");
|
||||||
|
expect(target).toBeDefined();
|
||||||
|
expect(target.intervalId).toBeTruthy();
|
||||||
|
expect(execFileMock).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"git",
|
||||||
|
["remote", "get-url", "origin"],
|
||||||
|
expect.objectContaining({
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
env: expect.objectContaining({ GIT_TERMINAL_PROMPT: "0" }),
|
||||||
|
}),
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
expect(execFileMock).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"git",
|
||||||
|
["fetch", "origin", "--prune"],
|
||||||
|
expect.objectContaining({
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
env: expect.objectContaining({ GIT_TERMINAL_PROMPT: "0" }),
|
||||||
|
}),
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
manager.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dedupes multiple subscribers for the same repo root behind one timer", async () => {
|
||||||
|
const logger = createLogger();
|
||||||
|
const manager = new BackgroundGitFetchManager({ logger: logger as any });
|
||||||
|
|
||||||
|
const listenerOne = vi.fn();
|
||||||
|
const listenerTwo = vi.fn();
|
||||||
|
const subscriptionOne = await manager.subscribe(
|
||||||
|
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
|
||||||
|
listenerOne,
|
||||||
|
);
|
||||||
|
const subscriptionTwo = await manager.subscribe(
|
||||||
|
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo-worktree" },
|
||||||
|
listenerTwo,
|
||||||
|
);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const managerAny = manager as any;
|
||||||
|
const target = managerAny.targets.get("/tmp/repo/.git");
|
||||||
|
expect(managerAny.targets.size).toBe(1);
|
||||||
|
expect(target.listeners).toEqual(new Set([listenerOne, listenerTwo]));
|
||||||
|
expect(execFileMock.mock.calls.filter((call) => call[1][0] === "remote")).toHaveLength(1);
|
||||||
|
|
||||||
|
subscriptionOne.unsubscribe();
|
||||||
|
subscriptionTwo.unsubscribe();
|
||||||
|
manager.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cleans up the timer when the last subscriber unsubscribes", async () => {
|
||||||
|
const logger = createLogger();
|
||||||
|
const manager = new BackgroundGitFetchManager({ logger: logger as any });
|
||||||
|
|
||||||
|
const subscription = await manager.subscribe(
|
||||||
|
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
|
||||||
|
vi.fn(),
|
||||||
|
);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const managerAny = manager as any;
|
||||||
|
const target = managerAny.targets.get("/tmp/repo/.git");
|
||||||
|
const intervalId = target.intervalId;
|
||||||
|
const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval");
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
|
||||||
|
expect(clearIntervalSpy).toHaveBeenCalledWith(intervalId);
|
||||||
|
expect(managerAny.targets.size).toBe(0);
|
||||||
|
|
||||||
|
clearIntervalSpy.mockRestore();
|
||||||
|
manager.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("logs fetch errors without crashing", async () => {
|
||||||
|
const logger = createLogger();
|
||||||
|
execFileMock.mockImplementation(
|
||||||
|
(
|
||||||
|
_file: string,
|
||||||
|
args: string[],
|
||||||
|
_options: unknown,
|
||||||
|
callback: (error: Error | null, stdout?: string, stderr?: string) => void,
|
||||||
|
) => {
|
||||||
|
if (args[0] === "remote") {
|
||||||
|
callback(null, "", "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback(new Error("fetch failed"));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const manager = new BackgroundGitFetchManager({ logger: logger as any });
|
||||||
|
|
||||||
|
await manager.subscribe({ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" }, vi.fn());
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(logger.debug).toHaveBeenCalledWith(
|
||||||
|
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
|
||||||
|
"Running background git fetch",
|
||||||
|
);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
err: expect.any(Error),
|
||||||
|
repoGitRoot: "/tmp/repo/.git",
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
},
|
||||||
|
"Background git fetch failed",
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calls listeners when a fetch completes", async () => {
|
||||||
|
const logger = createLogger();
|
||||||
|
const manager = new BackgroundGitFetchManager({ logger: logger as any });
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
await manager.subscribe({ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" }, listener);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(180_000);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
manager.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not create a timer when the repo has no origin remote", async () => {
|
||||||
|
const logger = createLogger();
|
||||||
|
execFileMock.mockImplementation(
|
||||||
|
(
|
||||||
|
_file: string,
|
||||||
|
_args: string[],
|
||||||
|
_options: unknown,
|
||||||
|
callback: (error: Error | null, stdout?: string, stderr?: string) => void,
|
||||||
|
) => {
|
||||||
|
callback(new Error("missing origin"));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const manager = new BackgroundGitFetchManager({ logger: logger as any });
|
||||||
|
|
||||||
|
const subscription = await manager.subscribe(
|
||||||
|
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
|
||||||
|
vi.fn(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((manager as any).targets.size).toBe(0);
|
||||||
|
subscription.unsubscribe();
|
||||||
|
manager.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dispose clears timers and listeners", async () => {
|
||||||
|
const logger = createLogger();
|
||||||
|
const manager = new BackgroundGitFetchManager({ logger: logger as any });
|
||||||
|
|
||||||
|
const listener = vi.fn();
|
||||||
|
await manager.subscribe({ repoGitRoot: "/tmp/repo-one/.git", cwd: "/tmp/repo-one" }, listener);
|
||||||
|
await manager.subscribe({ repoGitRoot: "/tmp/repo-two/.git", cwd: "/tmp/repo-two" }, vi.fn());
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const managerAny = manager as any;
|
||||||
|
const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval");
|
||||||
|
|
||||||
|
manager.dispose();
|
||||||
|
|
||||||
|
expect(clearIntervalSpy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(managerAny.targets.size).toBe(0);
|
||||||
|
|
||||||
|
clearIntervalSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import type pino from "pino";
|
||||||
|
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
||||||
|
|
||||||
|
type BackgroundGitFetchTarget = {
|
||||||
|
repoGitRoot: string;
|
||||||
|
cwd: string;
|
||||||
|
listeners: Set<() => void>;
|
||||||
|
intervalId: NodeJS.Timeout | null;
|
||||||
|
fetchInFlight: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class BackgroundGitFetchManager {
|
||||||
|
private readonly logger: pino.Logger;
|
||||||
|
private readonly targets = new Map<string, BackgroundGitFetchTarget>();
|
||||||
|
|
||||||
|
constructor(options: { logger: pino.Logger }) {
|
||||||
|
this.logger = options.logger.child({ module: "background-git-fetch-manager" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribe(
|
||||||
|
params: { repoGitRoot: string; cwd: string },
|
||||||
|
listener: () => void,
|
||||||
|
): Promise<{ unsubscribe: () => void }> {
|
||||||
|
const existingTarget = this.targets.get(params.repoGitRoot);
|
||||||
|
if (existingTarget) {
|
||||||
|
existingTarget.listeners.add(listener);
|
||||||
|
return {
|
||||||
|
unsubscribe: () => {
|
||||||
|
this.removeListener(params.repoGitRoot, listener);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasOrigin = await this.hasOriginRemote(params.cwd);
|
||||||
|
if (!hasOrigin) {
|
||||||
|
return { unsubscribe: () => {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetAfterProbe = this.targets.get(params.repoGitRoot);
|
||||||
|
if (targetAfterProbe) {
|
||||||
|
targetAfterProbe.listeners.add(listener);
|
||||||
|
return {
|
||||||
|
unsubscribe: () => {
|
||||||
|
this.removeListener(params.repoGitRoot, listener);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const target: BackgroundGitFetchTarget = {
|
||||||
|
repoGitRoot: params.repoGitRoot,
|
||||||
|
cwd: params.cwd,
|
||||||
|
listeners: new Set([listener]),
|
||||||
|
intervalId: setInterval(() => {
|
||||||
|
void this.runFetch(target);
|
||||||
|
}, BACKGROUND_GIT_FETCH_INTERVAL_MS),
|
||||||
|
fetchInFlight: false,
|
||||||
|
};
|
||||||
|
this.targets.set(params.repoGitRoot, target);
|
||||||
|
void this.runFetch(target);
|
||||||
|
|
||||||
|
return {
|
||||||
|
unsubscribe: () => {
|
||||||
|
this.removeListener(params.repoGitRoot, listener);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
for (const target of this.targets.values()) {
|
||||||
|
this.closeTarget(target);
|
||||||
|
}
|
||||||
|
this.targets.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeTarget(target: BackgroundGitFetchTarget): void {
|
||||||
|
if (target.intervalId) {
|
||||||
|
clearInterval(target.intervalId);
|
||||||
|
target.intervalId = null;
|
||||||
|
}
|
||||||
|
target.listeners.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeListener(targetKey: string, listener: () => void): void {
|
||||||
|
const target = this.targets.get(targetKey);
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
target.listeners.delete(listener);
|
||||||
|
if (target.listeners.size > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.closeTarget(target);
|
||||||
|
this.targets.delete(targetKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hasOriginRemote(cwd: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await execFileAsync("git", ["remote", "get-url", "origin"], {
|
||||||
|
cwd,
|
||||||
|
env: {
|
||||||
|
...READ_ONLY_GIT_ENV,
|
||||||
|
GIT_TERMINAL_PROMPT: "0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runFetch(target: BackgroundGitFetchTarget): Promise<void> {
|
||||||
|
if (target.fetchInFlight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
target.fetchInFlight = true;
|
||||||
|
this.logger.debug(
|
||||||
|
{ repoGitRoot: target.repoGitRoot, cwd: target.cwd },
|
||||||
|
"Running background git fetch",
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execFileAsync("git", ["fetch", "origin", "--prune"], {
|
||||||
|
cwd: target.cwd,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
GIT_TERMINAL_PROMPT: "0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
{ err: error, repoGitRoot: target.repoGitRoot, cwd: target.cwd },
|
||||||
|
"Background git fetch failed",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
target.fetchInFlight = false;
|
||||||
|
for (const listener of target.listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,11 +123,14 @@ export class CheckoutDiffManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private normalizeCompare(compare: CheckoutDiffCompareInput): CheckoutDiffCompareInput {
|
private normalizeCompare(compare: CheckoutDiffCompareInput): CheckoutDiffCompareInput {
|
||||||
|
const ignoreWhitespace = compare.ignoreWhitespace === true;
|
||||||
if (compare.mode === "uncommitted") {
|
if (compare.mode === "uncommitted") {
|
||||||
return { mode: "uncommitted" };
|
return { mode: "uncommitted", ignoreWhitespace };
|
||||||
}
|
}
|
||||||
const trimmedBaseRef = compare.baseRef?.trim();
|
const trimmedBaseRef = compare.baseRef?.trim();
|
||||||
return trimmedBaseRef ? { mode: "base", baseRef: trimmedBaseRef } : { mode: "base" };
|
return trimmedBaseRef
|
||||||
|
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
|
||||||
|
: { mode: "base", ignoreWhitespace };
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildTargetKey(cwd: string, compare: CheckoutDiffCompareInput): string {
|
private buildTargetKey(cwd: string, compare: CheckoutDiffCompareInput): string {
|
||||||
@@ -135,6 +138,7 @@ export class CheckoutDiffManager {
|
|||||||
cwd,
|
cwd,
|
||||||
compare.mode,
|
compare.mode,
|
||||||
compare.mode === "base" ? (compare.baseRef ?? "") : "",
|
compare.mode === "base" ? (compare.baseRef ?? "") : "",
|
||||||
|
compare.ignoreWhitespace === true,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +210,7 @@ export class CheckoutDiffManager {
|
|||||||
{
|
{
|
||||||
mode: compare.mode,
|
mode: compare.mode,
|
||||||
baseRef: compare.baseRef,
|
baseRef: compare.baseRef,
|
||||||
|
ignoreWhitespace: compare.ignoreWhitespace,
|
||||||
includeStructured: true,
|
includeStructured: true,
|
||||||
},
|
},
|
||||||
{ paseoHome: this.paseoHome },
|
{ paseoHome: this.paseoHome },
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js";
|
||||||
|
|
||||||
|
describe("editor-targets", () => {
|
||||||
|
it("lists available editors in deterministic order", () => {
|
||||||
|
const available = new Set(["code", "cursor", "explorer"]);
|
||||||
|
|
||||||
|
const editors = listAvailableEditorTargets({
|
||||||
|
platform: "win32",
|
||||||
|
findExecutable: (command) => (available.has(command) ? command : null),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(editors).toEqual([
|
||||||
|
{ id: "cursor", label: "Cursor" },
|
||||||
|
{ id: "vscode", label: "VS Code" },
|
||||||
|
{ id: "explorer", label: "Explorer" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Finder on macOS", () => {
|
||||||
|
const editors = listAvailableEditorTargets({
|
||||||
|
platform: "darwin",
|
||||||
|
findExecutable: (command) => (command === "open" ? "/usr/bin/open" : null),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(editors).toEqual([{ id: "finder", label: "Finder" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the generic file manager target on Linux", () => {
|
||||||
|
const editors = listAvailableEditorTargets({
|
||||||
|
platform: "linux",
|
||||||
|
findExecutable: (command) => (command === "xdg-open" ? "/usr/bin/xdg-open" : null),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(editors).toEqual([{ id: "file-manager", label: "File Manager" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("launches editors as detached processes", async () => {
|
||||||
|
const unref = vi.fn();
|
||||||
|
const once = vi.fn((event: string, handler: () => void) => {
|
||||||
|
if (event === "spawn") {
|
||||||
|
queueMicrotask(handler);
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
const child = { once, unref };
|
||||||
|
const spawn = vi.fn(() => child as any);
|
||||||
|
|
||||||
|
await openInEditorTarget(
|
||||||
|
{
|
||||||
|
editorId: "vscode",
|
||||||
|
path: "/tmp/repo",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platform: "darwin",
|
||||||
|
existsSync: () => true,
|
||||||
|
findExecutable: (command) => (command === "code" ? "/usr/local/bin/code" : null),
|
||||||
|
spawn,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(spawn).toHaveBeenCalledWith("/usr/local/bin/code", ["/tmp/repo"], {
|
||||||
|
detached: true,
|
||||||
|
shell: false,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
expect(unref).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects relative paths", async () => {
|
||||||
|
await expect(
|
||||||
|
openInEditorTarget(
|
||||||
|
{
|
||||||
|
editorId: "cursor",
|
||||||
|
path: "repo",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
existsSync: () => true,
|
||||||
|
findExecutable: () => "/usr/local/bin/cursor",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).rejects.toThrow("Editor target path must be an absolute local path");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects platform-specific targets that are unavailable on this OS", async () => {
|
||||||
|
await expect(
|
||||||
|
openInEditorTarget(
|
||||||
|
{
|
||||||
|
editorId: "finder",
|
||||||
|
path: "/tmp/repo",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platform: "linux",
|
||||||
|
existsSync: () => true,
|
||||||
|
findExecutable: () => "/usr/bin/open",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).rejects.toThrow("Editor target unavailable: Finder");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { posix, win32 } from "node:path";
|
||||||
|
import type { EditorTargetDescriptorPayload, EditorTargetId } from "../shared/messages.js";
|
||||||
|
import {
|
||||||
|
findExecutable,
|
||||||
|
quoteWindowsArgument,
|
||||||
|
quoteWindowsCommand,
|
||||||
|
} from "../utils/executable.js";
|
||||||
|
|
||||||
|
type EditorTargetDefinition = {
|
||||||
|
id: EditorTargetId;
|
||||||
|
label: string;
|
||||||
|
command: string;
|
||||||
|
platforms?: readonly NodeJS.Platform[];
|
||||||
|
excludedPlatforms?: readonly NodeJS.Platform[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ListAvailableEditorTargetsDependencies = {
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
|
findExecutable?: (command: string) => string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenInEditorTargetDependencies = ListAvailableEditorTargetsDependencies & {
|
||||||
|
existsSync?: typeof existsSync;
|
||||||
|
spawn?: typeof spawn;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EDITOR_TARGETS: readonly EditorTargetDefinition[] = [
|
||||||
|
{ id: "cursor", label: "Cursor", command: "cursor" },
|
||||||
|
{ id: "vscode", label: "VS Code", command: "code" },
|
||||||
|
{ id: "zed", label: "Zed", command: "zed" },
|
||||||
|
{ id: "finder", label: "Finder", command: "open", platforms: ["darwin"] },
|
||||||
|
{ id: "explorer", label: "Explorer", command: "explorer", platforms: ["win32"] },
|
||||||
|
{
|
||||||
|
id: "file-manager",
|
||||||
|
label: "File Manager",
|
||||||
|
command: "xdg-open",
|
||||||
|
excludedPlatforms: ["darwin", "win32"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function isAbsolutePath(value: string): boolean {
|
||||||
|
return posix.isAbsolute(value) || win32.isAbsolute(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTargetSupportedOnPlatform(
|
||||||
|
target: EditorTargetDefinition,
|
||||||
|
platform: NodeJS.Platform,
|
||||||
|
): boolean {
|
||||||
|
if (target.platforms && !target.platforms.includes(platform)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (target.excludedPlatforms?.includes(platform)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEditorTargetDefinition(editorId: EditorTargetId): EditorTargetDefinition {
|
||||||
|
const target = EDITOR_TARGETS.find((entry) => entry.id === editorId);
|
||||||
|
if (!target) {
|
||||||
|
throw new Error(`Unknown editor target: ${editorId}`);
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAvailableEditorTargets(
|
||||||
|
dependencies: ListAvailableEditorTargetsDependencies = {},
|
||||||
|
): EditorTargetDescriptorPayload[] {
|
||||||
|
const platform = dependencies.platform ?? process.platform;
|
||||||
|
const findExecutableFn = dependencies.findExecutable ?? findExecutable;
|
||||||
|
|
||||||
|
return EDITOR_TARGETS.flatMap((target) => {
|
||||||
|
if (!isTargetSupportedOnPlatform(target, platform)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const executable = findExecutableFn(target.command);
|
||||||
|
if (!executable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: target.id,
|
||||||
|
label: target.label,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type Launch = {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveEditorLaunch(input: {
|
||||||
|
editorId: EditorTargetId;
|
||||||
|
path: string;
|
||||||
|
platform: NodeJS.Platform;
|
||||||
|
findExecutableFn: typeof findExecutable;
|
||||||
|
}): Launch {
|
||||||
|
const target = resolveEditorTargetDefinition(input.editorId);
|
||||||
|
if (!isTargetSupportedOnPlatform(target, input.platform)) {
|
||||||
|
throw new Error(`Editor target unavailable: ${target.label}`);
|
||||||
|
}
|
||||||
|
const executable = input.findExecutableFn(target.command);
|
||||||
|
if (!executable) {
|
||||||
|
throw new Error(`Editor target unavailable: ${target.label}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: executable,
|
||||||
|
args: [input.path],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openInEditorTarget(
|
||||||
|
input: {
|
||||||
|
editorId: EditorTargetId;
|
||||||
|
path: string;
|
||||||
|
},
|
||||||
|
dependencies: OpenInEditorTargetDependencies = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const platform = dependencies.platform ?? process.platform;
|
||||||
|
const pathToOpen = input.path.trim();
|
||||||
|
const existsSyncFn = dependencies.existsSync ?? existsSync;
|
||||||
|
const findExecutableFn = dependencies.findExecutable ?? findExecutable;
|
||||||
|
const spawnFn = dependencies.spawn ?? spawn;
|
||||||
|
|
||||||
|
if (!pathToOpen || !isAbsolutePath(pathToOpen)) {
|
||||||
|
throw new Error("Editor target path must be an absolute local path");
|
||||||
|
}
|
||||||
|
if (!existsSyncFn(pathToOpen)) {
|
||||||
|
throw new Error(`Path does not exist: ${pathToOpen}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const launch = resolveEditorLaunch({
|
||||||
|
editorId: input.editorId,
|
||||||
|
path: pathToOpen,
|
||||||
|
platform,
|
||||||
|
findExecutableFn,
|
||||||
|
});
|
||||||
|
|
||||||
|
const command = platform === "win32" ? quoteWindowsCommand(launch.command) : launch.command;
|
||||||
|
const args =
|
||||||
|
platform === "win32" ? launch.args.map((argument) => quoteWindowsArgument(argument)) : launch.args;
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let child: ChildProcess;
|
||||||
|
try {
|
||||||
|
child = spawnFn(command, args, {
|
||||||
|
detached: true,
|
||||||
|
shell: platform === "win32",
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
child.once("error", reject);
|
||||||
|
child.once("spawn", () => {
|
||||||
|
child.unref();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
type SubscribeCheckoutDiffRequest,
|
type SubscribeCheckoutDiffRequest,
|
||||||
type UnsubscribeCheckoutDiffRequest,
|
type UnsubscribeCheckoutDiffRequest,
|
||||||
type DirectorySuggestionsRequest,
|
type DirectorySuggestionsRequest,
|
||||||
|
type EditorTargetId,
|
||||||
type ProjectPlacementPayload,
|
type ProjectPlacementPayload,
|
||||||
type WorkspaceSetupSnapshot,
|
type WorkspaceSetupSnapshot,
|
||||||
type WorkspaceDescriptorPayload,
|
type WorkspaceDescriptorPayload,
|
||||||
@@ -49,6 +50,7 @@ import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech
|
|||||||
import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js";
|
import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js";
|
||||||
import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js";
|
import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js";
|
||||||
import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js";
|
import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js";
|
||||||
|
import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js";
|
||||||
import {
|
import {
|
||||||
DictationStreamManager,
|
DictationStreamManager,
|
||||||
type DictationStreamOutboundMessage,
|
type DictationStreamOutboundMessage,
|
||||||
@@ -66,6 +68,7 @@ import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from
|
|||||||
import { buildWorkspaceServicePayloads } from "./service-status-projection.js";
|
import { buildWorkspaceServicePayloads } from "./service-status-projection.js";
|
||||||
import { spawnWorkspaceService } from "./worktree-bootstrap.js";
|
import { spawnWorkspaceService } from "./worktree-bootstrap.js";
|
||||||
import { readGitCommand } from "./workspace-git-metadata.js";
|
import { readGitCommand } from "./workspace-git-metadata.js";
|
||||||
|
import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js";
|
||||||
|
|
||||||
export type AgentMcpTransportFactory = () => Promise<Transport>;
|
export type AgentMcpTransportFactory = () => Promise<Transport>;
|
||||||
import { buildProviderRegistry } from "./agent/provider-registry.js";
|
import { buildProviderRegistry } from "./agent/provider-registry.js";
|
||||||
@@ -251,6 +254,17 @@ export function resolveCreateAgentTitles(options: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveWaitForFinishError(options: {
|
||||||
|
status: "permission" | "error" | "idle";
|
||||||
|
final: AgentSnapshotPayload | null;
|
||||||
|
}): string | null {
|
||||||
|
if (options.status !== "error") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const message = options.final?.lastError;
|
||||||
|
return typeof message === "string" && message.trim().length > 0 ? message : "Agent failed";
|
||||||
|
}
|
||||||
|
|
||||||
type ProcessingPhase = "idle" | "transcribing";
|
type ProcessingPhase = "idle" | "transcribing";
|
||||||
|
|
||||||
type WorkspaceGitWatchTarget = {
|
type WorkspaceGitWatchTarget = {
|
||||||
@@ -395,6 +409,7 @@ export type SessionOptions = {
|
|||||||
loopService: LoopService;
|
loopService: LoopService;
|
||||||
checkoutDiffManager: CheckoutDiffManager;
|
checkoutDiffManager: CheckoutDiffManager;
|
||||||
agentLoadingService?: AgentLoadingService;
|
agentLoadingService?: AgentLoadingService;
|
||||||
|
backgroundGitFetchManager: BackgroundGitFetchManager;
|
||||||
createAgentMcpTransport: AgentMcpTransportFactory;
|
createAgentMcpTransport: AgentMcpTransportFactory;
|
||||||
stt: Resolvable<SpeechToTextProvider | null>;
|
stt: Resolvable<SpeechToTextProvider | null>;
|
||||||
tts: Resolvable<TextToSpeechProvider | null>;
|
tts: Resolvable<TextToSpeechProvider | null>;
|
||||||
@@ -565,6 +580,7 @@ export class Session {
|
|||||||
private readonly loopService: LoopService;
|
private readonly loopService: LoopService;
|
||||||
private readonly checkoutDiffManager: CheckoutDiffManager;
|
private readonly checkoutDiffManager: CheckoutDiffManager;
|
||||||
private readonly agentLoadingService: AgentLoadingService;
|
private readonly agentLoadingService: AgentLoadingService;
|
||||||
|
private readonly backgroundGitFetchManager: BackgroundGitFetchManager;
|
||||||
private readonly createAgentMcpTransport: AgentMcpTransportFactory;
|
private readonly createAgentMcpTransport: AgentMcpTransportFactory;
|
||||||
private readonly downloadTokenStore: DownloadTokenStore;
|
private readonly downloadTokenStore: DownloadTokenStore;
|
||||||
private readonly pushTokenStore: PushTokenStore;
|
private readonly pushTokenStore: PushTokenStore;
|
||||||
@@ -604,6 +620,7 @@ export class Session {
|
|||||||
private readonly checkoutDiffSubscriptions = new Map<string, () => void>();
|
private readonly checkoutDiffSubscriptions = new Map<string, () => void>();
|
||||||
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>();
|
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>();
|
||||||
private readonly workspaceSetupSnapshots = new Map<string, WorkspaceSetupSnapshot>();
|
private readonly workspaceSetupSnapshots = new Map<string, WorkspaceSetupSnapshot>();
|
||||||
|
private readonly workspaceGitFetchSubscriptions = new Map<string, () => void>();
|
||||||
private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null;
|
private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null;
|
||||||
private readonly registerVoiceSpeakHandler?: (
|
private readonly registerVoiceSpeakHandler?: (
|
||||||
agentId: string,
|
agentId: string,
|
||||||
@@ -643,6 +660,7 @@ export class Session {
|
|||||||
loopService,
|
loopService,
|
||||||
checkoutDiffManager,
|
checkoutDiffManager,
|
||||||
agentLoadingService,
|
agentLoadingService,
|
||||||
|
backgroundGitFetchManager,
|
||||||
createAgentMcpTransport,
|
createAgentMcpTransport,
|
||||||
stt,
|
stt,
|
||||||
tts,
|
tts,
|
||||||
@@ -687,6 +705,8 @@ export class Session {
|
|||||||
agentStorage: this.agentStorage,
|
agentStorage: this.agentStorage,
|
||||||
logger: this.sessionLogger,
|
logger: this.sessionLogger,
|
||||||
});
|
});
|
||||||
|
this.backgroundGitFetchManager = backgroundGitFetchManager;
|
||||||
|
this.backgroundGitFetchManager = backgroundGitFetchManager;
|
||||||
this.createAgentMcpTransport = createAgentMcpTransport;
|
this.createAgentMcpTransport = createAgentMcpTransport;
|
||||||
this.terminalManager = terminalManager;
|
this.terminalManager = terminalManager;
|
||||||
this.providerSnapshotManager = providerSnapshotManager ?? null;
|
this.providerSnapshotManager = providerSnapshotManager ?? null;
|
||||||
@@ -1650,6 +1670,14 @@ export class Session {
|
|||||||
await this.handleWorkspaceSetupStatusRequest(msg);
|
await this.handleWorkspaceSetupStatusRequest(msg);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "list_available_editors_request":
|
||||||
|
await this.handleListAvailableEditorsRequest(msg);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "open_in_editor_request":
|
||||||
|
await this.handleOpenInEditorRequest(msg);
|
||||||
|
break;
|
||||||
|
|
||||||
case "open_project_request":
|
case "open_project_request":
|
||||||
await this.handleOpenProjectRequest(msg);
|
await this.handleOpenProjectRequest(msg);
|
||||||
break;
|
break;
|
||||||
@@ -4077,6 +4105,9 @@ export class Session {
|
|||||||
if (!target) {
|
if (!target) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const unsubscribeFetch = this.workspaceGitFetchSubscriptions.get(workspaceId);
|
||||||
|
unsubscribeFetch?.();
|
||||||
|
this.workspaceGitFetchSubscriptions.delete(workspaceId);
|
||||||
this.closeWorkspaceGitWatchTarget(target);
|
this.closeWorkspaceGitWatchTarget(target);
|
||||||
this.workspaceGitWatchTargets.delete(workspaceId);
|
this.workspaceGitWatchTargets.delete(workspaceId);
|
||||||
}
|
}
|
||||||
@@ -4123,7 +4154,7 @@ export class Session {
|
|||||||
workspaces: Iterable<WorkspaceDescriptorPayload>,
|
workspaces: Iterable<WorkspaceDescriptorPayload>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (const workspace of workspaces) {
|
for (const workspace of workspaces) {
|
||||||
const persistedWorkspace = await this.workspaceRegistry.get(Number(workspace.id));
|
const persistedWorkspace = await this.findWorkspaceByDirectory(workspace.id);
|
||||||
if (!persistedWorkspace) {
|
if (!persistedWorkspace) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -4216,6 +4247,16 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.workspaceGitWatchTargets.set(workspaceId, target);
|
this.workspaceGitWatchTargets.set(workspaceId, target);
|
||||||
|
const subscription = await this.backgroundGitFetchManager.subscribe(
|
||||||
|
{ repoGitRoot: refsRoot, cwd: workspaceId },
|
||||||
|
() => {
|
||||||
|
const activeTarget = this.workspaceGitWatchTargets.get(workspaceId);
|
||||||
|
if (activeTarget) {
|
||||||
|
this.scheduleWorkspaceGitWatchRefresh(activeTarget);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.workspaceGitFetchSubscriptions.set(workspaceId, subscription.unsubscribe);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async syncWorkspaceGitWatchTarget(
|
private async syncWorkspaceGitWatchTarget(
|
||||||
@@ -5240,7 +5281,28 @@ export class Session {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async listWorkspaceDescriptorsSnapshot(): Promise<WorkspaceDescriptorPayload[]> {
|
private async describeWorkspaceRecordWithGitData(
|
||||||
|
workspace: PersistedWorkspaceRecord,
|
||||||
|
projectRecord?: PersistedProjectRecord | null,
|
||||||
|
): Promise<WorkspaceDescriptorPayload> {
|
||||||
|
return this.describeWorkspaceRecord(workspace, projectRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildWorkspaceDescriptor(input: {
|
||||||
|
workspace: PersistedWorkspaceRecord;
|
||||||
|
projectRecord?: PersistedProjectRecord | null;
|
||||||
|
includeGitData: boolean;
|
||||||
|
}): Promise<WorkspaceDescriptorPayload> {
|
||||||
|
if (input.includeGitData && input.projectRecord?.kind === "git") {
|
||||||
|
return this.describeWorkspaceRecordWithGitData(input.workspace, input.projectRecord);
|
||||||
|
}
|
||||||
|
return this.describeWorkspaceRecord(input.workspace, input.projectRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildWorkspaceDescriptorMap(options: {
|
||||||
|
includeGitData: boolean;
|
||||||
|
workspaceIds?: Iterable<string>;
|
||||||
|
}): Promise<Map<string, WorkspaceDescriptorPayload>> {
|
||||||
const [agents, persistedWorkspaces, persistedProjects] = await Promise.all([
|
const [agents, persistedWorkspaces, persistedProjects] = await Promise.all([
|
||||||
this.listAgentPayloads(),
|
this.listAgentPayloads(),
|
||||||
this.workspaceRegistry.list(),
|
this.workspaceRegistry.list(),
|
||||||
@@ -5253,16 +5315,30 @@ export class Session {
|
|||||||
.filter((project) => !project.archivedAt)
|
.filter((project) => !project.archivedAt)
|
||||||
.map((project) => [project.id, project] as const),
|
.map((project) => [project.id, project] as const),
|
||||||
);
|
);
|
||||||
const descriptorsByWorkspaceId = new Map<number, WorkspaceDescriptorPayload>();
|
const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>();
|
||||||
const workspaceIdsByDirectory = new Map(activeRecords.map((workspace) => [workspace.directory, workspace.id]));
|
const workspaceIds = options.workspaceIds
|
||||||
|
? new Set(
|
||||||
|
Array.from(options.workspaceIds, (workspaceId) =>
|
||||||
|
normalizePersistedWorkspaceId(workspaceId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const workspaceIdsByDirectory = new Map(
|
||||||
|
activeRecords.map((workspace) => [workspace.directory, workspace.directory] as const),
|
||||||
|
);
|
||||||
|
|
||||||
for (const workspace of activeRecords) {
|
for (const workspace of activeRecords) {
|
||||||
|
if (workspaceIds && !workspaceIds.has(workspace.directory)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const projectRecord = activeProjects.get(workspace.projectId) ?? null;
|
||||||
descriptorsByWorkspaceId.set(
|
descriptorsByWorkspaceId.set(
|
||||||
workspace.id,
|
workspace.directory,
|
||||||
await this.describeWorkspaceRecord(
|
await this.buildWorkspaceDescriptor({
|
||||||
workspace,
|
workspace,
|
||||||
activeProjects.get(workspace.projectId) ?? null,
|
projectRecord,
|
||||||
),
|
includeGitData: options.includeGitData,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5290,7 +5366,41 @@ export class Session {
|
|||||||
existing.activityAt = this.accumulateLatestActivityAt(existing.activityAt, agent);
|
existing.activityAt = this.accumulateLatestActivityAt(existing.activityAt, agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(descriptorsByWorkspaceId.values());
|
return descriptorsByWorkspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listWorkspaceDescriptorsSnapshot(): Promise<WorkspaceDescriptorPayload[]> {
|
||||||
|
return Array.from(
|
||||||
|
(await this.buildWorkspaceDescriptorMap({
|
||||||
|
includeGitData: false,
|
||||||
|
})).values(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveRegisteredWorkspaceIdForCwd(
|
||||||
|
cwd: string,
|
||||||
|
workspaces: PersistedWorkspaceRecord[],
|
||||||
|
): string {
|
||||||
|
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
|
||||||
|
const exact = workspaces.find((workspace) => workspace.directory === normalizedCwd);
|
||||||
|
if (exact) {
|
||||||
|
return exact.directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bestMatch: PersistedWorkspaceRecord | null = null;
|
||||||
|
for (const workspace of workspaces) {
|
||||||
|
const prefix = workspace.directory.endsWith(sep)
|
||||||
|
? workspace.directory
|
||||||
|
: `${workspace.directory}${sep}`;
|
||||||
|
if (!normalizedCwd.startsWith(prefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!bestMatch || workspace.directory.length > bestMatch.directory.length) {
|
||||||
|
bestMatch = workspace;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestMatch?.directory ?? normalizedCwd;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
|
private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
|
||||||
@@ -5328,7 +5438,7 @@ export class Session {
|
|||||||
case "name":
|
case "name":
|
||||||
return workspace.name.toLocaleLowerCase();
|
return workspace.name.toLocaleLowerCase();
|
||||||
case "project_id":
|
case "project_id":
|
||||||
return workspace.projectId;
|
return workspace.projectId.toLocaleLowerCase();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5346,7 +5456,7 @@ export class Session {
|
|||||||
}
|
}
|
||||||
return spec.direction === "asc" ? base : -base;
|
return spec.direction === "asc" ? base : -base;
|
||||||
}
|
}
|
||||||
return Number(left.id) - Number(right.id);
|
return left.id.localeCompare(right.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private encodeFetchWorkspacesCursor(
|
private encodeFetchWorkspacesCursor(
|
||||||
@@ -5388,7 +5498,7 @@ export class Session {
|
|||||||
id?: unknown;
|
id?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!Array.isArray(payload.sort) || (typeof payload.id !== "number" && typeof payload.id !== "string")) {
|
if (!Array.isArray(payload.sort) || typeof payload.id !== "string") {
|
||||||
throw new SessionRequestError("invalid_cursor", "Invalid fetch_workspaces cursor");
|
throw new SessionRequestError("invalid_cursor", "Invalid fetch_workspaces cursor");
|
||||||
}
|
}
|
||||||
if (!payload.values || typeof payload.values !== "object") {
|
if (!payload.values || typeof payload.values !== "object") {
|
||||||
@@ -5455,7 +5565,7 @@ export class Session {
|
|||||||
}
|
}
|
||||||
return spec.direction === "asc" ? base : -base;
|
return spec.direction === "asc" ? base : -base;
|
||||||
}
|
}
|
||||||
return Number(workspace.id) - Number(cursor.id);
|
return workspace.id.localeCompare(cursor.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private matchesWorkspaceFilter(input: {
|
private matchesWorkspaceFilter(input: {
|
||||||
@@ -5467,8 +5577,8 @@ export class Session {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof filter.projectId === "number") {
|
if (filter.projectId && filter.projectId.trim().length > 0) {
|
||||||
if (workspace.projectId !== filter.projectId) {
|
if (workspace.projectId !== filter.projectId.trim()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5707,46 +5817,73 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async emitWorkspaceUpdateForCwd(
|
private async reconcileAndEmitWorkspaceUpdates(): Promise<void> {
|
||||||
cwd: string,
|
if (!this.workspaceUpdatesSubscription) {
|
||||||
options?: { dedupeGitState?: boolean },
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const changedWorkspaceIds = await this.reconcileActiveWorkspaceRecords();
|
||||||
|
if (changedWorkspaceIds.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.emitWorkspaceUpdatesForWorkspaceIds(changedWorkspaceIds, {
|
||||||
|
skipReconcile: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.sessionLogger.error({ err: error }, "Background workspace reconciliation failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reconcileActiveWorkspaceRecords(): Promise<Set<string>> {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async emitWorkspaceUpdatesForWorkspaceIds(
|
||||||
|
workspaceIds: Iterable<string>,
|
||||||
|
options?: { dedupeGitState?: boolean; skipReconcile?: boolean },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const subscription = this.workspaceUpdatesSubscription;
|
const subscription = this.workspaceUpdatesSubscription;
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedCwd = await this.resolveWorkspaceDirectory(cwd);
|
const uniqueWorkspaceIds = new Set(
|
||||||
const persistedWorkspace = await this.findWorkspaceByDirectory(normalizedCwd);
|
Array.from(workspaceIds, (workspaceId) => normalizePersistedWorkspaceId(workspaceId)),
|
||||||
const all = await this.listWorkspaceDescriptorsSnapshot();
|
);
|
||||||
const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const));
|
if (uniqueWorkspaceIds.size === 0) {
|
||||||
const workspaceIdsToEmit = persistedWorkspace ? [persistedWorkspace.directory] : [];
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const nextWorkspaceId of workspaceIdsToEmit) {
|
const descriptorsByWorkspaceId = await this.buildWorkspaceDescriptorMap({
|
||||||
const workspace = descriptorsByWorkspaceId.get(nextWorkspaceId);
|
workspaceIds: uniqueWorkspaceIds,
|
||||||
|
includeGitData: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const workspaceId of uniqueWorkspaceIds) {
|
||||||
|
const workspace = descriptorsByWorkspaceId.get(workspaceId);
|
||||||
const nextWorkspace =
|
const nextWorkspace =
|
||||||
workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })
|
workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })
|
||||||
? workspace
|
? workspace
|
||||||
: null;
|
: null;
|
||||||
if (
|
if (
|
||||||
options?.dedupeGitState &&
|
options?.dedupeGitState &&
|
||||||
this.shouldSkipWorkspaceGitWatchUpdate(normalizedCwd, nextWorkspace)
|
this.shouldSkipWorkspaceGitWatchUpdate(workspaceId, nextWorkspace)
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const watchTarget = this.workspaceGitWatchTargets.get(normalizedCwd);
|
const watchTarget = this.workspaceGitWatchTargets.get(workspaceId);
|
||||||
if (watchTarget && this.onBranchChanged) {
|
if (watchTarget && this.onBranchChanged) {
|
||||||
const newBranchName = nextWorkspace?.name ?? null;
|
const newBranchName = nextWorkspace?.name ?? null;
|
||||||
if (newBranchName !== watchTarget.lastBranchName) {
|
if (newBranchName !== watchTarget.lastBranchName) {
|
||||||
this.onBranchChanged(normalizedCwd, watchTarget.lastBranchName, newBranchName);
|
this.onBranchChanged(workspaceId, watchTarget.lastBranchName, newBranchName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.rememberWorkspaceGitWatchFingerprint(normalizedCwd, nextWorkspace);
|
this.rememberWorkspaceGitWatchFingerprint(workspaceId, nextWorkspace);
|
||||||
|
|
||||||
if (!nextWorkspace) {
|
if (!nextWorkspace) {
|
||||||
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
||||||
kind: "remove",
|
kind: "remove",
|
||||||
id: nextWorkspaceId,
|
id: workspaceId,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -5756,50 +5893,53 @@ export class Session {
|
|||||||
workspace: nextWorkspace,
|
workspace: nextWorkspace,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!options?.skipReconcile) {
|
||||||
|
void this.reconcileAndEmitWorkspaceUpdates();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async emitWorkspaceUpdatesForCwds(cwds: Iterable<string>): Promise<void> {
|
private scheduleWorkspaceGitBootstrapUpdates(options: {
|
||||||
if (!this.workspaceUpdatesSubscription) {
|
subscriptionId: string;
|
||||||
|
workspaces: Iterable<WorkspaceDescriptorPayload>;
|
||||||
|
}): void {
|
||||||
|
const gitWorkspaceIds = Array.from(options.workspaces, (workspace) => workspace)
|
||||||
|
.filter((workspace) => workspace.projectKind === "git")
|
||||||
|
.map((workspace) => workspace.id);
|
||||||
|
if (gitWorkspaceIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueWorkspaceCwds = new Set<string>();
|
queueMicrotask(() => {
|
||||||
for (const cwd of cwds) {
|
if (this.workspaceUpdatesSubscription?.subscriptionId !== options.subscriptionId) {
|
||||||
const normalized = await this.resolveWorkspaceDirectory(cwd);
|
return;
|
||||||
if (!normalized) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
uniqueWorkspaceCwds.add(normalized);
|
void this.emitWorkspaceUpdatesForWorkspaceIds(gitWorkspaceIds, {
|
||||||
}
|
skipReconcile: true,
|
||||||
|
|
||||||
const subscription = this.workspaceUpdatesSubscription;
|
|
||||||
const all = await this.listWorkspaceDescriptorsSnapshot();
|
|
||||||
const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const));
|
|
||||||
|
|
||||||
for (const workspaceCwd of uniqueWorkspaceCwds) {
|
|
||||||
const persistedWorkspace = await this.findWorkspaceByDirectory(workspaceCwd);
|
|
||||||
const workspace = persistedWorkspace ? descriptorsByWorkspaceId.get(persistedWorkspace.directory) : null;
|
|
||||||
const nextWorkspace =
|
|
||||||
workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })
|
|
||||||
? workspace
|
|
||||||
: null;
|
|
||||||
this.rememberWorkspaceGitWatchFingerprint(workspaceCwd, nextWorkspace);
|
|
||||||
|
|
||||||
if (!nextWorkspace) {
|
|
||||||
if (persistedWorkspace) {
|
|
||||||
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
|
||||||
kind: "remove",
|
|
||||||
id: persistedWorkspace.directory,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
|
||||||
kind: "upsert",
|
|
||||||
workspace: nextWorkspace,
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async emitWorkspaceUpdateForCwd(
|
||||||
|
cwd: string,
|
||||||
|
options?: { dedupeGitState?: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
const activeWorkspaces = (await this.workspaceRegistry.list()).filter(
|
||||||
|
(workspace) => !workspace.archivedAt,
|
||||||
|
);
|
||||||
|
const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, activeWorkspaces);
|
||||||
|
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async emitWorkspaceUpdatesForCwds(cwds: Iterable<string>): Promise<void> {
|
||||||
|
const activeWorkspaces = (await this.workspaceRegistry.list()).filter(
|
||||||
|
(workspace) => !workspace.archivedAt,
|
||||||
|
);
|
||||||
|
const uniqueWorkspaceIds = new Set<string>();
|
||||||
|
for (const cwd of cwds) {
|
||||||
|
uniqueWorkspaceIds.add(this.resolveRegisteredWorkspaceIdForCwd(cwd, activeWorkspaces));
|
||||||
}
|
}
|
||||||
|
await this.emitWorkspaceUpdatesForWorkspaceIds(uniqueWorkspaceIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleFetchAgents(
|
private async handleFetchAgents(
|
||||||
@@ -5905,6 +6045,11 @@ export class Session {
|
|||||||
|
|
||||||
if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) {
|
if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) {
|
||||||
this.flushBootstrappedWorkspaceUpdates({ snapshotLatestActivityByWorkspaceId });
|
this.flushBootstrappedWorkspaceUpdates({ snapshotLatestActivityByWorkspaceId });
|
||||||
|
void this.reconcileAndEmitWorkspaceUpdates();
|
||||||
|
this.scheduleWorkspaceGitBootstrapUpdates({
|
||||||
|
subscriptionId,
|
||||||
|
workspaces: payload.entries,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) {
|
if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) {
|
||||||
@@ -5931,7 +6076,7 @@ export class Session {
|
|||||||
try {
|
try {
|
||||||
const workspace = await this.findOrCreateWorkspaceForDirectory(request.cwd);
|
const workspace = await this.findOrCreateWorkspaceForDirectory(request.cwd);
|
||||||
await this.emitWorkspaceUpdateForCwd(workspace.directory);
|
await this.emitWorkspaceUpdateForCwd(workspace.directory);
|
||||||
const descriptor = await this.describeWorkspaceRecord(workspace);
|
const descriptor = await this.describeWorkspaceRecordWithGitData(workspace);
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "open_project_response",
|
type: "open_project_response",
|
||||||
payload: {
|
payload: {
|
||||||
@@ -5954,7 +6099,9 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildWorkspaceServicePayloadSnapshot(workspaceDirectory: string): WorkspaceDescriptorPayload["services"] {
|
private buildWorkspaceServicePayloadSnapshot(
|
||||||
|
workspaceDirectory: string,
|
||||||
|
): WorkspaceDescriptorPayload["services"] {
|
||||||
if (!this.serviceRouteStore) {
|
if (!this.serviceRouteStore) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -5976,6 +6123,14 @@ export class Session {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAvailableEditorTargets() {
|
||||||
|
return listAvailableEditorTargets();
|
||||||
|
}
|
||||||
|
|
||||||
|
async openEditorTarget(options: { editorId: EditorTargetId; path: string }): Promise<void> {
|
||||||
|
await openInEditorTarget(options);
|
||||||
|
}
|
||||||
|
|
||||||
private async handleStartWorkspaceServiceRequest(
|
private async handleStartWorkspaceServiceRequest(
|
||||||
request: StartWorkspaceServiceRequest,
|
request: StartWorkspaceServiceRequest,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -6035,13 +6190,76 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleListAvailableEditorsRequest(
|
||||||
|
request: Extract<SessionInboundMessage, { type: "list_available_editors_request" }>,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const editors = await this.getAvailableEditorTargets();
|
||||||
|
this.emit({
|
||||||
|
type: "list_available_editors_response",
|
||||||
|
payload: {
|
||||||
|
requestId: request.requestId,
|
||||||
|
editors,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to list available editors";
|
||||||
|
this.sessionLogger.error(
|
||||||
|
{ err: error, requestType: request.type },
|
||||||
|
"Failed to list available editors",
|
||||||
|
);
|
||||||
|
this.emit({
|
||||||
|
type: "list_available_editors_response",
|
||||||
|
payload: {
|
||||||
|
requestId: request.requestId,
|
||||||
|
editors: [],
|
||||||
|
error: message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleOpenInEditorRequest(
|
||||||
|
request: Extract<SessionInboundMessage, { type: "open_in_editor_request" }>,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.openEditorTarget({ editorId: request.editorId, path: request.path });
|
||||||
|
this.emit({
|
||||||
|
type: "open_in_editor_response",
|
||||||
|
payload: {
|
||||||
|
requestId: request.requestId,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to open in editor";
|
||||||
|
this.sessionLogger.error(
|
||||||
|
{
|
||||||
|
err: error,
|
||||||
|
editorId: request.editorId,
|
||||||
|
path: request.path,
|
||||||
|
requestType: request.type,
|
||||||
|
},
|
||||||
|
"Failed to open in editor",
|
||||||
|
);
|
||||||
|
this.emit({
|
||||||
|
type: "open_in_editor_response",
|
||||||
|
payload: {
|
||||||
|
requestId: request.requestId,
|
||||||
|
error: message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async handleCreatePaseoWorktreeRequest(
|
private async handleCreatePaseoWorktreeRequest(
|
||||||
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
|
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return handleCreateWorktreeRequest(
|
return handleCreateWorktreeRequest(
|
||||||
{
|
{
|
||||||
paseoHome: this.paseoHome,
|
paseoHome: this.paseoHome,
|
||||||
describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecord(workspace),
|
describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecordWithGitData(workspace),
|
||||||
emit: (message) => this.emit(message),
|
emit: (message) => this.emit(message),
|
||||||
registerPendingWorktreeWorkspace: (options) =>
|
registerPendingWorktreeWorkspace: (options) =>
|
||||||
this.registerPendingWorktreeWorkspace(options),
|
this.registerPendingWorktreeWorkspace(options),
|
||||||
@@ -6438,9 +6656,10 @@ export class Session {
|
|||||||
: record.lastStatus === "error"
|
: record.lastStatus === "error"
|
||||||
? "error"
|
? "error"
|
||||||
: "idle";
|
: "idle";
|
||||||
|
const error = resolveWaitForFinishError({ status, final });
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "wait_for_finish_response",
|
type: "wait_for_finish_response",
|
||||||
payload: { requestId, status, final, error: null, lastMessage: null },
|
payload: { requestId, status, final, error, lastMessage: null },
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -6467,10 +6686,11 @@ export class Session {
|
|||||||
: result.status === "error"
|
: result.status === "error"
|
||||||
? "error"
|
? "error"
|
||||||
: "idle";
|
: "idle";
|
||||||
|
const error = resolveWaitForFinishError({ status, final });
|
||||||
|
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "wait_for_finish_response",
|
type: "wait_for_finish_response",
|
||||||
payload: { requestId, status, final, error: null, lastMessage: result.lastMessage },
|
payload: { requestId, status, final, error, lastMessage: result.lastMessage },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const isAbort =
|
const isAbort =
|
||||||
@@ -7212,6 +7432,10 @@ export class Session {
|
|||||||
}
|
}
|
||||||
this.checkoutDiffSubscriptions.clear();
|
this.checkoutDiffSubscriptions.clear();
|
||||||
|
|
||||||
|
for (const unsubscribe of this.workspaceGitFetchSubscriptions.values()) {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
this.workspaceGitFetchSubscriptions.clear();
|
||||||
for (const target of this.workspaceGitWatchTargets.values()) {
|
for (const target of this.workspaceGitWatchTargets.values()) {
|
||||||
this.closeWorkspaceGitWatchTarget(target);
|
this.closeWorkspaceGitWatchTarget(target);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { resolveWaitForFinishError } from "./session.js";
|
||||||
|
|
||||||
|
describe("resolveWaitForFinishError", () => {
|
||||||
|
test("returns the agent error when the wait result is an error", () => {
|
||||||
|
expect(
|
||||||
|
resolveWaitForFinishError({
|
||||||
|
status: "error",
|
||||||
|
final: { lastError: "invalid_json_schema" } as any,
|
||||||
|
}),
|
||||||
|
).toBe("invalid_json_schema");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns a generic fallback when the agent ended in error without a message", () => {
|
||||||
|
expect(
|
||||||
|
resolveWaitForFinishError({
|
||||||
|
status: "error",
|
||||||
|
final: {} as any,
|
||||||
|
}),
|
||||||
|
).toBe("Agent failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null for non-error wait results", () => {
|
||||||
|
expect(
|
||||||
|
resolveWaitForFinishError({
|
||||||
|
status: "idle",
|
||||||
|
final: { lastError: "should not surface" } as any,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -48,7 +48,14 @@ vi.mock("node:fs", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("./checkout-git-utils.js", () => ({
|
vi.mock("./checkout-git-utils.js", () => ({
|
||||||
|
READ_ONLY_GIT_ENV: {
|
||||||
|
...process.env,
|
||||||
|
GIT_OPTIONAL_LOCKS: "0",
|
||||||
|
},
|
||||||
resolveCheckoutGitDir: resolveCheckoutGitDirMock,
|
resolveCheckoutGitDir: resolveCheckoutGitDirMock,
|
||||||
|
toCheckoutError: vi.fn((error: unknown) => ({
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@getpaseo/highlight", () => ({
|
vi.mock("@getpaseo/highlight", () => ({
|
||||||
@@ -61,10 +68,33 @@ function createSessionForWorkspaceGitWatchTests(): {
|
|||||||
emitted: Array<{ type: string; payload: unknown }>;
|
emitted: Array<{ type: string; payload: unknown }>;
|
||||||
projects: Map<number, ReturnType<typeof createPersistedProjectRecord>>;
|
projects: Map<number, ReturnType<typeof createPersistedProjectRecord>>;
|
||||||
workspaces: Map<number, ReturnType<typeof createPersistedWorkspaceRecord>>;
|
workspaces: Map<number, ReturnType<typeof createPersistedWorkspaceRecord>>;
|
||||||
|
backgroundGitFetchManager: {
|
||||||
|
subscribe: ReturnType<typeof vi.fn>;
|
||||||
|
subscriptions: Array<{
|
||||||
|
params: { repoGitRoot: string; cwd: string };
|
||||||
|
listener: () => void;
|
||||||
|
unsubscribe: ReturnType<typeof vi.fn>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
logger: {
|
||||||
|
child: () => unknown;
|
||||||
|
trace: ReturnType<typeof vi.fn>;
|
||||||
|
debug: ReturnType<typeof vi.fn>;
|
||||||
|
info: ReturnType<typeof vi.fn>;
|
||||||
|
warn: ReturnType<typeof vi.fn>;
|
||||||
|
error: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
} {
|
} {
|
||||||
const emitted: Array<{ type: string; payload: unknown }> = [];
|
const emitted: Array<{ type: string; payload: unknown }> = [];
|
||||||
const projects = new Map<number, ReturnType<typeof createPersistedProjectRecord>>();
|
const projects = new Map<number, ReturnType<typeof createPersistedProjectRecord>>();
|
||||||
const workspaces = new Map<number, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
const workspaces = new Map<number, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||||
|
let nextProjectId = 1;
|
||||||
|
let nextWorkspaceId = 1;
|
||||||
|
const backgroundGitFetchSubscriptions: Array<{
|
||||||
|
params: { repoGitRoot: string; cwd: string };
|
||||||
|
listener: () => void;
|
||||||
|
unsubscribe: ReturnType<typeof vi.fn>;
|
||||||
|
}> = [];
|
||||||
const logger = {
|
const logger = {
|
||||||
child: () => logger,
|
child: () => logger,
|
||||||
trace: vi.fn(),
|
trace: vi.fn(),
|
||||||
@@ -73,6 +103,19 @@ function createSessionForWorkspaceGitWatchTests(): {
|
|||||||
warn: vi.fn(),
|
warn: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
};
|
};
|
||||||
|
const backgroundGitFetchManager = {
|
||||||
|
subscribe: vi.fn(
|
||||||
|
async (params: { repoGitRoot: string; cwd: string }, listener: () => void) => {
|
||||||
|
const unsubscribe = vi.fn();
|
||||||
|
backgroundGitFetchSubscriptions.push({
|
||||||
|
params,
|
||||||
|
listener,
|
||||||
|
unsubscribe,
|
||||||
|
});
|
||||||
|
return { unsubscribe };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
clientId: "test-client",
|
clientId: "test-client",
|
||||||
@@ -95,7 +138,11 @@ function createSessionForWorkspaceGitWatchTests(): {
|
|||||||
existsOnDisk: async () => true,
|
existsOnDisk: async () => true,
|
||||||
list: async () => Array.from(projects.values()),
|
list: async () => Array.from(projects.values()),
|
||||||
get: async (id: number) => projects.get(id) ?? null,
|
get: async (id: number) => projects.get(id) ?? null,
|
||||||
insert: async () => 0,
|
insert: async (record: Omit<ReturnType<typeof createPersistedProjectRecord>, "id">) => {
|
||||||
|
const id = nextProjectId++;
|
||||||
|
projects.set(id, createPersistedProjectRecord({ id, ...record }));
|
||||||
|
return id;
|
||||||
|
},
|
||||||
upsert: async (record: any) => {
|
upsert: async (record: any) => {
|
||||||
projects.set(record.id, record);
|
projects.set(record.id, record);
|
||||||
},
|
},
|
||||||
@@ -119,7 +166,11 @@ function createSessionForWorkspaceGitWatchTests(): {
|
|||||||
existsOnDisk: async () => true,
|
existsOnDisk: async () => true,
|
||||||
list: async () => Array.from(workspaces.values()),
|
list: async () => Array.from(workspaces.values()),
|
||||||
get: async (id: number) => workspaces.get(id) ?? null,
|
get: async (id: number) => workspaces.get(id) ?? null,
|
||||||
insert: async () => 0,
|
insert: async (record: Omit<ReturnType<typeof createPersistedWorkspaceRecord>, "id">) => {
|
||||||
|
const id = nextWorkspaceId++;
|
||||||
|
workspaces.set(id, createPersistedWorkspaceRecord({ id, ...record }));
|
||||||
|
return id;
|
||||||
|
},
|
||||||
upsert: async (record: any) => {
|
upsert: async (record: any) => {
|
||||||
workspaces.set(record.id, record);
|
workspaces.set(record.id, record);
|
||||||
},
|
},
|
||||||
@@ -152,6 +203,7 @@ function createSessionForWorkspaceGitWatchTests(): {
|
|||||||
}),
|
}),
|
||||||
dispose: () => {},
|
dispose: () => {},
|
||||||
} as any,
|
} as any,
|
||||||
|
backgroundGitFetchManager: backgroundGitFetchManager as any,
|
||||||
createAgentMcpTransport: async () => {
|
createAgentMcpTransport: async () => {
|
||||||
throw new Error("not used");
|
throw new Error("not used");
|
||||||
},
|
},
|
||||||
@@ -162,7 +214,17 @@ function createSessionForWorkspaceGitWatchTests(): {
|
|||||||
|
|
||||||
(session as any).listAgentPayloads = async () => [];
|
(session as any).listAgentPayloads = async () => [];
|
||||||
|
|
||||||
return { session, emitted, projects, workspaces };
|
return {
|
||||||
|
session,
|
||||||
|
emitted,
|
||||||
|
projects,
|
||||||
|
workspaces,
|
||||||
|
backgroundGitFetchManager: {
|
||||||
|
subscribe: backgroundGitFetchManager.subscribe,
|
||||||
|
subscriptions: backgroundGitFetchSubscriptions,
|
||||||
|
},
|
||||||
|
logger,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function seedGitWorkspace(input: {
|
function seedGitWorkspace(input: {
|
||||||
@@ -233,19 +295,20 @@ describe("workspace git watch targets", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let descriptor = {
|
let descriptor = {
|
||||||
id: "10",
|
id: "/tmp/repo",
|
||||||
projectId: "1",
|
projectId: "/tmp/repo",
|
||||||
projectDisplayName: "repo",
|
projectDisplayName: "repo",
|
||||||
projectRootPath: "/tmp/repo",
|
projectRootPath: "/tmp/repo",
|
||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
workspaceKind: "checkout",
|
workspaceKind: "local_checkout",
|
||||||
name: "main",
|
name: "main",
|
||||||
status: "done",
|
status: "done",
|
||||||
activityAt: null,
|
activityAt: null,
|
||||||
diffStat: { additions: 1, deletions: 0 },
|
diffStat: { additions: 1, deletions: 0 },
|
||||||
|
workspaceDirectory: "/tmp/repo",
|
||||||
};
|
};
|
||||||
|
|
||||||
sessionAny.listWorkspaceDescriptorsSnapshot = async () => [descriptor];
|
sessionAny.buildWorkspaceDescriptorMap = async () => new Map([["/tmp/repo", descriptor]]);
|
||||||
|
|
||||||
await sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]);
|
await sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]);
|
||||||
|
|
||||||
@@ -274,7 +337,7 @@ describe("workspace git watch targets", () => {
|
|||||||
expect(workspaceUpdates[0]?.payload).toMatchObject({
|
expect(workspaceUpdates[0]?.payload).toMatchObject({
|
||||||
kind: "upsert",
|
kind: "upsert",
|
||||||
workspace: {
|
workspace: {
|
||||||
id: "10",
|
id: "/tmp/repo",
|
||||||
name: "renamed-branch",
|
name: "renamed-branch",
|
||||||
diffStat: { additions: 1, deletions: 0 },
|
diffStat: { additions: 1, deletions: 0 },
|
||||||
},
|
},
|
||||||
@@ -317,15 +380,16 @@ describe("workspace git watch targets", () => {
|
|||||||
|
|
||||||
await sessionAny.primeWorkspaceGitWatchFingerprints([
|
await sessionAny.primeWorkspaceGitWatchFingerprints([
|
||||||
{
|
{
|
||||||
id: 20,
|
id: "/tmp/repo-one",
|
||||||
projectId: 2,
|
projectId: "/tmp/repo-one",
|
||||||
projectDisplayName: "repo-one",
|
projectDisplayName: "repo-one",
|
||||||
projectRootPath: "/tmp/repo-one",
|
projectRootPath: "/tmp/repo-one",
|
||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
workspaceKind: "checkout",
|
workspaceKind: "local_checkout",
|
||||||
name: "main",
|
name: "main",
|
||||||
status: "done",
|
status: "done",
|
||||||
activityAt: null,
|
activityAt: null,
|
||||||
|
workspaceDirectory: "/tmp/repo-one",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(sessionAny.workspaceGitWatchTargets.size).toBe(1);
|
expect(sessionAny.workspaceGitWatchTargets.size).toBe(1);
|
||||||
@@ -341,15 +405,16 @@ describe("workspace git watch targets", () => {
|
|||||||
|
|
||||||
await sessionAny.primeWorkspaceGitWatchFingerprints([
|
await sessionAny.primeWorkspaceGitWatchFingerprints([
|
||||||
{
|
{
|
||||||
id: 30,
|
id: "/tmp/repo-two",
|
||||||
projectId: 3,
|
projectId: "/tmp/repo-two",
|
||||||
projectDisplayName: "repo-two",
|
projectDisplayName: "repo-two",
|
||||||
projectRootPath: "/tmp/repo-two",
|
projectRootPath: "/tmp/repo-two",
|
||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
workspaceKind: "checkout",
|
workspaceKind: "local_checkout",
|
||||||
name: "main",
|
name: "main",
|
||||||
status: "done",
|
status: "done",
|
||||||
activityAt: null,
|
activityAt: null,
|
||||||
|
workspaceDirectory: "/tmp/repo-two",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(sessionAny.workspaceGitWatchTargets.size).toBe(1);
|
expect(sessionAny.workspaceGitWatchTargets.size).toBe(1);
|
||||||
@@ -379,4 +444,143 @@ describe("workspace git watch targets", () => {
|
|||||||
await session.cleanup();
|
await session.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("subscribes to the background fetch manager when a git watch target is created", async () => {
|
||||||
|
const { session, projects, workspaces, backgroundGitFetchManager } =
|
||||||
|
createSessionForWorkspaceGitWatchTests();
|
||||||
|
const sessionAny = session as any;
|
||||||
|
|
||||||
|
seedGitWorkspace({
|
||||||
|
projects,
|
||||||
|
workspaces,
|
||||||
|
projectId: 4,
|
||||||
|
workspaceId: 40,
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
name: "main",
|
||||||
|
});
|
||||||
|
resolveCheckoutGitDirMock.mockResolvedValue("/tmp/repo/.git");
|
||||||
|
|
||||||
|
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true });
|
||||||
|
|
||||||
|
expect(backgroundGitFetchManager.subscribe).toHaveBeenCalledWith(
|
||||||
|
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
expect(sessionAny.workspaceGitFetchSubscriptions.size).toBe(1);
|
||||||
|
|
||||||
|
await session.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stores separate background fetch subscriptions per workspace and unsubscribes removed targets", async () => {
|
||||||
|
const { session, projects, workspaces, backgroundGitFetchManager } =
|
||||||
|
createSessionForWorkspaceGitWatchTests();
|
||||||
|
const sessionAny = session as any;
|
||||||
|
|
||||||
|
seedGitWorkspace({
|
||||||
|
projects,
|
||||||
|
workspaces,
|
||||||
|
projectId: 5,
|
||||||
|
workspaceId: 50,
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
name: "main",
|
||||||
|
});
|
||||||
|
seedGitWorkspace({
|
||||||
|
projects,
|
||||||
|
workspaces,
|
||||||
|
projectId: 6,
|
||||||
|
workspaceId: 60,
|
||||||
|
cwd: "/tmp/repo-feature",
|
||||||
|
name: "feature",
|
||||||
|
});
|
||||||
|
resolveCheckoutGitDirMock.mockImplementation(async (cwd: string) =>
|
||||||
|
cwd === "/tmp/repo" ? "/tmp/repo/.git" : "/tmp/repo/.git/worktrees/feature",
|
||||||
|
);
|
||||||
|
sessionAny.resolveWorkspaceGitRefsRoot = vi.fn(async () => "/tmp/repo/.git");
|
||||||
|
|
||||||
|
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true });
|
||||||
|
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo-feature", { isGit: true });
|
||||||
|
|
||||||
|
expect(backgroundGitFetchManager.subscribe).toHaveBeenCalledTimes(2);
|
||||||
|
expect(backgroundGitFetchManager.subscriptions[0]?.params).toEqual({
|
||||||
|
repoGitRoot: "/tmp/repo/.git",
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
});
|
||||||
|
expect(backgroundGitFetchManager.subscriptions[1]?.params).toEqual({
|
||||||
|
repoGitRoot: "/tmp/repo/.git",
|
||||||
|
cwd: "/tmp/repo-feature",
|
||||||
|
});
|
||||||
|
|
||||||
|
sessionAny.removeWorkspaceGitWatchTarget("/tmp/repo");
|
||||||
|
|
||||||
|
expect(backgroundGitFetchManager.subscriptions[0]?.unsubscribe).toHaveBeenCalledTimes(1);
|
||||||
|
expect(backgroundGitFetchManager.subscriptions[1]?.unsubscribe).not.toHaveBeenCalled();
|
||||||
|
expect(sessionAny.workspaceGitFetchSubscriptions.size).toBe(1);
|
||||||
|
|
||||||
|
await session.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refreshes the workspace when the background fetch manager callback fires and unsubscribes on cleanup", async () => {
|
||||||
|
const { session, emitted, projects, workspaces, backgroundGitFetchManager } =
|
||||||
|
createSessionForWorkspaceGitWatchTests();
|
||||||
|
const sessionAny = session as any;
|
||||||
|
|
||||||
|
seedGitWorkspace({
|
||||||
|
projects,
|
||||||
|
workspaces,
|
||||||
|
projectId: 7,
|
||||||
|
workspaceId: 70,
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
name: "main",
|
||||||
|
});
|
||||||
|
resolveCheckoutGitDirMock.mockResolvedValue("/tmp/repo/.git");
|
||||||
|
sessionAny.workspaceUpdatesSubscription = {
|
||||||
|
subscriptionId: "sub-1",
|
||||||
|
filter: undefined,
|
||||||
|
isBootstrapping: false,
|
||||||
|
pendingUpdatesByWorkspaceId: new Map(),
|
||||||
|
};
|
||||||
|
sessionAny.reconcileActiveWorkspaceRecords = async () => new Set();
|
||||||
|
|
||||||
|
let descriptor = {
|
||||||
|
id: "/tmp/repo",
|
||||||
|
projectId: "/tmp/repo",
|
||||||
|
projectDisplayName: "repo",
|
||||||
|
projectRootPath: "/tmp/repo",
|
||||||
|
projectKind: "git",
|
||||||
|
workspaceKind: "local_checkout",
|
||||||
|
name: "main",
|
||||||
|
status: "done",
|
||||||
|
activityAt: null,
|
||||||
|
diffStat: { additions: 1, deletions: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
sessionAny.buildWorkspaceDescriptorMap = async () => new Map([["/tmp/repo", descriptor]]);
|
||||||
|
|
||||||
|
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true });
|
||||||
|
sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]);
|
||||||
|
|
||||||
|
descriptor = {
|
||||||
|
...descriptor,
|
||||||
|
name: "updated-after-fetch",
|
||||||
|
};
|
||||||
|
|
||||||
|
backgroundGitFetchManager.subscriptions[0]?.listener();
|
||||||
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
|
|
||||||
|
const workspaceUpdates = emitted.filter(
|
||||||
|
(message) => message.type === "workspace_update",
|
||||||
|
) as any[];
|
||||||
|
expect(workspaceUpdates).toHaveLength(1);
|
||||||
|
expect(workspaceUpdates[0]?.payload).toMatchObject({
|
||||||
|
kind: "upsert",
|
||||||
|
workspace: {
|
||||||
|
id: "/tmp/repo",
|
||||||
|
name: "updated-after-fetch",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await session.cleanup();
|
||||||
|
|
||||||
|
expect(backgroundGitFetchManager.subscriptions[0]?.unsubscribe).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ function createSessionForWorkspaceTests(): {
|
|||||||
warn: vi.fn(),
|
warn: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
};
|
};
|
||||||
|
const backgroundGitFetchManager = {
|
||||||
|
subscribe: vi.fn(async () => ({ unsubscribe: vi.fn() })),
|
||||||
|
};
|
||||||
|
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
clientId: "test-client",
|
clientId: "test-client",
|
||||||
@@ -172,6 +175,7 @@ function createSessionForWorkspaceTests(): {
|
|||||||
}),
|
}),
|
||||||
dispose: () => {},
|
dispose: () => {},
|
||||||
} as any,
|
} as any,
|
||||||
|
backgroundGitFetchManager: backgroundGitFetchManager as any,
|
||||||
createAgentMcpTransport: async () => {
|
createAgentMcpTransport: async () => {
|
||||||
throw new Error("not used");
|
throw new Error("not used");
|
||||||
},
|
},
|
||||||
@@ -988,38 +992,47 @@ describe("workspace aggregation", () => {
|
|||||||
isBootstrapping: false,
|
isBootstrapping: false,
|
||||||
pendingUpdatesByWorkspaceId: new Map(),
|
pendingUpdatesByWorkspaceId: new Map(),
|
||||||
};
|
};
|
||||||
(session as any).listWorkspaceDescriptorsSnapshot = async () => [
|
(session as any).reconcileActiveWorkspaceRecords = async () => new Set();
|
||||||
{
|
(session as any).buildWorkspaceDescriptorMap = async () =>
|
||||||
id: "/tmp/repo",
|
new Map([
|
||||||
projectId: "/tmp/repo",
|
[
|
||||||
projectDisplayName: "repo",
|
"/tmp/repo",
|
||||||
projectRootPath: "/tmp/repo",
|
{
|
||||||
workspaceDirectory: "/tmp/repo",
|
id: "/tmp/repo",
|
||||||
projectKind: "non_git",
|
projectId: "/tmp/repo",
|
||||||
workspaceKind: "local_checkout",
|
projectDisplayName: "repo",
|
||||||
name: "repo",
|
projectRootPath: "/tmp/repo",
|
||||||
status: "running",
|
workspaceDirectory: "/tmp/repo",
|
||||||
activityAt: "2026-03-01T12:00:00.000Z",
|
projectKind: "non_git",
|
||||||
services: [],
|
workspaceKind: "local_checkout",
|
||||||
},
|
name: "repo",
|
||||||
];
|
status: "running",
|
||||||
|
activityAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
services: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
await (session as any).emitWorkspaceUpdateForCwd("/tmp/repo");
|
await (session as any).emitWorkspaceUpdateForCwd("/tmp/repo");
|
||||||
|
|
||||||
(session as any).listWorkspaceDescriptorsSnapshot = async () => [
|
(session as any).buildWorkspaceDescriptorMap = async () =>
|
||||||
{
|
new Map([
|
||||||
id: "/tmp/repo",
|
[
|
||||||
projectId: "/tmp/repo",
|
"/tmp/repo",
|
||||||
projectDisplayName: "repo",
|
{
|
||||||
projectRootPath: "/tmp/repo",
|
id: "/tmp/repo",
|
||||||
workspaceDirectory: "/tmp/repo",
|
projectId: "/tmp/repo",
|
||||||
projectKind: "non_git",
|
projectDisplayName: "repo",
|
||||||
workspaceKind: "local_checkout",
|
projectRootPath: "/tmp/repo",
|
||||||
name: "repo",
|
workspaceDirectory: "/tmp/repo",
|
||||||
status: "done",
|
projectKind: "non_git",
|
||||||
activityAt: null,
|
workspaceKind: "local_checkout",
|
||||||
services: [],
|
name: "repo",
|
||||||
},
|
status: "done",
|
||||||
];
|
activityAt: null,
|
||||||
|
services: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
await (session as any).emitWorkspaceUpdateForCwd("/tmp/repo");
|
await (session as any).emitWorkspaceUpdateForCwd("/tmp/repo");
|
||||||
|
|
||||||
const workspaceUpdates = emitted.filter((message) => message.type === "workspace_update");
|
const workspaceUpdates = emitted.filter((message) => message.type === "workspace_update");
|
||||||
@@ -1109,6 +1122,195 @@ describe("workspace aggregation", () => {
|
|||||||
rmSync(tempDir, { recursive: true, force: true });
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
test("workspace update fanout for multiple cwd values is deduplicated", async () => {
|
||||||
|
const emitted: Array<{ type: string; payload: unknown }> = [];
|
||||||
|
const { session, projects, workspaces } = createSessionForWorkspaceTests();
|
||||||
|
const sessionAny = session as any;
|
||||||
|
seedProject({
|
||||||
|
projects,
|
||||||
|
id: 6,
|
||||||
|
directory: "/tmp/repo",
|
||||||
|
displayName: "repo",
|
||||||
|
kind: "git",
|
||||||
|
});
|
||||||
|
seedWorkspace({
|
||||||
|
workspaces,
|
||||||
|
id: 60,
|
||||||
|
projectId: 6,
|
||||||
|
directory: "/tmp/repo",
|
||||||
|
displayName: "main",
|
||||||
|
kind: "checkout",
|
||||||
|
});
|
||||||
|
seedWorkspace({
|
||||||
|
workspaces,
|
||||||
|
id: 61,
|
||||||
|
projectId: 6,
|
||||||
|
directory: "/tmp/repo/worktree",
|
||||||
|
displayName: "feature",
|
||||||
|
kind: "worktree",
|
||||||
|
});
|
||||||
|
|
||||||
|
sessionAny.workspaceUpdatesSubscription = {
|
||||||
|
subscriptionId: "sub-dedup",
|
||||||
|
filter: undefined,
|
||||||
|
isBootstrapping: false,
|
||||||
|
pendingUpdatesByWorkspaceId: new Map(),
|
||||||
|
};
|
||||||
|
sessionAny.reconcileActiveWorkspaceRecords = async () => new Set();
|
||||||
|
sessionAny.buildWorkspaceDescriptorMap = async () =>
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
"/tmp/repo",
|
||||||
|
{
|
||||||
|
id: "/tmp/repo",
|
||||||
|
projectId: "/tmp/repo",
|
||||||
|
projectDisplayName: "repo",
|
||||||
|
projectRootPath: "/tmp/repo",
|
||||||
|
workspaceDirectory: "/tmp/repo",
|
||||||
|
projectKind: "git",
|
||||||
|
workspaceKind: "local_checkout",
|
||||||
|
name: "main",
|
||||||
|
status: "done",
|
||||||
|
activityAt: null,
|
||||||
|
services: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/tmp/repo/worktree",
|
||||||
|
{
|
||||||
|
id: "/tmp/repo/worktree",
|
||||||
|
projectId: "/tmp/repo",
|
||||||
|
projectDisplayName: "repo",
|
||||||
|
projectRootPath: "/tmp/repo",
|
||||||
|
workspaceDirectory: "/tmp/repo/worktree",
|
||||||
|
projectKind: "git",
|
||||||
|
workspaceKind: "worktree",
|
||||||
|
name: "feature",
|
||||||
|
status: "running",
|
||||||
|
activityAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
services: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
sessionAny.emit = (message: any) => emitted.push(message);
|
||||||
|
|
||||||
|
await sessionAny.emitWorkspaceUpdatesForCwds(["/tmp/repo/worktree", "/tmp/repo"]);
|
||||||
|
|
||||||
|
const workspaceUpdates = emitted.filter(
|
||||||
|
(message) => message.type === "workspace_update",
|
||||||
|
) as any[];
|
||||||
|
expect(workspaceUpdates).toHaveLength(2);
|
||||||
|
expect(workspaceUpdates.map((entry) => entry.payload.kind)).toEqual(["upsert", "upsert"]);
|
||||||
|
expect(workspaceUpdates.map((entry) => entry.payload.workspace.id).sort()).toEqual([
|
||||||
|
"/tmp/repo",
|
||||||
|
"/tmp/repo/worktree",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("open_project_request registers a workspace before any agent exists", async () => {
|
||||||
|
const { session, emitted, workspaces } = createSessionForWorkspaceTests();
|
||||||
|
const sessionAny = session as any;
|
||||||
|
|
||||||
|
sessionAny.resolveWorkspaceDirectory = async (cwd: string) => cwd;
|
||||||
|
|
||||||
|
await sessionAny.handleMessage({
|
||||||
|
type: "open_project_request",
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
requestId: "req-open",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Array.from(workspaces.values()).some((workspace) => workspace.directory === "/tmp/repo")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const response = emitted.find((message) => message.type === "open_project_response") as any;
|
||||||
|
expect(response?.payload.error).toBeNull();
|
||||||
|
expect(response?.payload.workspace?.id).toBe("/tmp/repo");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("open_project_request collapses a git subdirectory onto the repo root workspace", async () => {
|
||||||
|
const { session, emitted, workspaces } = createSessionForWorkspaceTests();
|
||||||
|
const sessionAny = session as any;
|
||||||
|
const repoRoot = "/tmp/repo";
|
||||||
|
const subdir = "/tmp/repo/packages/app";
|
||||||
|
|
||||||
|
sessionAny.resolveWorkspaceDirectory = async () => repoRoot;
|
||||||
|
sessionAny.buildProjectPlacement = async (cwd: string) => ({
|
||||||
|
projectKey: repoRoot,
|
||||||
|
projectName: "repo",
|
||||||
|
checkout: {
|
||||||
|
cwd,
|
||||||
|
isGit: true,
|
||||||
|
currentBranch: "main",
|
||||||
|
remoteUrl: null,
|
||||||
|
worktreeRoot: repoRoot,
|
||||||
|
isPaseoOwnedWorktree: false,
|
||||||
|
mainRepoRoot: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await sessionAny.handleMessage({
|
||||||
|
type: "open_project_request",
|
||||||
|
cwd: subdir,
|
||||||
|
requestId: "req-open-subdir",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Array.from(workspaces.values()).some((workspace) => workspace.directory === repoRoot)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(Array.from(workspaces.values()).some((workspace) => workspace.directory === subdir)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const response = emitted.find((message) => message.type === "open_project_response") as any;
|
||||||
|
expect(response?.payload.error).toBeNull();
|
||||||
|
expect(response?.payload.workspace?.id).toBe(repoRoot);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("list_available_editors_request returns available targets", async () => {
|
||||||
|
const { session, emitted } = createSessionForWorkspaceTests();
|
||||||
|
const sessionAny = session as any;
|
||||||
|
|
||||||
|
sessionAny.getAvailableEditorTargets = async () => [
|
||||||
|
{ id: "cursor", label: "Cursor" },
|
||||||
|
{ id: "finder", label: "Finder" },
|
||||||
|
];
|
||||||
|
|
||||||
|
await sessionAny.handleMessage({
|
||||||
|
type: "list_available_editors_request",
|
||||||
|
requestId: "req-editors",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = emitted.find(
|
||||||
|
(message) => message.type === "list_available_editors_response",
|
||||||
|
) as any;
|
||||||
|
expect(response?.payload.error).toBeNull();
|
||||||
|
expect(response?.payload.editors).toEqual([
|
||||||
|
{ id: "cursor", label: "Cursor" },
|
||||||
|
{ id: "finder", label: "Finder" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("open_in_editor_request launches the selected target", async () => {
|
||||||
|
const { session, emitted } = createSessionForWorkspaceTests();
|
||||||
|
const sessionAny = session as any;
|
||||||
|
const calls: Array<{ editorId: string; path: string }> = [];
|
||||||
|
|
||||||
|
sessionAny.openEditorTarget = async (input: { editorId: string; path: string }) => {
|
||||||
|
calls.push(input);
|
||||||
|
};
|
||||||
|
|
||||||
|
await sessionAny.handleMessage({
|
||||||
|
type: "open_in_editor_request",
|
||||||
|
requestId: "req-open-editor",
|
||||||
|
editorId: "vscode",
|
||||||
|
path: "/tmp/repo",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(calls).toEqual([{ editorId: "vscode", path: "/tmp/repo" }]);
|
||||||
|
const response = emitted.find(
|
||||||
|
(message) => message.type === "open_in_editor_response",
|
||||||
|
) as any;
|
||||||
|
expect(response?.payload.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
test("archive_workspace_request archives the persisted workspace row", async () => {
|
test("archive_workspace_request archives the persisted workspace row", async () => {
|
||||||
const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests();
|
const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests();
|
||||||
@@ -1550,4 +1752,186 @@ describe("backward compatibility", () => {
|
|||||||
rmSync(tempDir, { recursive: true, force: true });
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline descriptor path", async () => {
|
||||||
|
const { session } = createSessionForWorkspaceTests();
|
||||||
|
const sessionAny = session as any;
|
||||||
|
const project = createPersistedProjectRecord({
|
||||||
|
id: 70,
|
||||||
|
directory: "/tmp/repo",
|
||||||
|
kind: "git",
|
||||||
|
displayName: "repo",
|
||||||
|
gitRemote: "https://github.com/acme/repo.git",
|
||||||
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
});
|
||||||
|
const workspace = createPersistedWorkspaceRecord({
|
||||||
|
id: 71,
|
||||||
|
projectId: project.id,
|
||||||
|
directory: "/tmp/repo",
|
||||||
|
kind: "checkout",
|
||||||
|
displayName: "main",
|
||||||
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
sessionAny.listAgentPayloads = async () => [];
|
||||||
|
sessionAny.projectRegistry.list = async () => [project];
|
||||||
|
sessionAny.workspaceRegistry.list = async () => [workspace];
|
||||||
|
|
||||||
|
const baselineDescriptor = {
|
||||||
|
id: workspace.directory,
|
||||||
|
projectId: project.directory,
|
||||||
|
projectDisplayName: project.displayName,
|
||||||
|
projectRootPath: project.directory,
|
||||||
|
workspaceDirectory: workspace.directory,
|
||||||
|
projectKind: "git",
|
||||||
|
workspaceKind: "local_checkout",
|
||||||
|
name: "main",
|
||||||
|
status: "done",
|
||||||
|
activityAt: null,
|
||||||
|
diffStat: null,
|
||||||
|
services: [],
|
||||||
|
} as const;
|
||||||
|
const gitDescriptor = {
|
||||||
|
...baselineDescriptor,
|
||||||
|
diffStat: { additions: 3, deletions: 1 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
sessionAny.describeWorkspaceRecord = vi.fn(async () => baselineDescriptor);
|
||||||
|
sessionAny.describeWorkspaceRecordWithGitData = vi.fn(async () => gitDescriptor);
|
||||||
|
|
||||||
|
const descriptors = await sessionAny.listWorkspaceDescriptorsSnapshot();
|
||||||
|
|
||||||
|
expect(sessionAny.describeWorkspaceRecord).toHaveBeenCalledWith(workspace, project);
|
||||||
|
expect(sessionAny.describeWorkspaceRecordWithGitData).not.toHaveBeenCalled();
|
||||||
|
expect(descriptors).toEqual([baselineDescriptor]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subscribed fetch_workspaces emits git enrichment updates after the baseline snapshot", async () => {
|
||||||
|
const emitted: Array<{ type: string; payload: any }> = [];
|
||||||
|
const { session } = createSessionForWorkspaceTests();
|
||||||
|
const sessionAny = session as any;
|
||||||
|
const gitProject = createPersistedProjectRecord({
|
||||||
|
id: 80,
|
||||||
|
directory: "/tmp/repo",
|
||||||
|
kind: "git",
|
||||||
|
displayName: "repo",
|
||||||
|
gitRemote: "https://github.com/acme/repo.git",
|
||||||
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
});
|
||||||
|
const directoryProject = createPersistedProjectRecord({
|
||||||
|
id: 81,
|
||||||
|
directory: "/tmp/docs",
|
||||||
|
kind: "directory",
|
||||||
|
displayName: "docs",
|
||||||
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
});
|
||||||
|
const gitWorkspace = createPersistedWorkspaceRecord({
|
||||||
|
id: 82,
|
||||||
|
projectId: gitProject.id,
|
||||||
|
directory: "/tmp/repo",
|
||||||
|
kind: "checkout",
|
||||||
|
displayName: "main",
|
||||||
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
});
|
||||||
|
const directoryWorkspace = createPersistedWorkspaceRecord({
|
||||||
|
id: 83,
|
||||||
|
projectId: directoryProject.id,
|
||||||
|
directory: "/tmp/docs",
|
||||||
|
kind: "checkout",
|
||||||
|
displayName: "docs",
|
||||||
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
});
|
||||||
|
const baselineGitDescriptor = {
|
||||||
|
id: gitWorkspace.directory,
|
||||||
|
projectId: gitProject.directory,
|
||||||
|
projectDisplayName: gitProject.displayName,
|
||||||
|
projectRootPath: gitProject.directory,
|
||||||
|
workspaceDirectory: gitWorkspace.directory,
|
||||||
|
projectKind: "git",
|
||||||
|
workspaceKind: "local_checkout",
|
||||||
|
name: "main",
|
||||||
|
status: "done",
|
||||||
|
activityAt: null,
|
||||||
|
diffStat: null,
|
||||||
|
services: [],
|
||||||
|
} as const;
|
||||||
|
const enrichedGitDescriptor = {
|
||||||
|
...baselineGitDescriptor,
|
||||||
|
diffStat: { additions: 3, deletions: 1 },
|
||||||
|
} as const;
|
||||||
|
const directoryDescriptor = {
|
||||||
|
id: directoryWorkspace.directory,
|
||||||
|
projectId: directoryProject.directory,
|
||||||
|
projectDisplayName: directoryProject.displayName,
|
||||||
|
projectRootPath: directoryProject.directory,
|
||||||
|
workspaceDirectory: directoryWorkspace.directory,
|
||||||
|
projectKind: "non_git",
|
||||||
|
workspaceKind: "local_checkout",
|
||||||
|
name: "docs",
|
||||||
|
status: "done",
|
||||||
|
activityAt: null,
|
||||||
|
diffStat: null,
|
||||||
|
services: [],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
sessionAny.emit = (message: any) => emitted.push(message);
|
||||||
|
sessionAny.listAgentPayloads = async () => [];
|
||||||
|
sessionAny.projectRegistry.list = async () => [gitProject, directoryProject];
|
||||||
|
sessionAny.workspaceRegistry.list = async () => [gitWorkspace, directoryWorkspace];
|
||||||
|
sessionAny.reconcileAndEmitWorkspaceUpdates = vi.fn(async () => {});
|
||||||
|
sessionAny.describeWorkspaceRecord = vi.fn(
|
||||||
|
async (workspace: typeof gitWorkspace | typeof directoryWorkspace, project: any) => {
|
||||||
|
if (workspace.id === gitWorkspace.id) {
|
||||||
|
expect(project).toEqual(gitProject);
|
||||||
|
return baselineGitDescriptor;
|
||||||
|
}
|
||||||
|
expect(project).toEqual(directoryProject);
|
||||||
|
return directoryDescriptor;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
sessionAny.describeWorkspaceRecordWithGitData = vi.fn(async () => enrichedGitDescriptor);
|
||||||
|
|
||||||
|
await sessionAny.handleMessage({
|
||||||
|
type: "fetch_workspaces_request",
|
||||||
|
requestId: "req-fetch-workspaces",
|
||||||
|
subscribe: {},
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
const response = emitted.find(
|
||||||
|
(message) => message.type === "fetch_workspaces_response",
|
||||||
|
) as { type: "fetch_workspaces_response"; payload: any } | undefined;
|
||||||
|
expect(
|
||||||
|
response?.payload.entries.map((entry: typeof baselineGitDescriptor | typeof directoryDescriptor) => [
|
||||||
|
entry.id,
|
||||||
|
entry.diffStat,
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
[directoryDescriptor.id, directoryDescriptor.diffStat],
|
||||||
|
[baselineGitDescriptor.id, baselineGitDescriptor.diffStat],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const workspaceUpdates = emitted.filter(
|
||||||
|
(message) => message.type === "workspace_update",
|
||||||
|
) as Array<{ type: "workspace_update"; payload: any }>;
|
||||||
|
expect(workspaceUpdates).toEqual([
|
||||||
|
{
|
||||||
|
type: "workspace_update",
|
||||||
|
payload: {
|
||||||
|
kind: "upsert",
|
||||||
|
workspace: enrichedGitDescriptor,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(sessionAny.describeWorkspaceRecordWithGitData).toHaveBeenCalledWith(
|
||||||
|
gitWorkspace,
|
||||||
|
gitProject,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { FileBackedChatService } from "./chat/chat-service.js";
|
|||||||
import type { LoopService } from "./loop-service.js";
|
import type { LoopService } from "./loop-service.js";
|
||||||
import type { ScheduleService } from "./schedule/service.js";
|
import type { ScheduleService } from "./schedule/service.js";
|
||||||
import type { CheckoutDiffManager, CheckoutDiffMetrics } from "./checkout-diff-manager.js";
|
import type { CheckoutDiffManager, CheckoutDiffMetrics } from "./checkout-diff-manager.js";
|
||||||
|
import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js";
|
||||||
import {
|
import {
|
||||||
type ServerInfoStatusPayload,
|
type ServerInfoStatusPayload,
|
||||||
type WSHelloMessage,
|
type WSHelloMessage,
|
||||||
@@ -239,6 +240,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
private readonly loopService: LoopService;
|
private readonly loopService: LoopService;
|
||||||
private readonly scheduleService: ScheduleService;
|
private readonly scheduleService: ScheduleService;
|
||||||
private readonly checkoutDiffManager: CheckoutDiffManager;
|
private readonly checkoutDiffManager: CheckoutDiffManager;
|
||||||
|
private readonly backgroundGitFetchManager: BackgroundGitFetchManager;
|
||||||
private readonly downloadTokenStore: DownloadTokenStore;
|
private readonly downloadTokenStore: DownloadTokenStore;
|
||||||
private readonly paseoHome: string;
|
private readonly paseoHome: string;
|
||||||
private readonly pushTokenStore: PushTokenStore;
|
private readonly pushTokenStore: PushTokenStore;
|
||||||
@@ -356,6 +358,9 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
throw new Error("VoiceAssistantWebSocketServer requires a checkout diff manager.");
|
throw new Error("VoiceAssistantWebSocketServer requires a checkout diff manager.");
|
||||||
}
|
}
|
||||||
this.checkoutDiffManager = checkoutDiffManager;
|
this.checkoutDiffManager = checkoutDiffManager;
|
||||||
|
this.backgroundGitFetchManager = new BackgroundGitFetchManager({
|
||||||
|
logger: this.logger,
|
||||||
|
});
|
||||||
this.downloadTokenStore = downloadTokenStore;
|
this.downloadTokenStore = downloadTokenStore;
|
||||||
this.paseoHome = paseoHome;
|
this.paseoHome = paseoHome;
|
||||||
this.createAgentMcpTransport = createAgentMcpTransport;
|
this.createAgentMcpTransport = createAgentMcpTransport;
|
||||||
@@ -541,6 +546,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
|
|
||||||
await Promise.all(cleanupPromises);
|
await Promise.all(cleanupPromises);
|
||||||
this.providerSnapshotManager.destroy();
|
this.providerSnapshotManager.destroy();
|
||||||
|
this.backgroundGitFetchManager.dispose();
|
||||||
this.checkoutDiffManager.dispose();
|
this.checkoutDiffManager.dispose();
|
||||||
this.pendingConnections.clear();
|
this.pendingConnections.clear();
|
||||||
this.sessions.clear();
|
this.sessions.clear();
|
||||||
@@ -687,6 +693,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
loopService: this.loopService,
|
loopService: this.loopService,
|
||||||
scheduleService: this.scheduleService,
|
scheduleService: this.scheduleService,
|
||||||
checkoutDiffManager: this.checkoutDiffManager,
|
checkoutDiffManager: this.checkoutDiffManager,
|
||||||
|
backgroundGitFetchManager: this.backgroundGitFetchManager,
|
||||||
createAgentMcpTransport: this.createAgentMcpTransport,
|
createAgentMcpTransport: this.createAgentMcpTransport,
|
||||||
stt: () => this.speech?.resolveStt() ?? null,
|
stt: () => this.speech?.resolveStt() ?? null,
|
||||||
tts: () => this.speech?.resolveTts() ?? null,
|
tts: () => this.speech?.resolveTts() ?? null,
|
||||||
|
|||||||
@@ -203,6 +203,74 @@ describe("shared messages stream parsing", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses optional permission actions and selectedActionId compatibly", () => {
|
||||||
|
const requestParsed = AgentStreamMessageSchema.parse({
|
||||||
|
type: "agent_stream",
|
||||||
|
payload: {
|
||||||
|
agentId: "agent_live",
|
||||||
|
timestamp: "2026-02-08T20:10:00.000Z",
|
||||||
|
event: {
|
||||||
|
type: "permission_requested",
|
||||||
|
provider: "codex",
|
||||||
|
request: {
|
||||||
|
id: "perm-1",
|
||||||
|
provider: "codex",
|
||||||
|
name: "CodexPlanApproval",
|
||||||
|
kind: "plan",
|
||||||
|
input: { plan: "- step 1" },
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: "reject",
|
||||||
|
label: "Reject",
|
||||||
|
behavior: "deny",
|
||||||
|
variant: "danger",
|
||||||
|
intent: "dismiss",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "implement",
|
||||||
|
label: "Implement",
|
||||||
|
behavior: "allow",
|
||||||
|
variant: "primary",
|
||||||
|
intent: "implement",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(requestParsed.payload.event.type).toBe("permission_requested");
|
||||||
|
if (requestParsed.payload.event.type === "permission_requested") {
|
||||||
|
expect(requestParsed.payload.event.request.actions).toHaveLength(2);
|
||||||
|
expect(requestParsed.payload.event.request.actions?.[1]?.label).toBe("Implement");
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolutionParsed = AgentStreamMessageSchema.parse({
|
||||||
|
type: "agent_stream",
|
||||||
|
payload: {
|
||||||
|
agentId: "agent_live",
|
||||||
|
timestamp: "2026-02-08T20:10:01.000Z",
|
||||||
|
event: {
|
||||||
|
type: "permission_resolved",
|
||||||
|
provider: "claude",
|
||||||
|
requestId: "perm-1",
|
||||||
|
resolution: {
|
||||||
|
behavior: "allow",
|
||||||
|
selectedActionId: "implement_resume",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolutionParsed.payload.event.type).toBe("permission_resolved");
|
||||||
|
if (resolutionParsed.payload.event.type === "permission_resolved") {
|
||||||
|
expect(resolutionParsed.payload.event.resolution).toEqual({
|
||||||
|
behavior: "allow",
|
||||||
|
selectedActionId: "implement_resume",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects removed initialize_agent_request inbound payload", () => {
|
it("rejects removed initialize_agent_request inbound payload", () => {
|
||||||
const parsed = SessionInboundMessageSchema.safeParse({
|
const parsed = SessionInboundMessageSchema.safeParse({
|
||||||
type: "initialize_agent_request",
|
type: "initialize_agent_request",
|
||||||
|
|||||||
@@ -146,6 +146,8 @@ const AgentUsageSchema: z.ZodType<AgentUsage> = z.object({
|
|||||||
cachedInputTokens: z.number().optional(),
|
cachedInputTokens: z.number().optional(),
|
||||||
outputTokens: z.number().optional(),
|
outputTokens: z.number().optional(),
|
||||||
totalCostUsd: z.number().optional(),
|
totalCostUsd: z.number().optional(),
|
||||||
|
contextWindowMaxTokens: z.number().optional(),
|
||||||
|
contextWindowUsedTokens: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const McpStdioServerConfigSchema = z.object({
|
const McpStdioServerConfigSchema = z.object({
|
||||||
@@ -197,15 +199,24 @@ const AgentSessionConfigSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const AgentPermissionUpdateSchema = z.record(z.unknown());
|
const AgentPermissionUpdateSchema = z.record(z.unknown());
|
||||||
|
const AgentPermissionActionSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
label: z.string(),
|
||||||
|
behavior: z.enum(["allow", "deny"]),
|
||||||
|
variant: z.enum(["primary", "secondary", "danger"]).optional(),
|
||||||
|
intent: z.enum(["implement", "implement_resume", "dismiss"]).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> = z.union([
|
export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> = z.union([
|
||||||
z.object({
|
z.object({
|
||||||
behavior: z.literal("allow"),
|
behavior: z.literal("allow"),
|
||||||
|
selectedActionId: z.string().optional(),
|
||||||
updatedInput: z.record(z.unknown()).optional(),
|
updatedInput: z.record(z.unknown()).optional(),
|
||||||
updatedPermissions: z.array(AgentPermissionUpdateSchema).optional(),
|
updatedPermissions: z.array(AgentPermissionUpdateSchema).optional(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
behavior: z.literal("deny"),
|
behavior: z.literal("deny"),
|
||||||
|
selectedActionId: z.string().optional(),
|
||||||
message: z.string().optional(),
|
message: z.string().optional(),
|
||||||
interrupt: z.boolean().optional(),
|
interrupt: z.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
@@ -220,6 +231,7 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType<AgentPermissionReque
|
|||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
input: z.record(z.unknown()).optional(),
|
input: z.record(z.unknown()).optional(),
|
||||||
suggestions: z.array(AgentPermissionUpdateSchema).optional(),
|
suggestions: z.array(AgentPermissionUpdateSchema).optional(),
|
||||||
|
actions: z.array(AgentPermissionActionSchema).optional(),
|
||||||
metadata: z.record(z.unknown()).optional(),
|
metadata: z.record(z.unknown()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -978,6 +990,7 @@ const CheckoutErrorSchema = z.object({
|
|||||||
const CheckoutDiffCompareSchema = z.object({
|
const CheckoutDiffCompareSchema = z.object({
|
||||||
mode: z.enum(["uncommitted", "base"]),
|
mode: z.enum(["uncommitted", "base"]),
|
||||||
baseRef: z.string().optional(),
|
baseRef: z.string().optional(),
|
||||||
|
ignoreWhitespace: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CheckoutStatusRequestSchema = z.object({
|
export const CheckoutStatusRequestSchema = z.object({
|
||||||
@@ -1098,6 +1111,32 @@ export const WorkspaceSetupStatusRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const EditorTargetIdSchema = z.enum([
|
||||||
|
"cursor",
|
||||||
|
"vscode",
|
||||||
|
"zed",
|
||||||
|
"finder",
|
||||||
|
"explorer",
|
||||||
|
"file-manager",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const EditorTargetDescriptorPayloadSchema = z.object({
|
||||||
|
id: EditorTargetIdSchema,
|
||||||
|
label: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ListAvailableEditorsRequestSchema = z.object({
|
||||||
|
type: z.literal("list_available_editors_request"),
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OpenInEditorRequestSchema = z.object({
|
||||||
|
type: z.literal("open_in_editor_request"),
|
||||||
|
path: z.string(),
|
||||||
|
editorId: EditorTargetIdSchema,
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const OpenProjectRequestSchema = z.object({
|
export const OpenProjectRequestSchema = z.object({
|
||||||
type: z.literal("open_project_request"),
|
type: z.literal("open_project_request"),
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
@@ -1366,6 +1405,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
PaseoWorktreeArchiveRequestSchema,
|
PaseoWorktreeArchiveRequestSchema,
|
||||||
CreatePaseoWorktreeRequestSchema,
|
CreatePaseoWorktreeRequestSchema,
|
||||||
WorkspaceSetupStatusRequestSchema,
|
WorkspaceSetupStatusRequestSchema,
|
||||||
|
ListAvailableEditorsRequestSchema,
|
||||||
|
OpenInEditorRequestSchema,
|
||||||
OpenProjectRequestSchema,
|
OpenProjectRequestSchema,
|
||||||
ArchiveWorkspaceRequestSchema,
|
ArchiveWorkspaceRequestSchema,
|
||||||
FileExplorerRequestSchema,
|
FileExplorerRequestSchema,
|
||||||
@@ -1915,6 +1956,23 @@ export const StartWorkspaceServiceResponseMessageSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ListAvailableEditorsResponseMessageSchema = z.object({
|
||||||
|
type: z.literal("list_available_editors_response"),
|
||||||
|
payload: z.object({
|
||||||
|
requestId: z.string(),
|
||||||
|
editors: z.array(EditorTargetDescriptorPayloadSchema),
|
||||||
|
error: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OpenInEditorResponseMessageSchema = z.object({
|
||||||
|
type: z.literal("open_in_editor_response"),
|
||||||
|
payload: z.object({
|
||||||
|
requestId: z.string(),
|
||||||
|
error: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export const ArchiveWorkspaceResponseMessageSchema = z.object({
|
export const ArchiveWorkspaceResponseMessageSchema = z.object({
|
||||||
type: z.literal("archive_workspace_response"),
|
type: z.literal("archive_workspace_response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -2608,6 +2666,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
FetchWorkspacesResponseMessageSchema,
|
FetchWorkspacesResponseMessageSchema,
|
||||||
OpenProjectResponseMessageSchema,
|
OpenProjectResponseMessageSchema,
|
||||||
StartWorkspaceServiceResponseMessageSchema,
|
StartWorkspaceServiceResponseMessageSchema,
|
||||||
|
ListAvailableEditorsResponseMessageSchema,
|
||||||
|
OpenInEditorResponseMessageSchema,
|
||||||
ArchiveWorkspaceResponseMessageSchema,
|
ArchiveWorkspaceResponseMessageSchema,
|
||||||
FetchAgentResponseMessageSchema,
|
FetchAgentResponseMessageSchema,
|
||||||
FetchAgentTimelineResponseMessageSchema,
|
FetchAgentTimelineResponseMessageSchema,
|
||||||
@@ -2708,6 +2768,8 @@ export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPaylo
|
|||||||
export type WorkspaceServiceLifecycle = z.infer<typeof WorkspaceServiceLifecycleSchema>;
|
export type WorkspaceServiceLifecycle = z.infer<typeof WorkspaceServiceLifecycleSchema>;
|
||||||
export type WorkspaceServiceHealth = z.infer<typeof WorkspaceServiceHealthSchema>;
|
export type WorkspaceServiceHealth = z.infer<typeof WorkspaceServiceHealthSchema>;
|
||||||
export type WorkspaceServicePayload = z.infer<typeof WorkspaceServicePayloadSchema>;
|
export type WorkspaceServicePayload = z.infer<typeof WorkspaceServicePayloadSchema>;
|
||||||
|
export type EditorTargetId = z.infer<typeof EditorTargetIdSchema>;
|
||||||
|
export type EditorTargetDescriptorPayload = z.infer<typeof EditorTargetDescriptorPayloadSchema>;
|
||||||
export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>;
|
export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>;
|
||||||
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
|
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
|
||||||
export type ServiceStatusUpdateMessage = z.infer<typeof ServiceStatusUpdateMessageSchema>;
|
export type ServiceStatusUpdateMessage = z.infer<typeof ServiceStatusUpdateMessageSchema>;
|
||||||
@@ -2715,6 +2777,10 @@ export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessa
|
|||||||
export type StartWorkspaceServiceResponseMessage = z.infer<
|
export type StartWorkspaceServiceResponseMessage = z.infer<
|
||||||
typeof StartWorkspaceServiceResponseMessageSchema
|
typeof StartWorkspaceServiceResponseMessageSchema
|
||||||
>;
|
>;
|
||||||
|
export type ListAvailableEditorsResponseMessage = z.infer<
|
||||||
|
typeof ListAvailableEditorsResponseMessageSchema
|
||||||
|
>;
|
||||||
|
export type OpenInEditorResponseMessage = z.infer<typeof OpenInEditorResponseMessageSchema>;
|
||||||
export type ArchiveWorkspaceResponseMessage = z.infer<typeof ArchiveWorkspaceResponseMessageSchema>;
|
export type ArchiveWorkspaceResponseMessage = z.infer<typeof ArchiveWorkspaceResponseMessageSchema>;
|
||||||
export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessageSchema>;
|
export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessageSchema>;
|
||||||
export type FetchAgentTimelineResponseMessage = z.infer<
|
export type FetchAgentTimelineResponseMessage = z.infer<
|
||||||
@@ -2863,6 +2929,8 @@ export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponse
|
|||||||
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
|
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
|
||||||
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>;
|
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>;
|
||||||
export type WorkspaceSetupStatusRequest = z.infer<typeof WorkspaceSetupStatusRequestSchema>;
|
export type WorkspaceSetupStatusRequest = z.infer<typeof WorkspaceSetupStatusRequestSchema>;
|
||||||
|
export type ListAvailableEditorsRequest = z.infer<typeof ListAvailableEditorsRequestSchema>;
|
||||||
|
export type OpenInEditorRequest = z.infer<typeof OpenInEditorRequestSchema>;
|
||||||
export type OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema>;
|
export type OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema>;
|
||||||
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
|
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
|
||||||
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
||||||
|
|||||||
@@ -29,6 +29,27 @@ describe("workspace message schemas", () => {
|
|||||||
expect(parsed.type).toBe("open_project_request");
|
expect(parsed.type).toBe("open_project_request");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("parses list_available_editors_request", () => {
|
||||||
|
const parsed = SessionInboundMessageSchema.parse({
|
||||||
|
type: "list_available_editors_request",
|
||||||
|
requestId: "req-editors",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.type).toBe("list_available_editors_request");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses open_in_editor_response", () => {
|
||||||
|
const parsed = SessionOutboundMessageSchema.parse({
|
||||||
|
type: "open_in_editor_response",
|
||||||
|
payload: {
|
||||||
|
requestId: "req-open-editor",
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.type).toBe("open_in_editor_response");
|
||||||
|
});
|
||||||
|
|
||||||
test("rejects invalid workspace update payload", () => {
|
test("rejects invalid workspace update payload", () => {
|
||||||
const result = SessionOutboundMessageSchema.safeParse({
|
const result = SessionOutboundMessageSchema.safeParse({
|
||||||
type: "workspace_update",
|
type: "workspace_update",
|
||||||
|
|||||||
@@ -110,6 +110,21 @@ describe("checkout git utilities", () => {
|
|||||||
expect(message).toBe("update file");
|
expect(message).toBe("update file");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hides whitespace-only changes when requested", async () => {
|
||||||
|
writeFileSync(join(repoDir, "file.txt"), "hello \n");
|
||||||
|
|
||||||
|
const visibleDiff = await getCheckoutDiff(repoDir, { mode: "uncommitted" });
|
||||||
|
expect(visibleDiff.diff).toContain("file.txt");
|
||||||
|
|
||||||
|
const hiddenDiff = await getCheckoutDiff(repoDir, {
|
||||||
|
mode: "uncommitted",
|
||||||
|
ignoreWhitespace: true,
|
||||||
|
includeStructured: true,
|
||||||
|
});
|
||||||
|
expect(hiddenDiff.diff).toBe("");
|
||||||
|
expect(hiddenDiff.structured).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves removed-line syntax highlighting with structured diffs", async () => {
|
it("preserves removed-line syntax highlighting with structured diffs", async () => {
|
||||||
const originalContent = `/*
|
const originalContent = `/*
|
||||||
comment line 1
|
comment line 1
|
||||||
|
|||||||
@@ -309,13 +309,25 @@ export async function listBranchSuggestions(
|
|||||||
return ordered.slice(0, limit);
|
return ordered.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listCheckoutFileChanges(cwd: string, ref: string): Promise<CheckoutFileChange[]> {
|
async function listCheckoutFileChanges(
|
||||||
|
cwd: string,
|
||||||
|
ref: string,
|
||||||
|
ignoreWhitespace = false,
|
||||||
|
): Promise<CheckoutFileChange[]> {
|
||||||
const changes: CheckoutFileChange[] = [];
|
const changes: CheckoutFileChange[] = [];
|
||||||
|
|
||||||
const { stdout: nameStatusOut } = await execGit(`git diff --name-status ${ref}`, {
|
const { stdout: nameStatusOut } = await execFileAsync(
|
||||||
cwd,
|
"git",
|
||||||
env: READ_ONLY_GIT_ENV,
|
buildGitDiffArgs({
|
||||||
});
|
ignoreWhitespace,
|
||||||
|
extra: ["--name-status", ref],
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
maxBuffer: SMALL_OUTPUT_MAX_BUFFER,
|
||||||
|
},
|
||||||
|
);
|
||||||
for (const line of nameStatusOut
|
for (const line of nameStatusOut
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((l) => l.trim())
|
.map((l) => l.trim())
|
||||||
@@ -430,13 +442,24 @@ function normalizeNumstatPath(pathField: string): string {
|
|||||||
return pathField;
|
return pathField;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildGitDiffArgs(args: { ignoreWhitespace?: boolean; extra: string[] }): string[] {
|
||||||
|
return ["diff", ...(args.ignoreWhitespace ? ["-w"] : []), ...args.extra];
|
||||||
|
}
|
||||||
|
|
||||||
const TRACKED_DIFF_NUMSTAT_MAX_BYTES = 2 * 1024 * 1024; // 2MB
|
const TRACKED_DIFF_NUMSTAT_MAX_BYTES = 2 * 1024 * 1024; // 2MB
|
||||||
const TRACKED_MAX_CHANGED_LINES = 40_000;
|
const TRACKED_MAX_CHANGED_LINES = 40_000;
|
||||||
|
|
||||||
async function getTrackedNumstatByPath(cwd: string, ref: string): Promise<Map<string, FileStat>> {
|
async function getTrackedNumstatByPath(
|
||||||
|
cwd: string,
|
||||||
|
ref: string,
|
||||||
|
ignoreWhitespace = false,
|
||||||
|
): Promise<Map<string, FileStat>> {
|
||||||
const result = await spawnLimitedText({
|
const result = await spawnLimitedText({
|
||||||
cmd: "git",
|
cmd: "git",
|
||||||
args: ["diff", "--numstat", ref],
|
args: buildGitDiffArgs({
|
||||||
|
ignoreWhitespace,
|
||||||
|
extra: ["--numstat", ref],
|
||||||
|
}),
|
||||||
cwd,
|
cwd,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
maxBytes: TRACKED_DIFF_NUMSTAT_MAX_BYTES,
|
maxBytes: TRACKED_DIFF_NUMSTAT_MAX_BYTES,
|
||||||
@@ -612,6 +635,7 @@ export interface CheckoutDiffResult {
|
|||||||
export interface CheckoutDiffCompare {
|
export interface CheckoutDiffCompare {
|
||||||
mode: "uncommitted" | "base";
|
mode: "uncommitted" | "base";
|
||||||
baseRef?: string;
|
baseRef?: string;
|
||||||
|
ignoreWhitespace?: boolean;
|
||||||
includeStructured?: boolean;
|
includeStructured?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1106,6 +1130,7 @@ function buildPlaceholderParsedDiffFile(
|
|||||||
async function getUntrackedDiffText(
|
async function getUntrackedDiffText(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
change: CheckoutFileChange,
|
change: CheckoutFileChange,
|
||||||
|
ignoreWhitespace = false,
|
||||||
): Promise<{ text: string; truncated: boolean; stat: FileStat }> {
|
): Promise<{ text: string; truncated: boolean; stat: FileStat }> {
|
||||||
try {
|
try {
|
||||||
const inspected = await inspectUntrackedFile(cwd, change.path);
|
const inspected = await inspectUntrackedFile(cwd, change.path);
|
||||||
@@ -1118,7 +1143,10 @@ async function getUntrackedDiffText(
|
|||||||
|
|
||||||
const result = await spawnLimitedText({
|
const result = await spawnLimitedText({
|
||||||
cmd: "git",
|
cmd: "git",
|
||||||
args: ["diff", "--no-index", "/dev/null", "--", change.path],
|
args: buildGitDiffArgs({
|
||||||
|
ignoreWhitespace,
|
||||||
|
extra: ["--no-index", "/dev/null", "--", change.path],
|
||||||
|
}),
|
||||||
cwd,
|
cwd,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
maxBytes: PER_FILE_DIFF_MAX_BYTES,
|
maxBytes: PER_FILE_DIFF_MAX_BYTES,
|
||||||
@@ -1391,7 +1419,8 @@ export async function getCheckoutDiff(
|
|||||||
refForDiff = (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef;
|
refForDiff = (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef;
|
||||||
}
|
}
|
||||||
|
|
||||||
const changes = await listCheckoutFileChanges(cwd, refForDiff);
|
const ignoreWhitespace = compare.ignoreWhitespace === true;
|
||||||
|
const changes = await listCheckoutFileChanges(cwd, refForDiff, ignoreWhitespace);
|
||||||
changes.sort((a, b) => {
|
changes.sort((a, b) => {
|
||||||
if (a.path === b.path) return 0;
|
if (a.path === b.path) return 0;
|
||||||
return a.path < b.path ? -1 : 1;
|
return a.path < b.path ? -1 : 1;
|
||||||
@@ -1422,7 +1451,7 @@ export async function getCheckoutDiff(
|
|||||||
|
|
||||||
const trackedNumstatByPath =
|
const trackedNumstatByPath =
|
||||||
trackedChanges.length > 0
|
trackedChanges.length > 0
|
||||||
? await getTrackedNumstatByPath(cwd, refForDiff)
|
? await getTrackedNumstatByPath(cwd, refForDiff, ignoreWhitespace)
|
||||||
: new Map<string, FileStat>();
|
: new Map<string, FileStat>();
|
||||||
const trackedDiffPaths: string[] = [];
|
const trackedDiffPaths: string[] = [];
|
||||||
const trackedPlaceholderByPath = new Map<
|
const trackedPlaceholderByPath = new Map<
|
||||||
@@ -1448,7 +1477,10 @@ export async function getCheckoutDiff(
|
|||||||
if (trackedDiffPaths.length > 0) {
|
if (trackedDiffPaths.length > 0) {
|
||||||
const trackedDiffResult = await spawnLimitedText({
|
const trackedDiffResult = await spawnLimitedText({
|
||||||
cmd: "git",
|
cmd: "git",
|
||||||
args: ["diff", refForDiff, "--", ...trackedDiffPaths],
|
args: buildGitDiffArgs({
|
||||||
|
ignoreWhitespace,
|
||||||
|
extra: [refForDiff, "--", ...trackedDiffPaths],
|
||||||
|
}),
|
||||||
cwd,
|
cwd,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
maxBytes: TOTAL_DIFF_MAX_BYTES,
|
maxBytes: TOTAL_DIFF_MAX_BYTES,
|
||||||
@@ -1537,7 +1569,11 @@ export async function getCheckoutDiff(
|
|||||||
if (diffBytes >= TOTAL_DIFF_MAX_BYTES) {
|
if (diffBytes >= TOTAL_DIFF_MAX_BYTES) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const { text, truncated, stat } = await getUntrackedDiffText(cwd, change);
|
const { text, truncated, stat } = await getUntrackedDiffText(
|
||||||
|
cwd,
|
||||||
|
change,
|
||||||
|
ignoreWhitespace,
|
||||||
|
);
|
||||||
|
|
||||||
if (!compare.includeStructured) {
|
if (!compare.includeStructured) {
|
||||||
if (stat?.isBinary) {
|
if (stat?.isBinary) {
|
||||||
|
|||||||
@@ -149,6 +149,71 @@ describe("createWorktree", () => {
|
|||||||
expect(metadata).toMatchObject({ version: 1, baseRefName: "main" });
|
expect(metadata).toMatchObject({ version: 1, baseRefName: "main" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prefers origin/{branch} over local {branch} when both exist", async () => {
|
||||||
|
const remoteDir = join(tempDir, "remote.git");
|
||||||
|
const remoteCloneDir = join(tempDir, "remote-clone");
|
||||||
|
execSync(`git init --bare ${remoteDir}`);
|
||||||
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
||||||
|
execSync("git push -u origin main", { cwd: repoDir });
|
||||||
|
|
||||||
|
execSync(`git clone ${remoteDir} ${remoteCloneDir}`);
|
||||||
|
execSync("git config user.email 'test@test.com'", { cwd: remoteCloneDir });
|
||||||
|
execSync("git config user.name 'Test'", { cwd: remoteCloneDir });
|
||||||
|
writeFileSync(join(remoteCloneDir, "file.txt"), "from-origin\n");
|
||||||
|
execSync("git add file.txt", { cwd: remoteCloneDir });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'advance origin main'", {
|
||||||
|
cwd: remoteCloneDir,
|
||||||
|
});
|
||||||
|
execSync("git push origin main", { cwd: remoteCloneDir });
|
||||||
|
|
||||||
|
writeFileSync(join(repoDir, "file.txt"), "from-local\n");
|
||||||
|
execSync("git add file.txt", { cwd: repoDir });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'advance local main'", { cwd: repoDir });
|
||||||
|
|
||||||
|
execSync("git fetch origin", { cwd: repoDir });
|
||||||
|
|
||||||
|
const result = await createWorktree({
|
||||||
|
branchName: "prefer-origin-feature",
|
||||||
|
cwd: repoDir,
|
||||||
|
baseBranch: "main",
|
||||||
|
worktreeSlug: "prefer-origin-feature",
|
||||||
|
runSetup: false,
|
||||||
|
paseoHome,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-origin\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to local {branch} when origin/{branch} does not exist", async () => {
|
||||||
|
writeFileSync(join(repoDir, "file.txt"), "from-local-only\n");
|
||||||
|
execSync("git add file.txt", { cwd: repoDir });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'advance local main only'", { cwd: repoDir });
|
||||||
|
|
||||||
|
const result = await createWorktree({
|
||||||
|
branchName: "prefer-local-fallback-feature",
|
||||||
|
cwd: repoDir,
|
||||||
|
baseBranch: "main",
|
||||||
|
worktreeSlug: "prefer-local-fallback-feature",
|
||||||
|
runSetup: false,
|
||||||
|
paseoHome,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-local-only\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when neither origin/{branch} nor local {branch} exists", async () => {
|
||||||
|
await expect(
|
||||||
|
createWorktree({
|
||||||
|
branchName: "missing-base-feature",
|
||||||
|
cwd: repoDir,
|
||||||
|
baseBranch: "does-not-exist",
|
||||||
|
worktreeSlug: "missing-base-feature",
|
||||||
|
runSetup: false,
|
||||||
|
paseoHome,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Base branch not found: does-not-exist");
|
||||||
|
});
|
||||||
|
|
||||||
it("fails with invalid branch name", async () => {
|
it("fails with invalid branch name", async () => {
|
||||||
await expect(
|
await expect(
|
||||||
createWorktree({
|
createWorktree({
|
||||||
|
|||||||
@@ -1046,15 +1046,14 @@ export async function createWorktree({
|
|||||||
throw new Error("Base branch cannot be HEAD when creating a Paseo worktree");
|
throw new Error("Base branch cannot be HEAD when creating a Paseo worktree");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the base branch - try local first, then remote
|
// Resolve the base branch - prefer origin/{branch}, then fall back to local
|
||||||
let resolvedBaseBranch = normalizedBaseBranch;
|
let resolvedBaseBranch = normalizedBaseBranch;
|
||||||
try {
|
try {
|
||||||
await execAsync(`git rev-parse --verify ${normalizedBaseBranch}`, { cwd });
|
await execAsync(`git rev-parse --verify origin/${normalizedBaseBranch}`, { cwd });
|
||||||
|
resolvedBaseBranch = `origin/${normalizedBaseBranch}`;
|
||||||
} catch {
|
} catch {
|
||||||
// Local branch doesn't exist, try remote (origin/{branch})
|
|
||||||
try {
|
try {
|
||||||
await execAsync(`git rev-parse --verify origin/${normalizedBaseBranch}`, { cwd });
|
await execAsync(`git rev-parse --verify ${normalizedBaseBranch}`, { cwd });
|
||||||
resolvedBaseBranch = `origin/${normalizedBaseBranch}`;
|
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`Base branch not found: ${normalizedBaseBranch}`);
|
throw new Error(`Base branch not found: ${normalizedBaseBranch}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.1.48",
|
"version": "0.1.49",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -826,6 +826,8 @@ function GetStarted() {
|
|||||||
<AgentBadge name="Claude Code" icon={<ClaudeCodeIcon className="h-6 w-6" />} />
|
<AgentBadge name="Claude Code" icon={<ClaudeCodeIcon className="h-6 w-6" />} />
|
||||||
<AgentBadge name="Codex" icon={<CodexIcon className="h-6 w-6" />} />
|
<AgentBadge name="Codex" icon={<CodexIcon className="h-6 w-6" />} />
|
||||||
<AgentBadge name="OpenCode" icon={<OpenCodeIcon className="h-6 w-6" />} />
|
<AgentBadge name="OpenCode" icon={<OpenCodeIcon className="h-6 w-6" />} />
|
||||||
|
<AgentBadge name="Copilot" icon={<CopilotIcon className="h-6 w-6" />} />
|
||||||
|
<AgentBadge name="Pi" icon={<PiIcon className="h-6 w-6" />} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ import { Route as BlogIndexRouteImport } from './routes/blog/index'
|
|||||||
import { Route as DocsWorktreesRouteImport } from './routes/docs/worktrees'
|
import { Route as DocsWorktreesRouteImport } from './routes/docs/worktrees'
|
||||||
import { Route as DocsVoiceRouteImport } from './routes/docs/voice'
|
import { Route as DocsVoiceRouteImport } from './routes/docs/voice'
|
||||||
import { Route as DocsUpdatesRouteImport } from './routes/docs/updates'
|
import { Route as DocsUpdatesRouteImport } from './routes/docs/updates'
|
||||||
|
import { Route as DocsSkillsRouteImport } from './routes/docs/skills'
|
||||||
import { Route as DocsSecurityRouteImport } from './routes/docs/security'
|
import { Route as DocsSecurityRouteImport } from './routes/docs/security'
|
||||||
import { Route as DocsConfigurationRouteImport } from './routes/docs/configuration'
|
import { Route as DocsConfigurationRouteImport } from './routes/docs/configuration'
|
||||||
import { Route as DocsSkillsRouteImport } from './routes/docs/skills'
|
|
||||||
import { Route as DocsCliRouteImport } from './routes/docs/cli'
|
import { Route as DocsCliRouteImport } from './routes/docs/cli'
|
||||||
import { Route as DocsBestPracticesRouteImport } from './routes/docs/best-practices'
|
import { Route as DocsBestPracticesRouteImport } from './routes/docs/best-practices'
|
||||||
import { Route as BlogSplatRouteImport } from './routes/blog/$'
|
import { Route as BlogSplatRouteImport } from './routes/blog/$'
|
||||||
@@ -100,6 +100,11 @@ const DocsUpdatesRoute = DocsUpdatesRouteImport.update({
|
|||||||
path: '/updates',
|
path: '/updates',
|
||||||
getParentRoute: () => DocsRoute,
|
getParentRoute: () => DocsRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const DocsSkillsRoute = DocsSkillsRouteImport.update({
|
||||||
|
id: '/skills',
|
||||||
|
path: '/skills',
|
||||||
|
getParentRoute: () => DocsRoute,
|
||||||
|
} as any)
|
||||||
const DocsSecurityRoute = DocsSecurityRouteImport.update({
|
const DocsSecurityRoute = DocsSecurityRouteImport.update({
|
||||||
id: '/security',
|
id: '/security',
|
||||||
path: '/security',
|
path: '/security',
|
||||||
@@ -110,11 +115,6 @@ const DocsConfigurationRoute = DocsConfigurationRouteImport.update({
|
|||||||
path: '/configuration',
|
path: '/configuration',
|
||||||
getParentRoute: () => DocsRoute,
|
getParentRoute: () => DocsRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const DocsSkillsRoute = DocsSkillsRouteImport.update({
|
|
||||||
id: '/skills',
|
|
||||||
path: '/skills',
|
|
||||||
getParentRoute: () => DocsRoute,
|
|
||||||
} as any)
|
|
||||||
const DocsCliRoute = DocsCliRouteImport.update({
|
const DocsCliRoute = DocsCliRouteImport.update({
|
||||||
id: '/cli',
|
id: '/cli',
|
||||||
path: '/cli',
|
path: '/cli',
|
||||||
@@ -375,6 +375,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof DocsUpdatesRouteImport
|
preLoaderRoute: typeof DocsUpdatesRouteImport
|
||||||
parentRoute: typeof DocsRoute
|
parentRoute: typeof DocsRoute
|
||||||
}
|
}
|
||||||
|
'/docs/skills': {
|
||||||
|
id: '/docs/skills'
|
||||||
|
path: '/skills'
|
||||||
|
fullPath: '/docs/skills'
|
||||||
|
preLoaderRoute: typeof DocsSkillsRouteImport
|
||||||
|
parentRoute: typeof DocsRoute
|
||||||
|
}
|
||||||
'/docs/security': {
|
'/docs/security': {
|
||||||
id: '/docs/security'
|
id: '/docs/security'
|
||||||
path: '/security'
|
path: '/security'
|
||||||
@@ -389,13 +396,6 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof DocsConfigurationRouteImport
|
preLoaderRoute: typeof DocsConfigurationRouteImport
|
||||||
parentRoute: typeof DocsRoute
|
parentRoute: typeof DocsRoute
|
||||||
}
|
}
|
||||||
'/docs/skills': {
|
|
||||||
id: '/docs/skills'
|
|
||||||
path: '/skills'
|
|
||||||
fullPath: '/docs/skills'
|
|
||||||
preLoaderRoute: typeof DocsSkillsRouteImport
|
|
||||||
parentRoute: typeof DocsRoute
|
|
||||||
}
|
|
||||||
'/docs/cli': {
|
'/docs/cli': {
|
||||||
id: '/docs/cli'
|
id: '/docs/cli'
|
||||||
path: '/cli'
|
path: '/cli'
|
||||||
|
|||||||
Reference in New Issue
Block a user