diff --git a/CHANGELOG.md b/CHANGELOG.md index d1c22d94a..22241d6dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 9a94e305b..78020044a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/RELEASE.md b/docs/RELEASE.md index b327db20f..e8a46db4b 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -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 ..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 diff --git a/docs/plan-approval-normalization.md b/docs/plan-approval-normalization.md new file mode 100644 index 000000000..182d15ec5 --- /dev/null +++ b/docs/plan-approval-normalization.md @@ -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 `. +- 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` diff --git a/nix/package.nix b/nix/package.nix index 5fb51251c..8fc0b31b1 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -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). diff --git a/package-lock.json b/package-lock.json index 6686b0a1c..32509dbfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index a33618549..15f2fa86d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.1.48", + "version": "0.1.49", "private": true, "workspaces": [ "packages/expo-two-way-audio", diff --git a/packages/app/assets/images/editor-apps/cursor.png b/packages/app/assets/images/editor-apps/cursor.png new file mode 100644 index 000000000..7e30a3b6c Binary files /dev/null and b/packages/app/assets/images/editor-apps/cursor.png differ diff --git a/packages/app/assets/images/editor-apps/file-explorer.png b/packages/app/assets/images/editor-apps/file-explorer.png new file mode 100644 index 000000000..4d0ef95af Binary files /dev/null and b/packages/app/assets/images/editor-apps/file-explorer.png differ diff --git a/packages/app/assets/images/editor-apps/finder.png b/packages/app/assets/images/editor-apps/finder.png new file mode 100644 index 000000000..474dfe4b6 Binary files /dev/null and b/packages/app/assets/images/editor-apps/finder.png differ diff --git a/packages/app/assets/images/editor-apps/vscode.png b/packages/app/assets/images/editor-apps/vscode.png new file mode 100644 index 000000000..ced6ec214 Binary files /dev/null and b/packages/app/assets/images/editor-apps/vscode.png differ diff --git a/packages/app/assets/images/editor-apps/zed.png b/packages/app/assets/images/editor-apps/zed.png new file mode 100644 index 000000000..6111ab2ab Binary files /dev/null and b/packages/app/assets/images/editor-apps/zed.png differ diff --git a/packages/app/package.json b/packages/app/package.json index cdaf19c1b..3f4c48f1d 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index ccd9a12c5..5fa327b5d 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -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, diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 25bd5f5eb..5954ad2af 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -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" > + {selectedModelLabel} @@ -692,6 +698,7 @@ function ControlledStatusBar({ accessibilityLabel="Select thinking option" testID="agent-preferences-thinking" > + {displayThinking} @@ -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(); - 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) => { diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index f29d1ca37..d38bddad5 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -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(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, ]} > - [ - 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" ? ( - - ) : ( - - - - Deny - - - )} - + {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}`; - [ - 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" ? ( - - ) : ( - - - - Accept - - - )} - + return ( + [ + 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 ? ( + + ) : ( + + + + {action.label} + + + )} + + ); + })} ); diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/components/composer.tsx index f337fec01..f8e9bdd4a 100644 --- a/packages/app/src/components/composer.tsx +++ b/packages/app/src/components/composer.tsx @@ -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({ ) : 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 = ( + + {contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? ( + + ) : null} + + ); + const leftContent = resolveStatusControlMode(statusControls) === "draft" && 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, diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx new file mode 100644 index 000000000..7b8f12c30 --- /dev/null +++ b/packages/app/src/components/context-window-meter.tsx @@ -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["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 ( + + + + + + + + + + + + Context window + {`${roundedPercentage}% used`} + {`${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens`} + + + + ); +} + +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, + }, +})); diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 08a6dd284..44761930d 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -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>(() => new Set(["."])); const treeListRef = useRef>(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( diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 07968af47..e790af7f2 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -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; 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 ( - + {tokens.map((token, index) => ( - + {token.text} ))} @@ -115,10 +142,12 @@ function DiffLineView({ line, lineNumber, gutterWidth, + wrapLines, }: { line: DiffLine; lineNumber: number | null; gutterWidth: number; + wrapLines: boolean; }) { return ( {line.tokens && line.type !== "header" ? ( - + ) : ( + + + {line?.lineNumber != null ? String(line.lineNumber) : ""} + + + {line?.tokens ? ( + + ) : ( + + {line?.content ?? ""} + + )} + + ); +} + 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 ( { + 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 ( - - ); - }); - }); - })(); + 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 ( + + {row.content} + + ); + } + + 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 ( + + ); + }); + }); + + const availableWidth = bodyWidth > 0 ? bodyWidth : scrollViewWidth; + const contentContainer = ( + 0 && + (layout === "split" + ? { width: availableWidth, minWidth: availableWidth, maxWidth: availableWidth } + : { minWidth: availableWidth }), + ]} + > + {linesContent} + + ); if (wrapLines) { - return ( - - {linesContent} - - ); + return {contentContainer}; } return ( @@ -328,11 +445,7 @@ function DiffFileBody({ style={styles.diffContent} contentContainerStyle={styles.diffContentInner} > - 0 && { minWidth: scrollViewWidth }]} - > - {linesContent} - + {contentContainer} ); })()} @@ -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(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>({}); + 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>(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 = {}; - 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 ( ); }, - [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 = ( - {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}`} ); @@ -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 - {files.length > 0 ? ( - - - - [ - styles.expandAllButton, - (hovered || pressed) && styles.diffStatusRowHovered, - ]} - onPress={handleToggleWrapLines} - > - - - - - - {wrapLines ? "Scroll long lines" : "Wrap long lines"} - - - - - - [ - styles.expandAllButton, - (hovered || pressed) && styles.diffStatusRowHovered, - ]} - onPress={handleToggleExpandAll} - > - {allExpanded ? ( - + {canUseSplitLayout ? ( + + + + handleLayoutChange("unified")} + style={({ hovered, pressed }) => [ + styles.toggleButton, + styles.toggleButtonGroupStart, + changesPreferences.layout === "unified" && styles.toggleButtonSelected, + (hovered || pressed) && styles.diffStatusRowHovered, + ]} + > + - ) : ( - + + + Unified diff + + + + + handleLayoutChange("split")} + style={({ hovered, pressed }) => [ + styles.toggleButton, + styles.toggleButtonGroupEnd, + changesPreferences.layout === "split" && styles.toggleButtonSelected, + (hovered || pressed) && styles.diffStatusRowHovered, + ]} + > + - )} - - - - - {allExpanded ? "Collapse all files" : "Expand all files"} - - - - - ) : null} + + + + Side-by-side diff + + + + ) : null} + + + [ + styles.expandAllButton, + changesPreferences.hideWhitespace && styles.toggleButtonSelected, + (hovered || pressed) && styles.diffStatusRowHovered, + ]} + onPress={handleToggleHideWhitespace} + > + + + + + Hide whitespace + + + {files.length > 0 ? ( + + + + [ + styles.expandAllButton, + wrapLines && styles.toggleButtonSelected, + (hovered || pressed) && styles.diffStatusRowHovered, + ]} + onPress={handleToggleWrapLines} + > + + + + + + {wrapLines ? "Scroll long lines" : "Wrap long lines"} + + + + + + [ + styles.expandAllButton, + (hovered || pressed) && styles.diffStatusRowHovered, + ]} + onPress={handleToggleExpandAll} + > + {allExpanded ? ( + + ) : ( + + )} + + + + + {allExpanded ? "Collapse all files" : "Expand all files"} + + + + + ) : null} + ) : 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, diff --git a/packages/app/src/components/icons/editor-app-icons.tsx b/packages/app/src/components/icons/editor-app-icons.tsx new file mode 100644 index 000000000..a29341678 --- /dev/null +++ b/packages/app/src/components/icons/editor-app-icons.tsx @@ -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 = { + 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 ( + + ); +} diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 8240bc1c0..f60e0eb05 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -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(funct disabled = false, isInputActive = true, leftContent, + beforeVoiceContent, rightContent, voiceServerId, voiceAgentId, @@ -1024,6 +1027,7 @@ export const MessageInput = forwardRef(funct {/* Right: voice button, contextual button (realtime/send/cancel) */} + {beforeVoiceContent} + {marker} - {children} + {children} + + ); + }, + paragraph: (node: any, children: ReactNode[], parent: any, styles: any) => { + const isLastChild = parent[0]?.children?.at(-1)?.key === node.key; + return ( + + {children} ); }, diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index a13ab5c45..395d1806c 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -203,12 +203,12 @@ export function ComboboxItem({ ]} > {leadingContent} - + {label} {description ? ( - + {description} ) : 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], diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx index 6aad9afbc..b760ee60f 100644 --- a/packages/app/src/components/ui/tooltip.tsx +++ b/packages/app/src/components/ui/tooltip.tsx @@ -44,6 +44,7 @@ type TooltipContextValue = { setOpen: (open: boolean) => void; triggerRef: React.RefObject; enabled: boolean; + openOnPress: boolean; delayDuration: number; }; @@ -107,6 +108,18 @@ function measureElement(element: View): Promise { }); } +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( () => ({ @@ -227,9 +236,10 @@ export function Tooltip({ setOpen: setIsOpen, triggerRef, enabled, + openOnPress: isMobile, delayDuration, }), - [isOpen, setIsOpen, enabled, delayDuration], + [isOpen, setIsOpen, enabled, isMobile, delayDuration], ); return {children}; @@ -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)} > - + ctx.setOpen(false)}> {children} - + ); } diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index a88b12427..5694be493 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -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> = [ + "inputTokens", + "outputTokens", + "cachedInputTokens", + "totalCostUsd", + "contextWindowMaxTokens", + "contextWindowUsedTokens", + ]; + + return keys.some((key) => incomingUsage?.[key] !== currentUsage?.[key]); +} + type AudioOutputPayload = Extract["payload"]; interface BufferedAudioChunk { @@ -318,6 +336,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider } const workspaces = new Map(); + 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; diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 5a9fb1add..160c1fdc6 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -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(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( @@ -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( - 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(); - 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(); diff --git a/packages/app/src/hooks/use-changes-preferences.test.ts b/packages/app/src/hooks/use-changes-preferences.test.ts new file mode 100644 index 000000000..04451a73d --- /dev/null +++ b/packages/app/src/hooks/use-changes-preferences.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const asyncStorageMock = vi.hoisted(() => ({ + getItem: vi.fn<(_: string) => Promise>(), + setItem: vi.fn<(_: string, __: string) => Promise>(), +})); + +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(); + }); +}); diff --git a/packages/app/src/hooks/use-changes-preferences.ts b/packages/app/src/hooks/use-changes-preferences.ts new file mode 100644 index 000000000..cfc6ee57b --- /dev/null +++ b/packages/app/src/hooks/use-changes-preferences.ts @@ -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 { + 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 { + 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) => Promise; +} + +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) => { + const prev = + queryClient.getQueryData(CHANGES_PREFERENCES_QUERY_KEY) ?? + DEFAULT_CHANGES_PREFERENCES; + const next = { ...prev, ...updates }; + queryClient.setQueryData(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, + }; +} diff --git a/packages/app/src/hooks/use-checkout-diff-query.ts b/packages/app/src/hooks/use-checkout-diff-query.ts index 874809dca..e563a8220 100644 --- a/packages/app/src/hooks/use-checkout-diff-query.ts +++ b/packages/app/src/hooks/use-checkout-diff-query.ts @@ -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[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, ]); diff --git a/packages/app/src/hooks/use-preferred-editor.test.ts b/packages/app/src/hooks/use-preferred-editor.test.ts new file mode 100644 index 000000000..6ac4ca761 --- /dev/null +++ b/packages/app/src/hooks/use-preferred-editor.test.ts @@ -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(); + }); +}); diff --git a/packages/app/src/hooks/use-preferred-editor.ts b/packages/app/src/hooks/use-preferred-editor.ts new file mode 100644 index 000000000..f4c97f7db --- /dev/null +++ b/packages/app/src/hooks/use-preferred-editor.ts @@ -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 { + 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, + }; +} diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts new file mode 100644 index 000000000..ffe23fa78 --- /dev/null +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -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(), + }); +} diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 29a17ba84..d3f13a9b4 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -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(); + 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; diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index f098f6ca7..3d043cd9d 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -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([]); - const [loading, setLoading] = useState(false); + const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId); const [diagnosticProvider, setDiagnosticProvider] = useState(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 ( <> - Providers + + Providers + {hasServer && isConnected ? ( + + + Refresh + + + ) : null} + {!hasServer || !isConnected ? ( Connect to a host to see providers - ) : loading ? ( + ) : isLoading ? ( Loading... ) : ( {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); diff --git a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx new file mode 100644 index 000000000..31457d675 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx @@ -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({ + 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 ( + + + [ + 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 ? ( + + ) : ( + + + Open + + )} + + {availableEditors.length > 1 ? ( + + [ + styles.splitButtonCaret, + (hovered || pressed || open) && styles.splitButtonCaretHovered, + ]} + accessibilityRole="button" + accessibilityLabel="Choose editor" + > + + + + {availableEditors.map((editor: EditorTargetDescriptorPayload) => ( + + } + trailing={ + editor.id === effectivePreferredEditorId + ? + : undefined + } + onSelect={() => handleOpenEditor(editor.id)} + > + {editor.label} + + ))} + + + ) : null} + + + ); +} + +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, + }, +})); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index e6b6bab54..dd96be66d 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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={ + {!isMobile ? ( + + ) : null} {!isMobile && isGitCheckout ? ( <> {workspaceDirectory ? ( diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.test.ts b/packages/app/src/screens/workspace/workspace-tab-menu.test.ts index ba8395234..43bf39d3a 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.test.ts @@ -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", ]); }); diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.ts b/packages/app/src/screens/workspace/workspace-tab-menu.ts index e4d18f281..08e25f2c2 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts @@ -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", diff --git a/packages/app/src/stores/panel-store.ts b/packages/app/src/stores/panel-store.ts index 9799026fb..cf1551d2e 100644 --- a/packages/app/src/stores/panel-store.ts +++ b/packages/app/src/stores/panel-store.ts @@ -66,6 +66,8 @@ interface PanelState { // File explorer settings (shared between mobile/desktop) explorerTab: ExplorerTab; explorerTabByCheckout: Record; + expandedPathsByWorkspace: Record; + diffExpandedPathsByWorkspace: Record; 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()( // 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()( } 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()( }), { name: "panel-state", - version: 8, + version: 10, storage: createJSONStorage(() => AsyncStorage), migrate: (persistedState, version) => { const state = persistedState as Partial & Record; @@ -371,6 +388,22 @@ export const usePanelStore = create()( 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()( desktop: state.desktop, explorerTab: state.explorerTab, explorerTabByCheckout: state.explorerTabByCheckout, + expandedPathsByWorkspace: state.expandedPathsByWorkspace, + diffExpandedPathsByWorkspace: state.diffExpandedPathsByWorkspace, sidebarWidth: state.sidebarWidth, explorerWidth: state.explorerWidth, explorerSortOption: state.explorerSortOption, diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index 1aec73f5a..ae5330ca0 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -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 & Pick, ): 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); + }); +}); diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index c9aa3f093..7fd67ae1c 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -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"; diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index 027f0eca9..1c72d8b59 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -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, diff --git a/packages/app/src/utils/diff-layout.test.ts b/packages/app/src/utils/diff-layout.test.ts new file mode 100644 index 000000000..c5e756d81 --- /dev/null +++ b/packages/app/src/utils/diff-layout.test.ts @@ -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 }, + }); + }); +}); diff --git a/packages/app/src/utils/diff-layout.ts b/packages/app/src/utils/diff-layout.ts new file mode 100644 index 000000000..b5746eab3 --- /dev/null +++ b/packages/app/src/utils/diff-layout.ts @@ -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; +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 0219ade5d..3a39a8dc1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 39fd3571f..60c61e130 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -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" diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 43e2e1144..e7e396595 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -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", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index f28e94fad..f1e310167 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.1.48", + "version": "0.1.49", "type": "module", "publishConfig": { "access": "public" diff --git a/packages/relay/package.json b/packages/relay/package.json index aaa606040..b5f035fd4 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -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": { diff --git a/packages/server/package.json b/packages/server/package.json index 8412ff5a9..f89c89c52 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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", diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index cde640bed..768b517dc 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -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(); private terminalSlots = new Map(); @@ -1339,8 +1348,36 @@ export class DaemonClient { }); } + async listAvailableEditors(requestId?: string): Promise { + 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 { + 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 { 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 { 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 { const subscriptionId = options?.subscriptionId ?? crypto.randomUUID(); diff --git a/packages/server/src/server/agent/agent-management-mcp.ts b/packages/server/src/server/agent/agent-management-mcp.ts index e5b3070ea..edfdc794e 100644 --- a/packages/server/src/server/agent/agent-management-mcp.ts +++ b/packages/server/src/server/agent/agent-management-mcp.ts @@ -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; } diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index ae2e81788..76685e899 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -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 { + 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 { + 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 { + 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 { + 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"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 07a3ba0a4..39acf0856 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -150,6 +150,11 @@ type ManagedAgentBase = { features?: AgentFeature[]; currentModeId: string | null; pendingPermissions: Map; + bufferedPermissionResolutions: Map< + string, + Extract + >; + inFlightPermissionResponses: Set; pendingReplacement: boolean; persistence: AgentPersistenceHandle | null; historyPrimed: boolean; @@ -1378,25 +1383,32 @@ export class AgentManager { requestId: string, response: AgentPermissionResponse, ): Promise { - 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 { @@ -1840,6 +1852,8 @@ export class AgentManager { availableModes: [], currentModeId: null, pendingPermissions: new Map(), + bufferedPermissionResolutions: new Map(), + inFlightPermissionResponses: new Set(), pendingReplacement: false, activeForegroundTurnId: null, foregroundTurnWaiters: new Set(), @@ -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; diff --git a/packages/server/src/server/agent/agent-projections.test.ts b/packages/server/src/server/agent/agent-projections.test.ts index 1eefc4784..fef36e1fa 100644 --- a/packages/server/src/server/agent/agent-projections.test.ts +++ b/packages/server/src/server/agent/agent-projections.test.ts @@ -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 }); diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index eca90f0be..3c9f77304 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -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; } diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 2ff94a178..6c1332f62 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -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; }; diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index a75bec538..67cb533e3 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -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(), diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 7a2238c73..7f7911ffb 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -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; } diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index e51f57c47..b686497b5 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -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(); + }); }); diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 269d8ced2..6b41a3825 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -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 { + 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, diff --git a/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts b/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts index 74bd40656..0f490c08a 100644 --- a/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts @@ -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, + options: Record, + ) => Promise; + }; + + const pendingResolution = internal.handlePermissionRequest( + "ExitPlanMode", + { plan: "- Implement the approved plan" }, + {}, + ); + + const requestEvent = events.find( + (event): event is Extract => + 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 { diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index 9cde504e9..68732b9e9 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -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 { + const client = new ClaudeAgentClient({ logger }); + return client.createSession({ + provider: "claude", + cwd: process.cwd(), + }); + } + + function createQueryFactoryForTurns(turns: Array>>) { + return vi.fn(({ prompt }: { prompt: AsyncIterable }) => { + const queuedMessages: Array> = []; + const waiters: Array<() => void> = []; + let turnIndex = 0; + let closed = false; + + function wakeNextWaiter() { + const waiter = waiters.shift(); + waiter?.(); + } + + function enqueue(message: Record) { + 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((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, + }); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 417b9099f..fab2700eb 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -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)) { + 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(); private toolUseIndexToId = new Map(); @@ -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(); 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, }; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 1faf6e213..813a201c1 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -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 = {}) { 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 => + 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 => + 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); + }); }); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 1b49b62df..141a05d96 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isObjectSchemaNode(schema: Record): 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 = {}; + 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 { + 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(); @@ -2289,6 +2437,7 @@ class CodexAppServerAgentSession implements AgentSession { private warnedInvalidNotificationPayloads = new Set(); private warnedIncompleteEditToolCallIds = new Set(); 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 { + 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 { 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, }; diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index b894292e5..2e220b9ba 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -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); + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index c022caa57..7965c96f1 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -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; + }, +): 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; + }>; + } | 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; + }>; +} | null | undefined): Map { + const lookup = new Map(); + 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(); 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 { @@ -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 { + 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; /** Tracks the type of each part by ID, learned from message.part.updated events. */ partTypes: Map; + modelContextWindowsByModelKey?: ReadonlyMap; + 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; private currentMode: string = "default"; private pendingPermissions = new Map(); 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(); + private selectedModelContextWindowMaxTokens: number | undefined; constructor( config: OpenCodeAgentConfig, client: OpencodeClient, sessionId: string, logger: Logger, + modelContextWindowsByModelKey: ReadonlyMap = 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 { @@ -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); } } diff --git a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts index b89673385..b68a4f009 100644 --- a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts +++ b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts @@ -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(); diff --git a/packages/server/src/server/background-git-fetch-manager.test.ts b/packages/server/src/server/background-git-fetch-manager.test.ts new file mode 100644 index 000000000..aa2b1f8f9 --- /dev/null +++ b/packages/server/src/server/background-git-fetch-manager.test.ts @@ -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("node:child_process"); + return { + ...actual, + execFile: execFileMock, + }; +}); + +import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js"; + +async function flushPromises(): Promise { + 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(); + }); +}); diff --git a/packages/server/src/server/background-git-fetch-manager.ts b/packages/server/src/server/background-git-fetch-manager.ts new file mode 100644 index 000000000..190220254 --- /dev/null +++ b/packages/server/src/server/background-git-fetch-manager.ts @@ -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(); + + 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 { + 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 { + 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(); + } + } + } +} diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts index cd6372133..8a5246cd0 100644 --- a/packages/server/src/server/checkout-diff-manager.ts +++ b/packages/server/src/server/checkout-diff-manager.ts @@ -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 }, diff --git a/packages/server/src/server/editor-targets.test.ts b/packages/server/src/server/editor-targets.test.ts new file mode 100644 index 000000000..315ecab61 --- /dev/null +++ b/packages/server/src/server/editor-targets.test.ts @@ -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"); + }); +}); diff --git a/packages/server/src/server/editor-targets.ts b/packages/server/src/server/editor-targets.ts new file mode 100644 index 000000000..4bcadacf6 --- /dev/null +++ b/packages/server/src/server/editor-targets.ts @@ -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 { + 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((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(); + }); + }); +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 600ca796b..1a2e4c845 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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; 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; tts: Resolvable; @@ -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 void>(); private readonly workspaceGitWatchTargets = new Map(); private readonly workspaceSetupSnapshots = new Map(); + private readonly workspaceGitFetchSubscriptions = new Map 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, ): Promise { 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 { + private async describeWorkspaceRecordWithGitData( + workspace: PersistedWorkspaceRecord, + projectRecord?: PersistedProjectRecord | null, + ): Promise { + return this.describeWorkspaceRecord(workspace, projectRecord); + } + + private async buildWorkspaceDescriptor(input: { + workspace: PersistedWorkspaceRecord; + projectRecord?: PersistedProjectRecord | null; + includeGitData: boolean; + }): Promise { + 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; + }): Promise> { 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(); - const workspaceIdsByDirectory = new Map(activeRecords.map((workspace) => [workspace.directory, workspace.id])); + const descriptorsByWorkspaceId = new Map(); + 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 { + 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 { @@ -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 { + 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> { + return new Set(); + } + + private async emitWorkspaceUpdatesForWorkspaceIds( + workspaceIds: Iterable, + options?: { dedupeGitState?: boolean; skipReconcile?: boolean }, ): Promise { 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): Promise { - if (!this.workspaceUpdatesSubscription) { + private scheduleWorkspaceGitBootstrapUpdates(options: { + subscriptionId: string; + workspaces: Iterable; + }): 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(); - 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 { + 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): Promise { + const activeWorkspaces = (await this.workspaceRegistry.list()).filter( + (workspace) => !workspace.archivedAt, + ); + const uniqueWorkspaceIds = new Set(); + 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 { + await openInEditorTarget(options); + } + private async handleStartWorkspaceServiceRequest( request: StartWorkspaceServiceRequest, ): Promise { @@ -6035,13 +6190,76 @@ export class Session { } } + private async handleListAvailableEditorsRequest( + request: Extract, + ): Promise { + 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, + ): Promise { + 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, ): Promise { 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); } diff --git a/packages/server/src/server/session.wait-for-finish.test.ts b/packages/server/src/server/session.wait-for-finish.test.ts new file mode 100644 index 000000000..d84a5c1c8 --- /dev/null +++ b/packages/server/src/server/session.wait-for-finish.test.ts @@ -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(); + }); +}); diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index b2b6c5cc7..ce12eb58c 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -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>; workspaces: Map>; + backgroundGitFetchManager: { + subscribe: ReturnType; + subscriptions: Array<{ + params: { repoGitRoot: string; cwd: string }; + listener: () => void; + unsubscribe: ReturnType; + }>; + }; + logger: { + child: () => unknown; + trace: ReturnType; + debug: ReturnType; + info: ReturnType; + warn: ReturnType; + error: ReturnType; + }; } { const emitted: Array<{ type: string; payload: unknown }> = []; const projects = new Map>(); const workspaces = new Map>(); + let nextProjectId = 1; + let nextWorkspaceId = 1; + const backgroundGitFetchSubscriptions: Array<{ + params: { repoGitRoot: string; cwd: string }; + listener: () => void; + unsubscribe: ReturnType; + }> = []; 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, "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, "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); + }); }); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 410589a9f..fda83e65e 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -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, + ); + }); }); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 57da8941d..0f34fede5 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -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, diff --git a/packages/server/src/shared/messages.stream-parsing.test.ts b/packages/server/src/shared/messages.stream-parsing.test.ts index b1b2aee03..b645d5d11 100644 --- a/packages/server/src/shared/messages.stream-parsing.test.ts +++ b/packages/server/src/shared/messages.stream-parsing.test.ts @@ -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", diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 5db18def3..e43a36c29 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -146,6 +146,8 @@ const AgentUsageSchema: z.ZodType = 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 = 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; export type WorkspaceServiceHealth = z.infer; export type WorkspaceServicePayload = z.infer; +export type EditorTargetId = z.infer; +export type EditorTargetDescriptorPayload = z.infer; export type FetchAgentsResponseMessage = z.infer; export type FetchWorkspacesResponseMessage = z.infer; export type ServiceStatusUpdateMessage = z.infer; @@ -2715,6 +2777,10 @@ export type OpenProjectResponseMessage = z.infer; +export type ListAvailableEditorsResponseMessage = z.infer< + typeof ListAvailableEditorsResponseMessageSchema +>; +export type OpenInEditorResponseMessage = z.infer; export type ArchiveWorkspaceResponseMessage = z.infer; export type FetchAgentResponseMessage = z.infer; export type FetchAgentTimelineResponseMessage = z.infer< @@ -2863,6 +2929,8 @@ export type PaseoWorktreeListResponse = z.infer; export type PaseoWorktreeArchiveResponse = z.infer; export type WorkspaceSetupStatusRequest = z.infer; +export type ListAvailableEditorsRequest = z.infer; +export type OpenInEditorRequest = z.infer; export type OpenProjectRequest = z.infer; export type ArchiveWorkspaceRequest = z.infer; export type FileExplorerRequest = z.infer; diff --git a/packages/server/src/shared/messages.workspaces.test.ts b/packages/server/src/shared/messages.workspaces.test.ts index c8c1104a9..a38673039 100644 --- a/packages/server/src/shared/messages.workspaces.test.ts +++ b/packages/server/src/shared/messages.workspaces.test.ts @@ -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", diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 59da636b2..89e85276f 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -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 diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 00830f8ef..3ca4a4115 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -309,13 +309,25 @@ export async function listBranchSuggestions( return ordered.slice(0, limit); } -async function listCheckoutFileChanges(cwd: string, ref: string): Promise { +async function listCheckoutFileChanges( + cwd: string, + ref: string, + ignoreWhitespace = false, +): Promise { 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> { +async function getTrackedNumstatByPath( + cwd: string, + ref: string, + ignoreWhitespace = false, +): Promise> { 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(); 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) { diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index de6df46b1..e8ebb57dc 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -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({ diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index c386514cc..fd7e2bbfe 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -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}`); } diff --git a/packages/website/package.json b/packages/website/package.json index 9e82cb856..72a3e2419 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.1.48", + "version": "0.1.49", "private": true, "type": "module", "scripts": { diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index b4966d5ef..727926fd1 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -826,6 +826,8 @@ function GetStarted() { } /> } /> } /> + } /> + } /> diff --git a/packages/website/src/routeTree.gen.ts b/packages/website/src/routeTree.gen.ts index 506dbef54..037ca9c97 100644 --- a/packages/website/src/routeTree.gen.ts +++ b/packages/website/src/routeTree.gen.ts @@ -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'