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
|
||||
|
||||
## 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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,3 +1,58 @@
|
||||
# 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)
|
||||
|
||||
Before running any stable patch release command:
|
||||
|
||||
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
|
||||
- Make sure local `npm run typecheck` passes on that commit.
|
||||
- Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready.
|
||||
|
||||
```bash
|
||||
npm run release:patch
|
||||
```
|
||||
@@ -24,6 +30,7 @@ Use the direct stable path when the current `main` changes are ready to become t
|
||||
## Manual step-by-step
|
||||
|
||||
```bash
|
||||
npm run typecheck # Verify the exact commit you intend to release
|
||||
npm run release:check # Typecheck, build, dry-run pack
|
||||
npm run version:all:patch # Bump version, create commit + tag
|
||||
npm run release:publish # Publish to npm
|
||||
@@ -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:
|
||||
>
|
||||
> 1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
|
||||
> 2. **Backward compatibility** — mobile apps lag behind desktop/daemon updates by days. Users will update desktop and daemon immediately but keep running the old app. Flag anything that requires both sides to update in lockstep.
|
||||
> 2. **Backward compatibility** — the important direction is old app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Flag anything that breaks old clients against new daemons or requires both sides to update in lockstep.
|
||||
> 3. **Regressions** — anything that looks like it could break existing functionality.
|
||||
>
|
||||
> Diff: `git diff <latest-release-tag>..HEAD`
|
||||
@@ -150,6 +157,8 @@ In other words, RCs are checkpoints along the way; the changelog only records th
|
||||
## Completion checklist
|
||||
|
||||
- [ ] Run the pre-release sanity check (see above) and address any findings
|
||||
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any `release:*` patch/promote command
|
||||
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any `release:*` patch/promote command
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
|
||||
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
|
||||
|
||||
@@ -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.
|
||||
# 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
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
|
||||
Generated
+19
-19
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.49",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.49",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -36221,16 +36221,16 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.49",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.48",
|
||||
"@getpaseo/highlight": "0.1.48",
|
||||
"@getpaseo/server": "0.1.48",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.49",
|
||||
"@getpaseo/highlight": "0.1.49",
|
||||
"@getpaseo/server": "0.1.49",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -36347,11 +36347,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.49",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.48",
|
||||
"@getpaseo/server": "0.1.48",
|
||||
"@getpaseo/relay": "0.1.49",
|
||||
"@getpaseo/server": "0.1.49",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -36392,11 +36392,11 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.49",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.48",
|
||||
"@getpaseo/server": "0.1.48",
|
||||
"@getpaseo/cli": "0.1.49",
|
||||
"@getpaseo/server": "0.1.49",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
@@ -36430,7 +36430,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.49",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
@@ -36631,7 +36631,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.49",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -36657,7 +36657,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.49",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -36673,14 +36673,14 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.49",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.48",
|
||||
"@getpaseo/relay": "0.1.48",
|
||||
"@getpaseo/highlight": "0.1.49",
|
||||
"@getpaseo/relay": "0.1.49",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
@@ -37083,7 +37083,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.49",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.49",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"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",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.49",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
@@ -31,9 +31,9 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.48",
|
||||
"@getpaseo/highlight": "0.1.48",
|
||||
"@getpaseo/server": "0.1.48",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.49",
|
||||
"@getpaseo/highlight": "0.1.49",
|
||||
"@getpaseo/server": "0.1.49",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
@@ -30,6 +30,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
width: "100%",
|
||||
maxWidth: 520,
|
||||
maxHeight: "85%",
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderWidth: 1,
|
||||
@@ -61,11 +63,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
desktopScroll: {
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
desktopContent: {
|
||||
padding: theme.spacing[6],
|
||||
gap: theme.spacing[4],
|
||||
flexGrow: 1,
|
||||
},
|
||||
bottomSheetHandle: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
} from "lucide-react-native";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import {
|
||||
buildFavoriteModelKey,
|
||||
mergeProviderPreferences,
|
||||
@@ -51,7 +51,6 @@ import {
|
||||
getStatusSelectorHint,
|
||||
resolveAgentModelSelection,
|
||||
} from "@/components/agent-status-bar.utils";
|
||||
import { isProviderModelsQueryLoading } from "@/components/agent-status-bar.model-loading";
|
||||
|
||||
type StatusOption = {
|
||||
id: string;
|
||||
@@ -75,6 +74,7 @@ type ControlledAgentStatusBarProps = {
|
||||
modelOptions?: StatusOption[];
|
||||
selectedModelId?: string;
|
||||
onSelectModel?: (modelId: string) => void;
|
||||
onSelectProviderAndModel?: (provider: string, modelId: string) => void;
|
||||
thinkingOptions?: StatusOption[];
|
||||
selectedThinkingOptionId?: string;
|
||||
onSelectThinkingOption?: (thinkingOptionId: string) => void;
|
||||
@@ -203,6 +203,7 @@ function ControlledStatusBar({
|
||||
modelOptions,
|
||||
selectedModelId,
|
||||
onSelectModel,
|
||||
onSelectProviderAndModel,
|
||||
thinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
onSelectThinkingOption,
|
||||
@@ -264,7 +265,7 @@ function ControlledStatusBar({
|
||||
return null;
|
||||
}
|
||||
|
||||
const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0;
|
||||
const modelDisabled = disabled;
|
||||
|
||||
const SEARCH_THRESHOLD = 6;
|
||||
|
||||
@@ -648,10 +649,14 @@ function ControlledStatusBar({
|
||||
selectedModel={selectedModelId ?? ""}
|
||||
canSelectProvider={canSelectProviderInModelMenu}
|
||||
onSelect={(selectedProviderId, modelId) => {
|
||||
if (selectedProviderId !== provider) {
|
||||
onSelectProvider?.(selectedProviderId);
|
||||
if (onSelectProviderAndModel) {
|
||||
onSelectProviderAndModel(selectedProviderId, modelId);
|
||||
} else {
|
||||
if (selectedProviderId !== provider) {
|
||||
onSelectProvider?.(selectedProviderId);
|
||||
}
|
||||
onSelectModel?.(modelId);
|
||||
}
|
||||
onSelectModel?.(modelId);
|
||||
}}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavorite={onToggleFavoriteModel}
|
||||
@@ -667,6 +672,7 @@ function ControlledStatusBar({
|
||||
pointerEvents="none"
|
||||
testID="agent-preferences-model"
|
||||
>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
@@ -692,6 +698,7 @@ function ControlledStatusBar({
|
||||
accessibilityLabel="Select thinking option"
|
||||
testID="agent-preferences-thinking"
|
||||
>
|
||||
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.sheetSelectText}>{displayThinking}</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
@@ -852,6 +859,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
||||
model: currentAgent.model,
|
||||
features: currentAgent.features,
|
||||
thinkingOptionId: currentAgent.thinkingOptionId,
|
||||
lastUsage: currentAgent.lastUsage,
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
@@ -863,52 +871,34 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
||||
);
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
|
||||
const modelsQuery = useQuery({
|
||||
queryKey: ["providerModels", serverId, agent?.provider ?? "__missing_provider__"],
|
||||
enabled: Boolean(client && agent?.provider),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client || !agent) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.models ?? [];
|
||||
},
|
||||
});
|
||||
const {
|
||||
entries: snapshotEntries,
|
||||
isLoading: snapshotIsLoading,
|
||||
isFetching: snapshotIsFetching,
|
||||
} = useProvidersSnapshot(serverId);
|
||||
|
||||
const snapshotModels = useMemo(() => {
|
||||
if (!snapshotEntries || !agent?.provider) {
|
||||
return null;
|
||||
}
|
||||
const entry = snapshotEntries.find((e) => e.provider === agent.provider);
|
||||
return entry?.models ?? null;
|
||||
}, [snapshotEntries, agent?.provider]);
|
||||
|
||||
const models = snapshotModels;
|
||||
|
||||
const agentProviderDefinitions = useMemo(() => {
|
||||
const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider);
|
||||
return definition ? [definition] : [];
|
||||
}, [agent?.provider]);
|
||||
|
||||
const agentProviderModelQuery = useQuery({
|
||||
queryKey: ["providerModels", serverId, agent?.provider, agent?.cwd ?? ""],
|
||||
enabled: Boolean(client && agent?.cwd && agent?.provider),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client || !agent) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.models ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
const agentProviderModels = useMemo(() => {
|
||||
const map = new Map<string, AgentModelDefinition[]>();
|
||||
if (agent?.provider && agentProviderModelQuery.data) {
|
||||
map.set(agent.provider, agentProviderModelQuery.data);
|
||||
if (agent?.provider && snapshotModels) {
|
||||
map.set(agent.provider, snapshotModels);
|
||||
}
|
||||
return map;
|
||||
}, [agent?.provider, agentProviderModelQuery.data]);
|
||||
|
||||
const models = modelsQuery.data ?? null;
|
||||
}, [agent?.provider, snapshotModels]);
|
||||
|
||||
const displayMode =
|
||||
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
|
||||
@@ -1028,7 +1018,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
||||
console.warn("[AgentStatusBar] setAgentFeature failed", error);
|
||||
});
|
||||
}}
|
||||
isModelLoading={isProviderModelsQueryLoading(modelsQuery)}
|
||||
isModelLoading={snapshotIsLoading || snapshotIsFetching}
|
||||
onDropdownClose={onDropdownClose}
|
||||
disabled={!client}
|
||||
/>
|
||||
@@ -1135,6 +1125,7 @@ export function DraftAgentStatusBar({
|
||||
modelOptions={modelOptions}
|
||||
selectedModelId={selectedModel}
|
||||
onSelectModel={(modelId) => onSelectModel(modelId)}
|
||||
onSelectProviderAndModel={onSelectProviderAndModel}
|
||||
isModelLoading={isAllModelsLoading}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavoriteModel={(provider, modelId) => {
|
||||
|
||||
@@ -38,7 +38,10 @@ import {
|
||||
import { PlanCard } from "./plan-card";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
||||
import type {
|
||||
AgentPermissionAction,
|
||||
AgentPermissionResponse,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
@@ -700,6 +703,29 @@ function PermissionRequestCard({
|
||||
const isPlanRequest = request.kind === "plan";
|
||||
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
|
||||
const description = request.description ?? "";
|
||||
const resolvedActions = useMemo((): AgentPermissionAction[] => {
|
||||
if (request.kind === "question") {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(request.actions) && request.actions.length > 0) {
|
||||
return request.actions;
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: "reject",
|
||||
label: "Deny",
|
||||
behavior: "deny",
|
||||
variant: "danger",
|
||||
intent: "dismiss",
|
||||
},
|
||||
{
|
||||
id: "accept",
|
||||
label: isPlanRequest ? "Implement" : "Accept",
|
||||
behavior: "allow",
|
||||
variant: "primary",
|
||||
},
|
||||
];
|
||||
}, [isPlanRequest, request]);
|
||||
|
||||
const planMarkdown = useMemo(() => {
|
||||
if (!request) {
|
||||
@@ -740,11 +766,11 @@ function PermissionRequestCard({
|
||||
isPending: isResponding,
|
||||
} = permissionMutation;
|
||||
|
||||
const [respondingAction, setRespondingAction] = useState<"accept" | "deny" | null>(null);
|
||||
const [respondingActionId, setRespondingActionId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
resetPermissionMutation();
|
||||
setRespondingAction(null);
|
||||
setRespondingActionId(null);
|
||||
}, [permission.request.id, resetPermissionMutation]);
|
||||
const handleResponse = useCallback(
|
||||
(response: AgentPermissionResponse) => {
|
||||
@@ -758,6 +784,24 @@ function PermissionRequestCard({
|
||||
},
|
||||
[permission.agentId, permission.request.id, respondToPermission],
|
||||
);
|
||||
const handleActionPress = useCallback(
|
||||
(action: AgentPermissionAction) => {
|
||||
setRespondingActionId(action.id);
|
||||
if (action.behavior === "allow") {
|
||||
handleResponse({
|
||||
behavior: "allow",
|
||||
selectedActionId: action.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleResponse({
|
||||
behavior: "deny",
|
||||
selectedActionId: action.id,
|
||||
message: "Denied by user",
|
||||
});
|
||||
},
|
||||
[handleResponse],
|
||||
);
|
||||
|
||||
if (request.kind === "question") {
|
||||
return (
|
||||
@@ -784,64 +828,48 @@ function PermissionRequestCard({
|
||||
!isMobile && permissionStyles.optionsContainerDesktop,
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
testID="permission-request-deny"
|
||||
style={({ pressed, hovered = false }) => [
|
||||
permissionStyles.optionButton,
|
||||
{
|
||||
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
pressed ? permissionStyles.optionButtonPressed : null,
|
||||
]}
|
||||
onPress={() => {
|
||||
setRespondingAction("deny");
|
||||
handleResponse({
|
||||
behavior: "deny",
|
||||
message: "Denied by user",
|
||||
});
|
||||
}}
|
||||
disabled={isResponding}
|
||||
>
|
||||
{respondingAction === "deny" ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<View style={permissionStyles.optionContent}>
|
||||
<X size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={[permissionStyles.optionText, { color: theme.colors.foregroundMuted }]}>
|
||||
Deny
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
{resolvedActions.map((action) => {
|
||||
const isDanger = action.variant === "danger" || action.behavior === "deny";
|
||||
const isPrimary = action.variant === "primary";
|
||||
const isRespondingAction = respondingActionId === action.id;
|
||||
const textColor = isPrimary ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
const iconColor = textColor;
|
||||
const Icon = action.behavior === "allow" ? Check : X;
|
||||
const testID =
|
||||
action.behavior === "deny"
|
||||
? "permission-request-deny"
|
||||
: action.id === "accept" || action.id === "implement"
|
||||
? "permission-request-accept"
|
||||
: `permission-request-action-${action.id}`;
|
||||
|
||||
<Pressable
|
||||
testID="permission-request-accept"
|
||||
style={({ pressed, hovered = false }) => [
|
||||
permissionStyles.optionButton,
|
||||
{
|
||||
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
pressed ? permissionStyles.optionButtonPressed : null,
|
||||
]}
|
||||
onPress={() => {
|
||||
setRespondingAction("accept");
|
||||
handleResponse({ behavior: "allow" });
|
||||
}}
|
||||
disabled={isResponding}
|
||||
>
|
||||
{respondingAction === "accept" ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||
) : (
|
||||
<View style={permissionStyles.optionContent}>
|
||||
<Check size={14} color={theme.colors.foreground} />
|
||||
<Text style={[permissionStyles.optionText, { color: theme.colors.foreground }]}>
|
||||
Accept
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
return (
|
||||
<Pressable
|
||||
key={action.id}
|
||||
testID={testID}
|
||||
style={({ pressed, hovered = false }) => [
|
||||
permissionStyles.optionButton,
|
||||
{
|
||||
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
||||
borderColor: isDanger ? theme.colors.borderAccent : theme.colors.borderAccent,
|
||||
},
|
||||
pressed ? permissionStyles.optionButtonPressed : null,
|
||||
]}
|
||||
onPress={() => handleActionPress(action)}
|
||||
disabled={isResponding}
|
||||
>
|
||||
{isRespondingAction ? (
|
||||
<ActivityIndicator size="small" color={textColor} />
|
||||
) : (
|
||||
<View style={permissionStyles.optionContent}>
|
||||
<Icon size={14} color={iconColor} />
|
||||
<Text style={[permissionStyles.optionText, { color: textColor }]}>
|
||||
{action.label}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DraftAgentStatusBar,
|
||||
type DraftAgentStatusBarProps,
|
||||
} from "./agent-status-bar";
|
||||
import { ContextWindowMeter } from "./context-window-meter";
|
||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
@@ -140,6 +141,8 @@ export function Composer({
|
||||
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
||||
return {
|
||||
status: agent?.status ?? null,
|
||||
contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null,
|
||||
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -649,6 +652,25 @@ export function Composer({
|
||||
</View>
|
||||
) : 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 =
|
||||
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
|
||||
<DraftAgentStatusBar {...statusControls} />
|
||||
@@ -729,6 +751,7 @@ export function Composer({
|
||||
disabled={isSubmitLoading}
|
||||
isInputActive={isInputActive}
|
||||
leftContent={leftContent}
|
||||
beforeVoiceContent={beforeVoiceContent}
|
||||
rightContent={rightContent}
|
||||
voiceServerId={serverId}
|
||||
voiceAgentId={agentId}
|
||||
@@ -805,6 +828,12 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
contextWindowMeterSlot: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
realtimeVoiceButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import Svg, { Circle } from "react-native-svg";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
type ContextWindowMeterProps = {
|
||||
maxTokens: number;
|
||||
usedTokens: number;
|
||||
};
|
||||
|
||||
const SVG_SIZE = 20;
|
||||
const CENTER = SVG_SIZE / 2;
|
||||
const RADIUS = 7;
|
||||
const STROKE_WIDTH = 2.25;
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||
|
||||
function isValidMaxTokens(value: number): boolean {
|
||||
return Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
function isValidUsedTokens(value: number): boolean {
|
||||
return Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
function getUsagePercentage(maxTokens: number, usedTokens: number): number | null {
|
||||
if (!isValidMaxTokens(maxTokens) || !isValidUsedTokens(usedTokens)) {
|
||||
return null;
|
||||
}
|
||||
return (usedTokens / maxTokens) * 100;
|
||||
}
|
||||
|
||||
function clampPercentage(value: number): number {
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function formatTokenCount(value: number): string {
|
||||
if (value >= 1_000_000) {
|
||||
return `${Math.round(value / 1_000_000)}m`;
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${Math.round(value / 1_000)}k`;
|
||||
}
|
||||
return Math.round(value).toString();
|
||||
}
|
||||
|
||||
function getMeterColors(
|
||||
percentage: number,
|
||||
theme: ReturnType<typeof useUnistyles>["theme"],
|
||||
): { progress: string; track: string } {
|
||||
const track = theme.colors.surface3;
|
||||
if (percentage > 90) {
|
||||
return { progress: theme.colors.destructive, track };
|
||||
}
|
||||
if (percentage >= 70) {
|
||||
return { progress: theme.colors.palette.amber[500], track };
|
||||
}
|
||||
return { progress: theme.colors.foregroundMuted, track };
|
||||
}
|
||||
|
||||
export function ContextWindowMeter({ maxTokens, usedTokens }: ContextWindowMeterProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const percentage = getUsagePercentage(maxTokens, usedTokens);
|
||||
|
||||
if (percentage === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clampedPercentage = clampPercentage(percentage);
|
||||
const roundedPercentage = Math.round(percentage);
|
||||
const dashOffset = CIRCUMFERENCE - (clampedPercentage / 100) * CIRCUMFERENCE;
|
||||
const colors = getMeterColors(clampedPercentage, theme);
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
style={styles.container}
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={`Context window ${roundedPercentage}% used`}
|
||||
>
|
||||
<Svg
|
||||
width={SVG_SIZE}
|
||||
height={SVG_SIZE}
|
||||
viewBox={`0 0 ${SVG_SIZE} ${SVG_SIZE}`}
|
||||
style={styles.svg}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
<Circle
|
||||
cx={CENTER}
|
||||
cy={CENTER}
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
stroke={colors.track}
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
/>
|
||||
<Circle
|
||||
cx={CENTER}
|
||||
cy={CENTER}
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
stroke={colors.progress}
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={CIRCUMFERENCE}
|
||||
strokeDashoffset={dashOffset}
|
||||
/>
|
||||
</Svg>
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipContent}>
|
||||
<Text style={styles.tooltipTitle}>Context window</Text>
|
||||
<Text style={styles.tooltipText}>{`${roundedPercentage}% used`}</Text>
|
||||
<Text
|
||||
style={styles.tooltipDetail}
|
||||
>{`${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens`}</Text>
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
svg: {
|
||||
transform: [{ rotate: "-90deg" }],
|
||||
},
|
||||
tooltipContent: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
tooltipTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.4,
|
||||
},
|
||||
tooltipDetail: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: theme.fontSize.xs * 1.4,
|
||||
},
|
||||
}));
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -118,7 +118,6 @@ export function FileExplorerPane({
|
||||
);
|
||||
|
||||
const {
|
||||
workspaceStateKey: actionsWorkspaceStateKey,
|
||||
requestDirectoryListing,
|
||||
requestFileDownloadToken,
|
||||
selectExplorerEntry,
|
||||
@@ -129,6 +128,16 @@ export function FileExplorerPane({
|
||||
});
|
||||
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
||||
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
||||
const expandedPathsArray = usePanelStore((state) =>
|
||||
workspaceStateKey ? state.expandedPathsByWorkspace[workspaceStateKey] : undefined,
|
||||
);
|
||||
const setExpandedPathsForWorkspace = usePanelStore(
|
||||
(state) => state.setExpandedPathsForWorkspace,
|
||||
);
|
||||
const expandedPaths = useMemo(
|
||||
() => new Set(expandedPathsArray && expandedPathsArray.length > 0 ? expandedPathsArray : ["."]),
|
||||
[expandedPathsArray],
|
||||
);
|
||||
|
||||
const directories = explorerState?.directories ?? new Map();
|
||||
const pendingRequest = explorerState?.pendingRequest ?? null;
|
||||
@@ -144,7 +153,6 @@ export function FileExplorerPane({
|
||||
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path],
|
||||
);
|
||||
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
|
||||
const treeListRef = useRef<FlatList<TreeRow>>(null);
|
||||
const scrollbar = useWebScrollViewScrollbar(treeListRef, {
|
||||
enabled: showDesktopWebScrollbar,
|
||||
@@ -154,8 +162,7 @@ export function FileExplorerPane({
|
||||
|
||||
useEffect(() => {
|
||||
hasInitializedRef.current = false;
|
||||
setExpandedPaths(new Set(["."]));
|
||||
}, [actionsWorkspaceStateKey]);
|
||||
}, [workspaceStateKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasWorkspaceScope) {
|
||||
@@ -169,23 +176,35 @@ export function FileExplorerPane({
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}, [hasWorkspaceScope, requestDirectoryListing]);
|
||||
const persistedPaths = usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
|
||||
if (persistedPaths) {
|
||||
for (const path of persistedPaths) {
|
||||
if (path !== ".") {
|
||||
void requestDirectoryListing(path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
|
||||
|
||||
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
|
||||
useEffect(() => {
|
||||
if (!selectedEntryPath || !hasWorkspaceScope) {
|
||||
if (!selectedEntryPath || !workspaceStateKey) {
|
||||
return;
|
||||
}
|
||||
const parentDir = getParentDirectory(selectedEntryPath);
|
||||
const ancestors = getAncestorDirectories(parentDir);
|
||||
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
ancestors.forEach((path) => next.add(path));
|
||||
return next;
|
||||
});
|
||||
|
||||
ancestors.forEach((path) => {
|
||||
const newPaths = ancestors.filter((path) => !expandedPaths.has(path));
|
||||
if (newPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
setExpandedPathsForWorkspace(
|
||||
workspaceStateKey,
|
||||
[...Array.from(expandedPaths), ...newPaths],
|
||||
);
|
||||
newPaths.forEach((path) => {
|
||||
if (!directories.has(path)) {
|
||||
void requestDirectoryListing(path, {
|
||||
recordHistory: false,
|
||||
@@ -193,34 +212,46 @@ export function FileExplorerPane({
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [directories, hasWorkspaceScope, requestDirectoryListing, selectedEntryPath]);
|
||||
}, [
|
||||
directories,
|
||||
workspaceStateKey,
|
||||
expandedPaths,
|
||||
requestDirectoryListing,
|
||||
selectedEntryPath,
|
||||
setExpandedPathsForWorkspace,
|
||||
]);
|
||||
|
||||
const handleToggleDirectory = useCallback(
|
||||
(entry: ExplorerEntry) => {
|
||||
if (!hasWorkspaceScope) {
|
||||
if (!workspaceStateKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isExpanded = expandedPaths.has(entry.path);
|
||||
const nextExpanded = !isExpanded;
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (isExpanded) {
|
||||
next.delete(entry.path);
|
||||
} else {
|
||||
next.add(entry.path);
|
||||
if (isExpanded) {
|
||||
setExpandedPathsForWorkspace(
|
||||
workspaceStateKey,
|
||||
Array.from(expandedPaths).filter((path) => path !== entry.path),
|
||||
);
|
||||
} else {
|
||||
setExpandedPathsForWorkspace(
|
||||
workspaceStateKey,
|
||||
[...Array.from(expandedPaths), entry.path],
|
||||
);
|
||||
if (!directories.has(entry.path)) {
|
||||
void requestDirectoryListing(entry.path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
if (nextExpanded && !directories.has(entry.path)) {
|
||||
void requestDirectoryListing(entry.path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
[directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing],
|
||||
[
|
||||
workspaceStateKey,
|
||||
expandedPaths,
|
||||
directories,
|
||||
requestDirectoryListing,
|
||||
setExpandedPathsForWorkspace,
|
||||
],
|
||||
);
|
||||
|
||||
const handleOpenFile = useCallback(
|
||||
|
||||
@@ -18,17 +18,21 @@ import {
|
||||
type LayoutChangeEvent,
|
||||
type NativeSyntheticEvent,
|
||||
type NativeScrollEvent,
|
||||
TextStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
AlignJustify,
|
||||
Archive,
|
||||
ChevronDown,
|
||||
Columns2,
|
||||
GitBranch,
|
||||
GitCommitHorizontal,
|
||||
GitMerge,
|
||||
ListChevronsDownUp,
|
||||
ListChevronsUpDown,
|
||||
Pilcrow,
|
||||
RefreshCcw,
|
||||
Upload,
|
||||
WrapText,
|
||||
@@ -42,6 +46,7 @@ import {
|
||||
} from "@/hooks/use-checkout-diff-query";
|
||||
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
|
||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||
import { DiffScroll } from "./diff-scroll";
|
||||
import {
|
||||
darkHighlightColors,
|
||||
@@ -51,6 +56,7 @@ import {
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
|
||||
import { buildSplitDiffRows, type SplitDiffDisplayLine } from "@/utils/diff-layout";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -66,6 +72,8 @@ import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
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";
|
||||
|
||||
@@ -77,13 +85,27 @@ type HighlightStyle = NonNullable<HighlightToken["style"]>;
|
||||
|
||||
interface HighlightedTextProps {
|
||||
tokens: HighlightToken[];
|
||||
baseStyle: HighlightStyle | null;
|
||||
lineType: "add" | "remove" | "context" | "header";
|
||||
wrapLines?: boolean;
|
||||
}
|
||||
|
||||
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 isDark = theme.colorScheme === "dark";
|
||||
const lineHeight = theme.lineHeight.diff;
|
||||
|
||||
const getTokenColor = (style: HighlightStyle | null): string => {
|
||||
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
||||
@@ -93,9 +115,14 @@ function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Text style={styles.diffLineText}>
|
||||
<Text
|
||||
style={[
|
||||
styles.diffLineText,
|
||||
{ lineHeight, ...getWrappedTextStyle(wrapLines) },
|
||||
]}
|
||||
>
|
||||
{tokens.map((token, index) => (
|
||||
<Text key={index} style={{ color: getTokenColor(token.style) }}>
|
||||
<Text key={index} style={{ color: getTokenColor(token.style), lineHeight }}>
|
||||
{token.text}
|
||||
</Text>
|
||||
))}
|
||||
@@ -115,10 +142,12 @@ function DiffLineView({
|
||||
line,
|
||||
lineNumber,
|
||||
gutterWidth,
|
||||
wrapLines,
|
||||
}: {
|
||||
line: DiffLine;
|
||||
lineNumber: number | null;
|
||||
gutterWidth: number;
|
||||
wrapLines: boolean;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
@@ -142,11 +171,12 @@ function DiffLineView({
|
||||
</Text>
|
||||
</View>
|
||||
{line.tokens && line.type !== "header" ? (
|
||||
<HighlightedText tokens={line.tokens} baseStyle={null} lineType={line.type} />
|
||||
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.diffLineText,
|
||||
getWrappedTextStyle(wrapLines),
|
||||
line.type === "add" && styles.addLineText,
|
||||
line.type === "remove" && styles.removeLineText,
|
||||
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({
|
||||
file,
|
||||
isExpanded,
|
||||
@@ -245,21 +328,25 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
|
||||
function DiffFileBody({
|
||||
file,
|
||||
layout,
|
||||
wrapLines,
|
||||
onBodyHeightChange,
|
||||
testID,
|
||||
}: {
|
||||
file: ParsedDiffFile;
|
||||
layout: "unified" | "split";
|
||||
wrapLines: boolean;
|
||||
onBodyHeightChange?: (path: string, height: number) => void;
|
||||
testID?: string;
|
||||
}) {
|
||||
const [scrollViewWidth, setScrollViewWidth] = useState(0);
|
||||
const [bodyWidth, setBodyWidth] = useState(0);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
|
||||
onLayout={(event) => {
|
||||
setBodyWidth(event.nativeEvent.layout.width);
|
||||
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
|
||||
}}
|
||||
testID={testID}
|
||||
@@ -275,50 +362,80 @@ function DiffFileBody({
|
||||
);
|
||||
}
|
||||
|
||||
const linesContent = (() => {
|
||||
let maxLineNo = 0;
|
||||
for (const hunk of file.hunks) {
|
||||
maxLineNo = Math.max(
|
||||
maxLineNo,
|
||||
hunk.oldStart + hunk.oldCount,
|
||||
hunk.newStart + hunk.newCount,
|
||||
);
|
||||
}
|
||||
const gutterWidth = lineNumberGutterWidth(maxLineNo);
|
||||
return file.hunks.map((hunk, hunkIndex) => {
|
||||
let oldLineNo = hunk.oldStart;
|
||||
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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
});
|
||||
})();
|
||||
let maxLineNo = 0;
|
||||
for (const hunk of file.hunks) {
|
||||
maxLineNo = Math.max(maxLineNo, hunk.oldStart + hunk.oldCount, hunk.newStart + hunk.newCount);
|
||||
}
|
||||
const gutterWidth = lineNumberGutterWidth(maxLineNo);
|
||||
|
||||
const linesContent =
|
||||
layout === "split"
|
||||
? buildSplitDiffRows(file).map((row, rowIndex) => {
|
||||
if (row.kind === "header") {
|
||||
return (
|
||||
<View key={`header-${rowIndex}`} style={styles.splitHeaderRow}>
|
||||
<Text style={[styles.diffLineText, styles.headerLineText]}>{row.content}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View key={`pair-${rowIndex}`} style={styles.splitRow}>
|
||||
<SplitDiffCell line={row.left} gutterWidth={gutterWidth} wrapLines={wrapLines} />
|
||||
<SplitDiffCell
|
||||
line={row.right}
|
||||
gutterWidth={gutterWidth}
|
||||
wrapLines={wrapLines}
|
||||
showDivider
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
})
|
||||
: file.hunks.map((hunk, hunkIndex) => {
|
||||
let oldLineNo = hunk.oldStart;
|
||||
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) {
|
||||
return (
|
||||
<View style={styles.diffContent}>
|
||||
<View style={styles.linesContainer}>{linesContent}</View>
|
||||
</View>
|
||||
);
|
||||
return <View style={styles.diffContent}>{contentContainer}</View>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -328,11 +445,7 @@ function DiffFileBody({
|
||||
style={styles.diffContent}
|
||||
contentContainerStyle={styles.diffContentInner}
|
||||
>
|
||||
<View
|
||||
style={[styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }]}
|
||||
>
|
||||
{linesContent}
|
||||
</View>
|
||||
{contentContainer}
|
||||
</DiffScroll>
|
||||
);
|
||||
})()}
|
||||
@@ -355,26 +468,31 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||
const canUseSplitLayout = Platform.OS === "web" && !isMobile;
|
||||
const router = useRouter();
|
||||
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
||||
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
||||
const [wrapLines, setWrapLines] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
AsyncStorage.getItem("diff-wrap-lines").then((value) => {
|
||||
if (value === "true") setWrapLines(true);
|
||||
});
|
||||
}, []);
|
||||
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
|
||||
useChangesPreferences();
|
||||
const wrapLines = changesPreferences.wrapLines;
|
||||
const effectiveLayout = canUseSplitLayout ? changesPreferences.layout : "unified";
|
||||
|
||||
const handleToggleWrapLines = useCallback(() => {
|
||||
setWrapLines((prev) => {
|
||||
const next = !prev;
|
||||
AsyncStorage.setItem("diff-wrap-lines", String(next));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
void updateChangesPreferences({ wrapLines: !wrapLines });
|
||||
}, [updateChangesPreferences, wrapLines]);
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(nextLayout: "unified" | "split") => {
|
||||
void updateChangesPreferences({ layout: nextLayout });
|
||||
},
|
||||
[updateChangesPreferences],
|
||||
);
|
||||
|
||||
const handleToggleHideWhitespace = useCallback(() => {
|
||||
void updateChangesPreferences({ hideWhitespace: !changesPreferences.hideWhitespace });
|
||||
}, [changesPreferences.hideWhitespace, updateChangesPreferences]);
|
||||
|
||||
const {
|
||||
status,
|
||||
@@ -410,6 +528,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
cwd,
|
||||
mode: diffMode,
|
||||
baseRef,
|
||||
ignoreWhitespace: changesPreferences.hideWhitespace,
|
||||
enabled: isGit,
|
||||
});
|
||||
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
|
||||
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 scrollbar = useWebScrollViewScrollbar(diffListRef, {
|
||||
enabled: showDesktopWebScrollbar,
|
||||
@@ -484,7 +621,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
const stickyIndices: number[] = [];
|
||||
for (let i = 0; i < files.length; 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 });
|
||||
if (isExpanded) {
|
||||
stickyIndices.push(items.length - 1);
|
||||
@@ -494,7 +631,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
}
|
||||
}
|
||||
return { flatItems: items, stickyHeaderIndices: stickyIndices };
|
||||
}, [files, expandedByPath]);
|
||||
}, [expandedPaths, files]);
|
||||
|
||||
const handleHeaderHeightChange = useCallback((path: string, height: number) => {
|
||||
if (!Number.isFinite(height) || height <= 0) {
|
||||
@@ -540,18 +677,21 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
break;
|
||||
}
|
||||
offset += headerHeightByPathRef.current[file.path] ?? defaultHeaderHeight;
|
||||
if (expandedByPath[file.path]) {
|
||||
if (expandedPaths.has(file.path)) {
|
||||
offset += bodyHeightByPathRef.current[file.path] ?? 0;
|
||||
}
|
||||
}
|
||||
return Math.max(0, offset);
|
||||
},
|
||||
[expandedByPath, files],
|
||||
[expandedPaths, files],
|
||||
);
|
||||
|
||||
const handleToggleExpanded = useCallback(
|
||||
(path: string) => {
|
||||
const isCurrentlyExpanded = expandedByPath[path] ?? false;
|
||||
if (!workspaceStateKey) {
|
||||
return;
|
||||
}
|
||||
const isCurrentlyExpanded = expandedPaths.has(path);
|
||||
const nextExpanded = !isCurrentlyExpanded;
|
||||
const targetOffset = isCurrentlyExpanded ? computeHeaderOffset(path) : null;
|
||||
const headerHeight = headerHeightByPathRef.current[path] ?? defaultHeaderHeightRef.current;
|
||||
@@ -573,32 +713,32 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
});
|
||||
}
|
||||
|
||||
setExpandedByPath((prev) => ({
|
||||
...prev,
|
||||
// Use a deterministic target value (instead of toggling from prev) so duplicate
|
||||
// onPress events from sticky headers on Android can't flip back immediately.
|
||||
[path]: nextExpanded,
|
||||
}));
|
||||
const nextPaths = nextExpanded
|
||||
? [...expandedPaths, path]
|
||||
: Array.from(expandedPaths).filter((expandedPath) => expandedPath !== path);
|
||||
setDiffExpandedPathsForWorkspace(workspaceStateKey, nextPaths);
|
||||
},
|
||||
[computeHeaderOffset, expandedByPath],
|
||||
[computeHeaderOffset, expandedPaths, setDiffExpandedPathsForWorkspace, workspaceStateKey],
|
||||
);
|
||||
|
||||
const allExpanded = useMemo(() => {
|
||||
if (files.length === 0) return false;
|
||||
return files.every((file) => expandedByPath[file.path]);
|
||||
}, [files, expandedByPath]);
|
||||
return files.every((file) => expandedPaths.has(file.path));
|
||||
}, [expandedPaths, files]);
|
||||
|
||||
const handleToggleExpandAll = useCallback(() => {
|
||||
if (allExpanded) {
|
||||
setExpandedByPath({});
|
||||
} else {
|
||||
const newExpanded: Record<string, boolean> = {};
|
||||
for (const file of files) {
|
||||
newExpanded[file.path] = true;
|
||||
}
|
||||
setExpandedByPath(newExpanded);
|
||||
if (!workspaceStateKey) {
|
||||
return;
|
||||
}
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
@@ -726,13 +866,20 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
return (
|
||||
<DiffFileBody
|
||||
file={item.file}
|
||||
layout={effectiveLayout}
|
||||
wrapLines={wrapLines}
|
||||
onBodyHeightChange={handleBodyHeightChange}
|
||||
testID={`diff-file-${item.fileIndex}-body`}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded, wrapLines],
|
||||
[
|
||||
effectiveLayout,
|
||||
handleBodyHeightChange,
|
||||
handleHeaderHeightChange,
|
||||
handleToggleExpanded,
|
||||
wrapLines,
|
||||
],
|
||||
);
|
||||
|
||||
const flatKeyExtractor = useCallback(
|
||||
@@ -821,7 +968,11 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
bodyContent = (
|
||||
<View style={styles.emptyContainer}>
|
||||
<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>
|
||||
</View>
|
||||
);
|
||||
@@ -833,7 +984,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
renderItem={renderFlatItem}
|
||||
keyExtractor={flatKeyExtractor}
|
||||
stickyHeaderIndices={stickyHeaderIndices}
|
||||
extraData={expandedByPath}
|
||||
extraData={{ expandedPathsArray, effectiveLayout, wrapLines }}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
testID="git-diff-scroll"
|
||||
@@ -1019,56 +1170,149 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{files.length > 0 ? (
|
||||
<View style={styles.diffStatusButtons}>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.expandAllButton,
|
||||
(hovered || pressed) && styles.diffStatusRowHovered,
|
||||
]}
|
||||
onPress={handleToggleWrapLines}
|
||||
>
|
||||
<WrapText size={isMobile ? 18 : 14} color={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}
|
||||
<View style={styles.diffStatusButtons}>
|
||||
{canUseSplitLayout ? (
|
||||
<View style={styles.toggleButtonGroup}>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Unified diff"
|
||||
testID="changes-layout-unified"
|
||||
onPress={() => handleLayoutChange("unified")}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.toggleButton,
|
||||
styles.toggleButtonGroupStart,
|
||||
changesPreferences.layout === "unified" && styles.toggleButtonSelected,
|
||||
(hovered || pressed) && styles.diffStatusRowHovered,
|
||||
]}
|
||||
>
|
||||
<AlignJustify
|
||||
size={14}
|
||||
color={
|
||||
changesPreferences.layout === "unified"
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ListChevronsUpDown
|
||||
size={isMobile ? 18 : 14}
|
||||
color={theme.colors.foregroundMuted}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<Text style={styles.tooltipText}>
|
||||
{allExpanded ? "Collapse all files" : "Expand all files"}
|
||||
</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<Text style={styles.tooltipText}>Side-by-side diff</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</View>
|
||||
) : null}
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<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>
|
||||
) : null}
|
||||
@@ -1160,11 +1404,42 @@ const styles = StyleSheet.create((theme) => ({
|
||||
diffStatusButtons: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: {
|
||||
xs: theme.spacing[1],
|
||||
sm: theme.spacing[1],
|
||||
md: 0,
|
||||
gap: theme.spacing[1],
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
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: {
|
||||
flexDirection: "row",
|
||||
@@ -1343,6 +1618,36 @@ const styles = StyleSheet.create((theme) => ({
|
||||
linesContainer: {
|
||||
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: {
|
||||
flexDirection: "row",
|
||||
alignItems: "stretch",
|
||||
@@ -1352,13 +1657,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRightColor: theme.colors.border,
|
||||
marginRight: theme.spacing[2],
|
||||
alignSelf: "stretch",
|
||||
justifyContent: "center",
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
lineNumberText: {
|
||||
textAlign: "right",
|
||||
paddingRight: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: theme.lineHeight.diff,
|
||||
fontFamily: Fonts.mono,
|
||||
color: theme.colors.foregroundMuted,
|
||||
userSelect: "none",
|
||||
@@ -1372,8 +1677,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
diffLineText: {
|
||||
flex: 1,
|
||||
paddingRight: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: theme.lineHeight.diff,
|
||||
fontFamily: Fonts.mono,
|
||||
color: theme.colors.foreground,
|
||||
userSelect: "text",
|
||||
@@ -1402,6 +1707,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
contextLineText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
emptySplitCellText: {
|
||||
color: "transparent",
|
||||
},
|
||||
statusMessageContainer: {
|
||||
borderTopWidth: theme.borderWidth[1],
|
||||
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;
|
||||
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
|
||||
leftContent?: React.ReactNode;
|
||||
/** Content to render on the right side before the voice button (e.g., context window meter) */
|
||||
beforeVoiceContent?: React.ReactNode;
|
||||
/** Content to render on the right side after voice button (e.g., realtime button, cancel button) */
|
||||
rightContent?: React.ReactNode;
|
||||
voiceServerId?: string;
|
||||
@@ -207,6 +209,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
disabled = false,
|
||||
isInputActive = true,
|
||||
leftContent,
|
||||
beforeVoiceContent,
|
||||
rightContent,
|
||||
voiceServerId,
|
||||
voiceAgentId,
|
||||
@@ -1024,6 +1027,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
|
||||
{/* Right: voice button, contextual button (realtime/send/cancel) */}
|
||||
<View style={styles.rightButtonGroup}>
|
||||
{beforeVoiceContent}
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleVoicePress}
|
||||
|
||||
@@ -78,9 +78,17 @@ function createPlanMarkdownRules() {
|
||||
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
|
||||
|
||||
return (
|
||||
<View key={node.key} style={[styles.list_item, { flexShrink: 0 }]}>
|
||||
<View key={node.key} style={styles.list_item}>
|
||||
<Text style={iconStyle}>{marker}</Text>
|
||||
<Text style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</Text>
|
||||
<View style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</View>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
paragraph: (node: any, children: ReactNode[], parent: any, styles: any) => {
|
||||
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
|
||||
return (
|
||||
<View key={node.key} style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -203,12 +203,12 @@ export function ComboboxItem({
|
||||
]}
|
||||
>
|
||||
{leadingContent}
|
||||
<View style={styles.comboboxItemContent}>
|
||||
<View style={[styles.comboboxItemContent, description && styles.comboboxItemContentInline]}>
|
||||
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
|
||||
{label}
|
||||
</Text>
|
||||
{description ? (
|
||||
<Text numberOfLines={2} style={styles.comboboxItemDescription}>
|
||||
<Text numberOfLines={1} style={styles.comboboxItemDescription}>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -838,6 +838,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
},
|
||||
comboboxItemContentInline: {
|
||||
flexDirection: "row",
|
||||
alignItems: "baseline",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
comboboxItemLeadingSlot: {
|
||||
width: 16,
|
||||
alignItems: "center",
|
||||
@@ -848,9 +853,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
comboboxItemDescription: {
|
||||
marginTop: 2,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 1,
|
||||
},
|
||||
emptyText: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
|
||||
@@ -44,6 +44,7 @@ type TooltipContextValue = {
|
||||
setOpen: (open: boolean) => void;
|
||||
triggerRef: React.RefObject<View | null>;
|
||||
enabled: boolean;
|
||||
openOnPress: boolean;
|
||||
delayDuration: number;
|
||||
};
|
||||
|
||||
@@ -107,6 +108,18 @@ function measureElement(element: View): Promise<Rect> {
|
||||
});
|
||||
}
|
||||
|
||||
function isMobileTooltipEnvironment(): boolean {
|
||||
if (Platform.OS !== "web") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof navigator === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
|
||||
}
|
||||
|
||||
function computePosition({
|
||||
triggerRect,
|
||||
contentSize,
|
||||
@@ -214,12 +227,8 @@ export function Tooltip({
|
||||
onOpenChange,
|
||||
});
|
||||
|
||||
const isWeb = Platform.OS === "web";
|
||||
const isMobileWeb =
|
||||
isWeb &&
|
||||
typeof navigator !== "undefined" &&
|
||||
/Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
|
||||
const enabled = isWeb ? (isMobileWeb ? enabledOnMobile : enabledOnDesktop) : enabledOnMobile;
|
||||
const isMobile = isMobileTooltipEnvironment();
|
||||
const enabled = isMobile ? enabledOnMobile : enabledOnDesktop;
|
||||
|
||||
const value = useMemo<TooltipContextValue>(
|
||||
() => ({
|
||||
@@ -227,9 +236,10 @@ export function Tooltip({
|
||||
setOpen: setIsOpen,
|
||||
triggerRef,
|
||||
enabled,
|
||||
openOnPress: isMobile,
|
||||
delayDuration,
|
||||
}),
|
||||
[isOpen, setIsOpen, enabled, delayDuration],
|
||||
[isOpen, setIsOpen, enabled, isMobile, delayDuration],
|
||||
);
|
||||
|
||||
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
|
||||
@@ -322,9 +332,17 @@ export function TooltipTrigger({
|
||||
const handlePress = useCallback(
|
||||
(e: any) => {
|
||||
onPress?.(e);
|
||||
if (!ctx.enabled || disabled) {
|
||||
return;
|
||||
}
|
||||
if (ctx.openOnPress) {
|
||||
clearOpenTimer();
|
||||
ctx.setOpen(true);
|
||||
return;
|
||||
}
|
||||
close();
|
||||
},
|
||||
[close, onPress],
|
||||
[clearOpenTimer, close, ctx, disabled, onPress],
|
||||
);
|
||||
|
||||
const triggerProps = {
|
||||
@@ -491,7 +509,7 @@ export function TooltipContent({
|
||||
statusBarTranslucent={Platform.OS === "android"}
|
||||
onRequestClose={() => ctx.setOpen(false)}
|
||||
>
|
||||
<View pointerEvents="box-none" style={styles.overlay}>
|
||||
<Pressable style={styles.overlay} onPress={() => ctx.setOpen(false)}>
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
entering={FadeIn.duration(80)}
|
||||
@@ -512,7 +530,7 @@ export function TooltipContent({
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useClientActivity } from "@/hooks/use-client-activity";
|
||||
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
|
||||
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
|
||||
import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { generateMessageId, type StreamItem } from "@/types/stream";
|
||||
import {
|
||||
processTimelineResponse,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
type Agent,
|
||||
type SessionState,
|
||||
type WorkspaceDescriptor,
|
||||
mergeWorkspaceSnapshotWithExisting,
|
||||
normalizeWorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
@@ -69,6 +71,22 @@ export type {
|
||||
const HISTORY_STALE_AFTER_MS = 60_000;
|
||||
const AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS = 300;
|
||||
|
||||
function hasAgentUsageChanged(
|
||||
incomingUsage: Agent["lastUsage"] | undefined,
|
||||
currentUsage: Agent["lastUsage"] | undefined,
|
||||
): boolean {
|
||||
const keys: Array<keyof NonNullable<Agent["lastUsage"]>> = [
|
||||
"inputTokens",
|
||||
"outputTokens",
|
||||
"cachedInputTokens",
|
||||
"totalCostUsd",
|
||||
"contextWindowMaxTokens",
|
||||
"contextWindowUsedTokens",
|
||||
];
|
||||
|
||||
return keys.some((key) => incomingUsage?.[key] !== currentUsage?.[key]);
|
||||
}
|
||||
|
||||
type AudioOutputPayload = Extract<SessionOutboundMessage, { type: "audio_output" }>["payload"];
|
||||
|
||||
interface BufferedAudioChunk {
|
||||
@@ -318,6 +336,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
|
||||
const workspaces = new Map<string, WorkspaceDescriptor>();
|
||||
const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||
let cursor: string | null = null;
|
||||
let includeSubscribe = options?.subscribe ?? false;
|
||||
|
||||
@@ -333,7 +352,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
for (const entry of payload.entries) {
|
||||
const workspace = normalizeWorkspaceDescriptor(entry);
|
||||
workspaces.set(workspace.id, workspace);
|
||||
workspaces.set(
|
||||
workspace.id,
|
||||
mergeWorkspaceSnapshotWithExisting({
|
||||
incoming: workspace,
|
||||
existing: existingWorkspaces?.get(workspace.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
||||
@@ -358,6 +383,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
setAgents(serverId, (prev) => {
|
||||
const current = prev.get(agent.id);
|
||||
if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) {
|
||||
const hasUsageUpdate = hasAgentUsageChanged(agent.lastUsage, current.lastUsage);
|
||||
if (hasUsageUpdate) {
|
||||
const next = new Map(prev);
|
||||
next.set(agent.id, {
|
||||
...current,
|
||||
lastUsage: agent.lastUsage,
|
||||
});
|
||||
return next;
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
@@ -611,6 +645,34 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
updateSessionClient(serverId, client);
|
||||
}, [serverId, client, updateSessionClient]);
|
||||
|
||||
useEffect(() => {
|
||||
const serverInfo = client.getLastServerInfoMessage();
|
||||
if (!serverInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSessionServerInfo(serverId, {
|
||||
serverId: serverInfo.serverId,
|
||||
hostname: serverInfo.hostname,
|
||||
version: serverInfo.version,
|
||||
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
|
||||
...(serverInfo.features ? { features: serverInfo.features } : {}),
|
||||
});
|
||||
}, [client, serverId, updateSessionServerInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serverInfo = client.getLastServerInfoMessage();
|
||||
if (!serverInfo?.features?.providersSnapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
prefetchProvidersSnapshot(serverId, client);
|
||||
}, [client, isConnected, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!voiceRuntime) {
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
type AgentProviderDefinition,
|
||||
@@ -11,8 +10,7 @@ import type {
|
||||
ProviderSnapshotEntry,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useSessionForServer } from "./use-session-directory";
|
||||
import { useProvidersSnapshot } from "./use-providers-snapshot";
|
||||
import {
|
||||
useFormPreferences,
|
||||
mergeProviderPreferences,
|
||||
@@ -320,10 +318,6 @@ function combineInitialValues(
|
||||
return initialValues;
|
||||
}
|
||||
|
||||
function providersSnapshotQueryKey(serverId: string | null, cwd: string | undefined) {
|
||||
return ["providersSnapshot", serverId, cwd ?? ""] as const;
|
||||
}
|
||||
|
||||
export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult {
|
||||
const {
|
||||
initialServerId = null,
|
||||
@@ -341,7 +335,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
} = useFormPreferences();
|
||||
|
||||
const daemons = useHosts();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Build a set of valid server IDs for preference validation
|
||||
const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]);
|
||||
@@ -374,95 +367,19 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
// Session state for provider model listing
|
||||
const client = useHostRuntimeClient(formState.serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(formState.serverId ?? "");
|
||||
const supportsProvidersSnapshot = useSessionForServer(
|
||||
formState.serverId,
|
||||
(session) => session?.serverInfo?.features?.providersSnapshot === true,
|
||||
);
|
||||
const {
|
||||
entries: snapshotEntries,
|
||||
isLoading: snapshotIsLoading,
|
||||
isFetching: snapshotIsFetching,
|
||||
error: snapshotError,
|
||||
refresh: refreshSnapshot,
|
||||
} = useProvidersSnapshot(formState.serverId);
|
||||
|
||||
const [debouncedCwd, setDebouncedCwd] = useState<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
const trimmed = formState.workingDir.trim();
|
||||
const next = trimmed.length > 0 ? trimmed : undefined;
|
||||
const timer = setTimeout(() => setDebouncedCwd(next), 180);
|
||||
return () => clearTimeout(timer);
|
||||
}, [formState.workingDir]);
|
||||
|
||||
const snapshotQueryKey = useMemo(
|
||||
() => providersSnapshotQueryKey(formState.serverId, debouncedCwd),
|
||||
[debouncedCwd, formState.serverId],
|
||||
);
|
||||
|
||||
const providersSnapshotQuery = useQuery({
|
||||
queryKey: snapshotQueryKey,
|
||||
enabled: Boolean(
|
||||
supportsProvidersSnapshot &&
|
||||
isVisible &&
|
||||
isTargetDaemonReady &&
|
||||
formState.serverId &&
|
||||
client &&
|
||||
isConnected,
|
||||
),
|
||||
staleTime: 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return client.getProvidersSnapshot({ cwd: debouncedCwd });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!supportsProvidersSnapshot ||
|
||||
!client ||
|
||||
!isConnected ||
|
||||
!isVisible ||
|
||||
!isTargetDaemonReady ||
|
||||
!formState.serverId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return client.on("providers_snapshot_update", (message) => {
|
||||
if (message.type !== "providers_snapshot_update") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.cwd !== undefined && message.payload.cwd !== debouncedCwd) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData(snapshotQueryKey, {
|
||||
entries: message.payload.entries,
|
||||
generatedAt: message.payload.generatedAt,
|
||||
requestId: "providers_snapshot_update",
|
||||
});
|
||||
});
|
||||
}, [
|
||||
client,
|
||||
debouncedCwd,
|
||||
formState.serverId,
|
||||
isConnected,
|
||||
isTargetDaemonReady,
|
||||
isVisible,
|
||||
queryClient,
|
||||
snapshotQueryKey,
|
||||
supportsProvidersSnapshot,
|
||||
]);
|
||||
|
||||
const snapshotEntries = providersSnapshotQuery.data?.entries ?? undefined;
|
||||
const allProviderEntries = useMemo(
|
||||
() => (supportsProvidersSnapshot ? snapshotEntries ?? [] : undefined),
|
||||
[snapshotEntries, supportsProvidersSnapshot],
|
||||
);
|
||||
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
|
||||
const snapshotProviderDefinitions = useMemo(() => {
|
||||
if (!supportsProvidersSnapshot) {
|
||||
return [];
|
||||
}
|
||||
const snapshotProviders = new Set((snapshotEntries ?? []).map((entry) => entry.provider));
|
||||
return allProviderDefinitions.filter((definition) => snapshotProviders.has(definition.id));
|
||||
}, [snapshotEntries, supportsProvidersSnapshot]);
|
||||
}, [snapshotEntries]);
|
||||
const snapshotProviderDefinitionMap = useMemo(
|
||||
() =>
|
||||
new Map<AgentProvider, AgentProviderDefinition>(
|
||||
@@ -498,157 +415,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
snapshotSelectedEntry?.modes ??
|
||||
snapshotProviderDefinitionMap.get(formState.provider)?.modes ??
|
||||
[];
|
||||
|
||||
// COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots
|
||||
const availableProvidersQuery = useQuery({
|
||||
queryKey: ["availableProviders", formState.serverId],
|
||||
enabled: Boolean(
|
||||
!supportsProvidersSnapshot &&
|
||||
isVisible &&
|
||||
isTargetDaemonReady &&
|
||||
formState.serverId &&
|
||||
client &&
|
||||
isConnected,
|
||||
),
|
||||
staleTime: 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.listAvailableProviders();
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.providers.filter((entry) => entry.available).map((entry) => entry.provider);
|
||||
},
|
||||
});
|
||||
const legacyProviderDefinitions = useMemo(() => {
|
||||
const availableProviders = availableProvidersQuery.data;
|
||||
if (!availableProviders) {
|
||||
return [];
|
||||
}
|
||||
const available = new Set(availableProviders);
|
||||
return allProviderDefinitions.filter((definition) => available.has(definition.id));
|
||||
}, [availableProvidersQuery.data]);
|
||||
const legacyProviderDefinitionMap = useMemo(
|
||||
() =>
|
||||
new Map<AgentProvider, AgentProviderDefinition>(
|
||||
legacyProviderDefinitions.map((definition) => [definition.id, definition]),
|
||||
),
|
||||
[legacyProviderDefinitions],
|
||||
);
|
||||
|
||||
// COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots
|
||||
const legacySelectedProviderModelsQuery = useQuery({
|
||||
queryKey: ["providerModels", formState.serverId, formState.provider],
|
||||
enabled: Boolean(
|
||||
!supportsProvidersSnapshot &&
|
||||
isVisible &&
|
||||
isTargetDaemonReady &&
|
||||
formState.serverId &&
|
||||
client &&
|
||||
isConnected &&
|
||||
legacyProviderDefinitionMap.has(formState.provider),
|
||||
),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.listProviderModels(formState.provider, {
|
||||
cwd: debouncedCwd,
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.models ?? [];
|
||||
},
|
||||
});
|
||||
const legacySelectedProviderModels = legacySelectedProviderModelsQuery.data ?? null;
|
||||
|
||||
// COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots
|
||||
const providerModesQuery = useQuery({
|
||||
queryKey: ["providerModes", formState.serverId, formState.provider, debouncedCwd],
|
||||
enabled: Boolean(
|
||||
!supportsProvidersSnapshot &&
|
||||
isVisible &&
|
||||
isTargetDaemonReady &&
|
||||
formState.serverId &&
|
||||
client &&
|
||||
isConnected &&
|
||||
legacyProviderDefinitionMap.has(formState.provider),
|
||||
),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.listProviderModes(formState.provider, {
|
||||
cwd: debouncedCwd,
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.modes ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
// COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots
|
||||
const allProviderModelQueries = useQueries({
|
||||
queries: (supportsProvidersSnapshot ? [] : legacyProviderDefinitions).map((def) => ({
|
||||
queryKey: ["providerModels", formState.serverId, def.id],
|
||||
enabled: Boolean(
|
||||
isVisible && isTargetDaemonReady && formState.serverId && client && isConnected,
|
||||
),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.listProviderModels(def.id as AgentProvider, {
|
||||
cwd: debouncedCwd,
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.models ?? [];
|
||||
},
|
||||
})),
|
||||
});
|
||||
const legacyAllProviderModels = useMemo(() => {
|
||||
const map = new Map<string, AgentModelDefinition[]>();
|
||||
for (let i = 0; i < legacyProviderDefinitions.length; i++) {
|
||||
const query = allProviderModelQueries[i];
|
||||
if (query?.data) {
|
||||
map.set(legacyProviderDefinitions[i]!.id, query.data);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [allProviderModelQueries, legacyProviderDefinitions]);
|
||||
const legacySelectedProviderModes =
|
||||
providerModesQuery.data ?? legacyProviderDefinitionMap.get(formState.provider)?.modes ?? [];
|
||||
|
||||
const providerDefinitions = supportsProvidersSnapshot
|
||||
? snapshotProviderDefinitions
|
||||
: legacyProviderDefinitions;
|
||||
const providerDefinitionMap = supportsProvidersSnapshot
|
||||
? snapshotProviderDefinitionMap
|
||||
: legacyProviderDefinitionMap;
|
||||
const selectableProviderDefinitionMap = supportsProvidersSnapshot
|
||||
? snapshotSelectableProviderDefinitionMap
|
||||
: legacyProviderDefinitionMap;
|
||||
const allProviderModels = supportsProvidersSnapshot
|
||||
? snapshotAllProviderModels
|
||||
: legacyAllProviderModels;
|
||||
const availableModels = supportsProvidersSnapshot
|
||||
? snapshotSelectedProviderModels
|
||||
: legacySelectedProviderModels;
|
||||
const modeOptions = supportsProvidersSnapshot
|
||||
? snapshotSelectedProviderModes
|
||||
: legacySelectedProviderModes;
|
||||
const isAllModelsLoading = supportsProvidersSnapshot
|
||||
? providersSnapshotQuery.isLoading || providersSnapshotQuery.isFetching
|
||||
: allProviderModelQueries.some((q) => q.isLoading);
|
||||
const providerDefinitions = snapshotProviderDefinitions;
|
||||
const providerDefinitionMap = snapshotProviderDefinitionMap;
|
||||
const selectableProviderDefinitionMap = snapshotSelectableProviderDefinitionMap;
|
||||
const allProviderModels = snapshotAllProviderModels;
|
||||
const availableModels = snapshotSelectedProviderModels;
|
||||
const modeOptions = snapshotSelectedProviderModes;
|
||||
const isAllModelsLoading = snapshotIsLoading || snapshotIsFetching;
|
||||
|
||||
// Combine initialValues with initialServerId for resolution
|
||||
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
|
||||
@@ -870,15 +643,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
}, []);
|
||||
|
||||
const refreshProviderModels = useCallback(() => {
|
||||
if (supportsProvidersSnapshot) {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
void client.refreshProvidersSnapshot({ cwd: debouncedCwd });
|
||||
return;
|
||||
}
|
||||
void legacySelectedProviderModelsQuery.refetch();
|
||||
}, [client, debouncedCwd, legacySelectedProviderModelsQuery, supportsProvidersSnapshot]);
|
||||
refreshSnapshot();
|
||||
}, [refreshSnapshot]);
|
||||
|
||||
const persistFormPreferences = useCallback(async () => {
|
||||
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
||||
@@ -914,17 +680,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
||||
const resolvedModelId = effectiveModel?.id ?? formState.model;
|
||||
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||
const isModelLoading = supportsProvidersSnapshot
|
||||
? providersSnapshotQuery.isLoading || providersSnapshotQuery.isFetching
|
||||
: legacySelectedProviderModelsQuery.isLoading || legacySelectedProviderModelsQuery.isFetching;
|
||||
const modelError =
|
||||
supportsProvidersSnapshot
|
||||
? providersSnapshotQuery.error instanceof Error
|
||||
? providersSnapshotQuery.error.message
|
||||
: null
|
||||
: legacySelectedProviderModelsQuery.error instanceof Error
|
||||
? legacySelectedProviderModelsQuery.error.message
|
||||
: null;
|
||||
const isModelLoading = snapshotIsLoading || snapshotIsFetching;
|
||||
const modelError = snapshotError;
|
||||
|
||||
const workingDirIsEmpty = !formState.workingDir.trim();
|
||||
|
||||
|
||||
@@ -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,
|
||||
mode: "uncommitted" | "base",
|
||||
baseRef?: string,
|
||||
ignoreWhitespace?: boolean,
|
||||
) {
|
||||
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? ""] as const;
|
||||
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? "", ignoreWhitespace === true] as const;
|
||||
}
|
||||
|
||||
interface UseCheckoutDiffQueryOptions {
|
||||
@@ -22,6 +23,7 @@ interface UseCheckoutDiffQueryOptions {
|
||||
cwd: string;
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -35,12 +37,16 @@ export type HighlightToken = NonNullable<DiffLine["tokens"]>[number];
|
||||
function normalizeCheckoutDiffCompare(compare: {
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
}): { mode: "uncommitted" | "base"; baseRef?: string } {
|
||||
ignoreWhitespace?: boolean;
|
||||
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
|
||||
const ignoreWhitespace = compare.ignoreWhitespace === true;
|
||||
if (compare.mode === "uncommitted") {
|
||||
return { mode: "uncommitted" };
|
||||
return { mode: "uncommitted", ignoreWhitespace };
|
||||
}
|
||||
const trimmedBaseRef = compare.baseRef?.trim();
|
||||
return trimmedBaseRef ? { mode: "base", baseRef: trimmedBaseRef } : { mode: "base" };
|
||||
return trimmedBaseRef
|
||||
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
|
||||
: { mode: "base", ignoreWhitespace };
|
||||
}
|
||||
|
||||
export function useCheckoutDiffQuery({
|
||||
@@ -48,6 +54,7 @@ export function useCheckoutDiffQuery({
|
||||
cwd,
|
||||
mode,
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
enabled = true,
|
||||
}: UseCheckoutDiffQueryOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -60,14 +67,15 @@ export function useCheckoutDiffQuery({
|
||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
const hookInstanceId = useId();
|
||||
const normalizedCompare = useMemo(
|
||||
() => normalizeCheckoutDiffCompare({ mode, baseRef }),
|
||||
[mode, baseRef],
|
||||
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
|
||||
[mode, baseRef, ignoreWhitespace],
|
||||
);
|
||||
const compareMode = normalizedCompare.mode;
|
||||
const compareBaseRef = normalizedCompare.baseRef;
|
||||
const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace;
|
||||
const queryKey = useMemo(
|
||||
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef),
|
||||
[serverId, cwd, mode, baseRef],
|
||||
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
|
||||
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
@@ -79,6 +87,7 @@ export function useCheckoutDiffQuery({
|
||||
const payload = await client.getCheckoutDiff(cwd, {
|
||||
mode: compareMode,
|
||||
baseRef: compareBaseRef,
|
||||
ignoreWhitespace: compareIgnoreWhitespace,
|
||||
});
|
||||
return {
|
||||
...payload,
|
||||
@@ -104,6 +113,7 @@ export function useCheckoutDiffQuery({
|
||||
cwd,
|
||||
compareMode,
|
||||
compareBaseRef ?? "",
|
||||
compareIgnoreWhitespace ? "ignore-ws" : "keep-ws",
|
||||
].join(":");
|
||||
let cancelled = false;
|
||||
|
||||
@@ -145,6 +155,7 @@ export function useCheckoutDiffQuery({
|
||||
{
|
||||
mode: compareMode,
|
||||
baseRef: compareBaseRef,
|
||||
ignoreWhitespace: compareIgnoreWhitespace,
|
||||
},
|
||||
{ subscriptionId },
|
||||
)
|
||||
@@ -191,6 +202,7 @@ export function useCheckoutDiffQuery({
|
||||
serverId,
|
||||
compareMode,
|
||||
compareBaseRef,
|
||||
compareIgnoreWhitespace,
|
||||
queryKey,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
@@ -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 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 { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
@@ -372,6 +376,7 @@ export function useSidebarWorkspacesList(options?: {
|
||||
}
|
||||
void (async () => {
|
||||
const next = new Map<string, WorkspaceDescriptor>();
|
||||
const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||
let cursor: string | null = null;
|
||||
try {
|
||||
while (true) {
|
||||
@@ -381,7 +386,13 @@ export function useSidebarWorkspacesList(options?: {
|
||||
});
|
||||
for (const entry of payload.entries) {
|
||||
const workspace = toWorkspaceDescriptor(entry);
|
||||
next.set(workspace.id, workspace);
|
||||
next.set(
|
||||
workspace.id,
|
||||
mergeWorkspaceSnapshotWithExisting({
|
||||
incoming: workspace,
|
||||
existing: existingWorkspaces?.get(workspace.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
||||
break;
|
||||
|
||||
@@ -62,12 +62,12 @@ import { settingsStyles } from "@/styles/settings";
|
||||
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
|
||||
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { isCompactFormFactor } from "@/constants/layout";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section definitions
|
||||
@@ -431,55 +431,49 @@ interface ProvidersSectionProps {
|
||||
|
||||
function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const client = useHostRuntimeClient(routeServerId);
|
||||
const isConnected = useHostRuntimeIsConnected(routeServerId);
|
||||
const [entries, setEntries] = useState<ProviderSnapshotEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId);
|
||||
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected) {
|
||||
setEntries([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
client
|
||||
.getProvidersSnapshot()
|
||||
.then((result) => {
|
||||
if (!cancelled) setEntries(result.entries);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setEntries([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, isConnected]);
|
||||
|
||||
const hasServer = routeServerId.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={settingsStyles.section}>
|
||||
<Text style={settingsStyles.sectionTitle}>Providers</Text>
|
||||
<View style={settingsStyles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>Providers</Text>
|
||||
{hasServer && isConnected ? (
|
||||
<Pressable
|
||||
onPress={refresh}
|
||||
disabled={isFetching}
|
||||
style={[
|
||||
settingsStyles.sectionHeaderLink,
|
||||
isFetching ? { opacity: 0.5 } : null,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: theme.colors.primary,
|
||||
fontSize: theme.fontSize.xs,
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
{!hasServer || !isConnected ? (
|
||||
<View style={[settingsStyles.card, styles.emptyCard]}>
|
||||
<Text style={styles.emptyText}>Connect to a host to see providers</Text>
|
||||
</View>
|
||||
) : loading ? (
|
||||
) : isLoading ? (
|
||||
<View style={[settingsStyles.card, styles.emptyCard]}>
|
||||
<Text style={styles.emptyText}>Loading...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[settingsStyles.card, styles.audioCard]}>
|
||||
{AGENT_PROVIDER_DEFINITIONS.map((def) => {
|
||||
const entry = entries.find((e) => e.provider === def.id);
|
||||
const entry = entries?.find((e) => e.provider === def.id);
|
||||
const status = entry?.status ?? "unavailable";
|
||||
const ProviderIcon = getProviderIcon(def.id);
|
||||
|
||||
|
||||
@@ -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 { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
|
||||
import { WorkspaceGitActions } from "@/screens/workspace/workspace-git-actions";
|
||||
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
|
||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||
@@ -2098,6 +2099,12 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
}
|
||||
right={
|
||||
<View style={styles.headerRight}>
|
||||
{!isMobile ? (
|
||||
<WorkspaceOpenInEditorButton
|
||||
serverId={normalizedServerId}
|
||||
cwd={normalizedWorkspaceId}
|
||||
/>
|
||||
) : null}
|
||||
{!isMobile && isGitCheckout ? (
|
||||
<>
|
||||
{workspaceDirectory ? (
|
||||
|
||||
@@ -39,10 +39,10 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
|
||||
"Copy resume command",
|
||||
"Copy agent id",
|
||||
"Reload agent",
|
||||
"Close to the left",
|
||||
"Close to the right",
|
||||
"Close other tabs",
|
||||
"Reload agent",
|
||||
"Close",
|
||||
]);
|
||||
});
|
||||
@@ -66,10 +66,10 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
|
||||
"Copy resume command",
|
||||
"Copy agent id",
|
||||
"Reload agent",
|
||||
"Close tabs above",
|
||||
"Close tabs below",
|
||||
"Close other tabs",
|
||||
"Reload agent",
|
||||
"Close",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -138,17 +138,6 @@ export function buildWorkspaceTabMenuEntries(
|
||||
void onCopyAgentId(agentId);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "reload-agent",
|
||||
label: "Reload agent",
|
||||
icon: "rotate-cw",
|
||||
tooltip: "Reload agent to update skills, MCPs or login status.",
|
||||
testID: `${menuTestIDBase}-reload-agent`,
|
||||
onSelect: () => {
|
||||
void onReloadAgent(agentId);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
kind: "separator",
|
||||
key: "copy-separator",
|
||||
@@ -188,6 +177,20 @@ export function buildWorkspaceTabMenuEntries(
|
||||
void onCloseOtherTabs(tab.tabId);
|
||||
},
|
||||
});
|
||||
if (tab.target.kind === "agent") {
|
||||
const { agentId } = tab.target;
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "reload-agent",
|
||||
label: "Reload agent",
|
||||
icon: "rotate-cw",
|
||||
tooltip: "Reload agent to update skills, MCPs or login status.",
|
||||
testID: `${menuTestIDBase}-reload-agent`,
|
||||
onSelect: () => {
|
||||
void onReloadAgent(agentId);
|
||||
},
|
||||
});
|
||||
}
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "close",
|
||||
|
||||
@@ -66,6 +66,8 @@ interface PanelState {
|
||||
// File explorer settings (shared between mobile/desktop)
|
||||
explorerTab: ExplorerTab;
|
||||
explorerTabByCheckout: Record<string, ExplorerTab>;
|
||||
expandedPathsByWorkspace: Record<string, string[]>;
|
||||
diffExpandedPathsByWorkspace: Record<string, string[]>;
|
||||
activeExplorerCheckout: ExplorerCheckoutContext | null;
|
||||
sidebarWidth: number;
|
||||
explorerWidth: number;
|
||||
@@ -85,6 +87,8 @@ interface PanelState {
|
||||
// File explorer settings actions
|
||||
setExplorerTab: (tab: ExplorerTab) => void;
|
||||
setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void;
|
||||
setExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void;
|
||||
setDiffExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void;
|
||||
activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void;
|
||||
setActiveExplorerCheckout: (checkout: ExplorerCheckoutContext | null) => void;
|
||||
setSidebarWidth: (width: number) => void;
|
||||
@@ -142,6 +146,8 @@ export const usePanelStore = create<PanelState>()(
|
||||
// File explorer defaults
|
||||
explorerTab: "changes",
|
||||
explorerTabByCheckout: {},
|
||||
expandedPathsByWorkspace: {},
|
||||
diffExpandedPathsByWorkspace: {},
|
||||
activeExplorerCheckout: null,
|
||||
sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
|
||||
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
|
||||
@@ -261,6 +267,17 @@ export const usePanelStore = create<PanelState>()(
|
||||
}
|
||||
return nextState;
|
||||
}),
|
||||
setExpandedPathsForWorkspace: (workspaceKey, paths) =>
|
||||
set((state) => ({
|
||||
expandedPathsByWorkspace: { ...state.expandedPathsByWorkspace, [workspaceKey]: paths },
|
||||
})),
|
||||
setDiffExpandedPathsForWorkspace: (workspaceKey, paths) =>
|
||||
set((state) => ({
|
||||
diffExpandedPathsByWorkspace: {
|
||||
...state.diffExpandedPathsByWorkspace,
|
||||
[workspaceKey]: paths,
|
||||
},
|
||||
})),
|
||||
activateExplorerTabForCheckout: (checkout) =>
|
||||
set((state) => ({
|
||||
activeExplorerCheckout: checkout,
|
||||
@@ -295,7 +312,7 @@ export const usePanelStore = create<PanelState>()(
|
||||
}),
|
||||
{
|
||||
name: "panel-state",
|
||||
version: 8,
|
||||
version: 10,
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
migrate: (persistedState, version) => {
|
||||
const state = persistedState as Partial<PanelState> & Record<string, unknown>;
|
||||
@@ -371,6 +388,22 @@ export const usePanelStore = create<PanelState>()(
|
||||
state.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
|
||||
}
|
||||
|
||||
if (
|
||||
version < 9 ||
|
||||
typeof state.expandedPathsByWorkspace !== "object" ||
|
||||
!state.expandedPathsByWorkspace
|
||||
) {
|
||||
state.expandedPathsByWorkspace = {};
|
||||
}
|
||||
|
||||
if (
|
||||
version < 10 ||
|
||||
typeof state.diffExpandedPathsByWorkspace !== "object" ||
|
||||
!state.diffExpandedPathsByWorkspace
|
||||
) {
|
||||
state.diffExpandedPathsByWorkspace = {};
|
||||
}
|
||||
|
||||
state.activeExplorerCheckout = null;
|
||||
|
||||
return state as PanelState;
|
||||
@@ -380,6 +413,8 @@ export const usePanelStore = create<PanelState>()(
|
||||
desktop: state.desktop,
|
||||
explorerTab: state.explorerTab,
|
||||
explorerTabByCheckout: state.explorerTabByCheckout,
|
||||
expandedPathsByWorkspace: state.expandedPathsByWorkspace,
|
||||
diffExpandedPathsByWorkspace: state.diffExpandedPathsByWorkspace,
|
||||
sidebarWidth: state.sidebarWidth,
|
||||
explorerWidth: state.explorerWidth,
|
||||
explorerSortOption: state.explorerSortOption,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
|
||||
|
||||
import {
|
||||
mergeWorkspaceSnapshotWithExisting,
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
type WorkspaceDescriptor,
|
||||
} from "./session-store";
|
||||
|
||||
function workspace(
|
||||
function createWorkspace(
|
||||
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
||||
): WorkspaceDescriptor {
|
||||
return {
|
||||
@@ -17,7 +20,7 @@ function workspace(
|
||||
projectRootPath: input.projectRootPath ?? "/repo",
|
||||
workspaceDirectory: input.workspaceDirectory ?? "/repo",
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "checkout",
|
||||
workspaceKind: input.workspaceKind ?? "local_checkout",
|
||||
name: input.name ?? "main",
|
||||
status: input.status ?? "done",
|
||||
activityAt: input.activityAt ?? null,
|
||||
@@ -99,11 +102,11 @@ describe("mergeWorkspaces", () => {
|
||||
store.initializeSession("test-server", null as unknown as DaemonClient);
|
||||
store.setWorkspaces(
|
||||
"test-server",
|
||||
new Map([["/repo/main", workspace({ id: "/repo/main", services: [] })]]),
|
||||
new Map([["/repo/main", createWorkspace({ id: "/repo/main", services: [] })]]),
|
||||
);
|
||||
|
||||
store.mergeWorkspaces("test-server", [
|
||||
workspace({
|
||||
createWorkspace({
|
||||
id: "/repo/main",
|
||||
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 ExplorerFileKind = "text" | "image" | "binary";
|
||||
export type ExplorerEncoding = "utf-8" | "base64" | "none";
|
||||
|
||||
@@ -111,6 +111,7 @@ const lightSemanticColors = {
|
||||
surface2: "#f4f4f5", // Elevated: badges, inputs, sheets (was zinc-200, now zinc-100)
|
||||
surface3: "#e4e4e7", // Highest elevation (was zinc-300, now zinc-200)
|
||||
surface4: "#d4d4d8", // Extra emphasis (was zinc-400, now zinc-300)
|
||||
surfaceDiffEmpty: "#f6f6f6", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2
|
||||
surfaceSidebar: "#f4f4f5", // Sidebar background (darker than main)
|
||||
surfaceWorkspace: "#ffffff", // Workspace main background
|
||||
|
||||
@@ -185,6 +186,7 @@ const darkSemanticColors = {
|
||||
surface2: "#272A29", // Elevated: badges, inputs, sheets
|
||||
surface3: "#434645", // Highest elevation
|
||||
surface4: "#595B5B", // Extra emphasis
|
||||
surfaceDiffEmpty: "#252827", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2
|
||||
surfaceSidebar: "#141716", // Sidebar background (darker than main)
|
||||
surfaceWorkspace: "#1E2120", // Workspace main background (surface1)
|
||||
|
||||
@@ -279,6 +281,10 @@ const commonTheme = {
|
||||
"4xl": 34,
|
||||
},
|
||||
|
||||
lineHeight: {
|
||||
diff: 22,
|
||||
},
|
||||
|
||||
iconSize: {
|
||||
xs: 12,
|
||||
sm: 14,
|
||||
|
||||
@@ -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",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.49",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -24,8 +24,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.48",
|
||||
"@getpaseo/server": "0.1.48",
|
||||
"@getpaseo/relay": "0.1.49",
|
||||
"@getpaseo/server": "0.1.49",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.49",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"main": "dist/main.js",
|
||||
@@ -12,8 +12,8 @@
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.48",
|
||||
"@getpaseo/server": "0.1.48",
|
||||
"@getpaseo/cli": "0.1.49",
|
||||
"@getpaseo/server": "0.1.49",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.49",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.49",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.49",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.49",
|
||||
"description": "Paseo backend server",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -61,8 +61,8 @@
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.48",
|
||||
"@getpaseo/relay": "0.1.48",
|
||||
"@getpaseo/highlight": "0.1.49",
|
||||
"@getpaseo/relay": "0.1.49",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
|
||||
@@ -34,6 +34,8 @@ import type {
|
||||
PaseoWorktreeListResponse,
|
||||
PaseoWorktreeArchiveResponse,
|
||||
ProjectIconResponse,
|
||||
ListAvailableEditorsResponseMessage,
|
||||
OpenInEditorResponseMessage,
|
||||
OpenProjectResponseMessage,
|
||||
ArchiveWorkspaceResponseMessage,
|
||||
WorkspaceSetupStatusResponseMessage,
|
||||
@@ -55,6 +57,7 @@ import type {
|
||||
TerminalInput,
|
||||
SessionInboundMessage,
|
||||
SessionOutboundMessage,
|
||||
EditorTargetId,
|
||||
} from "../shared/messages.js";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
@@ -478,9 +481,12 @@ export type InspectScheduleOptions = {
|
||||
id: string;
|
||||
requestId?: string;
|
||||
};
|
||||
type ListAvailableEditorsPayload = ListAvailableEditorsResponseMessage["payload"];
|
||||
type OpenInEditorPayload = OpenInEditorResponseMessage["payload"];
|
||||
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
|
||||
type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"];
|
||||
type WorkspaceSetupStatusPayload = WorkspaceSetupStatusResponseMessage["payload"];
|
||||
export type EditorTargetDescriptor = ListAvailableEditorsPayload["editors"][number];
|
||||
|
||||
export type FetchAgentResult = {
|
||||
agent: AgentSnapshotPayload;
|
||||
@@ -618,7 +624,10 @@ export class DaemonClient {
|
||||
private connectionState: ConnectionState = { status: "idle" };
|
||||
private checkoutDiffSubscriptions = new Map<
|
||||
string,
|
||||
{ cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } }
|
||||
{
|
||||
cwd: string;
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
|
||||
}
|
||||
>();
|
||||
private terminalDirectorySubscriptions = new Set<string>();
|
||||
private terminalSlots = new Map<string, number>();
|
||||
@@ -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(
|
||||
workspaceId: number,
|
||||
workspaceId: string | number,
|
||||
requestId?: string,
|
||||
): Promise<ArchiveWorkspacePayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
@@ -2185,20 +2222,22 @@ export class DaemonClient {
|
||||
private normalizeCheckoutDiffCompare(compare: {
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
}): { mode: "uncommitted" | "base"; baseRef?: string } {
|
||||
ignoreWhitespace?: boolean;
|
||||
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
|
||||
const ignoreWhitespace = compare.ignoreWhitespace === true;
|
||||
if (compare.mode === "uncommitted") {
|
||||
return { mode: "uncommitted" };
|
||||
return { mode: "uncommitted", ignoreWhitespace };
|
||||
}
|
||||
const trimmedBaseRef = compare.baseRef?.trim();
|
||||
if (!trimmedBaseRef) {
|
||||
return { mode: "base" };
|
||||
return { mode: "base", ignoreWhitespace };
|
||||
}
|
||||
return { mode: "base", baseRef: trimmedBaseRef };
|
||||
return { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace };
|
||||
}
|
||||
|
||||
async getCheckoutDiff(
|
||||
cwd: string,
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string },
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
|
||||
requestId?: string,
|
||||
): Promise<CheckoutDiffPayload> {
|
||||
const oneShotSubscriptionId = `oneshot-checkout-diff:${crypto.randomUUID()}`;
|
||||
@@ -2224,7 +2263,7 @@ export class DaemonClient {
|
||||
|
||||
async subscribeCheckoutDiff(
|
||||
cwd: string,
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string },
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
|
||||
options?: { subscriptionId?: string; requestId?: string },
|
||||
): Promise<SubscribeCheckoutDiffPayload> {
|
||||
const subscriptionId = options?.subscriptionId ?? crypto.randomUUID();
|
||||
|
||||
@@ -174,6 +174,9 @@ function sanitizePermissionRequest(
|
||||
if (sanitized.suggestions === undefined) {
|
||||
delete sanitized.suggestions;
|
||||
}
|
||||
if (sanitized.actions === undefined) {
|
||||
delete sanitized.actions;
|
||||
}
|
||||
if (sanitized.metadata === undefined) {
|
||||
delete sanitized.metadata;
|
||||
}
|
||||
|
||||
@@ -3459,6 +3459,213 @@ describe("AgentManager", () => {
|
||||
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 () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -150,6 +150,11 @@ type ManagedAgentBase = {
|
||||
features?: AgentFeature[];
|
||||
currentModeId: string | null;
|
||||
pendingPermissions: Map<string, AgentPermissionRequest>;
|
||||
bufferedPermissionResolutions: Map<
|
||||
string,
|
||||
Extract<AgentStreamEvent, { type: "permission_resolved" }>
|
||||
>;
|
||||
inFlightPermissionResponses: Set<string>;
|
||||
pendingReplacement: boolean;
|
||||
persistence: AgentPersistenceHandle | null;
|
||||
historyPrimed: boolean;
|
||||
@@ -1378,25 +1383,32 @@ export class AgentManager {
|
||||
requestId: string,
|
||||
response: AgentPermissionResponse,
|
||||
): Promise<void> {
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
await agent.session.respondToPermission(requestId, response);
|
||||
agent.pendingPermissions.delete(requestId);
|
||||
const agent = this.requireAgent(agentId);
|
||||
agent.inFlightPermissionResponses.add(requestId);
|
||||
|
||||
// Update currentModeId - the session may have changed mode internally
|
||||
// (e.g., plan approval changes mode from "plan" to "acceptEdits")
|
||||
try {
|
||||
agent.currentModeId = await agent.session.getCurrentMode();
|
||||
if (agent.runtimeInfo) {
|
||||
agent.runtimeInfo = {
|
||||
...agent.runtimeInfo,
|
||||
modeId: agent.currentModeId,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors from getCurrentMode - mode tracking is best effort
|
||||
}
|
||||
await agent.session.respondToPermission(requestId, response);
|
||||
agent.pendingPermissions.delete(requestId);
|
||||
|
||||
this.emitState(agent);
|
||||
try {
|
||||
await this.refreshSessionState(agent);
|
||||
} catch {
|
||||
// Ignore refresh errors - state sync after permission approval is best effort.
|
||||
}
|
||||
|
||||
this.touchUpdatedAt(agent);
|
||||
await this.persistSnapshot(agent);
|
||||
this.emitState(agent);
|
||||
|
||||
const bufferedResolution = agent.bufferedPermissionResolutions.get(requestId);
|
||||
if (bufferedResolution) {
|
||||
agent.bufferedPermissionResolutions.delete(requestId);
|
||||
this.dispatchStream(agent.id, bufferedResolution);
|
||||
}
|
||||
} finally {
|
||||
agent.inFlightPermissionResponses.delete(requestId);
|
||||
agent.bufferedPermissionResolutions.delete(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
async cancelAgentRun(agentId: string): Promise<boolean> {
|
||||
@@ -1840,6 +1852,8 @@ export class AgentManager {
|
||||
availableModes: [],
|
||||
currentModeId: null,
|
||||
pendingPermissions: new Map<string, AgentPermissionRequest>(),
|
||||
bufferedPermissionResolutions: new Map(),
|
||||
inFlightPermissionResponses: new Set(),
|
||||
pendingReplacement: false,
|
||||
activeForegroundTurnId: null,
|
||||
foregroundTurnWaiters: new Set<ForegroundTurnWaiter>(),
|
||||
@@ -2097,6 +2111,7 @@ export class AgentManager {
|
||||
agent.pendingPermissions.clear();
|
||||
}
|
||||
|
||||
this.syncFeaturesFromSession(agent);
|
||||
await this.refreshRuntimeInfo(agent);
|
||||
}
|
||||
|
||||
@@ -2174,6 +2189,7 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
let timelineRow: AgentTimelineRow | null = null;
|
||||
let shouldDispatchEvent = true;
|
||||
|
||||
switch (event.type) {
|
||||
case "thread_started":
|
||||
@@ -2344,6 +2360,11 @@ export class AgentManager {
|
||||
break;
|
||||
case "permission_resolved":
|
||||
agent.pendingPermissions.delete(event.requestId);
|
||||
if (!options?.fromHistory && agent.inFlightPermissionResponses.has(event.requestId)) {
|
||||
agent.bufferedPermissionResolutions.set(event.requestId, event);
|
||||
shouldDispatchEvent = false;
|
||||
break;
|
||||
}
|
||||
this.emitState(agent);
|
||||
break;
|
||||
default:
|
||||
@@ -2355,7 +2376,7 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
// Skip dispatching individual stream events during history replay.
|
||||
if (!options?.fromHistory) {
|
||||
if (!options?.fromHistory && shouldDispatchEvent) {
|
||||
this.dispatchStream(
|
||||
agent.id,
|
||||
event,
|
||||
@@ -2436,9 +2457,7 @@ export class AgentManager {
|
||||
this.enqueueBackgroundPersist(agent);
|
||||
}
|
||||
|
||||
if (agent.session?.features) {
|
||||
agent.features = agent.session.features;
|
||||
}
|
||||
this.syncFeaturesFromSession(agent);
|
||||
|
||||
this.dispatch({
|
||||
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 {
|
||||
const previousStatus = this.previousStatuses.get(agent.id);
|
||||
const currentStatus = agent.lifecycle;
|
||||
|
||||
@@ -260,6 +260,34 @@ describe("toAgentPayload", () => {
|
||||
expect(permissionA.title).toBe("Run command");
|
||||
});
|
||||
|
||||
it("omits usage when any numeric usage field is NaN", () => {
|
||||
const fields = [
|
||||
"inputTokens",
|
||||
"cachedInputTokens",
|
||||
"outputTokens",
|
||||
"totalCostUsd",
|
||||
"contextWindowMaxTokens",
|
||||
"contextWindowUsedTokens",
|
||||
] as const;
|
||||
|
||||
for (const field of fields) {
|
||||
const agent = createManagedAgent({
|
||||
lastUsage: {
|
||||
inputTokens: 10,
|
||||
cachedInputTokens: 5,
|
||||
outputTokens: 20,
|
||||
totalCostUsd: 0.5,
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 100_000,
|
||||
[field]: Number.NaN,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = toAgentPayload(agent);
|
||||
expect(payload.lastUsage).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("produces null title and current mode even without overrides", () => {
|
||||
const agent = createManagedAgent({ currentModeId: null, lastUserMessageAt: null });
|
||||
const payload = toAgentPayload(agent);
|
||||
@@ -303,6 +331,56 @@ describe("toAgentPayload", () => {
|
||||
expect(payload).not.toHaveProperty("lastUsage");
|
||||
});
|
||||
|
||||
it("preserves context window usage fields when they are valid numbers", () => {
|
||||
const agent = createManagedAgent({
|
||||
lastUsage: {
|
||||
inputTokens: 10,
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 42_000,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = toAgentPayload(agent);
|
||||
|
||||
expect(payload.lastUsage).toEqual({
|
||||
inputTokens: 10,
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 42_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("omits lastUsage when context window usage fields are invalid", () => {
|
||||
const agent = createManagedAgent({
|
||||
lastUsage: {
|
||||
inputTokens: 10,
|
||||
contextWindowMaxTokens: "200000" as unknown as number,
|
||||
contextWindowUsedTokens: NaN,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = toAgentPayload(agent);
|
||||
|
||||
expect(payload).not.toHaveProperty("lastUsage");
|
||||
});
|
||||
|
||||
it("keeps existing lastUsage behavior when context window fields are absent", () => {
|
||||
const agent = createManagedAgent({
|
||||
lastUsage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
totalCostUsd: 1.25,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = toAgentPayload(agent);
|
||||
|
||||
expect(payload.lastUsage).toEqual({
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
totalCostUsd: 1.25,
|
||||
});
|
||||
});
|
||||
|
||||
it("includes features in the snapshot payload", () => {
|
||||
const features = [createFeature()];
|
||||
const agent = createManagedAgent({ features });
|
||||
|
||||
@@ -61,6 +61,7 @@ export function toStoredAgentRecord(
|
||||
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
|
||||
config: config ?? null,
|
||||
runtimeInfo,
|
||||
features: agent.features,
|
||||
persistence,
|
||||
lastError: agent.lastError ?? undefined,
|
||||
requiresAttention: agent.attention.requiresAttention,
|
||||
@@ -167,6 +168,7 @@ function sanitizePendingPermissions(
|
||||
...request,
|
||||
input: sanitizeMetadata(request.input),
|
||||
suggestions: sanitizeMetadataArray(request.suggestions),
|
||||
actions: request.actions?.map((action) => ({ ...action })),
|
||||
metadata: sanitizeMetadata(request.metadata),
|
||||
}));
|
||||
}
|
||||
@@ -260,29 +262,41 @@ function sanitizeUsage(value: unknown): AgentUsage | undefined {
|
||||
}
|
||||
const result: AgentUsage = {};
|
||||
const inputTokens = sanitized.inputTokens;
|
||||
if (typeof inputTokens === "number") {
|
||||
if (typeof inputTokens === "number" && Number.isFinite(inputTokens)) {
|
||||
result.inputTokens = inputTokens;
|
||||
} else if (inputTokens !== undefined && inputTokens !== null) {
|
||||
return undefined;
|
||||
}
|
||||
const cachedInputTokens = sanitized.cachedInputTokens;
|
||||
if (typeof cachedInputTokens === "number") {
|
||||
if (typeof cachedInputTokens === "number" && Number.isFinite(cachedInputTokens)) {
|
||||
result.cachedInputTokens = cachedInputTokens;
|
||||
} else if (cachedInputTokens !== undefined && cachedInputTokens !== null) {
|
||||
return undefined;
|
||||
}
|
||||
const outputTokens = sanitized.outputTokens;
|
||||
if (typeof outputTokens === "number") {
|
||||
if (typeof outputTokens === "number" && Number.isFinite(outputTokens)) {
|
||||
result.outputTokens = outputTokens;
|
||||
} else if (outputTokens !== undefined && outputTokens !== null) {
|
||||
return undefined;
|
||||
}
|
||||
const totalCostUsd = sanitized.totalCostUsd;
|
||||
if (typeof totalCostUsd === "number") {
|
||||
if (typeof totalCostUsd === "number" && Number.isFinite(totalCostUsd)) {
|
||||
result.totalCostUsd = totalCostUsd;
|
||||
} else if (totalCostUsd !== undefined && totalCostUsd !== null) {
|
||||
return undefined;
|
||||
}
|
||||
const contextWindowMaxTokens = sanitized.contextWindowMaxTokens;
|
||||
if (typeof contextWindowMaxTokens === "number" && Number.isFinite(contextWindowMaxTokens)) {
|
||||
result.contextWindowMaxTokens = contextWindowMaxTokens;
|
||||
} else if (contextWindowMaxTokens !== undefined && contextWindowMaxTokens !== null) {
|
||||
return undefined;
|
||||
}
|
||||
const contextWindowUsedTokens = sanitized.contextWindowUsedTokens;
|
||||
if (typeof contextWindowUsedTokens === "number" && Number.isFinite(contextWindowUsedTokens)) {
|
||||
result.contextWindowUsedTokens = contextWindowUsedTokens;
|
||||
} else if (contextWindowUsedTokens !== undefined && contextWindowUsedTokens !== null) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.keys(result).length ? result : undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,8 @@ export type AgentUsage = {
|
||||
cachedInputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalCostUsd?: number;
|
||||
contextWindowMaxTokens?: number;
|
||||
contextWindowUsedTokens?: number;
|
||||
};
|
||||
|
||||
export const TOOL_CALL_ICON_NAMES = [
|
||||
@@ -329,6 +331,14 @@ export type AgentPermissionRequestKind = "tool" | "plan" | "question" | "mode" |
|
||||
|
||||
export type AgentPermissionUpdate = AgentMetadata;
|
||||
|
||||
export type AgentPermissionAction = {
|
||||
id: string;
|
||||
label: string;
|
||||
behavior: "allow" | "deny";
|
||||
variant?: "primary" | "secondary" | "danger";
|
||||
intent?: "implement" | "implement_resume" | "dismiss";
|
||||
};
|
||||
|
||||
export type AgentPermissionRequest = {
|
||||
id: string;
|
||||
provider: AgentProvider;
|
||||
@@ -339,17 +349,20 @@ export type AgentPermissionRequest = {
|
||||
input?: AgentMetadata;
|
||||
detail?: ToolCallDetail;
|
||||
suggestions?: AgentPermissionUpdate[];
|
||||
actions?: AgentPermissionAction[];
|
||||
metadata?: AgentMetadata;
|
||||
};
|
||||
|
||||
export type AgentPermissionResponse =
|
||||
| {
|
||||
behavior: "allow";
|
||||
selectedActionId?: string;
|
||||
updatedInput?: AgentMetadata;
|
||||
updatedPermissions?: AgentPermissionUpdate[];
|
||||
}
|
||||
| {
|
||||
behavior: "deny";
|
||||
selectedActionId?: string;
|
||||
message?: string;
|
||||
interrupt?: boolean;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { AgentStatusSchema } from "../messages.js";
|
||||
import { AgentFeatureSchema, AgentStatusSchema } from "../messages.js";
|
||||
import { toStoredAgentRecord } from "./agent-projections.js";
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
|
||||
@@ -57,6 +57,7 @@ const STORED_AGENT_SCHEMA = z.object({
|
||||
extra: z.record(z.unknown()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
features: z.array(AgentFeatureSchema).optional(),
|
||||
persistence: PERSISTENCE_HANDLE_SCHEMA,
|
||||
lastError: z.string().nullable().optional(),
|
||||
requiresAttention: z.boolean().optional(),
|
||||
|
||||
@@ -236,6 +236,9 @@ function sanitizePermissionRequest(
|
||||
if (sanitized.suggestions === undefined) {
|
||||
delete sanitized.suggestions;
|
||||
}
|
||||
if (sanitized.actions === undefined) {
|
||||
delete sanitized.actions;
|
||||
}
|
||||
if (sanitized.metadata === undefined) {
|
||||
delete sanitized.metadata;
|
||||
}
|
||||
|
||||
@@ -747,4 +747,55 @@ describe("ACPAgentSession", () => {
|
||||
});
|
||||
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,
|
||||
};
|
||||
|
||||
const COPILOT_AUTOPILOT_MODE =
|
||||
"https://agentclientprotocol.com/protocol/session-modes#autopilot";
|
||||
|
||||
type ACPAgentClientOptions = {
|
||||
provider: string;
|
||||
logger: Logger;
|
||||
@@ -1084,6 +1087,18 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
async requestPermission(
|
||||
params: RequestPermissionRequest,
|
||||
): 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();
|
||||
let toolSnapshot =
|
||||
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(
|
||||
options: PermissionOption[],
|
||||
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 () => {
|
||||
const session = await createSession();
|
||||
const internal = session as unknown as {
|
||||
|
||||
@@ -112,6 +112,28 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
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", () => {
|
||||
const interruptEntry = {
|
||||
type: "user",
|
||||
@@ -261,3 +283,356 @@ describe("ClaudeAgentClient.listModels", () => {
|
||||
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 SDKMessage,
|
||||
type SDKPartialAssistantMessage,
|
||||
type SDKTaskProgressMessage,
|
||||
type SDKResultMessage,
|
||||
type SDKSystemMessage,
|
||||
type SDKUserMessage,
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
} from "./diagnostic-utils.js";
|
||||
|
||||
import type {
|
||||
AgentPermissionAction,
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentLaunchContext,
|
||||
@@ -669,6 +671,43 @@ function resolvePermissionKind(
|
||||
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 = {
|
||||
kind: "assistant" | "reasoning";
|
||||
text: string;
|
||||
@@ -967,7 +1006,8 @@ function isSyntheticUserEntry(entry: unknown): boolean {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
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 {
|
||||
@@ -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 {
|
||||
readonly provider: "claude" = "claude";
|
||||
readonly capabilities = CLAUDE_CAPABILITIES;
|
||||
@@ -1175,6 +1249,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private claudeSessionId: string | null;
|
||||
private persistence: AgentPersistenceHandle | null;
|
||||
private currentMode: PermissionMode;
|
||||
private planResumeMode: PermissionMode | null = null;
|
||||
private availableModes: AgentMode[] = DEFAULT_MODES;
|
||||
private toolUseCache = new Map<string, ToolUseCacheEntry>();
|
||||
private toolUseIndexToId = new Map<number, string>();
|
||||
@@ -1201,6 +1276,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private pendingInterruptAbort = false;
|
||||
private lastForegroundPromptText: string | null = null;
|
||||
private foregroundHasVisibleActivity = false;
|
||||
private lastContextWindowUsedTokens: number | undefined;
|
||||
private userMessageIds: string[] = [];
|
||||
private recentStderr = "";
|
||||
private closed = false;
|
||||
@@ -1235,6 +1311,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
this.currentMode = isPermissionMode(config.modeId) ? config.modeId : "default";
|
||||
if (this.currentMode !== "plan") {
|
||||
this.planResumeMode = this.currentMode;
|
||||
}
|
||||
}
|
||||
|
||||
get id(): string | null {
|
||||
@@ -1380,6 +1459,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
const sdkMessage = this.toSdkUserMessage(prompt);
|
||||
this.lastForegroundPromptText = this.extractPromptText(prompt);
|
||||
this.lastContextWindowUsedTokens = undefined;
|
||||
const turnId = this.createTurnId("foreground");
|
||||
this.activeForegroundTurnId = turnId;
|
||||
this.foregroundHasVisibleActivity = false;
|
||||
@@ -1476,8 +1556,16 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
const normalized = isPermissionMode(modeId) ? modeId : "default";
|
||||
const previousMode = this.currentMode;
|
||||
const query = await this.ensureQuery();
|
||||
await query.setPermissionMode(normalized);
|
||||
if (normalized === "plan") {
|
||||
if (previousMode !== "plan") {
|
||||
this.planResumeMode = previousMode;
|
||||
}
|
||||
} else {
|
||||
this.planResumeMode = normalized;
|
||||
}
|
||||
this.currentMode = normalized;
|
||||
}
|
||||
|
||||
@@ -1524,13 +1612,22 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
if (response.behavior === "allow") {
|
||||
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(
|
||||
mapClaudeCompletedToolCall({
|
||||
name: "plan_approval",
|
||||
callId: pending.request.id,
|
||||
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.queryPumpPromise = null;
|
||||
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();
|
||||
oldQuery.close?.();
|
||||
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 options = this.buildOptions();
|
||||
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
|
||||
@@ -2197,6 +2303,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.notifySubscribers(event);
|
||||
this.activeForegroundTurnId = null;
|
||||
this.lastForegroundPromptText = null;
|
||||
this.lastContextWindowUsedTokens = undefined;
|
||||
this.cancelCurrentTurn = null;
|
||||
this.syncTurnState("foreground turn terminal");
|
||||
}
|
||||
@@ -2212,10 +2319,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (this.activeForegroundTurnId) {
|
||||
this.activeForegroundTurnId = null;
|
||||
this.lastForegroundPromptText = null;
|
||||
this.lastContextWindowUsedTokens = undefined;
|
||||
this.cancelCurrentTurn = null;
|
||||
this.syncTurnState("foreground turn terminal");
|
||||
} else if (this.autonomousTurn) {
|
||||
this.autonomousTurn = null;
|
||||
this.lastContextWindowUsedTokens = undefined;
|
||||
this.syncTurnState("autonomous turn terminal");
|
||||
}
|
||||
}
|
||||
@@ -2228,6 +2337,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.autonomousTurn = {
|
||||
id: this.createTurnId("autonomous"),
|
||||
};
|
||||
this.lastContextWindowUsedTokens = undefined;
|
||||
this.notifySubscribers({ type: "turn_started", provider: "claude" });
|
||||
this.syncTurnState("autonomous turn started");
|
||||
}
|
||||
@@ -2238,6 +2348,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
this.notifySubscribers({ type: "turn_completed", provider: "claude" });
|
||||
this.autonomousTurn = null;
|
||||
this.lastContextWindowUsedTokens = undefined;
|
||||
this.syncTurnState("autonomous turn completed");
|
||||
}
|
||||
|
||||
@@ -2566,6 +2677,10 @@ class ClaudeAgentSession implements AgentSession {
|
||||
provider: "claude",
|
||||
});
|
||||
}
|
||||
} else if (message.subtype === "task_progress") {
|
||||
this.lastContextWindowUsedTokens =
|
||||
readContextWindowUsedTokensFromTaskProgress(message) ??
|
||||
this.lastContextWindowUsedTokens;
|
||||
}
|
||||
break;
|
||||
case "user": {
|
||||
@@ -2643,7 +2758,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
break;
|
||||
}
|
||||
case "result": {
|
||||
const usage = this.convertUsage(message);
|
||||
const usage = this.convertUsage(message, message.modelUsage);
|
||||
if (message.subtype === "success") {
|
||||
events.push({ type: "turn_completed", provider: "claude", usage });
|
||||
} else {
|
||||
@@ -2688,11 +2803,16 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (this.claudeSessionId === sessionId) {
|
||||
return null;
|
||||
}
|
||||
throw new Error(
|
||||
`CRITICAL: Claude session ID overwrite detected! ` +
|
||||
`Existing: ${this.claudeSessionId}, New: ${sessionId}. ` +
|
||||
`This indicates a session identity corruption bug.`,
|
||||
// Session ID changed mid-stream (e.g. a hook caused Claude to restart
|
||||
// with a new session). Accept the new ID and continue — the turn should
|
||||
// not be failed just because the underlying subprocess cycled.
|
||||
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 {
|
||||
@@ -2727,14 +2847,20 @@ class ClaudeAgentSession implements AgentSession {
|
||||
} else if (existingSessionId === newSessionId) {
|
||||
this.logger.debug({ sessionId: newSessionId }, "Claude session ID unchanged (same value)");
|
||||
} else {
|
||||
throw new Error(
|
||||
`CRITICAL: Claude session ID overwrite detected! ` +
|
||||
`Existing: ${existingSessionId}, New: ${newSessionId}. ` +
|
||||
`This indicates a session identity corruption bug.`,
|
||||
// Session ID changed in an init message (e.g. a hook restarted Claude
|
||||
// with a new session mid-turn). Accept the new ID and continue.
|
||||
this.logger.warn(
|
||||
{ existingSessionId, newSessionId },
|
||||
"Claude session ID changed in init message; accepting new session",
|
||||
);
|
||||
this.claudeSessionId = newSessionId;
|
||||
threadStartedSessionId = newSessionId;
|
||||
}
|
||||
this.availableModes = DEFAULT_MODES;
|
||||
this.currentMode = message.permissionMode;
|
||||
if (this.currentMode !== "plan") {
|
||||
this.planResumeMode = this.currentMode;
|
||||
}
|
||||
this.persistence = null;
|
||||
if (message.model) {
|
||||
const normalizedRuntimeModel = normalizeClaudeRuntimeModelId(message.model);
|
||||
@@ -2776,16 +2902,38 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return null;
|
||||
}
|
||||
|
||||
private convertUsage(message: SDKResultMessage): AgentUsage | undefined {
|
||||
private convertUsage(message: SDKResultMessage, modelUsage?: unknown): AgentUsage | undefined {
|
||||
if (!message.usage) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
const usage: AgentUsage = {
|
||||
inputTokens: message.usage.input_tokens,
|
||||
cachedInputTokens: message.usage.cache_read_input_tokens,
|
||||
outputTokens: message.usage.output_tokens,
|
||||
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 (
|
||||
@@ -2820,6 +2968,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
input,
|
||||
detail: toolDetail,
|
||||
suggestions: options.suggestions?.map((suggestion) => ({ ...suggestion })),
|
||||
actions:
|
||||
kind === "plan" ? buildClaudePlanPermissionActions(this.planResumeMode) : 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 type { AgentLaunchContext, AgentSession, AgentSessionConfig, AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
@@ -40,6 +40,183 @@ function createSession(configOverrides: Partial<AgentSessionConfig> = {}) {
|
||||
describe("Codex app-server provider", () => {
|
||||
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 () => {
|
||||
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 {
|
||||
AgentPermissionAction,
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentFeature,
|
||||
@@ -64,6 +65,8 @@ const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
||||
const CODEX_PROVIDER = "codex" as const;
|
||||
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 = {
|
||||
supportsStreaming: true,
|
||||
@@ -143,6 +146,73 @@ function normalizeCodexModelLabel(displayName: string): string {
|
||||
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 = {
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
@@ -677,12 +747,42 @@ function terminateChildProcessTree(child: ChildProcessWithoutNullStreams): void
|
||||
function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined {
|
||||
if (!tokenUsage || typeof tokenUsage !== "object") return undefined;
|
||||
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 {
|
||||
inputTokens: usage.last?.inputTokens,
|
||||
cachedInputTokens: usage.last?.cachedInputTokens,
|
||||
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 = {
|
||||
label: string;
|
||||
description?: string;
|
||||
@@ -2269,8 +2416,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
string,
|
||||
{
|
||||
resolve: (value: unknown) => void;
|
||||
kind: "command" | "file" | "question";
|
||||
kind: "command" | "file" | "question" | "plan";
|
||||
questions?: CodexQuestionPrompt[];
|
||||
planText?: string;
|
||||
}
|
||||
>();
|
||||
private resolvedPermissionRequests = new Set<string>();
|
||||
@@ -2289,6 +2437,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
private warnedInvalidNotificationPayloads = new Set<string>();
|
||||
private warnedIncompleteEditToolCallIds = new Set<string>();
|
||||
private latestUsage: AgentUsage | undefined;
|
||||
private latestPlanResult: { callId: string; text: string; turnId: string | null } | null = null;
|
||||
private connected = false;
|
||||
private collaborationModes: Array<{
|
||||
name: string;
|
||||
@@ -2472,6 +2621,79 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
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 {
|
||||
if (!this.client) return;
|
||||
|
||||
@@ -2774,7 +2996,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
params.cwd = this.config.cwd;
|
||||
}
|
||||
if (options?.outputSchema) {
|
||||
params.outputSchema = options.outputSchema;
|
||||
params.outputSchema = normalizeCodexOutputSchema(options.outputSchema);
|
||||
}
|
||||
if (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> {
|
||||
if (featureId === "fast_mode") {
|
||||
this.serviceTier = value ? "fast" : null;
|
||||
this.cachedRuntimeInfo = null;
|
||||
this.applyFeatureValue("fast_mode", Boolean(value));
|
||||
return;
|
||||
}
|
||||
if (featureId === "plan_mode") {
|
||||
this.planModeEnabled = Boolean(value);
|
||||
this.refreshResolvedCollaborationMode();
|
||||
this.cachedRuntimeInfo = null;
|
||||
this.applyFeatureValue("plan_mode", Boolean(value));
|
||||
return;
|
||||
}
|
||||
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}'`);
|
||||
}
|
||||
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.pendingPermissions.delete(requestId);
|
||||
this.resolvedPermissionRequests.add(requestId);
|
||||
@@ -3193,6 +3432,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
if (parsed.kind === "turn_started") {
|
||||
this.currentTurnId = parsed.turnId;
|
||||
this.latestPlanResult = null;
|
||||
this.emittedItemStartedIds.clear();
|
||||
this.emittedItemCompletedIds.clear();
|
||||
this.emittedExecCommandStartedCallIds.clear();
|
||||
@@ -3214,6 +3454,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
} else if (parsed.status === "interrupted") {
|
||||
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
|
||||
} else {
|
||||
if (this.planModeEnabled && this.latestPlanResult?.text) {
|
||||
this.emitSyntheticPlanApprovalRequest(this.latestPlanResult.text);
|
||||
}
|
||||
this.emitEvent({
|
||||
type: "turn_completed",
|
||||
provider: CODEX_PROVIDER,
|
||||
@@ -3221,6 +3464,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
});
|
||||
}
|
||||
this.activeForegroundTurnId = null;
|
||||
this.latestPlanResult = null;
|
||||
this.emittedItemStartedIds.clear();
|
||||
this.emittedItemCompletedIds.clear();
|
||||
this.emittedExecCommandStartedCallIds.clear();
|
||||
@@ -3242,6 +3486,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
),
|
||||
});
|
||||
if (timelineItem) {
|
||||
this.rememberPlanResult(timelineItem);
|
||||
this.emitEvent({
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
@@ -3435,6 +3680,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
if (timelineItem.type === "tool_call") {
|
||||
if (timelineItem.detail.type === "plan") {
|
||||
this.rememberPlanResult(timelineItem);
|
||||
}
|
||||
this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item);
|
||||
}
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||
@@ -4008,6 +4256,8 @@ export const __codexAppServerInternals = {
|
||||
mapCodexPatchNotificationToToolCall,
|
||||
planStepsToMarkdown,
|
||||
mapCodexPlanToToolCall,
|
||||
normalizeCodexOutputSchema,
|
||||
normalizeCodexQuestionPrompts,
|
||||
toAgentUsage,
|
||||
threadItemToTimeline,
|
||||
};
|
||||
|
||||
@@ -12,7 +12,11 @@ import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
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 type {
|
||||
AgentSessionConfig,
|
||||
@@ -202,6 +206,11 @@ const hasOpenCode = isBinaryInstalled("opencode");
|
||||
|
||||
// HARD ASSERT: Model ID contains provider prefix (format: providerId/modelId)
|
||||
expect(model.id).toContain("/");
|
||||
expect(model.metadata).toMatchObject({
|
||||
providerId: expect.any(String),
|
||||
modelId: expect.any(String),
|
||||
contextWindowMaxTokens: expect.any(Number),
|
||||
});
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
@@ -303,3 +312,149 @@ const hasOpenCode = isBinaryInstalled("opencode");
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 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 { 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 type { Logger } from "pino";
|
||||
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 {
|
||||
private static instance: OpenCodeServerManager | null = null;
|
||||
private static exitHandlerRegistered = false;
|
||||
@@ -423,6 +644,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
private readonly serverManager: OpenCodeServerManager;
|
||||
private readonly logger: Logger;
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly modelContextWindows = new Map<string, number>();
|
||||
|
||||
constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) {
|
||||
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");
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -485,7 +715,15 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
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[]> {
|
||||
@@ -533,6 +771,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
const models: AgentModelDefinition[] = [];
|
||||
this.modelContextWindows.clear();
|
||||
for (const provider of providers.all) {
|
||||
// Skip providers that aren't connected/configured
|
||||
if (!connectedProviderIds.has(provider.id)) {
|
||||
@@ -540,32 +779,15 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
for (const [modelId, model] of Object.entries(provider.models)) {
|
||||
const rawVariants = model.variants ? Object.keys(model.variants) : [];
|
||||
const thinkingOptions = rawVariants.map((id, index) => ({
|
||||
id,
|
||||
label: id,
|
||||
isDefault: index === 0,
|
||||
}));
|
||||
|
||||
models.push({
|
||||
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,
|
||||
},
|
||||
});
|
||||
const definition = buildOpenCodeModelDefinition(provider, modelId, model);
|
||||
const contextWindowMaxTokens = extractOpenCodeModelContextWindow(model);
|
||||
if (contextWindowMaxTokens !== undefined) {
|
||||
this.modelContextWindows.set(
|
||||
buildOpenCodeModelLookupKey(provider.id, modelId),
|
||||
contextWindowMaxTokens,
|
||||
);
|
||||
}
|
||||
models.push(definition);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -685,6 +907,22 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
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 = {
|
||||
@@ -695,6 +933,8 @@ export type OpenCodeEventTranslationState = {
|
||||
emittedStructuredMessageIds: Set<string>;
|
||||
/** Tracks the type of each part by ID, learned from message.part.updated events. */
|
||||
partTypes: Map<string, string>;
|
||||
modelContextWindowsByModelKey?: ReadonlyMap<string, number>;
|
||||
onAssistantModelContextWindowResolved?: (contextWindowMaxTokens: number) => void;
|
||||
};
|
||||
|
||||
function stringifyStructuredAssistantMessage(value: unknown): string | null {
|
||||
@@ -787,6 +1027,15 @@ export function translateOpenCodeEvent(
|
||||
|
||||
if (messageId && messageSessionId === state.sessionId && 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 (
|
||||
role === "assistant" &&
|
||||
!state.emittedStructuredMessageIds.has(messageId) &&
|
||||
@@ -894,20 +1143,7 @@ export function translateOpenCodeEvent(
|
||||
});
|
||||
}
|
||||
} else if (partType === "step-finish") {
|
||||
const tokens = part.tokens as
|
||||
| { 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;
|
||||
}
|
||||
mergeOpenCodeStepFinishUsage(state.accumulatedUsage, part);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1083,6 +1319,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private readonly client: OpencodeClient;
|
||||
private readonly sessionId: string;
|
||||
private readonly logger: Logger;
|
||||
private readonly modelContextWindowsByModelKey: ReadonlyMap<string, number>;
|
||||
private currentMode: string = "default";
|
||||
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
||||
private abortController: AbortController | null = null;
|
||||
@@ -1102,18 +1339,23 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private nextTurnOrdinal = 0;
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
private readonly runningToolCalls = new Map<string, ToolCallTimelineItem>();
|
||||
private selectedModelContextWindowMaxTokens: number | undefined;
|
||||
|
||||
constructor(
|
||||
config: OpenCodeAgentConfig,
|
||||
client: OpencodeClient,
|
||||
sessionId: string,
|
||||
logger: Logger,
|
||||
modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(),
|
||||
) {
|
||||
this.config = config;
|
||||
this.client = client;
|
||||
this.sessionId = sessionId;
|
||||
this.logger = logger;
|
||||
this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
|
||||
this.currentMode = normalizeOpenCodeModeId(config.modeId);
|
||||
this.selectedModelContextWindowMaxTokens =
|
||||
this.resolveConfiguredModelContextWindowMaxTokens(config.model);
|
||||
}
|
||||
|
||||
get id(): string | null {
|
||||
@@ -1133,6 +1375,8 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
const normalizedModelId =
|
||||
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
|
||||
this.config.model = normalizedModelId ?? undefined;
|
||||
this.selectedModelContextWindowMaxTokens =
|
||||
this.resolveConfiguredModelContextWindowMaxTokens(this.config.model);
|
||||
}
|
||||
|
||||
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
|
||||
@@ -1246,6 +1490,9 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
const turnAbortController = new AbortController();
|
||||
this.abortController = turnAbortController;
|
||||
await this.ensureMcpServersConfigured();
|
||||
const contextWindowMaxTokens = this.resolveSelectedModelContextWindowMaxTokens();
|
||||
this.accumulatedUsage =
|
||||
contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
|
||||
|
||||
const parts = this.buildPromptParts(prompt);
|
||||
const model = this.parseModel(this.config.model);
|
||||
@@ -1781,28 +2028,35 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
streamedPartKeys: this.streamedPartKeys,
|
||||
emittedStructuredMessageIds: this.emittedStructuredMessageIds,
|
||||
partTypes: this.partTypes,
|
||||
modelContextWindowsByModelKey: this.modelContextWindowsByModelKey,
|
||||
onAssistantModelContextWindowResolved: (contextWindowMaxTokens) => {
|
||||
this.accumulatedUsage.contextWindowMaxTokens = contextWindowMaxTokens;
|
||||
if (!this.config.model) {
|
||||
this.selectedModelContextWindowMaxTokens = contextWindowMaxTokens;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
for (const translatedEvent of translated) {
|
||||
if (translatedEvent.type === "permission_requested") {
|
||||
this.pendingPermissions.set(translatedEvent.request.id, translatedEvent.request);
|
||||
}
|
||||
if (translatedEvent.type === "turn_completed") {
|
||||
translatedEvent.usage = this.extractAndResetUsage();
|
||||
}
|
||||
}
|
||||
|
||||
return translated;
|
||||
}
|
||||
|
||||
private extractAndResetUsage(): AgentUsage | undefined {
|
||||
const usage = this.accumulatedUsage;
|
||||
this.accumulatedUsage = {};
|
||||
private resolveSelectedModelContextWindowMaxTokens(): number | undefined {
|
||||
return this.selectedModelContextWindowMaxTokens;
|
||||
}
|
||||
|
||||
if (!usage.inputTokens && !usage.outputTokens && !usage.totalCostUsd) {
|
||||
private resolveConfiguredModelContextWindowMaxTokens(
|
||||
modelId: string | undefined,
|
||||
): number | undefined {
|
||||
const modelLookupKey = parseOpenCodeModelLookupKey(modelId);
|
||||
if (!modelLookupKey) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return usage;
|
||||
return this.modelContextWindowsByModelKey.get(modelLookupKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,33 @@ function createState(sessionId = "session-1"): OpenCodeEventTranslationState {
|
||||
}
|
||||
|
||||
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", () => {
|
||||
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 {
|
||||
const ignoreWhitespace = compare.ignoreWhitespace === true;
|
||||
if (compare.mode === "uncommitted") {
|
||||
return { mode: "uncommitted" };
|
||||
return { mode: "uncommitted", ignoreWhitespace };
|
||||
}
|
||||
const trimmedBaseRef = compare.baseRef?.trim();
|
||||
return trimmedBaseRef ? { mode: "base", baseRef: trimmedBaseRef } : { mode: "base" };
|
||||
return trimmedBaseRef
|
||||
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
|
||||
: { mode: "base", ignoreWhitespace };
|
||||
}
|
||||
|
||||
private buildTargetKey(cwd: string, compare: CheckoutDiffCompareInput): string {
|
||||
@@ -135,6 +138,7 @@ export class CheckoutDiffManager {
|
||||
cwd,
|
||||
compare.mode,
|
||||
compare.mode === "base" ? (compare.baseRef ?? "") : "",
|
||||
compare.ignoreWhitespace === true,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -206,6 +210,7 @@ export class CheckoutDiffManager {
|
||||
{
|
||||
mode: compare.mode,
|
||||
baseRef: compare.baseRef,
|
||||
ignoreWhitespace: compare.ignoreWhitespace,
|
||||
includeStructured: true,
|
||||
},
|
||||
{ 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 UnsubscribeCheckoutDiffRequest,
|
||||
type DirectorySuggestionsRequest,
|
||||
type EditorTargetId,
|
||||
type ProjectPlacementPayload,
|
||||
type WorkspaceSetupSnapshot,
|
||||
type WorkspaceDescriptorPayload,
|
||||
@@ -49,6 +50,7 @@ import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech
|
||||
import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js";
|
||||
import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js";
|
||||
import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js";
|
||||
import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js";
|
||||
import {
|
||||
DictationStreamManager,
|
||||
type DictationStreamOutboundMessage,
|
||||
@@ -66,6 +68,7 @@ import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from
|
||||
import { buildWorkspaceServicePayloads } from "./service-status-projection.js";
|
||||
import { spawnWorkspaceService } from "./worktree-bootstrap.js";
|
||||
import { readGitCommand } from "./workspace-git-metadata.js";
|
||||
import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js";
|
||||
|
||||
export type AgentMcpTransportFactory = () => Promise<Transport>;
|
||||
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 WorkspaceGitWatchTarget = {
|
||||
@@ -395,6 +409,7 @@ export type SessionOptions = {
|
||||
loopService: LoopService;
|
||||
checkoutDiffManager: CheckoutDiffManager;
|
||||
agentLoadingService?: AgentLoadingService;
|
||||
backgroundGitFetchManager: BackgroundGitFetchManager;
|
||||
createAgentMcpTransport: AgentMcpTransportFactory;
|
||||
stt: Resolvable<SpeechToTextProvider | null>;
|
||||
tts: Resolvable<TextToSpeechProvider | null>;
|
||||
@@ -565,6 +580,7 @@ export class Session {
|
||||
private readonly loopService: LoopService;
|
||||
private readonly checkoutDiffManager: CheckoutDiffManager;
|
||||
private readonly agentLoadingService: AgentLoadingService;
|
||||
private readonly backgroundGitFetchManager: BackgroundGitFetchManager;
|
||||
private readonly createAgentMcpTransport: AgentMcpTransportFactory;
|
||||
private readonly downloadTokenStore: DownloadTokenStore;
|
||||
private readonly pushTokenStore: PushTokenStore;
|
||||
@@ -604,6 +620,7 @@ export class Session {
|
||||
private readonly checkoutDiffSubscriptions = new Map<string, () => void>();
|
||||
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>();
|
||||
private readonly workspaceSetupSnapshots = new Map<string, WorkspaceSetupSnapshot>();
|
||||
private readonly workspaceGitFetchSubscriptions = new Map<string, () => void>();
|
||||
private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null;
|
||||
private readonly registerVoiceSpeakHandler?: (
|
||||
agentId: string,
|
||||
@@ -643,6 +660,7 @@ export class Session {
|
||||
loopService,
|
||||
checkoutDiffManager,
|
||||
agentLoadingService,
|
||||
backgroundGitFetchManager,
|
||||
createAgentMcpTransport,
|
||||
stt,
|
||||
tts,
|
||||
@@ -687,6 +705,8 @@ export class Session {
|
||||
agentStorage: this.agentStorage,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
this.backgroundGitFetchManager = backgroundGitFetchManager;
|
||||
this.backgroundGitFetchManager = backgroundGitFetchManager;
|
||||
this.createAgentMcpTransport = createAgentMcpTransport;
|
||||
this.terminalManager = terminalManager;
|
||||
this.providerSnapshotManager = providerSnapshotManager ?? null;
|
||||
@@ -1650,6 +1670,14 @@ export class Session {
|
||||
await this.handleWorkspaceSetupStatusRequest(msg);
|
||||
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":
|
||||
await this.handleOpenProjectRequest(msg);
|
||||
break;
|
||||
@@ -4077,6 +4105,9 @@ export class Session {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const unsubscribeFetch = this.workspaceGitFetchSubscriptions.get(workspaceId);
|
||||
unsubscribeFetch?.();
|
||||
this.workspaceGitFetchSubscriptions.delete(workspaceId);
|
||||
this.closeWorkspaceGitWatchTarget(target);
|
||||
this.workspaceGitWatchTargets.delete(workspaceId);
|
||||
}
|
||||
@@ -4123,7 +4154,7 @@ export class Session {
|
||||
workspaces: Iterable<WorkspaceDescriptorPayload>,
|
||||
): Promise<void> {
|
||||
for (const workspace of workspaces) {
|
||||
const persistedWorkspace = await this.workspaceRegistry.get(Number(workspace.id));
|
||||
const persistedWorkspace = await this.findWorkspaceByDirectory(workspace.id);
|
||||
if (!persistedWorkspace) {
|
||||
continue;
|
||||
}
|
||||
@@ -4216,6 +4247,16 @@ export class Session {
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -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([
|
||||
this.listAgentPayloads(),
|
||||
this.workspaceRegistry.list(),
|
||||
@@ -5253,16 +5315,30 @@ export class Session {
|
||||
.filter((project) => !project.archivedAt)
|
||||
.map((project) => [project.id, project] as const),
|
||||
);
|
||||
const descriptorsByWorkspaceId = new Map<number, WorkspaceDescriptorPayload>();
|
||||
const workspaceIdsByDirectory = new Map(activeRecords.map((workspace) => [workspace.directory, workspace.id]));
|
||||
const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>();
|
||||
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) {
|
||||
if (workspaceIds && !workspaceIds.has(workspace.directory)) {
|
||||
continue;
|
||||
}
|
||||
const projectRecord = activeProjects.get(workspace.projectId) ?? null;
|
||||
descriptorsByWorkspaceId.set(
|
||||
workspace.id,
|
||||
await this.describeWorkspaceRecord(
|
||||
workspace.directory,
|
||||
await this.buildWorkspaceDescriptor({
|
||||
workspace,
|
||||
activeProjects.get(workspace.projectId) ?? null,
|
||||
),
|
||||
projectRecord,
|
||||
includeGitData: options.includeGitData,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5290,7 +5366,41 @@ export class Session {
|
||||
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[]> {
|
||||
@@ -5328,7 +5438,7 @@ export class Session {
|
||||
case "name":
|
||||
return workspace.name.toLocaleLowerCase();
|
||||
case "project_id":
|
||||
return workspace.projectId;
|
||||
return workspace.projectId.toLocaleLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5346,7 +5456,7 @@ export class Session {
|
||||
}
|
||||
return spec.direction === "asc" ? base : -base;
|
||||
}
|
||||
return Number(left.id) - Number(right.id);
|
||||
return left.id.localeCompare(right.id);
|
||||
}
|
||||
|
||||
private encodeFetchWorkspacesCursor(
|
||||
@@ -5388,7 +5498,7 @@ export class Session {
|
||||
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");
|
||||
}
|
||||
if (!payload.values || typeof payload.values !== "object") {
|
||||
@@ -5455,7 +5565,7 @@ export class Session {
|
||||
}
|
||||
return spec.direction === "asc" ? base : -base;
|
||||
}
|
||||
return Number(workspace.id) - Number(cursor.id);
|
||||
return workspace.id.localeCompare(cursor.id);
|
||||
}
|
||||
|
||||
private matchesWorkspaceFilter(input: {
|
||||
@@ -5467,8 +5577,8 @@ export class Session {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof filter.projectId === "number") {
|
||||
if (workspace.projectId !== filter.projectId) {
|
||||
if (filter.projectId && filter.projectId.trim().length > 0) {
|
||||
if (workspace.projectId !== filter.projectId.trim()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -5707,46 +5817,73 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async emitWorkspaceUpdateForCwd(
|
||||
cwd: string,
|
||||
options?: { dedupeGitState?: boolean },
|
||||
private async reconcileAndEmitWorkspaceUpdates(): Promise<void> {
|
||||
if (!this.workspaceUpdatesSubscription) {
|
||||
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> {
|
||||
const subscription = this.workspaceUpdatesSubscription;
|
||||
if (!subscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedCwd = await this.resolveWorkspaceDirectory(cwd);
|
||||
const persistedWorkspace = await this.findWorkspaceByDirectory(normalizedCwd);
|
||||
const all = await this.listWorkspaceDescriptorsSnapshot();
|
||||
const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const));
|
||||
const workspaceIdsToEmit = persistedWorkspace ? [persistedWorkspace.directory] : [];
|
||||
const uniqueWorkspaceIds = new Set(
|
||||
Array.from(workspaceIds, (workspaceId) => normalizePersistedWorkspaceId(workspaceId)),
|
||||
);
|
||||
if (uniqueWorkspaceIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const nextWorkspaceId of workspaceIdsToEmit) {
|
||||
const workspace = descriptorsByWorkspaceId.get(nextWorkspaceId);
|
||||
const descriptorsByWorkspaceId = await this.buildWorkspaceDescriptorMap({
|
||||
workspaceIds: uniqueWorkspaceIds,
|
||||
includeGitData: true,
|
||||
});
|
||||
|
||||
for (const workspaceId of uniqueWorkspaceIds) {
|
||||
const workspace = descriptorsByWorkspaceId.get(workspaceId);
|
||||
const nextWorkspace =
|
||||
workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })
|
||||
? workspace
|
||||
: null;
|
||||
if (
|
||||
options?.dedupeGitState &&
|
||||
this.shouldSkipWorkspaceGitWatchUpdate(normalizedCwd, nextWorkspace)
|
||||
this.shouldSkipWorkspaceGitWatchUpdate(workspaceId, nextWorkspace)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const watchTarget = this.workspaceGitWatchTargets.get(normalizedCwd);
|
||||
const watchTarget = this.workspaceGitWatchTargets.get(workspaceId);
|
||||
if (watchTarget && this.onBranchChanged) {
|
||||
const newBranchName = nextWorkspace?.name ?? null;
|
||||
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) {
|
||||
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
||||
kind: "remove",
|
||||
id: nextWorkspaceId,
|
||||
id: workspaceId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -5756,50 +5893,53 @@ export class Session {
|
||||
workspace: nextWorkspace,
|
||||
});
|
||||
}
|
||||
|
||||
if (!options?.skipReconcile) {
|
||||
void this.reconcileAndEmitWorkspaceUpdates();
|
||||
}
|
||||
}
|
||||
|
||||
private async emitWorkspaceUpdatesForCwds(cwds: Iterable<string>): Promise<void> {
|
||||
if (!this.workspaceUpdatesSubscription) {
|
||||
private scheduleWorkspaceGitBootstrapUpdates(options: {
|
||||
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;
|
||||
}
|
||||
|
||||
const uniqueWorkspaceCwds = new Set<string>();
|
||||
for (const cwd of cwds) {
|
||||
const normalized = await this.resolveWorkspaceDirectory(cwd);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
queueMicrotask(() => {
|
||||
if (this.workspaceUpdatesSubscription?.subscriptionId !== options.subscriptionId) {
|
||||
return;
|
||||
}
|
||||
uniqueWorkspaceCwds.add(normalized);
|
||||
}
|
||||
|
||||
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,
|
||||
void this.emitWorkspaceUpdatesForWorkspaceIds(gitWorkspaceIds, {
|
||||
skipReconcile: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -5905,6 +6045,11 @@ export class Session {
|
||||
|
||||
if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) {
|
||||
this.flushBootstrappedWorkspaceUpdates({ snapshotLatestActivityByWorkspaceId });
|
||||
void this.reconcileAndEmitWorkspaceUpdates();
|
||||
this.scheduleWorkspaceGitBootstrapUpdates({
|
||||
subscriptionId,
|
||||
workspaces: payload.entries,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) {
|
||||
@@ -5931,7 +6076,7 @@ export class Session {
|
||||
try {
|
||||
const workspace = await this.findOrCreateWorkspaceForDirectory(request.cwd);
|
||||
await this.emitWorkspaceUpdateForCwd(workspace.directory);
|
||||
const descriptor = await this.describeWorkspaceRecord(workspace);
|
||||
const descriptor = await this.describeWorkspaceRecordWithGitData(workspace);
|
||||
this.emit({
|
||||
type: "open_project_response",
|
||||
payload: {
|
||||
@@ -5954,7 +6099,9 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private buildWorkspaceServicePayloadSnapshot(workspaceDirectory: string): WorkspaceDescriptorPayload["services"] {
|
||||
private buildWorkspaceServicePayloadSnapshot(
|
||||
workspaceDirectory: string,
|
||||
): WorkspaceDescriptorPayload["services"] {
|
||||
if (!this.serviceRouteStore) {
|
||||
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(
|
||||
request: StartWorkspaceServiceRequest,
|
||||
): 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(
|
||||
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
|
||||
): Promise<void> {
|
||||
return handleCreateWorktreeRequest(
|
||||
{
|
||||
paseoHome: this.paseoHome,
|
||||
describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecord(workspace),
|
||||
describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecordWithGitData(workspace),
|
||||
emit: (message) => this.emit(message),
|
||||
registerPendingWorktreeWorkspace: (options) =>
|
||||
this.registerPendingWorktreeWorkspace(options),
|
||||
@@ -6438,9 +6656,10 @@ export class Session {
|
||||
: record.lastStatus === "error"
|
||||
? "error"
|
||||
: "idle";
|
||||
const error = resolveWaitForFinishError({ status, final });
|
||||
this.emit({
|
||||
type: "wait_for_finish_response",
|
||||
payload: { requestId, status, final, error: null, lastMessage: null },
|
||||
payload: { requestId, status, final, error, lastMessage: null },
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -6467,10 +6686,11 @@ export class Session {
|
||||
: result.status === "error"
|
||||
? "error"
|
||||
: "idle";
|
||||
const error = resolveWaitForFinishError({ status, final });
|
||||
|
||||
this.emit({
|
||||
type: "wait_for_finish_response",
|
||||
payload: { requestId, status, final, error: null, lastMessage: result.lastMessage },
|
||||
payload: { requestId, status, final, error, lastMessage: result.lastMessage },
|
||||
});
|
||||
} catch (error) {
|
||||
const isAbort =
|
||||
@@ -7212,6 +7432,10 @@ export class Session {
|
||||
}
|
||||
this.checkoutDiffSubscriptions.clear();
|
||||
|
||||
for (const unsubscribe of this.workspaceGitFetchSubscriptions.values()) {
|
||||
unsubscribe();
|
||||
}
|
||||
this.workspaceGitFetchSubscriptions.clear();
|
||||
for (const target of this.workspaceGitWatchTargets.values()) {
|
||||
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", () => ({
|
||||
READ_ONLY_GIT_ENV: {
|
||||
...process.env,
|
||||
GIT_OPTIONAL_LOCKS: "0",
|
||||
},
|
||||
resolveCheckoutGitDir: resolveCheckoutGitDirMock,
|
||||
toCheckoutError: vi.fn((error: unknown) => ({
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@getpaseo/highlight", () => ({
|
||||
@@ -61,10 +68,33 @@ function createSessionForWorkspaceGitWatchTests(): {
|
||||
emitted: Array<{ type: string; payload: unknown }>;
|
||||
projects: Map<number, ReturnType<typeof createPersistedProjectRecord>>;
|
||||
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 projects = new Map<number, ReturnType<typeof createPersistedProjectRecord>>();
|
||||
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 = {
|
||||
child: () => logger,
|
||||
trace: vi.fn(),
|
||||
@@ -73,6 +103,19 @@ function createSessionForWorkspaceGitWatchTests(): {
|
||||
warn: 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({
|
||||
clientId: "test-client",
|
||||
@@ -95,7 +138,11 @@ function createSessionForWorkspaceGitWatchTests(): {
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(projects.values()),
|
||||
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) => {
|
||||
projects.set(record.id, record);
|
||||
},
|
||||
@@ -119,7 +166,11 @@ function createSessionForWorkspaceGitWatchTests(): {
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(workspaces.values()),
|
||||
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) => {
|
||||
workspaces.set(record.id, record);
|
||||
},
|
||||
@@ -152,6 +203,7 @@ function createSessionForWorkspaceGitWatchTests(): {
|
||||
}),
|
||||
dispose: () => {},
|
||||
} as any,
|
||||
backgroundGitFetchManager: backgroundGitFetchManager as any,
|
||||
createAgentMcpTransport: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
@@ -162,7 +214,17 @@ function createSessionForWorkspaceGitWatchTests(): {
|
||||
|
||||
(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: {
|
||||
@@ -233,19 +295,20 @@ describe("workspace git watch targets", () => {
|
||||
};
|
||||
|
||||
let descriptor = {
|
||||
id: "10",
|
||||
projectId: "1",
|
||||
id: "/tmp/repo",
|
||||
projectId: "/tmp/repo",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: { additions: 1, deletions: 0 },
|
||||
workspaceDirectory: "/tmp/repo",
|
||||
};
|
||||
|
||||
sessionAny.listWorkspaceDescriptorsSnapshot = async () => [descriptor];
|
||||
sessionAny.buildWorkspaceDescriptorMap = async () => new Map([["/tmp/repo", descriptor]]);
|
||||
|
||||
await sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]);
|
||||
|
||||
@@ -274,7 +337,7 @@ describe("workspace git watch targets", () => {
|
||||
expect(workspaceUpdates[0]?.payload).toMatchObject({
|
||||
kind: "upsert",
|
||||
workspace: {
|
||||
id: "10",
|
||||
id: "/tmp/repo",
|
||||
name: "renamed-branch",
|
||||
diffStat: { additions: 1, deletions: 0 },
|
||||
},
|
||||
@@ -317,15 +380,16 @@ describe("workspace git watch targets", () => {
|
||||
|
||||
await sessionAny.primeWorkspaceGitWatchFingerprints([
|
||||
{
|
||||
id: 20,
|
||||
projectId: 2,
|
||||
id: "/tmp/repo-one",
|
||||
projectId: "/tmp/repo-one",
|
||||
projectDisplayName: "repo-one",
|
||||
projectRootPath: "/tmp/repo-one",
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
workspaceDirectory: "/tmp/repo-one",
|
||||
},
|
||||
]);
|
||||
expect(sessionAny.workspaceGitWatchTargets.size).toBe(1);
|
||||
@@ -341,15 +405,16 @@ describe("workspace git watch targets", () => {
|
||||
|
||||
await sessionAny.primeWorkspaceGitWatchFingerprints([
|
||||
{
|
||||
id: 30,
|
||||
projectId: 3,
|
||||
id: "/tmp/repo-two",
|
||||
projectId: "/tmp/repo-two",
|
||||
projectDisplayName: "repo-two",
|
||||
projectRootPath: "/tmp/repo-two",
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
workspaceDirectory: "/tmp/repo-two",
|
||||
},
|
||||
]);
|
||||
expect(sessionAny.workspaceGitWatchTargets.size).toBe(1);
|
||||
@@ -379,4 +444,143 @@ describe("workspace git watch targets", () => {
|
||||
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(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const backgroundGitFetchManager = {
|
||||
subscribe: vi.fn(async () => ({ unsubscribe: vi.fn() })),
|
||||
};
|
||||
|
||||
const session = new Session({
|
||||
clientId: "test-client",
|
||||
@@ -172,6 +175,7 @@ function createSessionForWorkspaceTests(): {
|
||||
}),
|
||||
dispose: () => {},
|
||||
} as any,
|
||||
backgroundGitFetchManager: backgroundGitFetchManager as any,
|
||||
createAgentMcpTransport: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
@@ -988,38 +992,47 @@ describe("workspace aggregation", () => {
|
||||
isBootstrapping: false,
|
||||
pendingUpdatesByWorkspaceId: new Map(),
|
||||
};
|
||||
(session as any).listWorkspaceDescriptorsSnapshot = async () => [
|
||||
{
|
||||
id: "/tmp/repo",
|
||||
projectId: "/tmp/repo",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
workspaceDirectory: "/tmp/repo",
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "repo",
|
||||
status: "running",
|
||||
activityAt: "2026-03-01T12:00:00.000Z",
|
||||
services: [],
|
||||
},
|
||||
];
|
||||
(session as any).reconcileActiveWorkspaceRecords = async () => new Set();
|
||||
(session as any).buildWorkspaceDescriptorMap = async () =>
|
||||
new Map([
|
||||
[
|
||||
"/tmp/repo",
|
||||
{
|
||||
id: "/tmp/repo",
|
||||
projectId: "/tmp/repo",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
workspaceDirectory: "/tmp/repo",
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "repo",
|
||||
status: "running",
|
||||
activityAt: "2026-03-01T12:00:00.000Z",
|
||||
services: [],
|
||||
},
|
||||
],
|
||||
]);
|
||||
await (session as any).emitWorkspaceUpdateForCwd("/tmp/repo");
|
||||
|
||||
(session as any).listWorkspaceDescriptorsSnapshot = async () => [
|
||||
{
|
||||
id: "/tmp/repo",
|
||||
projectId: "/tmp/repo",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
workspaceDirectory: "/tmp/repo",
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "repo",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
services: [],
|
||||
},
|
||||
];
|
||||
(session as any).buildWorkspaceDescriptorMap = async () =>
|
||||
new Map([
|
||||
[
|
||||
"/tmp/repo",
|
||||
{
|
||||
id: "/tmp/repo",
|
||||
projectId: "/tmp/repo",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
workspaceDirectory: "/tmp/repo",
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "repo",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
services: [],
|
||||
},
|
||||
],
|
||||
]);
|
||||
await (session as any).emitWorkspaceUpdateForCwd("/tmp/repo");
|
||||
|
||||
const workspaceUpdates = emitted.filter((message) => message.type === "workspace_update");
|
||||
@@ -1109,6 +1122,195 @@ describe("workspace aggregation", () => {
|
||||
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 () => {
|
||||
const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests();
|
||||
@@ -1550,4 +1752,186 @@ describe("backward compatibility", () => {
|
||||
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 { ScheduleService } from "./schedule/service.js";
|
||||
import type { CheckoutDiffManager, CheckoutDiffMetrics } from "./checkout-diff-manager.js";
|
||||
import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js";
|
||||
import {
|
||||
type ServerInfoStatusPayload,
|
||||
type WSHelloMessage,
|
||||
@@ -239,6 +240,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly loopService: LoopService;
|
||||
private readonly scheduleService: ScheduleService;
|
||||
private readonly checkoutDiffManager: CheckoutDiffManager;
|
||||
private readonly backgroundGitFetchManager: BackgroundGitFetchManager;
|
||||
private readonly downloadTokenStore: DownloadTokenStore;
|
||||
private readonly paseoHome: string;
|
||||
private readonly pushTokenStore: PushTokenStore;
|
||||
@@ -356,6 +358,9 @@ export class VoiceAssistantWebSocketServer {
|
||||
throw new Error("VoiceAssistantWebSocketServer requires a checkout diff manager.");
|
||||
}
|
||||
this.checkoutDiffManager = checkoutDiffManager;
|
||||
this.backgroundGitFetchManager = new BackgroundGitFetchManager({
|
||||
logger: this.logger,
|
||||
});
|
||||
this.downloadTokenStore = downloadTokenStore;
|
||||
this.paseoHome = paseoHome;
|
||||
this.createAgentMcpTransport = createAgentMcpTransport;
|
||||
@@ -541,6 +546,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
|
||||
await Promise.all(cleanupPromises);
|
||||
this.providerSnapshotManager.destroy();
|
||||
this.backgroundGitFetchManager.dispose();
|
||||
this.checkoutDiffManager.dispose();
|
||||
this.pendingConnections.clear();
|
||||
this.sessions.clear();
|
||||
@@ -687,6 +693,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
loopService: this.loopService,
|
||||
scheduleService: this.scheduleService,
|
||||
checkoutDiffManager: this.checkoutDiffManager,
|
||||
backgroundGitFetchManager: this.backgroundGitFetchManager,
|
||||
createAgentMcpTransport: this.createAgentMcpTransport,
|
||||
stt: () => this.speech?.resolveStt() ?? 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", () => {
|
||||
const parsed = SessionInboundMessageSchema.safeParse({
|
||||
type: "initialize_agent_request",
|
||||
|
||||
@@ -146,6 +146,8 @@ const AgentUsageSchema: z.ZodType<AgentUsage> = z.object({
|
||||
cachedInputTokens: z.number().optional(),
|
||||
outputTokens: z.number().optional(),
|
||||
totalCostUsd: z.number().optional(),
|
||||
contextWindowMaxTokens: z.number().optional(),
|
||||
contextWindowUsedTokens: z.number().optional(),
|
||||
});
|
||||
|
||||
const McpStdioServerConfigSchema = z.object({
|
||||
@@ -197,15 +199,24 @@ const AgentSessionConfigSchema = z.object({
|
||||
});
|
||||
|
||||
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([
|
||||
z.object({
|
||||
behavior: z.literal("allow"),
|
||||
selectedActionId: z.string().optional(),
|
||||
updatedInput: z.record(z.unknown()).optional(),
|
||||
updatedPermissions: z.array(AgentPermissionUpdateSchema).optional(),
|
||||
}),
|
||||
z.object({
|
||||
behavior: z.literal("deny"),
|
||||
selectedActionId: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
interrupt: z.boolean().optional(),
|
||||
}),
|
||||
@@ -220,6 +231,7 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType<AgentPermissionReque
|
||||
description: z.string().optional(),
|
||||
input: z.record(z.unknown()).optional(),
|
||||
suggestions: z.array(AgentPermissionUpdateSchema).optional(),
|
||||
actions: z.array(AgentPermissionActionSchema).optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
@@ -978,6 +990,7 @@ const CheckoutErrorSchema = z.object({
|
||||
const CheckoutDiffCompareSchema = z.object({
|
||||
mode: z.enum(["uncommitted", "base"]),
|
||||
baseRef: z.string().optional(),
|
||||
ignoreWhitespace: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const CheckoutStatusRequestSchema = z.object({
|
||||
@@ -1098,6 +1111,32 @@ export const WorkspaceSetupStatusRequestSchema = z.object({
|
||||
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({
|
||||
type: z.literal("open_project_request"),
|
||||
cwd: z.string(),
|
||||
@@ -1366,6 +1405,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
PaseoWorktreeArchiveRequestSchema,
|
||||
CreatePaseoWorktreeRequestSchema,
|
||||
WorkspaceSetupStatusRequestSchema,
|
||||
ListAvailableEditorsRequestSchema,
|
||||
OpenInEditorRequestSchema,
|
||||
OpenProjectRequestSchema,
|
||||
ArchiveWorkspaceRequestSchema,
|
||||
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({
|
||||
type: z.literal("archive_workspace_response"),
|
||||
payload: z.object({
|
||||
@@ -2608,6 +2666,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
FetchWorkspacesResponseMessageSchema,
|
||||
OpenProjectResponseMessageSchema,
|
||||
StartWorkspaceServiceResponseMessageSchema,
|
||||
ListAvailableEditorsResponseMessageSchema,
|
||||
OpenInEditorResponseMessageSchema,
|
||||
ArchiveWorkspaceResponseMessageSchema,
|
||||
FetchAgentResponseMessageSchema,
|
||||
FetchAgentTimelineResponseMessageSchema,
|
||||
@@ -2708,6 +2768,8 @@ export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPaylo
|
||||
export type WorkspaceServiceLifecycle = z.infer<typeof WorkspaceServiceLifecycleSchema>;
|
||||
export type WorkspaceServiceHealth = z.infer<typeof WorkspaceServiceHealthSchema>;
|
||||
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 FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
|
||||
export type ServiceStatusUpdateMessage = z.infer<typeof ServiceStatusUpdateMessageSchema>;
|
||||
@@ -2715,6 +2777,10 @@ export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessa
|
||||
export type StartWorkspaceServiceResponseMessage = z.infer<
|
||||
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 FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessageSchema>;
|
||||
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 PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>;
|
||||
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 ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
|
||||
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
||||
|
||||
@@ -29,6 +29,27 @@ describe("workspace message schemas", () => {
|
||||
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", () => {
|
||||
const result = SessionOutboundMessageSchema.safeParse({
|
||||
type: "workspace_update",
|
||||
|
||||
@@ -110,6 +110,21 @@ describe("checkout git utilities", () => {
|
||||
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 () => {
|
||||
const originalContent = `/*
|
||||
comment line 1
|
||||
|
||||
@@ -309,13 +309,25 @@ export async function listBranchSuggestions(
|
||||
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 { stdout: nameStatusOut } = await execGit(`git diff --name-status ${ref}`, {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const { stdout: nameStatusOut } = await execFileAsync(
|
||||
"git",
|
||||
buildGitDiffArgs({
|
||||
ignoreWhitespace,
|
||||
extra: ["--name-status", ref],
|
||||
}),
|
||||
{
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
maxBuffer: SMALL_OUTPUT_MAX_BUFFER,
|
||||
},
|
||||
);
|
||||
for (const line of nameStatusOut
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
@@ -430,13 +442,24 @@ function normalizeNumstatPath(pathField: string): string {
|
||||
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_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({
|
||||
cmd: "git",
|
||||
args: ["diff", "--numstat", ref],
|
||||
args: buildGitDiffArgs({
|
||||
ignoreWhitespace,
|
||||
extra: ["--numstat", ref],
|
||||
}),
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
maxBytes: TRACKED_DIFF_NUMSTAT_MAX_BYTES,
|
||||
@@ -612,6 +635,7 @@ export interface CheckoutDiffResult {
|
||||
export interface CheckoutDiffCompare {
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
includeStructured?: boolean;
|
||||
}
|
||||
|
||||
@@ -1106,6 +1130,7 @@ function buildPlaceholderParsedDiffFile(
|
||||
async function getUntrackedDiffText(
|
||||
cwd: string,
|
||||
change: CheckoutFileChange,
|
||||
ignoreWhitespace = false,
|
||||
): Promise<{ text: string; truncated: boolean; stat: FileStat }> {
|
||||
try {
|
||||
const inspected = await inspectUntrackedFile(cwd, change.path);
|
||||
@@ -1118,7 +1143,10 @@ async function getUntrackedDiffText(
|
||||
|
||||
const result = await spawnLimitedText({
|
||||
cmd: "git",
|
||||
args: ["diff", "--no-index", "/dev/null", "--", change.path],
|
||||
args: buildGitDiffArgs({
|
||||
ignoreWhitespace,
|
||||
extra: ["--no-index", "/dev/null", "--", change.path],
|
||||
}),
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
maxBytes: PER_FILE_DIFF_MAX_BYTES,
|
||||
@@ -1391,7 +1419,8 @@ export async function getCheckoutDiff(
|
||||
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) => {
|
||||
if (a.path === b.path) return 0;
|
||||
return a.path < b.path ? -1 : 1;
|
||||
@@ -1422,7 +1451,7 @@ export async function getCheckoutDiff(
|
||||
|
||||
const trackedNumstatByPath =
|
||||
trackedChanges.length > 0
|
||||
? await getTrackedNumstatByPath(cwd, refForDiff)
|
||||
? await getTrackedNumstatByPath(cwd, refForDiff, ignoreWhitespace)
|
||||
: new Map<string, FileStat>();
|
||||
const trackedDiffPaths: string[] = [];
|
||||
const trackedPlaceholderByPath = new Map<
|
||||
@@ -1448,7 +1477,10 @@ export async function getCheckoutDiff(
|
||||
if (trackedDiffPaths.length > 0) {
|
||||
const trackedDiffResult = await spawnLimitedText({
|
||||
cmd: "git",
|
||||
args: ["diff", refForDiff, "--", ...trackedDiffPaths],
|
||||
args: buildGitDiffArgs({
|
||||
ignoreWhitespace,
|
||||
extra: [refForDiff, "--", ...trackedDiffPaths],
|
||||
}),
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
maxBytes: TOTAL_DIFF_MAX_BYTES,
|
||||
@@ -1537,7 +1569,11 @@ export async function getCheckoutDiff(
|
||||
if (diffBytes >= TOTAL_DIFF_MAX_BYTES) {
|
||||
break;
|
||||
}
|
||||
const { text, truncated, stat } = await getUntrackedDiffText(cwd, change);
|
||||
const { text, truncated, stat } = await getUntrackedDiffText(
|
||||
cwd,
|
||||
change,
|
||||
ignoreWhitespace,
|
||||
);
|
||||
|
||||
if (!compare.includeStructured) {
|
||||
if (stat?.isBinary) {
|
||||
|
||||
@@ -149,6 +149,71 @@ describe("createWorktree", () => {
|
||||
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 () => {
|
||||
await expect(
|
||||
createWorktree({
|
||||
|
||||
@@ -1046,15 +1046,14 @@ export async function createWorktree({
|
||||
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;
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${normalizedBaseBranch}`, { cwd });
|
||||
await execAsync(`git rev-parse --verify origin/${normalizedBaseBranch}`, { cwd });
|
||||
resolvedBaseBranch = `origin/${normalizedBaseBranch}`;
|
||||
} catch {
|
||||
// Local branch doesn't exist, try remote (origin/{branch})
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify origin/${normalizedBaseBranch}`, { cwd });
|
||||
resolvedBaseBranch = `origin/${normalizedBaseBranch}`;
|
||||
await execAsync(`git rev-parse --verify ${normalizedBaseBranch}`, { cwd });
|
||||
} catch {
|
||||
throw new Error(`Base branch not found: ${normalizedBaseBranch}`);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.49",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -826,6 +826,8 @@ function GetStarted() {
|
||||
<AgentBadge name="Claude Code" icon={<ClaudeCodeIcon 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="Copilot" icon={<CopilotIcon className="h-6 w-6" />} />
|
||||
<AgentBadge name="Pi" icon={<PiIcon className="h-6 w-6" />} />
|
||||
</div>
|
||||
</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 DocsVoiceRouteImport } from './routes/docs/voice'
|
||||
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 DocsConfigurationRouteImport } from './routes/docs/configuration'
|
||||
import { Route as DocsSkillsRouteImport } from './routes/docs/skills'
|
||||
import { Route as DocsCliRouteImport } from './routes/docs/cli'
|
||||
import { Route as DocsBestPracticesRouteImport } from './routes/docs/best-practices'
|
||||
import { Route as BlogSplatRouteImport } from './routes/blog/$'
|
||||
@@ -100,6 +100,11 @@ const DocsUpdatesRoute = DocsUpdatesRouteImport.update({
|
||||
path: '/updates',
|
||||
getParentRoute: () => DocsRoute,
|
||||
} as any)
|
||||
const DocsSkillsRoute = DocsSkillsRouteImport.update({
|
||||
id: '/skills',
|
||||
path: '/skills',
|
||||
getParentRoute: () => DocsRoute,
|
||||
} as any)
|
||||
const DocsSecurityRoute = DocsSecurityRouteImport.update({
|
||||
id: '/security',
|
||||
path: '/security',
|
||||
@@ -110,11 +115,6 @@ const DocsConfigurationRoute = DocsConfigurationRouteImport.update({
|
||||
path: '/configuration',
|
||||
getParentRoute: () => DocsRoute,
|
||||
} as any)
|
||||
const DocsSkillsRoute = DocsSkillsRouteImport.update({
|
||||
id: '/skills',
|
||||
path: '/skills',
|
||||
getParentRoute: () => DocsRoute,
|
||||
} as any)
|
||||
const DocsCliRoute = DocsCliRouteImport.update({
|
||||
id: '/cli',
|
||||
path: '/cli',
|
||||
@@ -375,6 +375,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof DocsUpdatesRouteImport
|
||||
parentRoute: typeof DocsRoute
|
||||
}
|
||||
'/docs/skills': {
|
||||
id: '/docs/skills'
|
||||
path: '/skills'
|
||||
fullPath: '/docs/skills'
|
||||
preLoaderRoute: typeof DocsSkillsRouteImport
|
||||
parentRoute: typeof DocsRoute
|
||||
}
|
||||
'/docs/security': {
|
||||
id: '/docs/security'
|
||||
path: '/security'
|
||||
@@ -389,13 +396,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof DocsConfigurationRouteImport
|
||||
parentRoute: typeof DocsRoute
|
||||
}
|
||||
'/docs/skills': {
|
||||
id: '/docs/skills'
|
||||
path: '/skills'
|
||||
fullPath: '/docs/skills'
|
||||
preLoaderRoute: typeof DocsSkillsRouteImport
|
||||
parentRoute: typeof DocsRoute
|
||||
}
|
||||
'/docs/cli': {
|
||||
id: '/docs/cli'
|
||||
path: '/cli'
|
||||
|
||||
Reference in New Issue
Block a user