From e01a0abdf2b86ef2c5f035a50ca87e88ae7f7b95 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 6 Apr 2026 22:50:58 +0700 Subject: [PATCH 01/31] fix(app): wire atomic provider+model selection on mobile draft screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mobile preferences modal split CombinedModelSelector's onSelect into separate onSelectProvider and onSelectModel calls, but onSelectProvider was never passed from DraftAgentStatusBar — so selecting a non-cloud provider was silently ignored while only the model ID updated. Add onSelectProviderAndModel prop to ControlledStatusBar and use it on the mobile path, matching the web path's atomic behavior. --- packages/app/src/components/agent-status-bar.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 25bd5f5eb..b79c6402f 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -75,6 +75,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 +204,7 @@ function ControlledStatusBar({ modelOptions, selectedModelId, onSelectModel, + onSelectProviderAndModel, thinkingOptions, selectedThinkingOptionId, onSelectThinkingOption, @@ -648,10 +650,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} @@ -1135,6 +1141,7 @@ export function DraftAgentStatusBar({ modelOptions={modelOptions} selectedModelId={selectedModel} onSelectModel={(modelId) => onSelectModel(modelId)} + onSelectProviderAndModel={onSelectProviderAndModel} isModelLoading={isAllModelsLoading} favoriteKeys={favoriteKeys} onToggleFavoriteModel={(provider, modelId) => { From a5aca2312b76f5afb3394dee04e1d969e27d11f8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 6 Apr 2026 23:05:21 +0700 Subject: [PATCH 02/31] feat(website): add Copilot and Pi icons to hero supports section --- packages/website/src/components/landing-page.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index b6e0abd49..85060e2fe 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -779,6 +779,8 @@ function GetStarted() { } /> } /> } /> + } /> + } /> From c63240b18c301c0c71ffb7d5438c2170bb466bdf Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 6 Apr 2026 23:37:22 +0700 Subject: [PATCH 03/31] feat(app): unified provider snapshot, resilient model selector, and UX polish - Model selector is no longer disabled while providers load; opens immediately and streams available providers as they arrive - Selecting a non-default provider on mobile now works correctly - Provider icon added to model trigger in mobile preferences sheet - Thinking icon (brain) added to thinking trigger in mobile preferences - Model descriptions for OpenCode/Pi shown inline next to model name instead of in a tooltip - Running agents and draft screen share a single provider/model cache, eliminating duplicate network requests - Provider data is prefetched when entering a workspace so the model selector is instant on first open --- .../app/src/components/agent-status-bar.tsx | 66 ++++++++----- .../components/combined-model-selector.tsx | 30 ++---- packages/app/src/components/ui/combobox.tsx | 11 ++- .../app/src/hooks/use-agent-form-state.ts | 98 +++---------------- .../app/src/hooks/use-providers-snapshot.ts | 84 ++++++++++++++++ .../screens/workspace/workspace-screen.tsx | 11 +++ 6 files changed, 165 insertions(+), 135 deletions(-) create mode 100644 packages/app/src/hooks/use-providers-snapshot.ts diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index b79c6402f..ee7a63397 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -17,6 +17,7 @@ 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, @@ -266,7 +267,7 @@ function ControlledStatusBar({ return null; } - const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0; + const modelDisabled = disabled; const SEARCH_THRESHOLD = 6; @@ -673,6 +674,7 @@ function ControlledStatusBar({ pointerEvents="none" testID="agent-preferences-model" > + {selectedModelLabel} @@ -698,6 +700,7 @@ function ControlledStatusBar({ accessibilityLabel="Select thinking option" testID="agent-preferences-thinking" > + {displayThinking} @@ -869,9 +872,17 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat ); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); - const modelsQuery = useQuery({ + const { + entries: snapshotEntries, + isLoading: snapshotIsLoading, + isFetching: snapshotIsFetching, + supportsSnapshot, + } = useProvidersSnapshot(serverId); + + // COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support + const legacyModelsQuery = useQuery({ queryKey: ["providerModels", serverId, agent?.provider ?? "__missing_provider__"], - enabled: Boolean(client && agent?.provider), + enabled: Boolean(!supportsSnapshot && client && agent?.provider), staleTime: 5 * 60 * 1000, queryFn: async () => { if (!client || !agent) { @@ -885,36 +896,39 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat }, }); + const snapshotModels = useMemo(() => { + if (!supportsSnapshot || !snapshotEntries || !agent?.provider) { + return null; + } + const entry = snapshotEntries.find((e) => e.provider === agent.provider); + return entry?.models ?? null; + }, [supportsSnapshot, snapshotEntries, agent?.provider]); + + const models = supportsSnapshot ? snapshotModels : (legacyModelsQuery.data ?? null); + const agentProviderDefinitions = useMemo(() => { + if (supportsSnapshot && snapshotEntries) { + return AGENT_PROVIDER_DEFINITIONS.filter((d) => + snapshotEntries.some((e) => e.provider === d.id), + ); + } 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 ?? []; - }, - }); + }, [agent?.provider, supportsSnapshot, snapshotEntries]); const agentProviderModels = useMemo(() => { const map = new Map(); - if (agent?.provider && agentProviderModelQuery.data) { - map.set(agent.provider, agentProviderModelQuery.data); + if (supportsSnapshot && snapshotEntries) { + for (const entry of snapshotEntries) { + if (entry.models) { + map.set(entry.provider, entry.models); + } + } + } else if (agent?.provider && legacyModelsQuery.data) { + map.set(agent.provider, legacyModelsQuery.data); } return map; - }, [agent?.provider, agentProviderModelQuery.data]); - - const models = modelsQuery.data ?? null; + }, [supportsSnapshot, snapshotEntries, agent?.provider, legacyModelsQuery.data]); const displayMode = availableModes.find((mode) => mode.id === agent?.currentModeId)?.label || @@ -1034,7 +1048,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat console.warn("[AgentStatusBar] setAgentFeature failed", error); }); }} - isModelLoading={isProviderModelsQueryLoading(modelsQuery)} + isModelLoading={supportsSnapshot ? (snapshotIsLoading || snapshotIsFetching) : isProviderModelsQueryLoading(legacyModelsQuery)} onDropdownClose={onDropdownClose} disabled={!client} /> diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index a8aaa051d..ffb0ef6df 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -25,7 +25,6 @@ import type { AgentProviderDefinition } from "@server/server/agent/provider-mani const IS_WEB = Platform.OS === "web"; import { Combobox, ComboboxItem } from "@/components/ui/combobox"; -import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { getProviderIcon } from "@/components/provider-icons"; import type { FavoriteModelRow } from "@/hooks/use-form-preferences"; import { @@ -36,6 +35,9 @@ import { type SelectorModelRow, } from "./combined-model-selector.utils"; +// TODO: this should be configured per provider in the provider manifest +const PROVIDERS_WITH_MODEL_DESCRIPTIONS = new Set(["opencode", "pi"]); + type SelectorView = | { kind: "all" } | { kind: "provider"; providerId: string; providerLabel: string }; @@ -161,7 +163,6 @@ function ModelRow({ }) { const { theme } = useUnistyles(); const ProviderIcon = getProviderIcon(row.provider); - const isWeb = Platform.OS === "web"; const handleToggleFavorite = useCallback( (event: GestureResponderEvent) => { @@ -171,9 +172,13 @@ function ModelRow({ [onToggleFavorite, row.modelId, row.provider], ); - const item = ( + const showDescription = + row.description && PROVIDERS_WITH_MODEL_DESCRIPTIONS.has(row.provider); + + return ( ); - - if (!isWeb || !row.description) { - return item; - } - - return ( - - - {item} - - - {row.description} - - - ); } function FavoritesSection({ @@ -839,10 +829,6 @@ const styles = StyleSheet.create((theme) => ({ favoriteButtonPressed: { backgroundColor: theme.colors.surface1, }, - tooltipText: { - color: theme.colors.foreground, - fontSize: theme.fontSize.xs, - }, sheetLoadingState: { minHeight: 160, justifyContent: "center", diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index ab4ec98f8..9d709cbd7 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} @@ -847,6 +847,11 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, flexShrink: 1, }, + comboboxItemContentInline: { + flexDirection: "row", + alignItems: "baseline", + gap: theme.spacing[2], + }, comboboxItemLeadingSlot: { width: 16, alignItems: "center", @@ -857,9 +862,9 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, }, comboboxItemDescription: { - marginTop: 2, fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, + flexShrink: 1, }, emptyText: { paddingHorizontal: theme.spacing[3], diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 812e3fd0a..5d5865714 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useQuery, useQueries, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueries } from "@tanstack/react-query"; import { AGENT_PROVIDER_DEFINITIONS, type AgentProviderDefinition, @@ -12,7 +12,7 @@ import type { } from "@server/server/agent/agent-sdk-types"; import { useHosts } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { useSessionForServer } from "./use-session-directory"; +import { useProvidersSnapshot } from "./use-providers-snapshot"; import { useFormPreferences, mergeProviderPreferences, @@ -322,10 +322,6 @@ function combineInitialValues( return initialValues; } -function providersSnapshotQueryKey(serverId: string | null, cwd: string | undefined) { - return ["providersSnapshot", serverId, cwd ?? ""] as const; -} - export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult { const { initialServerId = null, @@ -343,7 +339,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]); @@ -379,10 +374,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg // 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, + supportsSnapshot: supportsProvidersSnapshot, + refresh: refreshSnapshot, + } = useProvidersSnapshot(formState.serverId); const [debouncedCwd, setDebouncedCwd] = useState(undefined); useEffect(() => { @@ -391,69 +389,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg 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], @@ -649,7 +584,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg ? snapshotSelectedProviderModes : legacySelectedProviderModes; const isAllModelsLoading = supportsProvidersSnapshot - ? providersSnapshotQuery.isLoading || providersSnapshotQuery.isFetching + ? snapshotIsLoading || snapshotIsFetching : allProviderModelQueries.some((q) => q.isLoading); // Combine initialValues with initialServerId for resolution @@ -873,14 +808,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const refreshProviderModels = useCallback(() => { if (supportsProvidersSnapshot) { - if (!client) { - return; - } - void client.refreshProvidersSnapshot({ cwd: debouncedCwd }); + refreshSnapshot(); return; } void legacySelectedProviderModelsQuery.refetch(); - }, [client, debouncedCwd, legacySelectedProviderModelsQuery, supportsProvidersSnapshot]); + }, [legacySelectedProviderModelsQuery, refreshSnapshot, supportsProvidersSnapshot]); const persistFormPreferences = useCallback(async () => { const resolvedModel = resolveEffectiveModel(availableModels, formState.model); @@ -917,13 +849,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const resolvedModelId = effectiveModel?.id ?? formState.model; const availableThinkingOptions = effectiveModel?.thinkingOptions ?? []; const isModelLoading = supportsProvidersSnapshot - ? providersSnapshotQuery.isLoading || providersSnapshotQuery.isFetching + ? snapshotIsLoading || snapshotIsFetching : legacySelectedProviderModelsQuery.isLoading || legacySelectedProviderModelsQuery.isFetching; const modelError = supportsProvidersSnapshot - ? providersSnapshotQuery.error instanceof Error - ? providersSnapshotQuery.error.message - : null + ? null : legacySelectedProviderModelsQuery.error instanceof Error ? legacySelectedProviderModelsQuery.error.message : null; 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..943791f8a --- /dev/null +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -0,0 +1,84 @@ +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; + 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, + 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/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index d4555c4ab..dbeb7f930 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -70,6 +70,7 @@ import { workspaceTabTargetsEqual, } from "@/utils/workspace-tab-identity"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention"; import { checkoutStatusQueryKey, @@ -608,6 +609,16 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) const client = useHostRuntimeClient(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId); + useEffect(() => { + if (!client || !isConnected || !normalizedServerId) { + return; + } + const session = useSessionStore.getState().sessions[normalizedServerId]; + if (session?.serverInfo?.features?.providersSnapshot) { + prefetchProvidersSnapshot(normalizedServerId, client); + } + }, [client, isConnected, normalizedServerId]); + const workspaceAgentVisibility = useStoreWithEqualityFn( useSessionStore, (state) => From 27ddc95862d01a595cc5e46ca177697ed422d7cf Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 6 Apr 2026 23:44:53 +0700 Subject: [PATCH 04/31] Remove agent status bar provider model fallback --- .../app/src/components/agent-status-bar.tsx | 38 ++++--------------- 1 file changed, 8 insertions(+), 30 deletions(-) diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index ee7a63397..bd273c15a 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -15,7 +15,6 @@ 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 { @@ -52,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; @@ -876,59 +874,39 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat entries: snapshotEntries, isLoading: snapshotIsLoading, isFetching: snapshotIsFetching, - supportsSnapshot, } = useProvidersSnapshot(serverId); - // COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support - const legacyModelsQuery = useQuery({ - queryKey: ["providerModels", serverId, agent?.provider ?? "__missing_provider__"], - enabled: Boolean(!supportsSnapshot && 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 snapshotModels = useMemo(() => { - if (!supportsSnapshot || !snapshotEntries || !agent?.provider) { + if (!snapshotEntries || !agent?.provider) { return null; } const entry = snapshotEntries.find((e) => e.provider === agent.provider); return entry?.models ?? null; - }, [supportsSnapshot, snapshotEntries, agent?.provider]); + }, [snapshotEntries, agent?.provider]); - const models = supportsSnapshot ? snapshotModels : (legacyModelsQuery.data ?? null); + const models = snapshotModels; const agentProviderDefinitions = useMemo(() => { - if (supportsSnapshot && snapshotEntries) { + if (snapshotEntries) { return AGENT_PROVIDER_DEFINITIONS.filter((d) => snapshotEntries.some((e) => e.provider === d.id), ); } const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider); return definition ? [definition] : []; - }, [agent?.provider, supportsSnapshot, snapshotEntries]); + }, [agent?.provider, snapshotEntries]); const agentProviderModels = useMemo(() => { const map = new Map(); - if (supportsSnapshot && snapshotEntries) { + if (snapshotEntries) { for (const entry of snapshotEntries) { if (entry.models) { map.set(entry.provider, entry.models); } } - } else if (agent?.provider && legacyModelsQuery.data) { - map.set(agent.provider, legacyModelsQuery.data); } return map; - }, [supportsSnapshot, snapshotEntries, agent?.provider, legacyModelsQuery.data]); + }, [snapshotEntries]); const displayMode = availableModes.find((mode) => mode.id === agent?.currentModeId)?.label || @@ -1048,7 +1026,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat console.warn("[AgentStatusBar] setAgentFeature failed", error); }); }} - isModelLoading={supportsSnapshot ? (snapshotIsLoading || snapshotIsFetching) : isProviderModelsQueryLoading(legacyModelsQuery)} + isModelLoading={snapshotIsLoading || snapshotIsFetching} onDropdownClose={onDropdownClose} disabled={!client} /> From 390a3402abdb27f8cf9cf3edd48df376bc713b5c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 00:11:43 +0700 Subject: [PATCH 05/31] Fix provider snapshot session hydration and agent scoping --- .../app/src/components/agent-status-bar.tsx | 17 +- packages/app/src/contexts/session-context.tsx | 29 +++ .../app/src/hooks/use-agent-form-state.ts | 201 ++---------------- .../app/src/hooks/use-providers-snapshot.ts | 2 + .../screens/workspace/workspace-screen.tsx | 11 - 5 files changed, 49 insertions(+), 211 deletions(-) diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index bd273c15a..b1cf99aa4 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -887,26 +887,17 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat const models = snapshotModels; const agentProviderDefinitions = useMemo(() => { - if (snapshotEntries) { - return AGENT_PROVIDER_DEFINITIONS.filter((d) => - snapshotEntries.some((e) => e.provider === d.id), - ); - } const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider); return definition ? [definition] : []; - }, [agent?.provider, snapshotEntries]); + }, [agent?.provider]); const agentProviderModels = useMemo(() => { const map = new Map(); - if (snapshotEntries) { - for (const entry of snapshotEntries) { - if (entry.models) { - map.set(entry.provider, entry.models); - } - } + if (agent?.provider && snapshotModels) { + map.set(agent.provider, snapshotModels); } return map; - }, [snapshotEntries]); + }, [agent?.provider, snapshotModels]); const displayMode = availableModes.find((mode) => mode.id === agent?.currentModeId)?.label || diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 9c36e8132..a7a2717ac 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, @@ -603,6 +604,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 5d5865714..09e0ae2fd 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 } from "@tanstack/react-query"; import { AGENT_PROVIDER_DEFINITIONS, type AgentProviderDefinition, @@ -11,7 +10,6 @@ import type { ProviderSnapshotEntry, } from "@server/server/agent/agent-sdk-types"; import { useHosts } from "@/runtime/host-runtime"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useProvidersSnapshot } from "./use-providers-snapshot"; import { useFormPreferences, @@ -371,35 +369,19 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg } }, [isVisible]); - // Session state for provider model listing - const client = useHostRuntimeClient(formState.serverId ?? ""); - const isConnected = useHostRuntimeIsConnected(formState.serverId ?? ""); const { entries: snapshotEntries, isLoading: snapshotIsLoading, isFetching: snapshotIsFetching, - supportsSnapshot: supportsProvidersSnapshot, + 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 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( @@ -435,157 +417,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg snapshotSelectedEntry?.modes ?? snapshotProviderDefinitionMap.get(formState.provider)?.modes ?? []; - - // COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots - const availableProvidersQuery = useQuery({ - queryKey: ["availableProviders", formState.serverId], - enabled: Boolean( - !supportsProvidersSnapshot && - isVisible && - isTargetDaemonReady && - formState.serverId && - client && - isConnected, - ), - staleTime: 60 * 1000, - queryFn: async () => { - if (!client) { - throw new Error("Host is not connected"); - } - const payload = await client.listAvailableProviders(); - if (payload.error) { - throw new Error(payload.error); - } - return payload.providers.filter((entry) => entry.available).map((entry) => entry.provider); - }, - }); - const legacyProviderDefinitions = useMemo(() => { - const availableProviders = availableProvidersQuery.data; - if (!availableProviders) { - return []; - } - const available = new Set(availableProviders); - return allProviderDefinitions.filter((definition) => available.has(definition.id)); - }, [availableProvidersQuery.data]); - const legacyProviderDefinitionMap = useMemo( - () => - new Map( - 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 - ? snapshotIsLoading || snapshotIsFetching - : 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 => { @@ -807,12 +645,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg }, []); const refreshProviderModels = useCallback(() => { - if (supportsProvidersSnapshot) { - refreshSnapshot(); - return; - } - void legacySelectedProviderModelsQuery.refetch(); - }, [legacySelectedProviderModelsQuery, refreshSnapshot, supportsProvidersSnapshot]); + refreshSnapshot(); + }, [refreshSnapshot]); const persistFormPreferences = useCallback(async () => { const resolvedModel = resolveEffectiveModel(availableModels, formState.model); @@ -848,15 +682,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const effectiveModel = resolveEffectiveModel(availableModels, formState.model); const resolvedModelId = effectiveModel?.id ?? formState.model; const availableThinkingOptions = effectiveModel?.thinkingOptions ?? []; - const isModelLoading = supportsProvidersSnapshot - ? snapshotIsLoading || snapshotIsFetching - : legacySelectedProviderModelsQuery.isLoading || legacySelectedProviderModelsQuery.isFetching; - const modelError = - supportsProvidersSnapshot - ? 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-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts index 943791f8a..ffe23fa78 100644 --- a/packages/app/src/hooks/use-providers-snapshot.ts +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -14,6 +14,7 @@ interface UseProvidersSnapshotResult { entries: ProviderSnapshotEntry[] | undefined; isLoading: boolean; isFetching: boolean; + error: string | null; supportsSnapshot: boolean; refresh: () => void; } @@ -69,6 +70,7 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps entries: snapshotQuery.data?.entries ?? undefined, isLoading: snapshotQuery.isLoading, isFetching: snapshotQuery.isFetching, + error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null, supportsSnapshot, refresh, }; diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index dbeb7f930..d4555c4ab 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -70,7 +70,6 @@ import { workspaceTabTargetsEqual, } from "@/utils/workspace-tab-identity"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention"; import { checkoutStatusQueryKey, @@ -609,16 +608,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) const client = useHostRuntimeClient(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId); - useEffect(() => { - if (!client || !isConnected || !normalizedServerId) { - return; - } - const session = useSessionStore.getState().sessions[normalizedServerId]; - if (session?.serverInfo?.features?.providersSnapshot) { - prefetchProvidersSnapshot(normalizedServerId, client); - } - }, [client, isConnected, normalizedServerId]); - const workspaceAgentVisibility = useStoreWithEqualityFn( useSessionStore, (state) => From 06fbeb413b9667d50f9e5de53709cb2ec63909d4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 00:15:33 +0700 Subject: [PATCH 06/31] docs(changelog): prepare 0.1.49 release notes --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1c22d94a..626eee17a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.1.49 - 2026-04-07 + +### Fixed +- Provider/model selector hydration on app connect — session state now seeds daemon feature flags immediately, so provider snapshots and model lists load reliably on first open instead of waiting for a later status refresh. +- Running agent model picker now stays scoped to the agent's current provider instead of exposing all providers from the shared snapshot cache. +- Provider snapshot warm-up now happens at the session layer instead of per workspace screen, so model data is prefetched consistently across entry points. +- Removed the remaining legacy provider/model fetch fallbacks in the app so draft and running-agent flows both use the same real-time provider snapshot path. + ## 0.1.48 - 2026-04-05 ### Added From e90241c445fc6489ae49f26ac0054f5c0efb015d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 00:16:12 +0700 Subject: [PATCH 07/31] chore(release): cut 0.1.49 --- package-lock.json | 38 ++++++++++++------------ package.json | 2 +- packages/app/package.json | 8 ++--- packages/cli/package.json | 6 ++-- packages/desktop/package.json | 6 ++-- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 6 ++-- packages/website/package.json | 2 +- 10 files changed, 37 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3a83c6663..9767a1a42 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": [ @@ -34906,16 +34906,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", @@ -35032,11 +35032,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", @@ -35077,11 +35077,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" @@ -35115,7 +35115,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", @@ -35316,7 +35316,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", @@ -35342,7 +35342,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.1.48", + "version": "0.1.49", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -35358,14 +35358,14 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.1.48", + "version": "0.1.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", @@ -35764,7 +35764,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 ce9c87bc0..bc33dd151 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/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/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 68b05ea78..34613ccbb 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 e55f219df..10e5088d0 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": { @@ -64,8 +64,8 @@ "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", - "@getpaseo/highlight": "0.1.48", - "@getpaseo/relay": "0.1.48", + "@getpaseo/highlight": "0.1.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/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": { From 64c2515b94c8e7bb9056c483f226aac38f5e347b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 6 Apr 2026 17:17:36 +0000 Subject: [PATCH 08/31] fix: update lockfile signatures and Nix hash --- nix/package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index b136d5e96..b1c364dc7 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-epuepM6DMEpOfzvPiS6K/qKJqGM89FqdsKHovWi2LS8="; + npmDepsHash = "sha256-TEsFzJRgVubRnjAy7OO6Xkn6HY7CRO3LVxsVdbw3IH4="; # 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). From 6ce0e1e91f32df692ccd81666c9186528a7af6f8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 00:18:57 +0700 Subject: [PATCH 09/31] docs: clarify stable release and compatibility guidance --- CLAUDE.md | 2 +- docs/RELEASE.md | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a4eddcbad..78020044a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir - **NEVER assume a timeout means the service needs restarting** — timeouts can be transient. - **NEVER add auth checks to tests** — agent providers handle their own auth. - **Always run typecheck after every change.** -- **NEVER make breaking changes to WebSocket or message schemas.** The mobile app in the App Store always lags behind the daemon, and daemons in the wild lag behind new app releases. Both directions must work. Every schema change MUST be backward-compatible: +- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons: - New fields: always `.optional()` with a sensible default or `.transform()` fallback. - Never change a field from optional to required. - Never remove a field — deprecate it (keep accepting it, stop sending it). 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 From 03380cfad0298df28847f9aea26bcc897d0213de Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 00:23:42 +0700 Subject: [PATCH 10/31] docs(changelog): make 0.1.49 release notes user-friendly --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 626eee17a..22241d6dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,10 @@ ## 0.1.49 - 2026-04-07 ### Fixed -- Provider/model selector hydration on app connect — session state now seeds daemon feature flags immediately, so provider snapshots and model lists load reliably on first open instead of waiting for a later status refresh. -- Running agent model picker now stays scoped to the agent's current provider instead of exposing all providers from the shared snapshot cache. -- Provider snapshot warm-up now happens at the session layer instead of per workspace screen, so model data is prefetched consistently across entry points. -- Removed the remaining legacy provider/model fetch fallbacks in the app so draft and running-agent flows both use the same real-time provider snapshot path. +- 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 From fd9dfb0cc83eb0d34314cc81475d2ac2d2a757f7 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Tue, 7 Apr 2026 04:22:20 +0200 Subject: [PATCH 11/31] feat: add side-by-side diff layout and whitespace toggle (#208) --- packages/app/src/components/git-diff-pane.tsx | 533 ++++++++++++++---- .../src/hooks/use-changes-preferences.test.ts | 74 +++ .../app/src/hooks/use-changes-preferences.ts | 89 +++ .../app/src/hooks/use-checkout-diff-query.ts | 28 +- packages/app/src/styles/theme.ts | 6 + packages/app/src/utils/diff-layout.test.ts | 81 +++ packages/app/src/utils/diff-layout.ts | 147 +++++ packages/server/src/client/daemon-client.ts | 19 +- .../src/server/checkout-diff-manager.ts | 9 +- packages/server/src/shared/messages.ts | 1 + .../server/src/utils/checkout-git.test.ts | 15 + packages/server/src/utils/checkout-git.ts | 60 +- packages/website/src/routeTree.gen.ts | 26 +- 13 files changed, 925 insertions(+), 163 deletions(-) create mode 100644 packages/app/src/hooks/use-changes-preferences.test.ts create mode 100644 packages/app/src/hooks/use-changes-preferences.ts create mode 100644 packages/app/src/utils/diff-layout.test.ts create mode 100644 packages/app/src/utils/diff-layout.ts diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 07968af47..3d2348a67 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, @@ -77,13 +83,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 +113,14 @@ function HighlightedText({ tokens, lineType }: HighlightedTextProps) { }; return ( - + {tokens.map((token, index) => ( - + {token.text} ))} @@ -115,10 +140,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 +326,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 +360,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 +443,7 @@ function DiffFileBody({ style={styles.diffContent} contentContainerStyle={styles.diffContentInner} > - 0 && { minWidth: scrollViewWidth }]} - > - {linesContent} - + {contentContainer} ); })()} @@ -355,26 +466,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 +526,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi cwd, mode: diffMode, baseRef, + ignoreWhitespace: changesPreferences.hideWhitespace, enabled: isGit, }); const { @@ -726,13 +843,20 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi return ( ); }, - [handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded, wrapLines], + [ + effectiveLayout, + handleBodyHeightChange, + handleHeaderHeightChange, + handleToggleExpanded, + wrapLines, + ], ); const flatKeyExtractor = useCallback( @@ -821,7 +945,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 +961,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi renderItem={renderFlatItem} keyExtractor={flatKeyExtractor} stickyHeaderIndices={stickyHeaderIndices} - extraData={expandedByPath} + extraData={{ expandedByPath, effectiveLayout, wrapLines }} style={styles.scrollView} contentContainerStyle={styles.contentContainer} testID="git-diff-scroll" @@ -1019,56 +1147,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} @@ -1165,6 +1386,43 @@ const styles = StyleSheet.create((theme) => ({ sm: theme.spacing[1], md: 0, }, + flexWrap: "wrap", + }, + toggleButtonGroup: { + flexDirection: "row", + alignItems: "center", + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.base, + overflow: "hidden", + }, + toggleButton: { + alignItems: "center", + justifyContent: "center", + minWidth: { + xs: 32, + sm: 32, + md: 28, + }, + height: { + xs: 32, + sm: 32, + md: 24, + }, + paddingHorizontal: { + xs: theme.spacing[2], + sm: theme.spacing[2], + md: theme.spacing[1], + }, + backgroundColor: theme.colors.surface1, + }, + toggleButtonGroupStart: { + borderRightWidth: 1, + borderRightColor: theme.colors.border, + }, + toggleButtonGroupEnd: {}, + toggleButtonSelected: { + backgroundColor: theme.colors.surface3, }, expandAllButton: { flexDirection: "row", @@ -1343,6 +1601,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 +1640,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 +1660,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 +1690,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/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/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/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 942319554..e2f85e46c 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -611,7 +611,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(); @@ -2146,20 +2149,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()}`; @@ -2185,7 +2190,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/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/shared/messages.ts b/packages/server/src/shared/messages.ts index 156ce6510..6804bb8ef 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -969,6 +969,7 @@ const CheckoutErrorSchema = z.object({ const CheckoutDiffCompareSchema = z.object({ mode: z.enum(["uncommitted", "base"]), baseRef: z.string().optional(), + ignoreWhitespace: z.boolean().optional(), }); export const CheckoutStatusRequestSchema = z.object({ diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index cdd7f8ff0..40a8ebbe3 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -104,6 +104,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 3745731ca..37c50ea2c 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -275,13 +275,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()) @@ -396,13 +408,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, @@ -578,6 +601,7 @@ export interface CheckoutDiffResult { export interface CheckoutDiffCompare { mode: "uncommitted" | "base"; baseRef?: string; + ignoreWhitespace?: boolean; includeStructured?: boolean; } @@ -1072,6 +1096,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); @@ -1084,7 +1109,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, @@ -1299,7 +1327,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; @@ -1330,7 +1359,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< @@ -1356,7 +1385,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, @@ -1445,7 +1477,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/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' From 5cb424b2e6d525f4d1c4ba3fc35377128ab72584 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Tue, 7 Apr 2026 04:25:25 +0200 Subject: [PATCH 12/31] feat(app): add open in editor toolbar action (#209) * feat(app): add open in editor toolbar action * fix(app): normalize editor icon asset permissions * chore(website): drop unrelated generated route tree change --- .../app/assets/images/editor-apps/cursor.png | Bin 0 -> 2478 bytes .../images/editor-apps/file-explorer.png | Bin 0 -> 2638 bytes .../app/assets/images/editor-apps/finder.png | Bin 0 -> 3893 bytes .../app/assets/images/editor-apps/vscode.png | Bin 0 -> 2804 bytes .../app/assets/images/editor-apps/zed.png | Bin 0 -> 4789 bytes .../src/components/icons/editor-app-icons.tsx | 32 +++ .../src/hooks/use-preferred-editor.test.ts | 20 ++ .../app/src/hooks/use-preferred-editor.ts | 57 ++++ .../workspace-open-in-editor-button.tsx | 246 ++++++++++++++++++ .../screens/workspace/workspace-screen.tsx | 7 + packages/server/src/client/daemon-client.ts | 34 +++ .../server/src/server/editor-targets.test.ts | 100 +++++++ packages/server/src/server/editor-targets.ts | 168 ++++++++++++ packages/server/src/server/session.ts | 81 ++++++ .../src/server/session.workspaces.test.ts | 49 ++++ packages/server/src/shared/messages.ts | 55 ++++ .../src/shared/messages.workspaces.test.ts | 21 ++ 17 files changed, 870 insertions(+) create mode 100644 packages/app/assets/images/editor-apps/cursor.png create mode 100644 packages/app/assets/images/editor-apps/file-explorer.png create mode 100644 packages/app/assets/images/editor-apps/finder.png create mode 100644 packages/app/assets/images/editor-apps/vscode.png create mode 100644 packages/app/assets/images/editor-apps/zed.png create mode 100644 packages/app/src/components/icons/editor-app-icons.tsx create mode 100644 packages/app/src/hooks/use-preferred-editor.test.ts create mode 100644 packages/app/src/hooks/use-preferred-editor.ts create mode 100644 packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx create mode 100644 packages/server/src/server/editor-targets.test.ts create mode 100644 packages/server/src/server/editor-targets.ts 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 0000000000000000000000000000000000000000..7e30a3b6c70d2be55f04382c783165c2f065e525 GIT binary patch literal 2478 zcmV;f2~qZmP)m!&+QLv}N?NId zco~6K>a^6cSi_}O7?9CY2q7wz6bm7cKrSTN&EC&BzV8<{(we>HoK1qwGqaQIIeX52 z@9lXn-&tS=Gnl~)W{@@{Og~I$O<70}3cXa1$CDin2kx^d(h41VA$_xkDS!^hl7#MP zSUZsG3H`aN3xU`gQ@uy3TI0`2xw*C)yT$SsdRL*5$hxj$IzS>VD6&LW(A&e_=;sE5 z!Ok0}k{U)vaHfZ?m~A?-%4dU%;sFN#&DL zIIow^cDIG2xQ)Ik-H@3YNKh3?I&!to*Mw0e#PtLv*{zBS-Fu=c?!IM&9|a3t;c{9E z{DH_m2)Y;_i9A5bb6GxSb=X@qZAv)KFceva>Q~G9{Qe%q@q$D#qS_s{*R@+!dV;EH zs_c~CLV^&8rvS<2!fXvv1yUo*w7{$#4o4u#if~R~`s<4Ofp{luHru0F zvh*343})<+kr0-C;w~&+yad_VIk4MpxN@Z%7K`~bqf?YCS$I1fR^J`(d|U;%owo0j zRqA09*uq|~A9ZzgxNqhCRKduIM57oQ9E8K+6vzF3KL!Q{V6|FdCL~Gj4F)4Ea6)Yi^HTU#q#Gs0eIO1(k% zD2aD2t^)J~sUsDz;E_m_|HG=)E8%jv$-*NEV_j9F^a2_i?}X3iBSo}C@wC+ zx%Sdh;NqR+r)LF`NZ7O(&+#N05kWdDA^; z-m+ON%IqGB36BLo-F5S`Q2b`*;jHQ?xx!(+#dVt``( z)XC$bIxH)zz!OhAj>^g^m;uuZAtt2inp!;bQtxqa+-Jalz+l7&ywFhhlf*j~R; z#xt+%-TQjd>(fO>6%Ye2xTdxiEiHeGl9gkt~`gOEYoDUBV!DhFA;Cl9` z^0IO~^`j>R^P7oD9dt5lS5;Nxhd=lp#k3vUxBmutc{wb-_R&1?)4~G+G#$!>5=>J-Y&gP` z8Wx;~Bk2krgOkeM-Me2BM9E{ZN=lvg-1|u}0O5I|nTjwT0#dA?k;DL#7a5!0OawZ0 zml4`EYCnA%vpgPgj6;B54UKoePYqGqsaDgJV8{c+-Z=@+X>IP@x-l;>0uKrO3o4oO z3kv?{T$rrS#nj!)?nMnXT&yUgFr)xZQ|zKlbiCnV6de2K-a97-s)<;iyOL_;X`6y(FhE2 z058a9<{nW8-z&pp^77U*H^<-I^AM8JHK=cJ425*l1_2?07*suvc?+|%` zeR>W=cu}5NF0{6uz_Gvm9S^TrBQ`U5j-QCN-Q@B+$+884`2H&xXMunIqa8op`ZHv^ ztm58gcY_(J1i9qRc4cADt9#(4DscV!$3%U=Bd!lWmBVD`rR}qhjt**#^G2shq!8Xx zYku}w5$}094skpaPo~9HfFy?A69+HW;jlncLwN4F=P`fY9ISbGtzh_@OJa{FADcJ- zM5I41e|fCF`?oLQ{R53?9R`Z-wmvAT;jQnZ5b8!PRC&%$^YnJ>xOhoM2 zAXSsFZta&*R$eJ~e7kAt`|``L&_pPUNKi4=)kQ0=h94Rn48Jz+`FI!7=0l;&E?rYI z(1wbqM*JZZ5w1?drI(;q&==X`TJZz=O~%sMBGk zFz``%8z9SDGa@`em7t$P9N`&>>HygvQdbiXO#0l=EGUYk_yXZCAOY7U5>6W)_8(^g z0khIA1|3oqDt_nlh0h|vZQd<{tQuyiu_ z<_oiWdc75-szb6Y#g0u^Xk1IsGXHQxcy|4S>rHj4Ahbvg$Wo_W`8_4pB2Aw@zZu}H z!p0%OyGAkJL~efuqp~G6R=Q&qp$Lb=QBKX-gBDp@K^*Dfu?VIHG8v&a8dVQ!lJym@ s*V~UIE^ik8Z+M4N?V)Dfjsp;i0PP7y*QW}-o~ydXl~I~tWpNNu0` zfHczNbu`sTT_i*vDphUDNR>#MutXrulv2u|#DuYvcuBn7<;?$IUpuyQujAaH3|8~wj0ocI~cCdr5F*w)+`uqE(moHz6&CSics;XQvndG6u5CnmVqR8kS z$5o@zsCxJAT?H>Ouz`&dz_quxJH1}7^TLG-yAB;X^xd|$wgHF3(e6PY32s@IC640+ zP1CYuNRkA24e&SpJJ0jk&x|p^`$;iS6h;4B2&27**Hmn);Cl?m3B_WuXNak%PoMs4 zbaXU;WW@1%(k>b6fj@ovwCCQvd+&upp&-t77=?(TU@25W!Ix%$RSML%F@TD1G-eV> zizO0?D9RL=nwomItEC;OZnxWqCMcSj=~oSsiDYxRT<*TUzPHdSKEW8}zkU05(RS;Z z3XiNF92y!L!;ZZ;0JjyDF92UxSLZ!{{`?rp4x0${+~DA#mrRNRP$hl4+{na=rKP30 zo}QjYumg zv-1?{V`|Bw*^ih$#3M(J97hw%yH3>{_6MQ)PhsZozXO$X0?(C{ZuQcPX$rV%MCdy4 zOK562qLV;XVI_eEEFC?1v>yW^>5?kEZb|^=P`;s|;Q%(yla9*O@X|aq1_t0ez7?%%JAr2wD( zM}w}!oA5Zg0-fJ}XH&6-2*d?h-eM~Q%!u^GMLLI^_i+yWtG=ZLJQCaRt^-5m zHB6;@JG!9dTd$$=K|^`iJeY!}Zl02vnO_*NB*3zPve=u!K&sX4~W{2YW7Q!veriev4TEt5O!wX{N!p znguX>4~I4!1k*5&0Sv04r`lQU(+~5w2W8`A`1%Cd!2gKxY=@nGLeGxBc?+YB5Gyfn6n4G-bIKnCyaL;IPU|s*>I&zkgVb zq}ftDhXBpm1<(R487LM5ifnwVYYEUZ16!3TZ`N|}Stk+}))<-lGktJu)dF)}z+x?c z6G>+UEOjv@AQTEwqqt!Oc@C6HCG^3Vk^9r5u>2sDRh|`mE+t*%_EVsIwjU!18w4;c z7D&`&GH%srg3cA1T`(np4k9865sCwaTk0(>-#|ZQz#kBE@*4JCD}e(8o`An;SwWc| z6D9o3+kiZ_i@+_dlX#jX@IK_hfvzMQ-DH%<);I<#djMS|CS^I{1nk?bz`p&jtm%$U zil8oGr_-sGOaMMg)>c+l=BP0}5zgrhe61befAV{HHuDkik|!+yz8YN)2n+i_-S-L_ zL_9ltWGSg#P4M4f8$6r%Im|B?f28EAliBkhF=IL)*RK`u;3F^ws2A)^d~rC17vPWMAs z*EQ32BpaPyDELiz`0(Ka<2Y@};4fXe)E|q*BBdTmN+#DoOC`&4)$Yk49*;+_T)EN* z7G=Lxg`iDOPyY`GDw#EUJiQ(YXQz_)5Qu<8B9R;$8~Z<4qzTIs$>;OQlarGlA_;UB z7^)3SL%7YC!eEbOw9Se>e9 z1}TMyMn^~A86O{?nw_0Z!Y0ABwzj&5hlh`oJET%6d8=9i%9bP$S}5b+ z4bZID_V)JfzIE%?`&cL~Aj>NlVH1=oR64Amwmep)mque^jFU+QbkSur`Nf+zZ+<{N zPZI_u`u#@CneG+p>+3zpE{{dhv7Vlu@Avifb$dLXR|i~732Pdfpe!H%i~s-t07*qoM6N<$f`&Bbs{jB1 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..474dfe4b650faca3acbdd788c8143a49629de2cf GIT binary patch literal 3893 zcmV-556bX~P)`&UF-Mh_nvj@ykyw>jDJmYN zF&ZmXVyYxU#1Kmo6io^viinCxI97I9*j-rmoSB{J?w9ZNdp*-LPWMd1>Oa}9>M_0D zufP7j?|t`s0K+g0!!QiP@c#=8;lX`JEVzByr0KKHU)*@olnZJaYNtgjqO}YxZh=La z0YeCyD{$0c5sr*SKNd{b)?jiFJ%>9Ex9s23@#cv9_!GQ=-G9nJt)dSVz zfR;~N)@)dI$&&W1Tay}7S)va^9>46~=cYV#|MefQebI0es08LL(8zll_upS z^>#EU2%@9`(TMtGJa^xw_baO{Ys|#du4m7#K8bG8EHaxC-z^2ky{mUEyx^=Ue|vBh zvuUL{=NM8jiSXVOaLreN*%OKC0Q{2R&H7(ad8=tI|pUU7cW4FI(_D0-Rxx0L1*O=0E<*f4CiwCDYmtg3rA5k5|A)+kFMlaN22AnW~y8Nj?^swBsE1-z;+%2+Ya z{LX$N^d!wNa`)wm3QsoaH=JC-b8XoYYH&ocjW#6UZiAMn$U5_FH8>`i;Zl>ZUKtlf`81mC&7l;1a^5A3oeW9P#RFL{- z;N*iT(uQ<(HDwssR!Ekg07SHIbYp6-Ldpb6GZr+bV6I%11t+U(TmR!~Kpq}H+}^)+ zDCPRT6oAB8MOx59X2zrjP?BYgIwq6=FrqqMm$sB-%i+ySP^lL>NVC4vL>U~k6SH&n z@As->ip3s^oDy=Oe&w{7p#&{c{#Up$A-#vY{Y?yK_m|DhDlrn z&6Nbs7^iI1bOiu^dyf!jZ@m2x{n|9w_Vo$2#~;Xy(9*a~T1a7;4WR@;NpIBhiQmOy zK1Oi#uDS~cpEw(K)OJ4VH-FBtp5k37Th2PcBBSF_A1=N4N?dg9QdCgBTa5WUA!r_Q zizAfTpPYIN&3Bb|> z>tqIiZ^`E1B4c)aL|%O>a%#*aqf9$OEnq`y0Tabtxvu~uG4e$&^1c-q?S$sU7HS!o zc0%4&ok|7jNb=C#>70#1Ag~8u(nMKX80ev6`|V0#>)mj}P5fWH7-8`d1V%=^)4 zQ(m8{KOMaGeF!58+fU#*MRqgq`pGUHoqRFNBHXY7rr6UNrwKq|AI_F_sIZ zQV3a}qE+9pgM}NY0WK1+0Nm>=0q#BNd1qaX=$T7k_gZBCM$RD#Md(2ae@jjS`qElm zwIH(0`-r!L@b$L7i=LFAj*;zgEO7LlX+gl_1icxmDvUx3K)AMp5RjoJFfk{+Up6`P zIJY{=&W@`C+6Qtuyu*Bn9vCBhZQbr1Rv;%OT{jVOnB_V3cTK?0m-%ZzNGIqaN0F&R zlM9lN3s2GNFLLqUc}E<1_dM(USEd`60|nPiU)#d- zm@c~efLqR>DlMh%xuz9Xz<$8>$Fg+={XzQ^Sq%_jHNcIxcA@7Bd#(SL0+~bEcuTiV zfC}wWcrFUxFQlPR@N)k}tA0ege^-0mQ0S)w{u*Exgh8(+lvDY6{S^H@`#35}v}16x z#3DlSUWyU|b;11%)A@jt2k`vN&iXi8@b`26nxo&?4&mwE3TrhI@b ze3-5r>6z_`0)9&CTMXq@a0pQq7MUBP%Rp}gjI2BIZB}`3gcyRN zicpb7e^X_QIU$7BfV|48oW*dV*j$+;Q3vRf$B1dSP@o~mB<{O5g2i9qvTdcAQHCSR z?nE%P1{1k$0=H#)z=i1 ziCNjdlM?H?gQ@a2zgOw=c3>VLL~s%j7P6?A8@)IvW6m}&Ew@HMSP;%aal&fqMuRGi zsWC%Rks9E~Xkaan?Y{bcmpU{tGGZ17OR3V2m8mgqsFUhykp^V40uX^t&?0qdXow*( zkV1cgn4>gV8i7q5Q_1VJFPzO=$IMvCQ0VIf-Fm)g>)S=j(l~DAc`(-A3A>t81!)(b zYZt+z@k?-9W3Cps4y|ycDU3`zK8ADWU5mMsXJSk}ckXB8e9L`T4WT70bU5?_|0s~n z({rm;c#U`k+QbCZWZDp0bt@J6)U=>F_;YiM;n4z+8e^5ml4QH;Y!>4@LHM3+Um;Fee zVDZ@DV&C_3f-KY5-_6*li6x_v+&Ld0Z+q6=80cJt^t#(o)wvPTmtIBp9j~=y;L>R`72upwQ3^s3i7nKItdT$T@vT-fqv0Ak3GgZZD3&#OE zNgz{XRZ%=s|K&jM;2RBEUXu8ggxeY|?v`c7iUC#~49VI$sp^DU%Ov@M+>F4{Zr z-DMAA@zv*`xjEx#h67n)Y9Vl?a1yN)NYWJ9&QXjSF?QFsa02LWYuO#Ento2E9052& zkmuJ(Vo8bwr!0nj`V#c)SP%Z;vxxM5irT?0#9CKUs3IrHW3UbCQd95n=Jgq*k|`2& z2GKa(%VdyjYs2`Hr{R`+?!>}{P1xINV)q_X+ObbvuRJWLfG=|ad5mr#nR_~d6foJh zyA2@(z`B}0e7kba!W$e!VX{HF6moSaFA$UG!J0ZB)?fnZ{U3p~ZKRIKcCch8&FN8| zImHo;#xbsOJf_W`*Ix;(&a_r*FuE3!R+^*%;LGT@NbHzM%qw)L%5tQb)kt-CgPpuDA_2c8uy6 z_4Wv)scF!0P;%-~y}*c~(_ou9LXfCaX^BhPm-{RCk5eXw>%W6YM~ zkA|0kmp-1~IyUXxN>ZFV&_(I6$&pOBRiJ>#Z})gWWbb7gA-W^PHTsbwfZzWs16E5p z2|bKQXt$zy*LwIsP@vNFlA5tvgOlC7;TKe81*2}zIuY_gj^ z>-~+7#i}jwg@}N@{^~onb5cYqhLg_9l|}F{^~yULB%!IIFn9W7;Jk+t=$E_4Nqpp_(TK#y5xpa29)hFIBDj*c!PtN$L^qYG9jvdt zJ<`3ag{Ka+c7FUuKZ@_%f9Wv9u5lQKVHk$ti--RK7x0z{9Ysu400000NkvXXu0mjf D0a|me literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ced6ec21407ed4ca8e3a10663146d90f1dbb3129 GIT binary patch literal 2804 zcmVZd{{Mge*ZI#eFn|FJU;qR7{{@H0(AHmBwQ5y%etv#oZfvU=v0cndnZ?>&NT0 z9Xxn&H8GF7jd=!&6pUtZ|Ni|iLuaCOH9!eDI~JxQ4VOJekOCPWek+SP9s$H!HEwO=s8G^e1T;9;#cO#x7rHwkfs z95OXLvi1Nr?yUqIX3_*s)d+h62b-10qP<=uyp-nuk|rzlZ+f(b^$LO_QLd5JvMyd3q9Y3AgoraTdPe|04XUc zcEkYzIS2!JyTOaL;@?dzki_;Rb>@s|gjwW};o$N5Ngs@_)v4X|WI*c_hgO@W0LXdh z#?M$lBIZSbN8;SgxM}1eMc{9lAWQ-YE>*y*C%j!niA1diy*!{-V?R@SiQGCj>wQQr ztA-kPEf6M%?|pvVJ}eSww&^{GUV=nR;5v^ViI4sf;t+=_j60)yUXF6ruDWPSGHL-Z z(kM&92nJkfMn0x1(#*;UjKfg$gY&wPUYbcjP+U z&z54?^JREp&lUAIJ$~4jpH|jl?BZ<@hB;LqpanLTK+OSPm$Ta?^mvI%&6&WLZT|+v z+b@F4Gz000SbwMnBVPKPJUWZ%9s8>#8j{-k$Eq>&H@m?PNr9{r#MKFHmQk2v!ke?R z(JFP%6rzD%6wrL=!!z~RR8j*aNkq7^1=%Q|nrX(9M^IVYf;OSIX-o0WFY&W?O2DVv zs7;$8CP1auk9Qv*gdHm;!6PK0mnvK;qS64b*8@cqJCVh*IU&=6`-)HFRniCsHk&{G z!%?i=@dewXj<3|pB@^n6o znWJto9}h&8?64WLW{t$#GezWBYw*%hGk9CDvwBmCs{|=Y8F@c&^#ag%jq+G)NbIZ> z&Mpmpoc--^xH8?ar)MJG=uxthoRNd4rw+lEnH~fixLXB`j-Vg?v}j4C^+@(;O4}O2 zQTXFLGDeYqtABGWGIK{Gtq+)Ih$;%Sha)WuvnJ(Y|2!{gw+WgaTc)ChNQIfa2ca~1 zvAWQPW6v>_&x1YLhQ2~niO}t^yV8)BGZM<#qkxgEJ9HdZfV5%cB)HroU?VdU41HT} z?6C&OB&g4-Zeq-moe%~k1LenPrW2s)^2sE4K7eO-H(Lb67R&iGX<1{}WXg}CA~v;>0a$&#QaMav$o1zz9t z6@I#AANVYLD7$0ffoYDFZ2GHGa(0YUZSo{GiYo!OWyi3)q9OF&D3qZm`V;k9V9DlF z_}vG@WSX@j(bULMk%Kqckayz@9vDO~k#&SXLgKS5D0;6Fk8S!oY!ZkYqFW1;Uc#UD zoP}af>}2YU0i{94f{7NKe0drU%uT=)mk(S(TL`KElSS+~-vrAqPSE#uCeh38Au1DS zX=#CC5IZqX6OUkREB>)G6Km&=Lt^@Hn6gG;&l66}A5K}LkJGhRiAr4lWFt&Z9m9dD zCiS=8c8^h+z~X{bJd$Sy*AfyEV~VGIHzgpU_Kne)m_Hh+Swqw^H%}jP);-wxXa-h} zr(B^~D`5(OMFc+Ef*-zh7K=Wr!U(4cy%{8;^8pDCY&@BpR z1Et%{x543bVZ^;-aA9c+vd*}%ufL?^%o($)ReB4+O0N)g(t+CsC zbW;Q9IisN8H+)#)j?fE(E$C&~tQI8Xj7R-53X)o7st>f6g1!b&rGYk6cwB21@vBjd z`h7W0lNU_^)YjJOj~XLb5={`?`S@;yh@pa?O5t5I?kC2t`b?OYas}1X%3yJ3>poLg zSEtE~rT{!1&lSW82}UD0_gI`=Y{tZ-R`3!NLPxdcDAM?X0fFkMQ_l=2Xbu{1(`Uf5 z=~cM+&?zh(XF&=v?#>ymo+8x)^M9vLpZ=FtyH*%fjvqh%DdG%)!f9gmD120O9izqA`C0_Pvw(C7BNs?a(73Sl9NUG-YC2);b zXa+e19oa}HK}5Po5I*3gd&o<3#EBCpikX?2j@;bbAN0j3v83c=n4K9&O3gwt-FLS_ z)gJ78buzPg|Kbgw)d3q{{{MkAlhhP$K!#|Y(xhZKG{B4TMnvj6QCFWuM-B> z?y6Se*cY<(Kut|eE8RwXo+g$y67O80@3xZ$Fn|I4&)`2K06wVj18Ttl0000yK5}bzl3O`-mo`~i*H%6~zs z=40G1jTHM^n%I1YXn8D;<*_`L$M5cNG~7t}$Tx1>IL4n(kF{FQG-~yI!MTmKT76@d zWv*7Mk@vYwoTe#%_p(d~fm}jVOKX(px%@7OaE_8Bk#jtU{KkQS=j-*lT*G&KVqRYJ zQm@zhhQM6HFd6ZVc38#Kc)pn=!w zcDvB5%^Nmu`n!o$D^Fg&bm?8bDWN4mSPB4P{^8ww{+0>--rVimK1q|5iE^o2!Jz~> zXIj7nM#)6RWxxag7XPj>lX;%WbBQt7Xw-yhOqr%mo~_qwGQZ=a*=$g|Jx8^~i5AHm zy{-TUvlDIM3+;A09~m9}`j$;w^2x~yKVJ%jr2t5`J^0|W^)z`Y%eubNY$SQsQwEu@ zBvIQH#zYbT1M05VSosSSQ@M}SKRBgFLG$w4vzmh`m&)BAEq7G5`Q(nqHfoo$(L^Y&@81N9I8c zR1oJ^=KJ=e+C-nZ`1V$3fEKy74X zdCBKFdaW25fcx&-{Q36WoJ>~4V5Oem06?U8@kGNDnU~MyzJz+rkZJ^b_dLk=1ey`x z0NHMPITP#U8t#ES!E`Q%N~2>@D}!uD&TAsK^>FbR4PU0@NyywTk4 zb^?R&+|U4wj)p(RJc#$A*g>TM34jc25D(_TSi&~nCDyr^R)u3!6@Zrr$~jw)4YV0Fn1#c`SgjiC$?M8LLt4M5zJ zvJU~eBco#sN>}a`hswv+ty={Yn1qB%)=FqinU8n|SCcCM$x(LO&;aCluO-rv3H4m` z19eE%2!_)Evu8i}KlUqd@kTzy>gkp`qi%p!z)&dNy6et z)jKle6jbr|;wlqOhehShRJ1D8)L4LH$I96!UHkZBI(+yrZQHhuPMkPNuN^u}_iw#l zv`=e>TxnvH&^ko@lrujNwQwi^n3yq1G{nPu0U_`La4uv{4e4jkoDReX$s^<2yk(0> zBelc^>r2wpR~wKJE0ta?kwgd%Oj2v7MOCvoLZ?rkrnlaDi}vl?M{l1xMW6o6r)k&j zN2t^8m`*iBhX;VYeO+B>lwT0%mFLm9cwQj4k~b9QAfXY= zGf>kPFVdRTt3}G{k=3(HPXOV{tAovX9?T1smQR%Py!O=81$y(%H)-$Qz4Z3mZ_~kp z2kDVV9_6B_W112q6y}^5>D1;(otlGGnEBw=z@h-aYbPd&G%|;r06987otgz|dPmM2 zT3Hu>`IldQnVxy(8Tw5Jm_Kmf0PSYUb7CWzV3uh*?sEkzMkR5cFk80$R8sX+v|?xg z1Vyi*E0>Z~^Li*#B8z2Gy&({L11MOs1aI9O84>0`X8S$z^CL9E>WmRV3MMH%NdaM1yoL#h2zEnF(lU05k%S-M++ddL?*g*^A^4G$}8&aS@MswxG?HPrS6qh;=1fG62mj0{>s#3l;H`Sa%)fY)hiQsexpRjWku2ZZ?@oKVe)z+B8*QLQ(bn`q%$Mn>K9{iG4IRNheR9q{kk6jG5Os59Tuob?V)l-1J+cA3tu)v*Zu#=OSl2JGQokP|Xa-xULv)jq9XdHHq7FG|ERV z8W1Z$ja0#kAEeze`H}ubmJbhrG&O^@!CbHuH>hJ&p(~?(dA0(CkW`)_t;VDgn$ibc zHiP;7`}foFH;>8Zmd%^!Q|mrOfBB7XaJam$Si8x2!NkgyG6`%MQb&S?BR3NTbHSn! z2Am;GBDV3wSO;jpKof{{Jz3f5oOWa-b3=$UP|CX972w4Ii3P7c|8p$)6UR?TsJn6F z2L1K7zC|xQ|Ga!(vvv(}^HM!GDbS;%qv9Ol@M59^6G$va(b}IRLc~fM5g`~US}sFi z>F@xU&Pq9_-J3+tn9~`tZZ^DtT6S3^=}{ zNB>*Yp8NK6nU6MClecDWT3;`h>~UgzLLIEhVr<0^^3FqYPrJwIX6GN(r;TkW4bs=;Vzq$yw%QrNy4*umA+rBP)nBs|Z%agrbv^ zP;Q{a9cU4dZPxg351AlO=A)gH?ds^}MNB^OKFzv(`7+JU&T7H~=DB%!3|{`R{X*3B z>(}X5M~{llql*O9?{Zrl^*wAK=b#nH0O7^&+?k^_Yu1PpjLkx53@SzoD&nAgP=_ei z8GLC9RuuUG-JpR0aGdqdIxCtIn}@eWBpMjhzsKGio*iTJmd$kG!lX$4AWJ@uuYc`p zMZ}2pdoFteZEABPb*`<=R1{1d0|C26Km6B!VPJ1_uW^M4qu!`nCF!N+NNc7uF$I=X zwc1rK^lLZ(v>)ONv}4An*PH?2LZ|q~?(#)NvZ5T3?j^=>rVKV3-dgg?P{TG|<6dO+{82DPkrtaYVNeiU$_Wyc zrKDCh+URhVriY~qm$@~5lx-YL|MaJ?(wDyUM?%0Q4sAM|ZguymW#!YAJ%kYESCbJS z&L6nR+O>MY6|V=B$yllSp(w}(Auu2lbUg}XyDKiD+x1DI6%qBld-u^3Pdp(KJ$Uf< zd4pum`T*WulJI>8v;xsKl=}<2V9Y%S_+)|5>j!1 zx=WM{NQDt=l z19pY#0`{U)kq}uX2s$9Cm0Y>?nM};l166Fc>>Id>BdeY(6C&!nr}}I}rS$OnjE~xC zF5G}QyBMFz002&fd4xG5NXtvj1$6UBJ21X@AJ9|zYE1dOhBB(eXJN4t9c;b04&BW> zRoM-`*onfKyz2BIB#WxN?im^u0ABDXZs7|>aS<~`j>M8E$=e~`_7P1AWov99@2}LB z9^ZC)sZ{CWS=&%4fpV&UR;B^*gCY6{zrV3ct(f@yA{T+7TZhVHmOaBF4;ZKc_T zm>kp+>+*bu*>;NGRyt<`zn@LRICdV7}xwew00k;)1(7 zLKOv;7EEj*`5TKdUs;6~1Et7OM?I98EVb&xtw&QU78H9L#9L-4{4d_va_32L%t$fPvXRy~4ee4zH zeUgu_(#+4&$kdx$xm_82rC|{Z53-$TW3~#6T_opq!&YeV<;z@VkFeR4x9*-B#5~55 zZ;NwWf0vsOU6#nxHc=A$F3}XKk}ATIFH`79AldtS6gN+!)aGBe=d9Who}#28aDIL` zjn@6ZU0Zcn^Qx&qs@i##<2edziN* zXrS{G9D3}Nx(xgbf1lzLr$ZKgGS{8MOVM{|$p134JeJ4uSRTvccX|9j#Rgoesgx{o P00000NkvXXu0mjf1FbTA literal 0 HcmV?d00001 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/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/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 d4555c4ab..29c6ffb81 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -48,6 +48,7 @@ import { ExplorerSidebar } from "@/components/explorer-sidebar"; import { SplitContainer } from "@/components/split-container"; import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon"; import { WorkspaceGitActions } from "@/screens/workspace/workspace-git-actions"; +import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button"; import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context"; import { useToast } from "@/contexts/toast-context"; import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; @@ -2045,6 +2046,12 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) } right={ + {!isMobile ? ( + + ) : null} {!isMobile && isGitCheckout ? ( <> { + 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: string, requestId?: string, 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 a647e6495..ac0da00a8 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -28,6 +28,7 @@ import { type SubscribeCheckoutDiffRequest, type UnsubscribeCheckoutDiffRequest, type DirectorySuggestionsRequest, + type EditorTargetId, type ProjectPlacementPayload, type WorkspaceDescriptorPayload, type WorkspaceStateBucket, @@ -47,6 +48,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, @@ -1742,6 +1744,14 @@ export class Session { await this.handleCreatePaseoWorktreeRequest(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; @@ -5981,6 +5991,77 @@ export class Session { } } + async getAvailableEditorTargets() { + return listAvailableEditorTargets(); + } + + async openEditorTarget(options: { editorId: EditorTargetId; path: string }): Promise { + await openInEditorTarget(options); + } + + 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 { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 859192291..fb3bf0dad 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1223,6 +1223,55 @@ describe("workspace aggregation", () => { expect(response?.payload.workspace?.id).toBe(repoRoot); }); + test("list_available_editors_request returns available targets", async () => { + const emitted: Array<{ type: string; payload: unknown }> = []; + const session = createSessionForWorkspaceTests() as any; + + session.emit = (message: any) => emitted.push(message); + session.getAvailableEditorTargets = async () => [ + { id: "cursor", label: "Cursor" }, + { id: "finder", label: "Finder" }, + ]; + + await session.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 emitted: Array<{ type: string; payload: unknown }> = []; + const session = createSessionForWorkspaceTests() as any; + const calls: Array<{ editorId: string; path: string }> = []; + + session.emit = (message: any) => emitted.push(message); + session.openEditorTarget = async (input: { editorId: string; path: string }) => { + calls.push(input); + }; + + await session.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 hides non-destructive workspace records", async () => { const emitted: Array<{ type: string; payload: unknown }> = []; const session = createSessionForWorkspaceTests() as any; diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 6804bb8ef..e32c682f9 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1084,6 +1084,32 @@ export const CreatePaseoWorktreeRequestSchema = z.object({ requestId: z.string(), }); +export const EditorTargetIdSchema = z.enum([ + "cursor", + "vscode", + "zed", + "finder", + "explorer", + "file-manager", +]); + +export const EditorTargetDescriptorPayloadSchema = z.object({ + id: EditorTargetIdSchema, + label: z.string(), +}); + +export const ListAvailableEditorsRequestSchema = z.object({ + type: z.literal("list_available_editors_request"), + requestId: z.string(), +}); + +export const OpenInEditorRequestSchema = z.object({ + type: z.literal("open_in_editor_request"), + path: z.string(), + editorId: EditorTargetIdSchema, + requestId: z.string(), +}); + export const OpenProjectRequestSchema = z.object({ type: z.literal("open_project_request"), cwd: z.string(), @@ -1341,6 +1367,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ PaseoWorktreeListRequestSchema, PaseoWorktreeArchiveRequestSchema, CreatePaseoWorktreeRequestSchema, + ListAvailableEditorsRequestSchema, + OpenInEditorRequestSchema, OpenProjectRequestSchema, ArchiveWorkspaceRequestSchema, FileExplorerRequestSchema, @@ -1828,6 +1856,23 @@ export const OpenProjectResponseMessageSchema = z.object({ }), }); +export const ListAvailableEditorsResponseMessageSchema = z.object({ + type: z.literal("list_available_editors_response"), + payload: z.object({ + requestId: z.string(), + editors: z.array(EditorTargetDescriptorPayloadSchema), + error: z.string().nullable(), + }), +}); + +export const OpenInEditorResponseMessageSchema = z.object({ + type: z.literal("open_in_editor_response"), + payload: z.object({ + requestId: z.string(), + error: z.string().nullable(), + }), +}); + export const ArchiveWorkspaceResponseMessageSchema = z.object({ type: z.literal("archive_workspace_response"), payload: z.object({ @@ -2475,6 +2520,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ FetchAgentsResponseMessageSchema, FetchWorkspacesResponseMessageSchema, OpenProjectResponseMessageSchema, + ListAvailableEditorsResponseMessageSchema, + OpenInEditorResponseMessageSchema, ArchiveWorkspaceResponseMessageSchema, FetchAgentResponseMessageSchema, FetchAgentTimelineResponseMessageSchema, @@ -2567,9 +2614,15 @@ export type ProjectCheckoutLitePayload = z.infer; export type WorkspaceStateBucket = z.infer; export type WorkspaceDescriptorPayload = z.infer; +export type EditorTargetId = z.infer; +export type EditorTargetDescriptorPayload = z.infer; export type FetchAgentsResponseMessage = z.infer; export type FetchWorkspacesResponseMessage = z.infer; 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< @@ -2717,6 +2770,8 @@ export type PaseoWorktreeListRequest = z.infer; export type PaseoWorktreeArchiveRequest = z.infer; export type PaseoWorktreeArchiveResponse = 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 18085890e..2a8796577 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", From 102ef06c30dc650d1756a8094c3b6e1a1b14ad08 Mon Sep 17 00:00:00 2001 From: Huss Martinez <22710836+hussmartinez@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:26:53 +0700 Subject: [PATCH 13/31] fix: show direct connection and pairing modal content on tablets (#211) On tablet-sized layouts the welcome-screen modals for direct connection and pair link could collapse so only the header row remained visible. Adjust the desktop modal card and scroll container flex behavior so the modal body can shrink within the available height and remain visible instead of being clipped out. --- packages/app/src/components/adaptive-modal-sheet.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index a9c282f4f..a3ccd5f28 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -28,6 +28,8 @@ const styles = StyleSheet.create((theme) => ({ width: "100%", maxWidth: 520, maxHeight: "85%", + flexShrink: 1, + minHeight: 0, backgroundColor: theme.colors.surface1, borderRadius: theme.borderRadius.xl, borderWidth: 1, @@ -54,11 +56,13 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface2, }, desktopScroll: { - flex: 1, + flexShrink: 1, + minHeight: 0, }, desktopContent: { padding: theme.spacing[6], gap: theme.spacing[4], + flexGrow: 1, }, bottomSheetHandle: { backgroundColor: theme.colors.surface2, From d888c8f1262429555be4c66858f2ef68486f9ae0 Mon Sep 17 00:00:00 2001 From: thatdaveguy1 <92842682+thatdaveguy1@users.noreply.github.com> Date: Mon, 6 Apr 2026 20:28:12 -0600 Subject: [PATCH 14/31] fix(server): bypass Copilot ACP prompts in autopilot (#206) * fix(server): bypass Copilot ACP prompts in autopilot * Delete lessons.md --------- Co-authored-by: Paseo Bot --- .../server/agent/providers/acp-agent.test.ts | 51 +++++++++++++++++++ .../src/server/agent/providers/acp-agent.ts | 19 +++++++ 2 files changed, 70 insertions(+) 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, From b2229a28b9390bc10e60ea01365d198b60df58a7 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 6 Apr 2026 21:28:46 -0500 Subject: [PATCH 15/31] fix(server): reset session ID on query restart to prevent overwrite crash (#201) * fix(server): reset session ID on query restart to prevent overwrite crash When ensureQuery() restarts a query, the old claudeSessionId was retained. If the new query landed on a different Claude session (e.g. after a hook or reconnect), captureSessionIdFromMessage() would detect the mismatch and throw a fatal "session ID overwrite" error, killing the agent mid-turn. Reset claudeSessionId and persistence during query restart so the new session can establish its own identity cleanly. Closes #200 * fix(server): recover from session ID overwrite and load projects instantly - Reset claudeSessionId unconditionally before every new query creation, not just on explicit restarts. Fixes unrecoverable crash loop where a pump failure left stale session ID, causing every subsequent query to immediately throw the overwrite error again. - Decouple fetch_workspaces_request from reconciliation: serve registry snapshot immediately instead of awaiting git operations on all workspaces. Background reconciliation runs after response is sent and pushes workspace_update events for any changed workspaces (e.g. stale worktrees getting archived). * fix(server): auto-resume Claude session after overwrite error to preserve history When the query pump dies (e.g. after a session ID overwrite error), preserve claudeSessionId so the next query automatically passes resume: sessionId to the Claude SDK. Claude responds with the same session ID, the overwrite check passes, and the conversation history is intact. Previously claudeSessionId was reset unconditionally before every new query, causing the next query to start a fresh session and lose the conversation. Now it is only reset for explicit restarts (queryRestartNeeded), where a fresh session is the intended behavior. * fix(server): reset session ID before throwing overwrite error to break retry loop When the overwrite error fired, claudeSessionId was left set to the old value. Our auto-resume fix then passed resume: oldSessionId on every retry, Claude returned yet another new session ID each time, and the overwrite error looped indefinitely (same "Existing" UUID across all failures in the log). Fix: reset claudeSessionId = null in both captureSessionIdFromMessage and handleSystemMessage before throwing, so the next query starts a fresh session instead of looping on a dead resume target. Auto-resume still works for legitimate pump failures (network drops etc.) since those exit without throwing, leaving claudeSessionId intact. * fix(server): accept session ID change mid-stream instead of failing the turn Hooks can cause Claude to restart with a new session ID mid-turn. Previously both captureSessionIdFromMessage and handleSystemMessage threw a CRITICAL error when the session ID changed, failing the turn and leaving the user with an error message on what should have been a transparent hook execution. Now both paths log a warning and accept the new session ID, allowing the turn to continue uninterrupted through hook-triggered subprocess restarts. --- .../server/agent/providers/claude-agent.ts | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 68ddee65e..d330d66d0 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -1848,6 +1848,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 { @@ -1857,6 +1860,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"); @@ -2689,11 +2698,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 { @@ -2728,11 +2742,14 @@ 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; From cdbaa8d29c0844c05a4e9eae19cc971793e730e9 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 6 Apr 2026 21:35:07 -0500 Subject: [PATCH 16/31] fix(server): serve workspace list instantly on fetch, reconcile in background (#204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(server): serve workspace list instantly on fetch, reconcile in background Previously fetch_workspaces_request awaited reconcileActiveWorkspaceRecords() before responding — this ran git operations (getCheckoutStatusLite) on every active workspace, causing projects to appear empty for several seconds after daemon restart or reconnect. Now the registry snapshot is returned immediately and reconciliation runs in the background. Any changed workspaces (e.g. stale worktrees being archived) are pushed as workspace_update events through the existing subscription. * fix(server): make workspace snapshot instant by removing per-workspace git calls describeWorkspaceRecord was running two git operations per workspace: - buildProjectPlacement (to refresh display name) - getCheckoutShortstat (for diff indicator) With 13 workspaces this caused fetch_workspaces_request to take 7-15s (observed as ws_slow_request in logs). Split into two methods: - describeWorkspaceRecord: uses only persisted data, returns instantly - describeWorkspaceRecordWithGitData: runs git ops, used for open_project and create_worktree responses where fresh data is needed immediately The snapshot path (initial load + background reconciliation) now uses the fast variant. diffStat is null on initial load; it will be pushed later via workspace_update once a git-aware path triggers. --- packages/server/src/server/session.ts | 64 ++++++++++++++++++++------- 1 file changed, 49 insertions(+), 15 deletions(-) diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index ac0da00a8..916650f10 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -5307,6 +5307,26 @@ export class Session { ): Promise { const resolvedProjectRecord = projectRecord ?? (await this.projectRegistry.get(workspace.projectId)); + + return { + id: workspace.workspaceId, + projectId: workspace.projectId, + projectDisplayName: resolvedProjectRecord?.displayName ?? workspace.projectId, + projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd, + projectKind: resolvedProjectRecord?.kind ?? "non_git", + workspaceKind: workspace.kind, + name: workspace.displayName, + status: "done", + activityAt: null, + diffStat: null, + }; + } + + private async describeWorkspaceRecordWithGitData( + workspace: PersistedWorkspaceRecord, + projectRecord?: PersistedProjectRecord | null, + ): Promise { + const base = await this.describeWorkspaceRecord(workspace, projectRecord); let displayName = workspace.displayName; try { const placement = await this.buildProjectPlacement(workspace.cwd); @@ -5325,18 +5345,7 @@ export class Session { // Non-critical — leave null on failure. } - return { - id: workspace.workspaceId, - projectId: workspace.projectId, - projectDisplayName: resolvedProjectRecord?.displayName ?? workspace.projectId, - projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd, - projectKind: resolvedProjectRecord?.kind ?? "non_git", - workspaceKind: workspace.kind, - name: displayName, - status: "done", - activityAt: null, - diffStat, - }; + return { ...base, name: displayName, diffStat }; } private async listWorkspaceDescriptorsSnapshot(): Promise { @@ -5412,7 +5421,6 @@ export class Session { } private async listWorkspaceDescriptors(): Promise { - await this.reconcileActiveWorkspaceRecords(); return this.listWorkspaceDescriptorsSnapshot(); } @@ -5742,6 +5750,31 @@ export class Session { } } + private async reconcileAndEmitWorkspaceUpdates(): Promise { + const subscription = this.workspaceUpdatesSubscription; + if (!subscription) { + return; + } + try { + const changedWorkspaceIds = await this.reconcileActiveWorkspaceRecords(); + if (changedWorkspaceIds.size === 0) { + return; + } + const all = await this.listWorkspaceDescriptorsSnapshot(); + const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const)); + for (const workspaceId of changedWorkspaceIds) { + const workspace = descriptorsByWorkspaceId.get(workspaceId) ?? null; + if (workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })) { + this.bufferOrEmitWorkspaceUpdate(subscription, { kind: "upsert", workspace }); + } else { + this.bufferOrEmitWorkspaceUpdate(subscription, { kind: "remove", id: workspaceId }); + } + } + } catch (error) { + this.sessionLogger.error({ err: error }, "Background workspace reconciliation failed"); + } + } + private async emitWorkspaceUpdateForCwd( cwd: string, options?: { dedupeGitState?: boolean }, @@ -5942,6 +5975,7 @@ export class Session { if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) { this.flushBootstrappedWorkspaceUpdates({ snapshotLatestActivityByWorkspaceId }); + void this.reconcileAndEmitWorkspaceUpdates(); } } catch (error) { if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) { @@ -5968,7 +6002,7 @@ export class Session { try { const workspace = await this.ensureWorkspaceRegistered(request.cwd); await this.emitWorkspaceUpdateForCwd(workspace.cwd); - const descriptor = await this.describeWorkspaceRecord(workspace); + const descriptor = await this.describeWorkspaceRecordWithGitData(workspace); this.emit({ type: "open_project_response", payload: { @@ -6068,7 +6102,7 @@ export class Session { 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), From 51b83768c78517a2546df1a09e56b472796d7b68 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 09:50:39 +0700 Subject: [PATCH 17/31] fix(server): push workspace updates instantly, reconcile in background emitWorkspaceUpdateForCwd and emitWorkspaceUpdatesForCwds blocked on reconcileActiveWorkspaceRecords() (stat + reconcile on every active workspace) before pushing the target workspace update to clients. This caused new worktrees to appear in the sidebar with a noticeable delay after the agent was already visible. Emit the target workspace immediately from the registry snapshot, then run reconciliation in the background via reconcileAndEmitWorkspaceUpdates. Ensure the dedupeGitState early-return still triggers background reconciliation so metadata changes (branch renames, display names) are not silently dropped. --- packages/server/src/server/session.ts | 60 +++++++++++++-------------- 1 file changed, 28 insertions(+), 32 deletions(-) diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 916650f10..0ea3939b5 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -5784,66 +5784,60 @@ export class Session { return; } - const changedWorkspaceIds = await this.reconcileActiveWorkspaceRecords(); const activeWorkspaces = (await this.workspaceRegistry.list()).filter( (workspace) => !workspace.archivedAt, ); const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, activeWorkspaces); const all = await this.listWorkspaceDescriptorsSnapshot(); const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const)); - const workspaceIdsToEmit = new Set([ - workspaceId, - ...changedWorkspaceIds, - ]); - for (const nextWorkspaceId of workspaceIdsToEmit) { - const workspace = descriptorsByWorkspaceId.get(nextWorkspaceId); - const nextWorkspace = - workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter }) - ? workspace - : null; - if ( - options?.dedupeGitState && - this.shouldSkipWorkspaceGitWatchUpdate(nextWorkspaceId, nextWorkspace) - ) { - continue; - } - this.rememberWorkspaceGitWatchFingerprint(nextWorkspaceId, nextWorkspace); - - if (!nextWorkspace) { - this.bufferOrEmitWorkspaceUpdate(subscription, { - kind: "remove", - id: nextWorkspaceId, - }); - continue; - } + const workspace = descriptorsByWorkspaceId.get(workspaceId); + const nextWorkspace = + workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter }) + ? workspace + : null; + if ( + options?.dedupeGitState && + this.shouldSkipWorkspaceGitWatchUpdate(workspaceId, nextWorkspace) + ) { + void this.reconcileAndEmitWorkspaceUpdates(); + return; + } + this.rememberWorkspaceGitWatchFingerprint(workspaceId, nextWorkspace); + if (!nextWorkspace) { + this.bufferOrEmitWorkspaceUpdate(subscription, { + kind: "remove", + id: workspaceId, + }); + } else { this.bufferOrEmitWorkspaceUpdate(subscription, { kind: "upsert", workspace: nextWorkspace, }); } + + void this.reconcileAndEmitWorkspaceUpdates(); } private async emitWorkspaceUpdatesForCwds(cwds: Iterable): Promise { - if (!this.workspaceUpdatesSubscription) { + const subscription = this.workspaceUpdatesSubscription; + if (!subscription) { return; } - const changedWorkspaceIds = await this.reconcileActiveWorkspaceRecords(); const activeWorkspaces = (await this.workspaceRegistry.list()).filter( (workspace) => !workspace.archivedAt, ); - const uniqueWorkspaceCwds = new Set(changedWorkspaceIds); + const uniqueWorkspaceIds = new Set(); for (const cwd of cwds) { - uniqueWorkspaceCwds.add(this.resolveRegisteredWorkspaceIdForCwd(cwd, activeWorkspaces)); + uniqueWorkspaceIds.add(this.resolveRegisteredWorkspaceIdForCwd(cwd, activeWorkspaces)); } - const subscription = this.workspaceUpdatesSubscription; const all = await this.listWorkspaceDescriptorsSnapshot(); const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const)); - for (const workspaceId of uniqueWorkspaceCwds) { + for (const workspaceId of uniqueWorkspaceIds) { const workspace = descriptorsByWorkspaceId.get(workspaceId); const nextWorkspace = workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter }) @@ -5864,6 +5858,8 @@ export class Session { workspace: nextWorkspace, }); } + + void this.reconcileAndEmitWorkspaceUpdates(); } private async handleFetchAgents( From 940bc6243b49894954e66f3a168db4464c7e75ba Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 09:50:52 +0700 Subject: [PATCH 18/31] fix(app): polish diff toolbar toggle buttons - Lower active button surface from surface3 to surface2 for dark mode - Add gap between toolbar button groups - Remove outer border from unified/split group, use per-button radius --- packages/app/src/components/git-diff-pane.tsx | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 3d2348a67..6c27ee1bb 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -1381,20 +1381,12 @@ 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", - borderWidth: 1, - borderColor: theme.colors.border, - borderRadius: theme.borderRadius.base, - overflow: "hidden", }, toggleButton: { alignItems: "center", @@ -1402,7 +1394,7 @@ const styles = StyleSheet.create((theme) => ({ minWidth: { xs: 32, sm: 32, - md: 28, + md: 24, }, height: { xs: 32, @@ -1414,15 +1406,17 @@ const styles = StyleSheet.create((theme) => ({ sm: theme.spacing[2], md: theme.spacing[1], }, - backgroundColor: theme.colors.surface1, }, toggleButtonGroupStart: { - borderRightWidth: 1, - borderRightColor: theme.colors.border, + borderTopLeftRadius: theme.borderRadius.base, + borderBottomLeftRadius: theme.borderRadius.base, + }, + toggleButtonGroupEnd: { + borderTopRightRadius: theme.borderRadius.base, + borderBottomRightRadius: theme.borderRadius.base, }, - toggleButtonGroupEnd: {}, toggleButtonSelected: { - backgroundColor: theme.colors.surface3, + backgroundColor: theme.colors.surface2, }, expandAllButton: { flexDirection: "row", From 7b1144dafe70500e071b8983eff5ff7d89f49f24 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 11:40:37 +0700 Subject: [PATCH 19/31] feat(app): persist expanded state in file explorer and diff panes Remember which directories and diff entries are expanded across sidebar close/reopen cycles. State is stored per-workspace in the panel store (zustand + AsyncStorage) with no local React state copy, following the proven explorerTabByCheckout pattern. On desktop remount, expanded subdirectory listings are re-fetched so the tree renders fully. --- .../app/src/components/file-explorer-pane.tsx | 99 ++++++++++++------- packages/app/src/components/git-diff-pane.tsx | 73 +++++++++----- packages/app/src/stores/panel-store.ts | 37 ++++++- 3 files changed, 149 insertions(+), 60 deletions(-) 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 6c27ee1bb..e790af7f2 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -72,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"; @@ -541,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, @@ -601,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); @@ -611,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) { @@ -657,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; @@ -690,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(() => { @@ -961,7 +984,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi renderItem={renderFlatItem} keyExtractor={flatKeyExtractor} stickyHeaderIndices={stickyHeaderIndices} - extraData={{ expandedByPath, effectiveLayout, wrapLines }} + extraData={{ expandedPathsArray, effectiveLayout, wrapLines }} style={styles.scrollView} contentContainerStyle={styles.contentContainer} testID="git-diff-scroll" 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, From 638c2086096ac6677b4716486d7250200dd2db07 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 11:40:43 +0700 Subject: [PATCH 20/31] feat(server): add background git fetch manager Periodically fetch from remotes in the background so workspace git watch targets pick up upstream changes without user intervention. The manager deduplicates subscriptions per repo and triggers a workspace refresh callback after each successful fetch. --- .../background-git-fetch-manager.test.ts | 248 ++++++++++++++++++ .../server/background-git-fetch-manager.ts | 150 +++++++++++ packages/server/src/server/session.ts | 23 ++ .../session.workspace-git-watch.test.ts | 190 ++++++++++++++ .../server/src/server/websocket-server.ts | 7 + 5 files changed, 618 insertions(+) create mode 100644 packages/server/src/server/background-git-fetch-manager.test.ts create mode 100644 packages/server/src/server/background-git-fetch-manager.ts 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/session.ts b/packages/server/src/server/session.ts index 0ea3939b5..8857aebf6 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -65,6 +65,7 @@ import { import { experimental_createMCPClient } from "ai"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js"; +import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js"; export type AgentMcpTransportFactory = () => Promise; import { buildProviderRegistry } from "./agent/provider-registry.js"; @@ -398,6 +399,7 @@ export type SessionOptions = { scheduleService: ScheduleService; loopService: LoopService; checkoutDiffManager: CheckoutDiffManager; + backgroundGitFetchManager: BackgroundGitFetchManager; createAgentMcpTransport: AgentMcpTransportFactory; stt: Resolvable; tts: Resolvable; @@ -583,6 +585,7 @@ export class Session { private readonly scheduleService: ScheduleService; private readonly loopService: LoopService; private readonly checkoutDiffManager: CheckoutDiffManager; + private readonly backgroundGitFetchManager: BackgroundGitFetchManager; private readonly createAgentMcpTransport: AgentMcpTransportFactory; private readonly downloadTokenStore: DownloadTokenStore; private readonly pushTokenStore: PushTokenStore; @@ -611,6 +614,7 @@ export class Session { private peakInflightRequests = 0; private readonly checkoutDiffSubscriptions = new Map void>(); private readonly workspaceGitWatchTargets = new Map(); + private readonly workspaceGitFetchSubscriptions = new Map void>(); private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null; private readonly registerVoiceSpeakHandler?: ( agentId: string, @@ -649,6 +653,7 @@ export class Session { scheduleService, loopService, checkoutDiffManager, + backgroundGitFetchManager, createAgentMcpTransport, stt, tts, @@ -677,6 +682,7 @@ export class Session { this.scheduleService = scheduleService; this.loopService = loopService; this.checkoutDiffManager = checkoutDiffManager; + this.backgroundGitFetchManager = backgroundGitFetchManager; this.createAgentMcpTransport = createAgentMcpTransport; this.terminalManager = terminalManager; this.providerSnapshotManager = providerSnapshotManager ?? null; @@ -4199,6 +4205,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); } @@ -4329,6 +4338,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( @@ -7284,6 +7303,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.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index b9b65602d..38c3a0290 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -43,7 +43,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), + })), })); import { Session } from "./session.js"; @@ -51,10 +58,31 @@ import { Session } from "./session.js"; function createSessionForWorkspaceGitWatchTests(): { session: Session; emitted: Array<{ type: string; payload: unknown }>; + 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(); + const backgroundGitFetchSubscriptions: Array<{ + params: { repoGitRoot: string; cwd: string }; + listener: () => void; + unsubscribe: ReturnType; + }> = []; const logger = { child: () => logger, trace: vi.fn(), @@ -63,6 +91,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", @@ -140,6 +181,7 @@ function createSessionForWorkspaceGitWatchTests(): { }), dispose: () => {}, } as any, + backgroundGitFetchManager: backgroundGitFetchManager as any, createAgentMcpTransport: async () => { throw new Error("not used"); }, @@ -153,6 +195,11 @@ function createSessionForWorkspaceGitWatchTests(): { return { session, emitted, + backgroundGitFetchManager: { + subscribe: backgroundGitFetchManager.subscribe, + subscriptions: backgroundGitFetchSubscriptions, + }, + logger, }; } @@ -316,4 +363,147 @@ 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, backgroundGitFetchManager } = createSessionForWorkspaceGitWatchTests(); + const sessionAny = session as any; + + sessionAny.buildProjectPlacement = async (cwd: string) => ({ + projectKey: cwd, + projectName: path.basename(cwd), + checkout: { + cwd, + isGit: true, + currentBranch: "main", + remoteUrl: "https://github.com/acme/repo.git", + worktreeRoot: cwd, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }); + resolveCheckoutGitDirMock.mockResolvedValue("/tmp/repo/.git"); + + await sessionAny.ensureWorkspaceRegistered("/tmp/repo"); + + 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, backgroundGitFetchManager } = createSessionForWorkspaceGitWatchTests(); + const sessionAny = session as any; + + sessionAny.buildProjectPlacement = async (cwd: string) => ({ + projectKey: cwd, + projectName: path.basename(cwd), + checkout: { + cwd, + isGit: true, + currentBranch: "main", + remoteUrl: "https://github.com/acme/repo.git", + worktreeRoot: cwd, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }); + 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.ensureWorkspaceRegistered("/tmp/repo"); + await sessionAny.ensureWorkspaceRegistered("/tmp/repo-feature"); + + 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, backgroundGitFetchManager } = createSessionForWorkspaceGitWatchTests(); + const sessionAny = session as any; + + sessionAny.buildProjectPlacement = async (cwd: string) => ({ + projectKey: cwd, + projectName: path.basename(cwd), + checkout: { + cwd, + isGit: true, + currentBranch: "main", + remoteUrl: "https://github.com/acme/repo.git", + worktreeRoot: cwd, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }); + 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.listWorkspaceDescriptorsSnapshot = async () => [descriptor]; + + await sessionAny.ensureWorkspaceRegistered("/tmp/repo"); + 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/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 00c636e34..7c719b454 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, @@ -237,6 +238,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; @@ -338,6 +340,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; @@ -507,6 +512,7 @@ export class VoiceAssistantWebSocketServer { await Promise.all(cleanupPromises); this.providerSnapshotManager.destroy(); + this.backgroundGitFetchManager.dispose(); this.checkoutDiffManager.dispose(); this.pendingConnections.clear(); this.sessions.clear(); @@ -653,6 +659,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, From 29b6f2a86fa61b4f05ec72d568749b8a7202b245 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 11:40:59 +0700 Subject: [PATCH 21/31] fix(server): prefer origin/{branch} over local branch for worktrees When creating a worktree, resolve the base branch by checking origin/{branch} first, falling back to the local branch only when the remote ref doesn't exist. This ensures worktrees start from the latest upstream state rather than a potentially stale local branch. --- packages/server/src/utils/worktree.test.ts | 65 ++++++++++++++++++++++ packages/server/src/utils/worktree.ts | 9 ++- 2 files changed, 69 insertions(+), 5 deletions(-) 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 193901553..35ce3671a 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -924,15 +924,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}`); } From 1900f4304913485aabab5248f1ac3cc32b823c44 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 12:20:15 +0700 Subject: [PATCH 22/31] fix(app): move reload agent away from close action --- .../workspace/workspace-tab-menu.test.ts | 4 +-- .../screens/workspace/workspace-tab-menu.ts | 25 +++++++++++-------- 2 files changed, 16 insertions(+), 13 deletions(-) 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 00741d215..1b31729a0 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts @@ -135,17 +135,6 @@ export function buildWorkspaceTabMenuEntries( void onCopyAgentId(agentId); }, }); - entries.push({ - kind: "item", - key: "reload-agent", - label: "Reload agent", - icon: "rotate-cw", - tooltip: "Reload agent to update skills, MCPs or login status.", - testID: `${menuTestIDBase}-reload-agent`, - onSelect: () => { - void onReloadAgent(agentId); - }, - }); entries.push({ kind: "separator", key: "copy-separator", @@ -185,6 +174,20 @@ export function buildWorkspaceTabMenuEntries( void onCloseOtherTabs(tab.tabId); }, }); + if (tab.target.kind === "agent") { + const { agentId } = tab.target; + entries.push({ + kind: "item", + key: "reload-agent", + label: "Reload agent", + icon: "rotate-cw", + tooltip: "Reload agent to update skills, MCPs or login status.", + testID: `${menuTestIDBase}-reload-agent`, + onSelect: () => { + void onReloadAgent(agentId); + }, + }); + } entries.push({ kind: "item", key: "close", From bdaa6b65aac0391d9c03123702c86742b4fc2e6e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 13:22:58 +0700 Subject: [PATCH 23/31] fix(app): fix garbled overlapping text in plan card markdown PlanCard list_item rule wrapped children in instead of , causing layout corruption when children contained elements (nested lists, paragraphs). Also added missing paragraph rule to match AssistantMessage renderer. --- packages/app/src/components/plan-card.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/plan-card.tsx b/packages/app/src/components/plan-card.tsx index 8579eeac7..11741bb22 100644 --- a/packages/app/src/components/plan-card.tsx +++ b/packages/app/src/components/plan-card.tsx @@ -78,9 +78,17 @@ function createPlanMarkdownRules() { const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content; return ( - + {marker} - {children} + {children} + + ); + }, + paragraph: (node: any, children: ReactNode[], parent: any, styles: any) => { + const isLastChild = parent[0]?.children?.at(-1)?.key === node.key; + return ( + + {children} ); }, From 52dfdb191358dc1b93ec8e7e8225dbb7553be77a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 14:17:52 +0700 Subject: [PATCH 24/31] fix(app): use shared provider snapshot hook in settings and add refresh button Settings providers section now uses useProvidersSnapshot instead of a one-shot fetch, so it shares the same real-time data source as the status bar and draft form. Adds a Refresh button to re-detect installed provider CLIs without restarting the daemon. --- packages/app/src/screens/settings-screen.tsx | 58 +++++++++----------- 1 file changed, 26 insertions(+), 32 deletions(-) diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index bb6b2ace6..796c21bf2 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 @@ -432,55 +432,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); From 161b2c237833c2cb3b36718262fbf395ed761a41 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 14:30:32 +0700 Subject: [PATCH 25/31] fix(app): port optimistic workspace tab closing --- .../workspace/workspace-bulk-close.test.ts | 35 +++++----- .../screens/workspace/workspace-bulk-close.ts | 45 +++--------- .../screens/workspace/workspace-screen.tsx | 69 +++++++------------ 3 files changed, 52 insertions(+), 97 deletions(-) diff --git a/packages/app/src/screens/workspace/workspace-bulk-close.test.ts b/packages/app/src/screens/workspace/workspace-bulk-close.test.ts index e154824c4..7327b253a 100644 --- a/packages/app/src/screens/workspace/workspace-bulk-close.test.ts +++ b/packages/app/src/screens/workspace/workspace-bulk-close.test.ts @@ -73,7 +73,7 @@ describe("workspace bulk close helpers", () => { ); }); - it("uses one mixed closeItems RPC for agent and terminal tabs, then applies local cleanup", async () => { + it("closes all tabs immediately and fires one mixed closeItems RPC in the background", async () => { const groups = classifyBulkClosableTabs([ makeAgentTab("a1"), makeTerminalTab("t1"), @@ -91,7 +91,7 @@ describe("workspace bulk close helpers", () => { requestId: "req-1", })); - const result = await closeBulkWorkspaceTabs({ + await closeBulkWorkspaceTabs({ groups, client: { closeItems }, closeTab: async (tabId, action) => { @@ -109,23 +109,21 @@ describe("workspace bulk close helpers", () => { agentIds: ["a1"], terminalIds: ["t1", "t2"], }); - expect(result).toEqual({ - agents: [{ agentId: "a1", archivedAt: "2026-04-01T04:00:00.000Z" }], - terminals: [ - { terminalId: "t1", success: true }, - { terminalId: "t2", success: false }, - ], - requestId: "req-1", - }); - expect(closedTabIds).toEqual(["agent_a1", "terminal_t1", "file_/repo/README.md"]); + expect(closedTabIds).toEqual([ + "agent_a1", + "terminal_t1", + "terminal_t2", + "file_/repo/README.md", + ]); expect(cleanupCalls).toEqual([ { tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } }, { tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } }, + { tabId: "terminal_t2", target: { kind: "terminal", terminalId: "t2" } }, { tabId: "file_/repo/README.md" }, ]); }); - it("still closes passive tabs when the mixed closeItems RPC fails", async () => { + it("still closes all tabs when the mixed closeItems RPC fails", async () => { const groups = classifyBulkClosableTabs([ makeAgentTab("a1"), makeTerminalTab("t1"), @@ -135,7 +133,7 @@ describe("workspace bulk close helpers", () => { const cleanupCalls: Array<{ tabId: string; target?: WorkspaceTabDescriptor["target"] }> = []; const warn = vi.fn(); - const result = await closeBulkWorkspaceTabs({ + await closeBulkWorkspaceTabs({ groups, client: { closeItems: async () => { @@ -153,9 +151,14 @@ describe("workspace bulk close helpers", () => { logLabel: "others", }); + await Promise.resolve(); + expect(warn).toHaveBeenCalledTimes(1); - expect(result).toBeNull(); - expect(closedTabIds).toEqual(["file_/repo/README.md"]); - expect(cleanupCalls).toEqual([{ tabId: "file_/repo/README.md" }]); + expect(closedTabIds).toEqual(["agent_a1", "terminal_t1", "file_/repo/README.md"]); + expect(cleanupCalls).toEqual([ + { tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } }, + { tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } }, + { tabId: "file_/repo/README.md" }, + ]); }); }); diff --git a/packages/app/src/screens/workspace/workspace-bulk-close.ts b/packages/app/src/screens/workspace/workspace-bulk-close.ts index 70b662d12..647799b46 100644 --- a/packages/app/src/screens/workspace/workspace-bulk-close.ts +++ b/packages/app/src/screens/workspace/workspace-bulk-close.ts @@ -7,8 +7,6 @@ export type BulkClosableTabGroups = { otherTabs: Array<{ tabId: string }>; }; -type CloseItemsPayload = Awaited>; - interface CloseWorkspaceTabWithCleanupInput { tabId: string; target?: WorkspaceTabDescriptor["target"]; @@ -68,47 +66,27 @@ export function buildBulkCloseConfirmationMessage(input: BulkClosableTabGroups): return `This will archive ${agentTabs.length} agent(s).`; } -function toSuccessfulAgentIds(payload: CloseItemsPayload | null): Set { - return new Set(payload?.agents.map((agent) => agent.agentId) ?? []); -} - -function toSuccessfulTerminalIds(payload: CloseItemsPayload | null): Set { - return new Set( - payload?.terminals.filter((terminal) => terminal.success).map((terminal) => terminal.terminalId) ?? - [], - ); -} - -export async function closeBulkWorkspaceTabs( - input: CloseBulkWorkspaceTabsInput, -): Promise { +export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput): Promise { const { client, groups, closeTab, closeWorkspaceTabWithCleanup, logLabel, warn } = input; const hasDestructiveTabs = groups.agentTabs.length > 0 || groups.terminalTabs.length > 0; - let payload: CloseItemsPayload | null = null; if (hasDestructiveTabs && client) { - try { - payload = await client.closeItems({ + void client + .closeItems({ agentIds: groups.agentTabs.map((tab) => tab.agentId), terminalIds: groups.terminalTabs.map((tab) => tab.terminalId), + }) + .catch((error) => { + warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, { error }); }); - } catch (error) { - warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, { error }); - } } else if (hasDestructiveTabs) { warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, { error: new Error("Daemon client not available"), }); } - const successfulAgentIds = toSuccessfulAgentIds(payload); - const successfulTerminalIds = toSuccessfulTerminalIds(payload); - for (const { tabId, agentId } of groups.agentTabs) { - if (!successfulAgentIds.has(agentId)) { - continue; - } - await closeTab(tabId, async () => { + void closeTab(tabId, async () => { closeWorkspaceTabWithCleanup({ tabId, target: { kind: "agent", agentId }, @@ -117,10 +95,7 @@ export async function closeBulkWorkspaceTabs( } for (const { tabId, terminalId } of groups.terminalTabs) { - if (!successfulTerminalIds.has(terminalId)) { - continue; - } - await closeTab(tabId, async () => { + void closeTab(tabId, async () => { closeWorkspaceTabWithCleanup({ tabId, target: { kind: "terminal", terminalId }, @@ -129,10 +104,8 @@ export async function closeBulkWorkspaceTabs( } for (const { tabId } of groups.otherTabs) { - await closeTab(tabId, async () => { + void closeTab(tabId, async () => { closeWorkspaceTabWithCleanup({ tabId }); }); } - - return payload; } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 29c6ffb81..186d81283 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -79,7 +79,7 @@ import { import type { ListTerminalsResponse } from "@server/shared/messages"; import { upsertTerminalListEntry } from "@/utils/terminal-list"; import { confirmDialog } from "@/utils/confirm-dialog"; -import { applyArchivedAgentCloseResults, useArchiveAgent } from "@/hooks/use-archive-agent"; +import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useStableEvent } from "@/hooks/use-stable-event"; import { buildProviderCommand } from "@/utils/provider-command-templates"; import { generateDraftId } from "@/stores/draft-keys"; @@ -1214,10 +1214,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) return; } - await killTerminalAsync(terminalId); - setHoveredTabKey((current) => (current === tabId ? null : current)); - setHoveredCloseTabKey((current) => (current === tabId ? null : current)); - queryClient.setQueryData(terminalsQueryKey, (current) => { if (!current) { return current; @@ -1227,13 +1223,18 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) terminals: current.terminals.filter((terminal) => terminal.id !== terminalId), }; }); - + setHoveredTabKey((current) => (current === tabId ? null : current)); + setHoveredCloseTabKey((current) => (current === tabId ? null : current)); if (persistenceKey) { closeWorkspaceTabWithCleanup({ tabId, target: { kind: "terminal", terminalId }, }); } + + void killTerminalAsync(terminalId).catch(() => { + void queryClient.invalidateQueries({ queryKey: terminalsQueryKey }); + }); }); }, [ @@ -1254,18 +1255,22 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) return; } - const confirmed = await confirmDialog({ - title: "Archive agent?", - message: "This closes the tab and archives the agent.", - confirmLabel: "Archive", - cancelLabel: "Cancel", - destructive: true, - }); - if (!confirmed) { - return; + const agent = + useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; + + if (agent?.status !== "idle") { + const confirmed = await confirmDialog({ + title: "Archive agent?", + message: "This closes the tab and archives the agent.", + confirmLabel: "Archive", + cancelLabel: "Cancel", + destructive: true, + }); + if (!confirmed) { + return; + } } - await archiveAgent({ serverId: normalizedServerId, agentId }); setHoveredTabKey((current) => (current === tabId ? null : current)); setHoveredCloseTabKey((current) => (current === tabId ? null : current)); if (persistenceKey) { @@ -1274,6 +1279,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) target: { kind: "agent", agentId }, }); } + + void archiveAgent({ serverId: normalizedServerId, agentId }); }); }, [archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey], @@ -1418,7 +1425,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) return; } - const closeItemsPayload = await closeBulkWorkspaceTabs({ + await closeBulkWorkspaceTabs({ client, groups, closeTab, @@ -1434,31 +1441,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) }, }); - if (closeItemsPayload) { - for (const terminal of closeItemsPayload.terminals) { - if (!terminal.success) { - continue; - } - queryClient.setQueryData(terminalsQueryKey, (current) => { - if (!current) { - return current; - } - return { - ...current, - terminals: current.terminals.filter((entry) => entry.id !== terminal.terminalId), - }; - }); - } - - if (normalizedServerId) { - applyArchivedAgentCloseResults({ - queryClient, - serverId: normalizedServerId, - results: closeItemsPayload.agents, - }); - } - } - const closedKeys = new Set(tabsToClose.map((tab) => tab.key)); setHoveredTabKey((current) => (current && closedKeys.has(current) ? null : current)); setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current)); @@ -1467,10 +1449,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) client, closeTab, closeWorkspaceTabWithCleanup, - normalizedServerId, persistenceKey, - queryClient, - terminalsQueryKey, ], ); From 022eb33234d7be82a89e81ed364dcdb320416ce7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 14:53:00 +0700 Subject: [PATCH 26/31] Fix OpenCode context window meter after first turn --- packages/app/src/contexts/session-context.tsx | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index a7a2717ac..3b58c5d2f 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -68,6 +68,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 { @@ -350,6 +366,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); From e06b691d5d4ea6559769eae1565c1d8a2152c5f8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 15:03:29 +0700 Subject: [PATCH 27/31] feat: show remaining context window for Claude Code, Codex, and OpenCode Track and display context window usage across all three agent providers, letting users see how much context remains before hitting limits. --- .../app/src/components/agent-input-area.tsx | 29 ++ .../app/src/components/agent-status-bar.tsx | 1 + .../src/components/context-window-meter.tsx | 153 +++++++ packages/app/src/components/message-input.tsx | 4 + packages/app/src/components/ui/tooltip.tsx | 38 +- .../server/agent/agent-projections.test.ts | 78 ++++ .../src/server/agent/agent-projections.ts | 20 +- .../src/server/agent/agent-sdk-types.ts | 2 + .../agent/providers/claude-agent.test.ts | 375 ++++++++++++++++++ .../server/agent/providers/claude-agent.ts | 77 +++- .../providers/codex-app-server-agent.test.ts | 76 ++++ .../agent/providers/codex-app-server-agent.ts | 33 +- .../agent/providers/opencode-agent.test.ts | 157 +++++++- .../server/agent/providers/opencode-agent.ts | 358 ++++++++++++++--- .../opencode/event-translator.test.ts | 27 ++ packages/server/src/shared/messages.ts | 2 + 16 files changed, 1358 insertions(+), 72 deletions(-) create mode 100644 packages/app/src/components/context-window-meter.tsx diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 1e41fd620..847f23488 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.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 { @@ -135,6 +136,8 @@ export function AgentInputArea({ const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null; return { status: agent?.status ?? null, + contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null, + contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null, }; }), ); @@ -636,6 +639,25 @@ export function AgentInputArea({ ); + 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 ? ( @@ -715,6 +737,7 @@ export function AgentInputArea({ disabled={isSubmitLoading} isInputActive={isInputActive} leftContent={leftContent} + beforeVoiceContent={beforeVoiceContent} rightContent={rightContent} voiceServerId={serverId} voiceAgentId={agentId} @@ -790,6 +813,12 @@ const styles = StyleSheet.create(((theme: Theme) => ({ alignItems: "center", gap: theme.spacing[2], }, + contextWindowMeterSlot: { + width: 28, + height: 28, + alignItems: "center", + justifyContent: "center", + }, realtimeVoiceButton: { width: 28, height: 28, diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index b1cf99aa4..5954ad2af 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -859,6 +859,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat model: currentAgent.model, features: currentAgent.features, thinkingOptionId: currentAgent.thinkingOptionId, + lastUsage: currentAgent.lastUsage, } : null; }), 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/message-input.tsx b/packages/app/src/components/message-input.tsx index c94bd8fbd..8e18b3706 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -79,6 +79,8 @@ export interface MessageInputProps { isInputActive?: boolean; /** Content to render on the left side of the button row (e.g., AgentStatusBar) */ leftContent?: React.ReactNode; + /** Content to render on the right side before the voice button (e.g., context window meter) */ + beforeVoiceContent?: React.ReactNode; /** Content to render on the right side after voice button (e.g., realtime button, cancel button) */ rightContent?: React.ReactNode; voiceServerId?: string; @@ -201,6 +203,7 @@ export const MessageInput = forwardRef(funct disabled = false, isInputActive = true, leftContent, + beforeVoiceContent, rightContent, voiceServerId, voiceAgentId, @@ -1015,6 +1018,7 @@ export const MessageInput = forwardRef(funct {/* Right: voice button, contextual button (realtime/send/cancel) */} + {beforeVoiceContent} 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}; @@ -323,9 +333,17 @@ export function TooltipTrigger({ const handlePress = useCallback( (e: any) => { onPress?.(e); + if (!ctx.enabled || disabled) { + return; + } + if (ctx.openOnPress) { + clearOpenTimer(); + ctx.setOpen(true); + return; + } close(); }, - [close, onPress], + [clearOpenTimer, close, ctx, disabled, onPress], ); const triggerProps = { @@ -492,7 +510,7 @@ export function TooltipContent({ statusBarTranslucent={Platform.OS === "android"} onRequestClose={() => ctx.setOpen(false)} > - + ctx.setOpen(false)}> {children} - + ); } 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 a3f040d04..9e204aaad 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -259,29 +259,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 5b91fa1e2..0ab82be09 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 = [ 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 d330d66d0..f9940e8a7 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, @@ -967,7 +968,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 { @@ -1161,6 +1163,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; @@ -1202,6 +1238,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; @@ -1381,6 +1418,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; @@ -2207,6 +2245,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"); } @@ -2222,10 +2261,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"); } } @@ -2238,6 +2279,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"); } @@ -2248,6 +2290,7 @@ class ClaudeAgentSession implements AgentSession { } this.notifySubscribers({ type: "turn_completed", provider: "claude" }); this.autonomousTurn = null; + this.lastContextWindowUsedTokens = undefined; this.syncTurnState("autonomous turn completed"); } @@ -2576,6 +2619,10 @@ class ClaudeAgentSession implements AgentSession { provider: "claude", }); } + } else if (message.subtype === "task_progress") { + this.lastContextWindowUsedTokens = + readContextWindowUsedTokensFromTaskProgress(message) ?? + this.lastContextWindowUsedTokens; } break; case "user": { @@ -2653,7 +2700,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 { @@ -2794,16 +2841,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 ( 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..b40bbc6c4 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 @@ -40,6 +40,82 @@ 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("maps image prompt blocks to Codex localImage input", async () => { const input = await codexAppServerTurnInputFromPrompt( [ 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..4f09c9b1d 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 @@ -677,12 +677,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 } : {}), }; } @@ -4009,5 +4039,6 @@ export const __codexAppServerInternals = { planStepsToMarkdown, mapCodexPlanToToolCall, 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/shared/messages.ts b/packages/server/src/shared/messages.ts index e32c682f9..ba55577c2 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({ From 682fc54778c5a83aadc3f24e02cd7006eb306bd1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 15:43:14 +0700 Subject: [PATCH 28/31] feat: normalize plan approval across providers --- docs/plan-approval-normalization.md | 68 +++++ .../app/src/components/agent-stream-view.tsx | 148 ++++++---- .../src/server/agent/agent-management-mcp.ts | 3 + .../src/server/agent/agent-manager.test.ts | 207 ++++++++++++++ .../server/src/server/agent/agent-manager.ts | 57 +++- .../src/server/agent/agent-projections.ts | 2 + .../src/server/agent/agent-sdk-types.ts | 11 + .../server/src/server/agent/agent-storage.ts | 3 +- .../server/src/server/agent/mcp-server.ts | 3 + .../providers/claude-agent.redesign.test.ts | 76 +++++ .../server/agent/providers/claude-agent.ts | 68 ++++- .../providers/codex-app-server-agent.test.ts | 264 +++++++++++++++++- .../agent/providers/codex-app-server-agent.ts | 233 +++++++++++++++- .../shared/messages.stream-parsing.test.ts | 68 +++++ packages/server/src/shared/messages.ts | 10 + 15 files changed, 1137 insertions(+), 84 deletions(-) create mode 100644 docs/plan-approval-normalization.md 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/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index fd17d4223..f99667d54 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"; @@ -694,6 +697,29 @@ function PermissionRequestCard({ const isPlanRequest = request.kind === "plan"; const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required"); const description = request.description ?? ""; + const resolvedActions = useMemo((): AgentPermissionAction[] => { + if (request.kind === "question") { + return []; + } + if (Array.isArray(request.actions) && request.actions.length > 0) { + return request.actions; + } + return [ + { + id: "reject", + label: "Deny", + behavior: "deny", + variant: "danger", + intent: "dismiss", + }, + { + id: "accept", + label: isPlanRequest ? "Implement" : "Accept", + behavior: "allow", + variant: "primary", + }, + ]; + }, [isPlanRequest, request]); const planMarkdown = useMemo(() => { if (!request) { @@ -734,11 +760,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) => { @@ -752,6 +778,24 @@ function PermissionRequestCard({ }, [permission.agentId, permission.request.id, respondToPermission], ); + const handleActionPress = useCallback( + (action: AgentPermissionAction) => { + setRespondingActionId(action.id); + if (action.behavior === "allow") { + handleResponse({ + behavior: "allow", + selectedActionId: action.id, + }); + return; + } + handleResponse({ + behavior: "deny", + selectedActionId: action.id, + message: "Denied by user", + }); + }, + [handleResponse], + ); if (request.kind === "question") { return ( @@ -778,64 +822,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/server/src/server/agent/agent-management-mcp.ts b/packages/server/src/server/agent/agent-management-mcp.ts index 14086d973..001a5bc5b 100644 --- a/packages/server/src/server/agent/agent-management-mcp.ts +++ b/packages/server/src/server/agent/agent-management-mcp.ts @@ -171,6 +171,9 @@ function sanitizePermissionRequest( if (sanitized.suggestions === undefined) { delete sanitized.suggestions; } + if (sanitized.actions === undefined) { + delete sanitized.actions; + } if (sanitized.metadata === undefined) { delete sanitized.metadata; } diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index ed41c70b9..80161dbb8 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -2890,6 +2890,213 @@ describe("AgentManager", () => { expect(updatedAgent?.currentModeId).toBe("acceptEdits"); }); + test("respondToPermission refreshes features and runtime info after provider-managed plan approval", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class RefreshingPermissionSession extends TestAgentSession { + private featureState: AgentFeature[] = [ + createFeature({ id: "fast_mode", label: "Fast", value: true }), + createFeature({ id: "plan_mode", label: "Plan", value: true }), + ]; + private modeId = "auto"; + private pending = [ + { + id: "perm-plan-1", + provider: "codex" as const, + name: "CodexPlanApproval", + kind: "plan" as const, + input: { plan: "- Implement the feature" }, + }, + ]; + + get features(): AgentFeature[] { + return this.featureState; + } + + override async getRuntimeInfo() { + return { + provider: this.provider, + sessionId: this.id, + model: "gpt-5.4", + modeId: this.modeId, + extra: { collaborationMode: this.features[1]?.value ? "Plan" : "Code" }, + }; + } + + override async getCurrentMode() { + return this.modeId; + } + + override getPendingPermissions() { + return this.pending; + } + + override async respondToPermission(): Promise { + 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 50e4efa5f..f95f29205 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -172,6 +172,11 @@ type ManagedAgentBase = { features?: AgentFeature[]; currentModeId: string | null; pendingPermissions: Map; + bufferedPermissionResolutions: Map< + string, + Extract + >; + inFlightPermissionResponses: Set; pendingReplacement: boolean; timeline: AgentTimelineItem[]; timelineRows: AgentTimelineRow[]; @@ -1437,18 +1442,31 @@ export class AgentManager { response: AgentPermissionResponse, ): Promise { const agent = this.requireAgent(agentId); - await agent.session.respondToPermission(requestId, response); - agent.pendingPermissions.delete(requestId); + agent.inFlightPermissionResponses.add(requestId); - // Update currentModeId - the session may have changed mode internally - // (e.g., plan approval changes mode from "plan" to "acceptEdits") try { - agent.currentModeId = await agent.session.getCurrentMode(); - } catch { - // Ignore errors from getCurrentMode - mode tracking is best effort - } + 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 { @@ -1799,6 +1817,8 @@ export class AgentManager { availableModes: [], currentModeId: null, pendingPermissions: new Map(), + bufferedPermissionResolutions: new Map(), + inFlightPermissionResponses: new Set(), pendingReplacement: false, activeForegroundTurnId: null, foregroundTurnWaiters: new Set(), @@ -2011,6 +2031,7 @@ export class AgentManager { agent.pendingPermissions.clear(); } + this.syncFeaturesFromSession(agent); await this.refreshRuntimeInfo(agent); } @@ -2087,6 +2108,7 @@ export class AgentManager { } let timelineRow: AgentTimelineRow | null = null; + let shouldDispatchEvent = true; switch (event.type) { case "thread_started": @@ -2259,6 +2281,11 @@ export class AgentManager { break; case "permission_resolved": agent.pendingPermissions.delete(event.requestId); + if (!options?.fromHistory && agent.inFlightPermissionResponses.has(event.requestId)) { + agent.bufferedPermissionResolutions.set(event.requestId, event); + shouldDispatchEvent = false; + break; + } this.emitState(agent); break; default: @@ -2270,7 +2297,7 @@ export class AgentManager { } // Skip dispatching individual stream events during history replay. - if (!options?.fromHistory) { + if (!options?.fromHistory && shouldDispatchEvent) { this.dispatchStream( agent.id, event, @@ -2365,9 +2392,7 @@ export class AgentManager { // Keep attention as an edge-triggered unread signal, not a level signal. this.checkAndSetAttention(agent); - if (agent.session?.features) { - agent.features = agent.session.features; - } + this.syncFeaturesFromSession(agent); this.dispatch({ type: "agent_state", @@ -2375,6 +2400,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.ts b/packages/server/src/server/agent/agent-projections.ts index 9e204aaad..deebedd7c 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, requiresAttention: agent.attention.requiresAttention, attentionReason: agent.attention.requiresAttention ? agent.attention.attentionReason : null, @@ -166,6 +167,7 @@ function sanitizePendingPermissions( ...request, input: sanitizeMetadata(request.input), suggestions: sanitizeMetadataArray(request.suggestions), + actions: request.actions?.map((action) => ({ ...action })), metadata: sanitizeMetadata(request.metadata), })); } diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 0ab82be09..795e375a0 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -330,6 +330,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; @@ -340,17 +348,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 e7fce3d0c..ee28b2355 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 { AgentSessionConfig } from "./agent-sdk-types.js"; @@ -56,6 +56,7 @@ const STORED_AGENT_SCHEMA = z.object({ extra: z.record(z.unknown()).optional(), }) .optional(), + features: z.array(AgentFeatureSchema).optional(), persistence: PERSISTENCE_HANDLE_SCHEMA, requiresAttention: z.boolean().optional(), attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(), diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 60f20cf5a..a982bc483 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -233,6 +233,9 @@ function sanitizePermissionRequest( if (sanitized.suggestions === undefined) { delete sanitized.suggestions; } + if (sanitized.actions === undefined) { + delete sanitized.actions; + } if (sanitized.metadata === undefined) { delete sanitized.metadata; } 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.ts b/packages/server/src/server/agent/providers/claude-agent.ts index f9940e8a7..364a8af77 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -48,6 +48,7 @@ import { } from "./diagnostic-utils.js"; import type { + AgentPermissionAction, AgentCapabilityFlags, AgentClient, AgentLaunchContext, @@ -670,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; @@ -1212,6 +1250,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(); @@ -1273,6 +1312,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 { @@ -1515,8 +1557,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; } @@ -1563,13 +1613,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", + }, }), ); } @@ -2800,6 +2859,9 @@ class ClaudeAgentSession implements AgentSession { } 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); @@ -2907,6 +2969,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 b40bbc6c4..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"; @@ -116,6 +116,107 @@ describe("Codex app-server provider", () => { }); }); + 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( [ @@ -421,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 4f09c9b1d..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; @@ -769,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; @@ -2299,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(); @@ -2319,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; @@ -2502,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; @@ -2804,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(); @@ -2899,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}`); @@ -2922,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); @@ -3223,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(); @@ -3244,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, @@ -3251,6 +3464,7 @@ class CodexAppServerAgentSession implements AgentSession { }); } this.activeForegroundTurnId = null; + this.latestPlanResult = null; this.emittedItemStartedIds.clear(); this.emittedItemCompletedIds.clear(); this.emittedExecCommandStartedCallIds.clear(); @@ -3272,6 +3486,7 @@ class CodexAppServerAgentSession implements AgentSession { ), }); if (timelineItem) { + this.rememberPlanResult(timelineItem); this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, @@ -3465,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 }); @@ -4038,6 +4256,7 @@ export const __codexAppServerInternals = { mapCodexPatchNotificationToToolCall, planStepsToMarkdown, mapCodexPlanToToolCall, + normalizeCodexOutputSchema, normalizeCodexQuestionPrompts, toAgentUsage, threadItemToTimeline, diff --git a/packages/server/src/shared/messages.stream-parsing.test.ts b/packages/server/src/shared/messages.stream-parsing.test.ts index 01dc36799..c5698c901 100644 --- a/packages/server/src/shared/messages.stream-parsing.test.ts +++ b/packages/server/src/shared/messages.stream-parsing.test.ts @@ -139,6 +139,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 ba55577c2..4805343b4 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -199,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(), }), @@ -222,6 +231,7 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType Date: Tue, 7 Apr 2026 16:09:47 +0700 Subject: [PATCH 29/31] Defer workspace diff stats until post-bootstrap updates --- packages/server/src/server/session.ts | 179 ++++++----- .../src/server/session.workspaces.test.ts | 287 +++++++++++++++--- 2 files changed, 346 insertions(+), 120 deletions(-) diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 8857aebf6..9982ae2fc 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -5367,7 +5367,21 @@ export class Session { return { ...base, name: displayName, diffStat }; } - private async listWorkspaceDescriptorsSnapshot(): Promise { + 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(), @@ -5381,14 +5395,26 @@ export class Session { .map((project) => [project.projectId, project] as const), ); const descriptorsByWorkspaceId = new Map(); + const workspaceIds = options.workspaceIds + ? new Set( + Array.from(options.workspaceIds, (workspaceId) => + normalizePersistedWorkspaceId(workspaceId), + ), + ) + : null; for (const workspace of activeRecords) { + if (workspaceIds && !workspaceIds.has(workspace.workspaceId)) { + continue; + } + const projectRecord = activeProjects.get(workspace.projectId) ?? null; descriptorsByWorkspaceId.set( workspace.workspaceId, - await this.describeWorkspaceRecord( + await this.buildWorkspaceDescriptor({ workspace, - activeProjects.get(workspace.projectId) ?? null, - ), + projectRecord, + includeGitData: options.includeGitData, + }), ); } @@ -5410,7 +5436,15 @@ 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( @@ -5770,8 +5804,7 @@ export class Session { } private async reconcileAndEmitWorkspaceUpdates(): Promise { - const subscription = this.workspaceUpdatesSubscription; - if (!subscription) { + if (!this.workspaceUpdatesSubscription) { return; } try { @@ -5779,82 +5812,34 @@ export class Session { if (changedWorkspaceIds.size === 0) { return; } - const all = await this.listWorkspaceDescriptorsSnapshot(); - const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const)); - for (const workspaceId of changedWorkspaceIds) { - const workspace = descriptorsByWorkspaceId.get(workspaceId) ?? null; - if (workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })) { - this.bufferOrEmitWorkspaceUpdate(subscription, { kind: "upsert", workspace }); - } else { - this.bufferOrEmitWorkspaceUpdate(subscription, { kind: "remove", id: workspaceId }); - } - } + await this.emitWorkspaceUpdatesForWorkspaceIds(changedWorkspaceIds, { + skipReconcile: true, + }); } catch (error) { this.sessionLogger.error({ err: error }, "Background workspace reconciliation failed"); } } - private async emitWorkspaceUpdateForCwd( - cwd: string, - options?: { dedupeGitState?: boolean }, + private async emitWorkspaceUpdatesForWorkspaceIds( + workspaceIds: Iterable, + options?: { dedupeGitState?: boolean; skipReconcile?: boolean }, ): Promise { const subscription = this.workspaceUpdatesSubscription; if (!subscription) { return; } - const activeWorkspaces = (await this.workspaceRegistry.list()).filter( - (workspace) => !workspace.archivedAt, + const uniqueWorkspaceIds = new Set( + Array.from(workspaceIds, (workspaceId) => normalizePersistedWorkspaceId(workspaceId)), ); - const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, activeWorkspaces); - const all = await this.listWorkspaceDescriptorsSnapshot(); - const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const)); - - const workspace = descriptorsByWorkspaceId.get(workspaceId); - const nextWorkspace = - workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter }) - ? workspace - : null; - if ( - options?.dedupeGitState && - this.shouldSkipWorkspaceGitWatchUpdate(workspaceId, nextWorkspace) - ) { - void this.reconcileAndEmitWorkspaceUpdates(); - return; - } - this.rememberWorkspaceGitWatchFingerprint(workspaceId, nextWorkspace); - - if (!nextWorkspace) { - this.bufferOrEmitWorkspaceUpdate(subscription, { - kind: "remove", - id: workspaceId, - }); - } else { - this.bufferOrEmitWorkspaceUpdate(subscription, { - kind: "upsert", - workspace: nextWorkspace, - }); - } - - void this.reconcileAndEmitWorkspaceUpdates(); - } - - private async emitWorkspaceUpdatesForCwds(cwds: Iterable): Promise { - const subscription = this.workspaceUpdatesSubscription; - if (!subscription) { + if (uniqueWorkspaceIds.size === 0) { return; } - 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)); - } - - const all = await this.listWorkspaceDescriptorsSnapshot(); - const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const)); + const descriptorsByWorkspaceId = await this.buildWorkspaceDescriptorMap({ + workspaceIds: uniqueWorkspaceIds, + includeGitData: true, + }); for (const workspaceId of uniqueWorkspaceIds) { const workspace = descriptorsByWorkspaceId.get(workspaceId); @@ -5862,6 +5847,12 @@ export class Session { workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter }) ? workspace : null; + if ( + options?.dedupeGitState && + this.shouldSkipWorkspaceGitWatchUpdate(workspaceId, nextWorkspace) + ) { + continue; + } this.rememberWorkspaceGitWatchFingerprint(workspaceId, nextWorkspace); if (!nextWorkspace) { @@ -5878,7 +5869,52 @@ export class Session { }); } - void this.reconcileAndEmitWorkspaceUpdates(); + if (!options?.skipReconcile) { + void this.reconcileAndEmitWorkspaceUpdates(); + } + } + + 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; + } + + queueMicrotask(() => { + if (this.workspaceUpdatesSubscription?.subscriptionId !== options.subscriptionId) { + return; + } + 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( @@ -5991,6 +6027,10 @@ 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) { @@ -6558,7 +6598,6 @@ export class Session { : result.status === "error" ? "error" : "idle"; - this.emit({ type: "wait_for_finish_response", payload: { requestId, status, final, error: null, lastMessage: result.lastMessage }, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index fb3bf0dad..879ed0370 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -964,34 +964,42 @@ describe("workspace aggregation", () => { }; session.reconcileActiveWorkspaceRecords = async () => new Set(); - session.listWorkspaceDescriptorsSnapshot = async () => [ - { - id: "/tmp/repo", - projectId: "/tmp/repo", - projectDisplayName: "repo", - projectRootPath: "/tmp/repo", - projectKind: "non_git", - workspaceKind: "directory", - name: "repo", - status: "running", - activityAt: "2026-03-01T12:00:00.000Z", - }, - ]; + session.buildWorkspaceDescriptorMap = async () => + new Map([ + [ + "/tmp/repo", + { + id: "/tmp/repo", + projectId: "/tmp/repo", + projectDisplayName: "repo", + projectRootPath: "/tmp/repo", + projectKind: "non_git", + workspaceKind: "directory", + name: "repo", + status: "running", + activityAt: "2026-03-01T12:00:00.000Z", + }, + ], + ]); await session.emitWorkspaceUpdateForCwd("/tmp/repo"); - session.listWorkspaceDescriptorsSnapshot = async () => [ - { - id: "/tmp/repo", - projectId: "/tmp/repo", - projectDisplayName: "repo", - projectRootPath: "/tmp/repo", - projectKind: "non_git", - workspaceKind: "directory", - name: "repo", - status: "done", - activityAt: null, - }, - ]; + session.buildWorkspaceDescriptorMap = async () => + new Map([ + [ + "/tmp/repo", + { + id: "/tmp/repo", + projectId: "/tmp/repo", + projectDisplayName: "repo", + projectRootPath: "/tmp/repo", + projectKind: "non_git", + workspaceKind: "directory", + name: "repo", + status: "done", + activityAt: null, + }, + ], + ]); await session.emitWorkspaceUpdateForCwd("/tmp/repo"); const workspaceUpdates = emitted.filter((message) => message.type === "workspace_update"); @@ -1083,30 +1091,37 @@ describe("workspace aggregation", () => { }; session.reconcileActiveWorkspaceRecords = async () => new Set(["/tmp/repo", "/tmp/repo/worktree"]); - session.listWorkspaceDescriptorsSnapshot = async () => [ - { - id: "/tmp/repo", - projectId: "/tmp/repo", - projectDisplayName: "repo", - projectRootPath: "/tmp/repo", - projectKind: "git", - workspaceKind: "local_checkout", - name: "main", - status: "done", - activityAt: null, - }, - { - id: "/tmp/repo/worktree", - projectId: "/tmp/repo", - projectDisplayName: "repo", - projectRootPath: "/tmp/repo", - projectKind: "git", - workspaceKind: "worktree", - name: "feature", - status: "running", - activityAt: "2026-03-01T12:00:00.000Z", - }, - ]; + session.buildWorkspaceDescriptorMap = async () => + new Map([ + [ + "/tmp/repo", + { + id: "/tmp/repo", + projectId: "/tmp/repo", + projectDisplayName: "repo", + projectRootPath: "/tmp/repo", + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + status: "done", + activityAt: null, + }, + ], + [ + "/tmp/repo/worktree", + { + id: "/tmp/repo/worktree", + projectId: "/tmp/repo", + projectDisplayName: "repo", + projectRootPath: "/tmp/repo", + projectKind: "git", + workspaceKind: "worktree", + name: "feature", + status: "running", + activityAt: "2026-03-01T12:00:00.000Z", + }, + ], + ]); session.onMessage = (message: { type: string; payload: unknown }) => { emitted.push(message); }; @@ -1605,4 +1620,176 @@ describe("workspace aggregation", () => { rmSync(tempDir, { recursive: true, force: true }); } }); + + test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline descriptor path", async () => { + const session = createSessionForWorkspaceTests() as any; + const project = createPersistedProjectRecord({ + projectId: "/tmp/repo", + rootPath: "/tmp/repo", + kind: "git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "/tmp/repo", + projectId: project.projectId, + cwd: "/tmp/repo", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + + session.listAgentPayloads = async () => []; + session.projectRegistry.list = async () => [project]; + session.workspaceRegistry.list = async () => [workspace]; + + const baselineDescriptor = { + id: workspace.workspaceId, + projectId: project.projectId, + projectDisplayName: project.displayName, + projectRootPath: project.rootPath, + projectKind: project.kind, + workspaceKind: workspace.kind, + name: "main", + status: "done", + activityAt: null, + diffStat: null, + } as const; + const gitDescriptor = { + ...baselineDescriptor, + diffStat: { additions: 3, deletions: 1 }, + } as const; + + session.describeWorkspaceRecord = vi.fn(async () => baselineDescriptor); + session.describeWorkspaceRecordWithGitData = vi.fn(async () => gitDescriptor); + + const descriptors = await session.listWorkspaceDescriptorsSnapshot(); + + expect(session.describeWorkspaceRecord).toHaveBeenCalledWith(workspace, project); + expect(session.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() as any; + const gitProject = createPersistedProjectRecord({ + projectId: "/tmp/repo", + rootPath: "/tmp/repo", + kind: "git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const directoryProject = createPersistedProjectRecord({ + projectId: "/tmp/docs", + rootPath: "/tmp/docs", + kind: "non_git", + displayName: "docs", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const gitWorkspace = createPersistedWorkspaceRecord({ + workspaceId: "/tmp/repo", + projectId: gitProject.projectId, + cwd: "/tmp/repo", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const directoryWorkspace = createPersistedWorkspaceRecord({ + workspaceId: "/tmp/docs", + projectId: directoryProject.projectId, + cwd: "/tmp/docs", + kind: "directory", + displayName: "docs", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const baselineGitDescriptor = { + id: gitWorkspace.workspaceId, + projectId: gitProject.projectId, + projectDisplayName: gitProject.displayName, + projectRootPath: gitProject.rootPath, + projectKind: gitProject.kind, + workspaceKind: gitWorkspace.kind, + name: "main", + status: "done", + activityAt: null, + diffStat: null, + } as const; + const enrichedGitDescriptor = { + ...baselineGitDescriptor, + diffStat: { additions: 3, deletions: 1 }, + } as const; + const directoryDescriptor = { + id: directoryWorkspace.workspaceId, + projectId: directoryProject.projectId, + projectDisplayName: directoryProject.displayName, + projectRootPath: directoryProject.rootPath, + projectKind: directoryProject.kind, + workspaceKind: directoryWorkspace.kind, + name: "docs", + status: "done", + activityAt: null, + diffStat: null, + } as const; + + session.emit = (message: any) => emitted.push(message); + session.listAgentPayloads = async () => []; + session.projectRegistry.list = async () => [gitProject, directoryProject]; + session.workspaceRegistry.list = async () => [gitWorkspace, directoryWorkspace]; + session.reconcileAndEmitWorkspaceUpdates = vi.fn(async () => {}); + session.describeWorkspaceRecord = vi.fn( + async (workspace: typeof gitWorkspace | typeof directoryWorkspace, project: any) => { + if (workspace.workspaceId === gitWorkspace.workspaceId) { + expect(project).toEqual(gitProject); + return baselineGitDescriptor; + } + expect(project).toEqual(directoryProject); + return directoryDescriptor; + }, + ); + session.describeWorkspaceRecordWithGitData = vi.fn(async () => enrichedGitDescriptor); + + await session.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(session.describeWorkspaceRecordWithGitData).toHaveBeenCalledWith( + gitWorkspace, + gitProject, + ); + }); }); From 5ce4562eedbef3a68c5f9a644530fe6e6ef531fb Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 16:48:51 +0700 Subject: [PATCH 30/31] Preserve workspace diff stats across rehydration --- packages/app/src/contexts/session-context.tsx | 10 +++- .../src/hooks/use-sidebar-workspaces-list.ts | 15 +++++- packages/app/src/stores/session-store.test.ts | 53 +++++++++++++++++++ packages/app/src/stores/session-store.ts | 15 ++++++ 4 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 packages/app/src/stores/session-store.test.ts diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 3b58c5d2f..675caa210 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -33,6 +33,7 @@ import { type Agent, type SessionState, type WorkspaceDescriptor, + mergeWorkspaceSnapshotWithExisting, normalizeWorkspaceDescriptor, } from "@/stores/session-store"; import { useDraftStore } from "@/stores/draft-store"; @@ -326,6 +327,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; @@ -341,7 +343,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider for (const entry of payload.entries) { const workspace = normalizeWorkspaceDescriptor(entry); - workspaces.set(workspace.id, workspace); + workspaces.set( + workspace.id, + mergeWorkspaceSnapshotWithExisting({ + incoming: workspace, + existing: existingWorkspaces?.get(workspace.id), + }), + ); } if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) { diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 205deb6a4..947c83423 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -1,5 +1,9 @@ import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; -import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; +import { + mergeWorkspaceSnapshotWithExisting, + normalizeWorkspaceDescriptor, + useSessionStore, +} from "@/stores/session-store"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import type { WorkspaceDescriptor } from "@/stores/session-store"; @@ -356,6 +360,7 @@ export function useSidebarWorkspacesList(options?: { } void (async () => { const next = new Map(); + const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces; let cursor: string | null = null; try { while (true) { @@ -365,7 +370,13 @@ export function useSidebarWorkspacesList(options?: { }); for (const entry of payload.entries) { const workspace = toWorkspaceDescriptor(entry); - next.set(workspace.id, workspace); + next.set( + workspace.id, + mergeWorkspaceSnapshotWithExisting({ + incoming: workspace, + existing: existingWorkspaces?.get(workspace.id), + }), + ); } if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) { break; diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts new file mode 100644 index 000000000..b265b7b70 --- /dev/null +++ b/packages/app/src/stores/session-store.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + mergeWorkspaceSnapshotWithExisting, + type WorkspaceDescriptor, +} from "./session-store"; + +function createWorkspace( + input: Partial & Pick, +): WorkspaceDescriptor { + return { + id: input.id, + projectId: input.projectId ?? "remote:github.com/getpaseo/paseo", + projectDisplayName: input.projectDisplayName ?? "getpaseo/paseo", + projectRootPath: input.projectRootPath ?? "/tmp/repo", + projectKind: input.projectKind ?? "git", + workspaceKind: input.workspaceKind ?? "local_checkout", + name: input.name ?? "main", + status: input.status ?? "done", + activityAt: input.activityAt ?? null, + diffStat: input.diffStat ?? null, + }; +} + +describe("mergeWorkspaceSnapshotWithExisting", () => { + it("preserves the last known diff stat when a snapshot only has baseline null data", () => { + const existing = createWorkspace({ + id: "/tmp/repo", + diffStat: { additions: 4, deletions: 2 }, + }); + const incoming = createWorkspace({ + id: "/tmp/repo", + diffStat: null, + }); + + expect(mergeWorkspaceSnapshotWithExisting({ incoming, existing })).toEqual({ + ...incoming, + diffStat: { additions: 4, deletions: 2 }, + }); + }); + + it("uses the incoming diff stat when the server provides a known value", () => { + const existing = createWorkspace({ + id: "/tmp/repo", + diffStat: { additions: 4, deletions: 2 }, + }); + const incoming = createWorkspace({ + id: "/tmp/repo", + diffStat: { additions: 0, deletions: 0 }, + }); + + expect(mergeWorkspaceSnapshotWithExisting({ incoming, existing })).toEqual(incoming); + }); +}); diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 11071eb16..cc31a8a9f 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -142,6 +142,21 @@ export function normalizeWorkspaceDescriptor( }; } +export function mergeWorkspaceSnapshotWithExisting(input: { + incoming: WorkspaceDescriptor; + existing?: WorkspaceDescriptor | null; +}): WorkspaceDescriptor { + const { incoming, existing } = input; + if (!existing || existing.id !== incoming.id) { + return incoming; + } + + return { + ...incoming, + diffStat: incoming.diffStat ?? existing.diffStat, + }; +} + export type ExplorerEntryKind = "file" | "directory"; export type ExplorerFileKind = "text" | "image" | "binary"; export type ExplorerEncoding = "utf-8" | "base64" | "none"; From ee611d65b693519eb3bd4a90e347292bd19e1d5b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 16:50:17 +0700 Subject: [PATCH 31/31] Surface wait_for_finish agent errors --- packages/server/src/server/session.ts | 18 +++++++++-- .../server/session.wait-for-finish.test.ts | 32 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 packages/server/src/server/session.wait-for-finish.test.ts diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 9982ae2fc..813cf0e4d 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -257,6 +257,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 = { @@ -6569,9 +6580,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; } @@ -6598,9 +6610,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 = 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(); + }); +});