diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 2c4c7261d..125a31448 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -213,7 +213,13 @@ export function AgentInputArea({ const focusInput = useCallback(() => { if (Platform.OS !== "web") return; - messageInputRef.current?.focus(); + focusWithRetries({ + focus: () => messageInputRef.current?.focus(), + isFocused: () => { + const el = messageInputRef.current?.getNativeElement?.() ?? null; + return el != null && document.activeElement === el; + }, + }); }, []); useEffect(() => { diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 93cd8e6ca..bca90dcb0 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -87,6 +87,7 @@ type ControlledAgentStatusBarProps = { onToggleFavoriteModel?: (provider: string, modelId: string) => void; features?: AgentFeature[]; onSetFeature?: (featureId: string, value: unknown) => void; + onDropdownClose?: () => void; }; export interface DraftAgentStatusBarProps { @@ -108,6 +109,7 @@ export interface DraftAgentStatusBarProps { onSelectThinkingOption: (thinkingOptionId: string) => void; features?: AgentFeature[]; onSetFeature?: (featureId: string, value: unknown) => void; + onDropdownClose?: () => void; disabled?: boolean; } @@ -212,6 +214,7 @@ function ControlledStatusBar({ onToggleFavoriteModel, features, onSetFeature, + onDropdownClose, }: ControlledAgentStatusBarProps) { const { theme } = useUnistyles(); const isWeb = Platform.OS === "web"; @@ -331,8 +334,11 @@ function ControlledStatusBar({ const handleOpenChange = useCallback( (selector: StatusSelector) => (nextOpen: boolean) => { setOpenSelector(nextOpen ? selector : null); + if (!nextOpen) { + onDropdownClose?.(); + } }, - [], + [onDropdownClose], ); const handleSelectorPress = useCallback( @@ -555,9 +561,7 @@ function ControlledStatusBar({ - setOpenSelector(open ? `feature-${feature.id}` : null) - } + onOpenChange={handleOpenChange(`feature-${feature.id}`)} > @@ -784,9 +788,7 @@ function ControlledStatusBar({ - setOpenSelector(open ? `feature-${feature.id}` : null) - } + onOpenChange={handleOpenChange(`feature-${feature.id}`)} > @@ -1115,30 +1120,32 @@ export function DraftAgentStatusBar({ })); return ( - onSelectModel(modelId)} - isModelLoading={isAllModelsLoading} - favoriteKeys={favoriteKeys} - onToggleFavoriteModel={(provider, modelId) => { - void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => { - console.warn("[DraftAgentStatusBar] toggle favorite model failed", error); - }); - }} - thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined} - selectedThinkingOptionId={effectiveSelectedThinkingOption} - onSelectThinkingOption={onSelectThinkingOption} - features={features} - onSetFeature={onSetFeature} - disabled={disabled} - /> + <> + onSelectModel(modelId)} + isModelLoading={isAllModelsLoading} + favoriteKeys={favoriteKeys} + onToggleFavoriteModel={(provider, modelId) => { + void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => { + console.warn("[DraftAgentStatusBar] toggle favorite model failed", error); + }); + }} + thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined} + selectedThinkingOptionId={effectiveSelectedThinkingOption} + onSelectThinkingOption={onSelectThinkingOption} + features={features} + onSetFeature={onSetFeature} + disabled={disabled} + /> + ); } diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index 1d62aa555..442be354e 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -2,11 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { View, Text, + TextInput, Pressable, Platform, ActivityIndicator, type GestureResponderEvent, } from "react-native"; +import { BottomSheetTextInput } from "@gorhom/bottom-sheet"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ArrowLeft, @@ -15,9 +17,12 @@ import { Search, Star, } from "lucide-react-native"; -import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types"; +import type { + AgentModelDefinition, + AgentProvider, +} from "@server/server/agent/agent-sdk-types"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; -import { Combobox, ComboboxItem, SearchInput } from "@/components/ui/combobox"; +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"; @@ -29,8 +34,6 @@ import { type SelectorModelRow, } from "./combined-model-selector.utils"; -const INLINE_MODEL_THRESHOLD = Number.POSITIVE_INFINITY; - type SelectorView = | { kind: "all" } | { kind: "provider"; providerId: string; providerLabel: string }; @@ -51,6 +54,7 @@ interface CombinedModelSelectorProps { disabled: boolean; isOpen: boolean; }) => React.ReactNode; + onClose?: () => void; disabled?: boolean; } @@ -67,7 +71,6 @@ interface SelectorContentProps { canSelectProvider: (provider: string) => boolean; onToggleFavorite?: (provider: string, modelId: string) => void; onDrillDown: (providerId: string, providerLabel: string) => void; - onBack?: () => void; } function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string { @@ -99,6 +102,22 @@ function partitionRows( return { favoriteRows, regularRows }; } +function sortFavoritesFirst( + rows: SelectorModelRow[], + favoriteKeys: Set, +): SelectorModelRow[] { + const favorites: SelectorModelRow[] = []; + const rest: SelectorModelRow[] = []; + for (const row of rows) { + if (favoriteKeys.has(row.favoriteKey)) { + favorites.push(row); + } else { + rest.push(row); + } + } + return [...favorites, ...rest]; +} + function groupRowsByProvider( rows: SelectorModelRow[], ): Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }> { @@ -126,6 +145,7 @@ function ModelRow({ isSelected, isFavorite, disabled = false, + elevated = false, onPress, onToggleFavorite, }: { @@ -133,6 +153,7 @@ function ModelRow({ isSelected: boolean; isFavorite: boolean; disabled?: boolean; + elevated?: boolean; onPress: () => void; onToggleFavorite?: (provider: string, modelId: string) => void; }) { @@ -153,8 +174,9 @@ function ModelRow({ label={row.modelLabel} selected={isSelected} disabled={disabled} + elevated={elevated} onPress={onPress} - leadingSlot={} + leadingSlot={} trailingSlot={ onToggleFavorite && !disabled ? ( + Favorites @@ -239,11 +261,11 @@ function FavoritesSection({ isSelected={row.provider === selectedProvider && row.modelId === selectedModel} isFavorite={favoriteKeys.has(row.favoriteKey)} disabled={!canSelectProvider(row.provider)} + elevated onPress={() => onSelect(row.provider, row.modelId)} onToggleFavorite={onToggleFavorite} /> ))} - ); } @@ -258,6 +280,7 @@ function GroupedProviderRows({ canSelectProvider, onToggleFavorite, onDrillDown, + viewKind, }: { providerDefinitions: AgentProviderDefinition[]; groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>; @@ -268,6 +291,7 @@ function GroupedProviderRows({ canSelectProvider: (provider: string) => boolean; onToggleFavorite?: (provider: string, modelId: string) => void; onDrillDown: (providerId: string, providerLabel: string) => void; + viewKind: SelectorView["kind"]; }) { const { theme } = useUnistyles(); @@ -276,19 +300,14 @@ function GroupedProviderRows({ {groupedRows.map((group, index) => { const providerDefinition = providerDefinitions.find((definition) => definition.id === group.providerId); const ProvIcon = getProviderIcon(group.providerId); - const isInline = group.rows.length <= INLINE_MODEL_THRESHOLD; + const isInline = viewKind === "provider"; return ( {index > 0 ? : null} {isInline ? ( <> - - - {providerDefinition?.label ?? group.providerLabel} - - - {group.rows.map((row) => ( + {sortFavoritesFirst(group.rows, favoriteKeys).map((row) => ( - + {group.providerLabel} - {group.rows.length} - + + {group.rows.length} {group.rows.length === 1 ? "model" : "models"} + + )} @@ -324,6 +345,46 @@ function GroupedProviderRows({ ); } +function ProviderSearchInput({ + value, + onChangeText, + autoFocus = false, +}: { + value: string; + onChangeText: (text: string) => void; + autoFocus?: boolean; +}) { + const { theme } = useUnistyles(); + const inputRef = useRef(null); + const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput; + + useEffect(() => { + if (autoFocus && Platform.OS === "web" && inputRef.current) { + const timer = setTimeout(() => { + inputRef.current?.focus(); + }, 50); + return () => clearTimeout(timer); + } + }, [autoFocus]); + + return ( + + + + + ); +} + function SelectorContent({ view, providerDefinitions, @@ -337,8 +398,8 @@ function SelectorContent({ canSelectProvider, onToggleFavorite, onDrillDown, - onBack, }: SelectorContentProps) { + const { theme } = useUnistyles(); const allRows = useMemo( () => buildModelRows(providerDefinitions, allProviderModels), [allProviderModels, providerDefinitions], @@ -363,35 +424,41 @@ function SelectorContent({ [favoriteKeys, visibleRows], ); - const groupedRegularRows = useMemo(() => groupRowsByProvider(regularRows), [regularRows]); + // Group ALL visible rows by provider — favorites are a cross-cutting view, + // not a partition. A model being favorited doesn't remove it from its provider. + const allGroupedRows = useMemo(() => groupRowsByProvider(visibleRows), [visibleRows]); + + // When searching at Level 1, filter grouped rows to only providers whose name or models match + const filteredGroupedRows = useMemo(() => { + if (view.kind === "provider" || !normalizedQuery) { + return allGroupedRows; + } + return allGroupedRows.filter( + (group) => + group.providerLabel.toLowerCase().includes(normalizedQuery) || group.rows.length > 0, + ); + }, [allGroupedRows, normalizedQuery, view.kind]); + + const hasResults = favoriteRows.length > 0 || filteredGroupedRows.length > 0; return ( - {view.kind === "provider" ? ( - + {view.kind === "all" ? ( + ) : null} - - - - - {groupedRegularRows.length > 0 ? ( + {filteredGroupedRows.length > 0 ? ( ) : null} - {favoriteRows.length === 0 && groupedRegularRows.length === 0 ? ( + {!hasResults ? ( - + No models match your search ) : null} @@ -437,8 +505,8 @@ function ProviderBackButton({ pressed && styles.backButtonPressed, ]} > - - + + {providerLabel} ); @@ -455,6 +523,7 @@ export function CombinedModelSelector({ favoriteKeys = new Set(), onToggleFavorite, renderTrigger, + onClose, disabled = false, }: CombinedModelSelectorProps) { const { theme } = useUnistyles(); @@ -465,25 +534,35 @@ export function CombinedModelSelector({ const [view, setView] = useState({ kind: "all" }); const [searchQuery, setSearchQuery] = useState(""); + // Single-provider mode: only one provider with models → skip Level 1 entirely + const singleProviderView = useMemo(() => { + const providers = Array.from(allProviderModels.keys()); + if (providers.length !== 1) return null; + const providerId = providers[0]!; + const label = resolveProviderLabel(providerDefinitions, providerId); + return { kind: "provider", providerId, providerLabel: label }; + }, [allProviderModels, providerDefinitions]); + const handleOpenChange = useCallback( (open: boolean) => { setIsOpen(open); - setView({ kind: "all" }); + setView(singleProviderView ?? { kind: "all" }); if (!open) { setSearchQuery(""); + onClose?.(); } }, - [], + [onClose, singleProviderView], ); const handleSelect = useCallback( (provider: string, modelId: string) => { onSelect(provider as AgentProvider, modelId); setIsOpen(false); - setView({ kind: "all" }); + setView(singleProviderView ?? { kind: "all" }); setSearchQuery(""); }, - [onSelect], + [onSelect, singleProviderView], ); const ProviderIcon = getProviderIcon(selectedProvider); @@ -501,6 +580,15 @@ export function CombinedModelSelector({ return model?.label ?? resolveDefaultModelLabel(models); }, [allProviderModels, isLoading, selectedModel, selectedProvider]); + const desktopFixedHeight = useMemo(() => { + if (view.kind !== "provider") { + return undefined; + } + const models = allProviderModels.get(view.providerId); + const modelCount = models?.length ?? 0; + return Math.min(80 + modelCount * 40, 400); + }, [allProviderModels, view]); + const triggerLabel = useMemo(() => { if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") { return selectedModelLabel; @@ -568,7 +656,30 @@ export function CombinedModelSelector({ stackBehavior="push" anchorRef={anchorRef} desktopPlacement="top-start" + desktopMinWidth={360} + desktopFixedHeight={desktopFixedHeight} title="Select model" + stickyHeader={ + view.kind === "provider" ? ( + + {!singleProviderView ? ( + { + setView({ kind: "all" }); + setSearchQuery(""); + }} + /> + ) : null} + + + ) : undefined + } > {isContentReady ? ( { setView({ kind: "provider", providerId, providerLabel }); }} - onBack={ - view.kind === "provider" - ? () => { - setView({ kind: "all" }); - } - : undefined - } /> ) : ( @@ -634,17 +738,22 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: 0, height: "auto", }, + favoritesContainer: { + backgroundColor: theme.colors.surface1, + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, separator: { height: 1, backgroundColor: theme.colors.border, - marginVertical: theme.spacing[1], }, sectionHeading: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[1], + paddingTop: theme.spacing[2], + paddingBottom: theme.spacing[1], }, sectionHeadingText: { fontSize: theme.fontSize.xs, @@ -667,8 +776,8 @@ const styles = StyleSheet.create((theme) => ({ }, drillDownText: { flex: 1, - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, }, drillDownTrailing: { flexDirection: "row", @@ -679,6 +788,11 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, }, + level2Header: { + backgroundColor: theme.colors.surface1, + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, backButton: { flexDirection: "row", alignItems: "center", @@ -689,7 +803,7 @@ const styles = StyleSheet.create((theme) => ({ borderBottomColor: theme.colors.border, }, backButtonHovered: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surface2, }, backButtonPressed: { backgroundColor: theme.colors.surface2, @@ -734,4 +848,16 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, + providerSearchContainer: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: theme.spacing[3], + gap: theme.spacing[2], + }, + providerSearchInput: { + flex: 1, + paddingVertical: theme.spacing[3], + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, })); diff --git a/packages/app/src/components/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx new file mode 100644 index 000000000..20ff08967 --- /dev/null +++ b/packages/app/src/components/provider-diagnostic-sheet.tsx @@ -0,0 +1,102 @@ +import { useCallback, useEffect, useState } from "react"; +import { View, Text, ActivityIndicator, ScrollView } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; +import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; +import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest"; + +interface ProviderDiagnosticSheetProps { + provider: string; + visible: boolean; + onClose: () => void; + serverId: string; +} + +export function ProviderDiagnosticSheet({ + provider, + visible, + onClose, + serverId, +}: ProviderDiagnosticSheetProps) { + const { theme } = useUnistyles(); + const client = useHostRuntimeClient(serverId); + const [diagnostic, setDiagnostic] = useState(null); + const [loading, setLoading] = useState(false); + + const providerLabel = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === provider)?.label ?? provider; + + const fetchDiagnostic = useCallback(async () => { + if (!client || !provider) return; + + setLoading(true); + setDiagnostic(null); + + try { + const result = await client.getProviderDiagnostic(provider as AgentProvider); + setDiagnostic(result.diagnostic); + } catch (err) { + setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic"); + } finally { + setLoading(false); + } + }, [client, provider]); + + useEffect(() => { + if (visible) { + fetchDiagnostic(); + } else { + setDiagnostic(null); + } + }, [visible, fetchDiagnostic]); + + return ( + + {loading ? ( + + + Fetching diagnostic… + + ) : diagnostic ? ( + + + {diagnostic} + + + ) : null} + + ); +} + +const sheetStyles = StyleSheet.create((theme) => ({ + loadingContainer: { + paddingVertical: theme.spacing[6], + alignItems: "center", + gap: theme.spacing[2], + }, + loadingText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + scrollContainer: { + flex: 1, + }, + scrollContent: { + paddingBottom: theme.spacing[4], + }, + diagnosticText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + fontFamily: "monospace", + lineHeight: theme.fontSize.sm * 1.6, + }, +})); diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 179f94c15..ab4ec98f8 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -73,6 +73,12 @@ export interface ComboboxProps { * for that combobox instance to avoid animation overriding hidden opacity. */ desktopPreventInitialFlash?: boolean; + /** Minimum width for the desktop popover (overrides trigger-based width). */ + desktopMinWidth?: number; + /** Fixed height for the desktop popover (overrides default 400px max). */ + desktopFixedHeight?: number; + /** Content rendered above the scroll area on desktop (sticky header). */ + stickyHeader?: ReactNode; anchorRef: React.RefObject; children?: ReactNode; } @@ -150,6 +156,8 @@ export interface ComboboxItemProps { selected?: boolean; active?: boolean; disabled?: boolean; + /** When true, bumps hover/pressed colors up one surface level (for items on elevated backgrounds). */ + elevated?: boolean; onPress: () => void; testID?: string; } @@ -163,6 +171,7 @@ export function ComboboxItem({ selected, active, disabled, + elevated, onPress, testID, }: ComboboxItemProps): ReactElement { @@ -187,8 +196,8 @@ export function ComboboxItem({ onPress={onPress} style={({ pressed, hovered = false }) => [ styles.comboboxItem, - hovered && styles.comboboxItemHovered, - pressed && styles.comboboxItemPressed, + hovered && (elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered), + pressed && (elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed), active && styles.comboboxItemActive, disabled && styles.comboboxItemDisabled, ]} @@ -246,6 +255,9 @@ export function Combobox({ stackBehavior, desktopPlacement = "top-start", desktopPreventInitialFlash = true, + desktopMinWidth, + desktopFixedHeight, + stickyHeader, anchorRef, children, }: ComboboxProps): ReactElement { @@ -390,12 +402,27 @@ export function Combobox({ ((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin); const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition; const shouldUseDesktopFade = !desktopPreventInitialFlash; + // For top-placed popups: once position resolves, use bottom-based CSS positioning + // so height changes grow upward naturally without floating-ui needing to reposition. + const useStableBottom = + !isDesktopAboveSearch && + IS_WEB && + !isMobile && + hasResolvedDesktopPosition && + desktopPlacement.startsWith("top") && + referenceTop !== null; + const desktopPositionStyle = isDesktopAboveSearch ? { left: floatingLeft ?? 0, bottom: desktopAboveSearchBottom ?? 0, } - : floatingStyles; + : useStableBottom + ? { + left: floatingLeft ?? 0, + bottom: Math.max(windowHeight - referenceTop!, collisionPadding), + } + : floatingStyles; useEffect(() => { if (!isMobile) return; @@ -662,6 +689,7 @@ export function Combobox({ {title} + {stickyHeader} update()} > {children ? ( - - {content} - + <> + {stickyHeader} + + {content} + + ) : ( <> {effectiveOptionsPosition === "above-search" ? ( @@ -783,9 +817,15 @@ const styles = StyleSheet.create((theme) => ({ comboboxItemHovered: { backgroundColor: theme.colors.surface1, }, + comboboxItemHoveredElevated: { + backgroundColor: theme.colors.surface2, + }, comboboxItemPressed: { backgroundColor: theme.colors.surface1, }, + comboboxItemPressedElevated: { + backgroundColor: theme.colors.surface2, + }, comboboxItemActive: { backgroundColor: theme.colors.surface1, }, @@ -876,6 +916,9 @@ const styles = StyleSheet.create((theme) => ({ desktopScrollContent: { paddingVertical: theme.spacing[1], }, + desktopChildrenScrollContent: { + // No padding — custom children (e.g. model selector) control their own spacing + }, desktopScrollContentAboveSearch: { flexGrow: 1, justifyContent: "flex-end", diff --git a/packages/app/src/components/ui/status-badge.tsx b/packages/app/src/components/ui/status-badge.tsx new file mode 100644 index 000000000..ca80ebb7a --- /dev/null +++ b/packages/app/src/components/ui/status-badge.tsx @@ -0,0 +1,65 @@ +import { View, Text } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; + +type StatusBadgeVariant = "success" | "error" | "muted"; + +interface StatusBadgeProps { + label: string; + variant?: StatusBadgeVariant; +} + +export function StatusBadge({ label, variant = "muted" }: StatusBadgeProps) { + const { theme } = useUnistyles(); + + return ( + + + {label} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + pill: { + flexDirection: "row", + alignItems: "center", + borderRadius: theme.borderRadius.full, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface3, + paddingHorizontal: theme.spacing[2], + paddingVertical: 3, + }, + pillSuccess: { + backgroundColor: theme.colors.palette.green[900], + borderColor: theme.colors.palette.green[800], + }, + pillError: { + backgroundColor: theme.colors.palette.red[900], + borderColor: theme.colors.palette.red[800], + }, + pillText: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.foregroundMuted, + }, + pillTextSuccess: { + color: theme.colors.palette.green[400], + }, + pillTextError: { + color: theme.colors.palette.red[500], + }, +})); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 96f2993fc..9c36e8132 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -1105,6 +1105,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider hostname: serverInfo.hostname, version: serverInfo.version, ...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}), + ...(serverInfo.features ? { features: serverInfo.features } : {}), }); return; } diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index badebe2b6..812e3fd0a 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 } from "@tanstack/react-query"; +import { useQuery, useQueries, useQueryClient } from "@tanstack/react-query"; import { AGENT_PROVIDER_DEFINITIONS, type AgentProviderDefinition, @@ -8,9 +8,11 @@ import type { AgentMode, AgentModelDefinition, AgentProvider, + ProviderSnapshotEntry, } from "@server/server/agent/agent-sdk-types"; import { useHosts } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useSessionForServer } from "./use-session-directory"; import { useFormPreferences, mergeProviderPreferences, @@ -84,6 +86,7 @@ type UseAgentFormStateResult = { providerDefinitions: AgentProviderDefinition[]; providerDefinitionMap: Map; agentDefinition?: AgentProviderDefinition; + allProviderEntries?: ProviderSnapshotEntry[]; modeOptions: AgentMode[]; availableModels: AgentModelDefinition[]; allProviderModels: Map; @@ -319,6 +322,10 @@ 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, @@ -336,6 +343,7 @@ 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]); @@ -371,11 +379,138 @@ 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 [debouncedCwd, setDebouncedCwd] = useState(undefined); + useEffect(() => { + const trimmed = formState.workingDir.trim(); + const next = trimmed.length > 0 ? trimmed : undefined; + const timer = setTimeout(() => setDebouncedCwd(next), 180); + return () => clearTimeout(timer); + }, [formState.workingDir]); + + const snapshotQueryKey = useMemo( + () => providersSnapshotQueryKey(formState.serverId, debouncedCwd), + [debouncedCwd, formState.serverId], + ); + + const providersSnapshotQuery = useQuery({ + queryKey: snapshotQueryKey, + enabled: Boolean( + supportsProvidersSnapshot && + isVisible && + isTargetDaemonReady && + formState.serverId && + client && + isConnected, + ), + staleTime: 60 * 1000, + queryFn: async () => { + if (!client) { + throw new Error("Host is not connected"); + } + return client.getProvidersSnapshot({ cwd: debouncedCwd }); + }, + }); + + useEffect(() => { + if ( + !supportsProvidersSnapshot || + !client || + !isConnected || + !isVisible || + !isTargetDaemonReady || + !formState.serverId + ) { + return; + } + + return client.on("providers_snapshot_update", (message) => { + if (message.type !== "providers_snapshot_update") { + return; + } + if (message.payload.cwd !== undefined && message.payload.cwd !== debouncedCwd) { + return; + } + queryClient.setQueryData(snapshotQueryKey, { + entries: message.payload.entries, + generatedAt: message.payload.generatedAt, + requestId: "providers_snapshot_update", + }); + }); + }, [ + client, + debouncedCwd, + formState.serverId, + isConnected, + isTargetDaemonReady, + isVisible, + queryClient, + snapshotQueryKey, + supportsProvidersSnapshot, + ]); + + const snapshotEntries = providersSnapshotQuery.data?.entries ?? undefined; + const allProviderEntries = useMemo( + () => (supportsProvidersSnapshot ? snapshotEntries ?? [] : undefined), + [snapshotEntries, supportsProvidersSnapshot], + ); + const snapshotProviderDefinitions = useMemo(() => { + if (!supportsProvidersSnapshot) { + return []; + } + const snapshotProviders = new Set((snapshotEntries ?? []).map((entry) => entry.provider)); + return allProviderDefinitions.filter((definition) => snapshotProviders.has(definition.id)); + }, [snapshotEntries, supportsProvidersSnapshot]); + const snapshotProviderDefinitionMap = useMemo( + () => + new Map( + snapshotProviderDefinitions.map((definition) => [definition.id, definition]), + ), + [snapshotProviderDefinitions], + ); + const snapshotSelectableProviderDefinitionMap = useMemo(() => { + const readyProviders = new Set( + (snapshotEntries ?? []) + .filter((entry) => entry.status === "ready") + .map((entry) => entry.provider), + ); + return new Map( + snapshotProviderDefinitions + .filter((definition) => readyProviders.has(definition.id)) + .map((definition) => [definition.id, definition]), + ); + }, [snapshotEntries, snapshotProviderDefinitions]); + const snapshotAllProviderModels = useMemo(() => { + const map = new Map(); + for (const entry of snapshotEntries ?? []) { + map.set(entry.provider, entry.models ?? []); + } + return map; + }, [snapshotEntries]); + const snapshotSelectedEntry = useMemo( + () => (snapshotEntries ?? []).find((entry) => entry.provider === formState.provider) ?? null, + [formState.provider, snapshotEntries], + ); + const snapshotSelectedProviderModels = snapshotSelectedEntry?.models ?? null; + const snapshotSelectedProviderModes = + 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( - isVisible && isTargetDaemonReady && formState.serverId && client && isConnected, + !supportsProvidersSnapshot && + isVisible && + isTargetDaemonReady && + formState.serverId && + client && + isConnected, ), staleTime: 60 * 1000, queryFn: async () => { @@ -389,43 +524,33 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg return payload.providers.filter((entry) => entry.available).map((entry) => entry.provider); }, }); - - const providerDefinitions = useMemo(() => { + const legacyProviderDefinitions = useMemo(() => { const availableProviders = availableProvidersQuery.data; if (!availableProviders) { return []; } const available = new Set(availableProviders); - return allProviderDefinitions.filter((definition) => - available.has(definition.id as AgentProvider), - ); + return allProviderDefinitions.filter((definition) => available.has(definition.id)); }, [availableProvidersQuery.data]); - - const providerDefinitionMap = useMemo( + const legacyProviderDefinitionMap = useMemo( () => new Map( - providerDefinitions.map((definition) => [definition.id as AgentProvider, definition]), + legacyProviderDefinitions.map((definition) => [definition.id, definition]), ), - [providerDefinitions], + [legacyProviderDefinitions], ); - 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 providerModelsQuery = useQuery({ + // 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 && - providerDefinitionMap.has(formState.provider), + legacyProviderDefinitionMap.has(formState.provider), ), staleTime: 5 * 60 * 1000, queryFn: async () => { @@ -441,18 +566,19 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg return payload.models ?? []; }, }); + const legacySelectedProviderModels = legacySelectedProviderModelsQuery.data ?? null; - const availableModels = providerModelsQuery.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( - isVisible && + !supportsProvidersSnapshot && + isVisible && isTargetDaemonReady && formState.serverId && client && isConnected && - providerDefinitionMap.has(formState.provider), + legacyProviderDefinitionMap.has(formState.provider), ), staleTime: 5 * 60 * 1000, queryFn: async () => { @@ -469,8 +595,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg }, }); + // COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots const allProviderModelQueries = useQueries({ - queries: providerDefinitions.map((def) => ({ + queries: (supportsProvidersSnapshot ? [] : legacyProviderDefinitions).map((def) => ({ queryKey: ["providerModels", formState.serverId, def.id], enabled: Boolean( isVisible && isTargetDaemonReady && formState.serverId && client && isConnected, @@ -490,19 +617,40 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg }, })), }); - - const allProviderModels = useMemo(() => { + const legacyAllProviderModels = useMemo(() => { const map = new Map(); - for (let i = 0; i < providerDefinitions.length; i++) { + for (let i = 0; i < legacyProviderDefinitions.length; i++) { const query = allProviderModelQueries[i]; if (query?.data) { - map.set(providerDefinitions[i]!.id, query.data); + map.set(legacyProviderDefinitions[i]!.id, query.data); } } return map; - }, [allProviderModelQueries, providerDefinitions]); + }, [allProviderModelQueries, legacyProviderDefinitions]); + const legacySelectedProviderModes = + providerModesQuery.data ?? legacyProviderDefinitionMap.get(formState.provider)?.modes ?? []; - const isAllModelsLoading = allProviderModelQueries.some((q) => q.isLoading); + const providerDefinitions = supportsProvidersSnapshot + ? snapshotProviderDefinitions + : legacyProviderDefinitions; + const providerDefinitionMap = supportsProvidersSnapshot + ? snapshotProviderDefinitionMap + : legacyProviderDefinitionMap; + const selectableProviderDefinitionMap = supportsProvidersSnapshot + ? snapshotSelectableProviderDefinitionMap + : legacyProviderDefinitionMap; + const allProviderModels = supportsProvidersSnapshot + ? snapshotAllProviderModels + : legacyAllProviderModels; + const availableModels = supportsProvidersSnapshot + ? snapshotSelectedProviderModels + : legacySelectedProviderModels; + const modeOptions = supportsProvidersSnapshot + ? snapshotSelectedProviderModes + : legacySelectedProviderModes; + const isAllModelsLoading = supportsProvidersSnapshot + ? providersSnapshotQuery.isLoading || providersSnapshotQuery.isFetching + : allProviderModelQueries.some((q) => q.isLoading); // Combine initialValues with initialServerId for resolution const combinedInitialValues = useMemo((): FormInitialValues | undefined => { @@ -527,7 +675,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg userModified, formStateRef.current, validServerIds, - providerDefinitionMap, + selectableProviderDefinitionMap, ); // Only update if something changed @@ -552,7 +700,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg availableModels, userModified, validServerIds, - providerDefinitionMap, + selectableProviderDefinitionMap, ]); // Auto-select the first online host when: @@ -592,8 +740,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const setProviderFromUser = useCallback( (provider: AgentProvider) => { + if (!selectableProviderDefinitionMap.has(provider)) { + return; + } const providerModels = allProviderModels.get(provider) ?? null; - const providerDef = providerDefinitionMap.get(provider); + const providerDef = selectableProviderDefinitionMap.get(provider); const providerPrefs = preferences?.providerPreferences?.[provider]; const isValidModel = (m: string) => @@ -631,12 +782,20 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg thinkingOptionId: nextThinkingOptionId, })); }, - [allProviderModels, preferences?.providerPreferences, providerDefinitionMap, updatePreferences], + [ + allProviderModels, + preferences?.providerPreferences, + selectableProviderDefinitionMap, + updatePreferences, + ], ); const setProviderAndModelFromUser = useCallback( (provider: AgentProvider, modelId: string) => { - const providerDef = providerDefinitionMap.get(provider); + if (!selectableProviderDefinitionMap.has(provider)) { + return; + } + const providerDef = selectableProviderDefinitionMap.get(provider); const providerModels = allProviderModels.get(provider) ?? null; const normalizedModelId = normalizeSelectedModelId(modelId); const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels); @@ -656,7 +815,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg setUserModified((prev) => ({ ...prev, provider: true, model: true })); void updatePreferences({ provider }); }, - [allProviderModels, providerDefinitionMap, updatePreferences], + [allProviderModels, selectableProviderDefinitionMap, updatePreferences], ); const setModeFromUser = useCallback( @@ -713,8 +872,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg }, []); const refreshProviderModels = useCallback(() => { - void providerModelsQuery.refetch(); - }, [providerModelsQuery]); + if (supportsProvidersSnapshot) { + if (!client) { + return; + } + void client.refreshProvidersSnapshot({ cwd: debouncedCwd }); + return; + } + void legacySelectedProviderModelsQuery.refetch(); + }, [client, debouncedCwd, legacySelectedProviderModelsQuery, supportsProvidersSnapshot]); const persistFormPreferences = useCallback(async () => { const resolvedModel = resolveEffectiveModel(availableModels, formState.model); @@ -747,13 +913,20 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg ]); const agentDefinition = providerDefinitionMap.get(formState.provider); - const modeOptions = providerModesQuery.data ?? agentDefinition?.modes ?? []; const effectiveModel = resolveEffectiveModel(availableModels, formState.model); const resolvedModelId = effectiveModel?.id ?? formState.model; const availableThinkingOptions = effectiveModel?.thinkingOptions ?? []; - const isModelLoading = providerModelsQuery.isLoading || providerModelsQuery.isFetching; + const isModelLoading = supportsProvidersSnapshot + ? providersSnapshotQuery.isLoading || providersSnapshotQuery.isFetching + : legacySelectedProviderModelsQuery.isLoading || legacySelectedProviderModelsQuery.isFetching; const modelError = - providerModelsQuery.error instanceof Error ? providerModelsQuery.error.message : null; + supportsProvidersSnapshot + ? providersSnapshotQuery.error instanceof Error + ? providersSnapshotQuery.error.message + : null + : legacySelectedProviderModelsQuery.error instanceof Error + ? legacySelectedProviderModelsQuery.error.message + : null; const workingDirIsEmpty = !formState.workingDir.trim(); @@ -776,6 +949,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg providerDefinitions, providerDefinitionMap, agentDefinition, + allProviderEntries, modeOptions, availableModels: availableModels ?? [], allProviderModels, @@ -806,6 +980,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg providerDefinitions, providerDefinitionMap, agentDefinition, + allProviderEntries, modeOptions, availableModels, allProviderModels, diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 1ca014d32..69f7c0c05 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -220,6 +220,7 @@ function DraftAgentScreenContent({ modeOptions, availableModels, allProviderModels, + allProviderEntries, isAllModelsLoading, availableThinkingOptions, isModelLoading, diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index c5fabd2da..bb6b2ace6 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -21,6 +21,7 @@ import { Info, Shield, Puzzle, + Blocks, } from "lucide-react-native"; import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; import type { HostProfile, HostConnection } from "@/types/host-connection"; @@ -62,6 +63,11 @@ import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pc import { useVoiceAudioEngineOptional } from "@/contexts/voice-context"; import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; 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 @@ -72,6 +78,7 @@ type SettingsSectionId = | "appearance" | "shortcuts" | "integrations" + | "providers" | "diagnostics" | "about" | "permissions" @@ -99,6 +106,7 @@ function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectio } sections.push( + { id: "providers", label: "Providers", icon: Blocks }, { id: "diagnostics", label: "Diagnostics", icon: Stethoscope }, { id: "about", label: "About", icon: Info }, ); @@ -418,6 +426,116 @@ function AppearanceSection({ settings, handleThemeChange }: AppearanceSectionPro } +interface ProvidersSectionProps { + routeServerId: string; +} + +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 [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 + {!hasServer || !isConnected ? ( + + Connect to a host to see providers + + ) : loading ? ( + + Loading... + + ) : ( + + {AGENT_PROVIDER_DEFINITIONS.map((def) => { + const entry = entries.find((e) => e.provider === def.id); + const status = entry?.status ?? "unavailable"; + const ProviderIcon = getProviderIcon(def.id); + + return ( + + + + {def.label} + + + + + + + ); + })} + + )} + + + {diagnosticProvider ? ( + setDiagnosticProvider(null)} + serverId={routeServerId} + /> + ) : null} + + ); +} + interface DiagnosticsSectionProps { voiceAudioEngine: ReturnType; isPlaybackTestRunning: boolean; @@ -486,6 +604,7 @@ interface SettingsSectionContentProps { sectionId: SettingsSectionId; hostsProps: HostsSectionProps; appearanceProps: AppearanceSectionProps; + providersProps: ProvidersSectionProps; diagnosticsProps: DiagnosticsSectionProps; aboutProps: AboutSectionProps; appVersion: string | null; @@ -497,6 +616,7 @@ function SettingsSectionContent({ sectionId, hostsProps, appearanceProps, + providersProps, diagnosticsProps, aboutProps, appVersion, @@ -510,6 +630,8 @@ function SettingsSectionContent({ return ; case "shortcuts": return ; + case "providers": + return ; case "diagnostics": return ; case "about": @@ -567,7 +689,7 @@ function SettingsDesktopLayout({ sections, sectionContentProps }: SettingsLayout const isSelected = section.id === selectedSectionId; const IconComponent = section.icon; const showSeparator = - section.id === "integrations" || section.id === "diagnostics"; + section.id === "integrations" || section.id === "providers"; return ( {showSeparator ? : null} @@ -951,9 +1073,14 @@ export default function SettingsScreen() { isDesktopApp, }; + const providersProps: ProvidersSectionProps = { + routeServerId, + }; + const sectionContentProps: Omit = { hostsProps, appearanceProps, + providersProps, diagnosticsProps, aboutProps, appVersion, @@ -1761,6 +1888,11 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, fontSize: theme.fontSize.base, }, + providerActions: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, aboutValue: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index 7ec57e62a..c681c62eb 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -75,6 +75,7 @@ export function WorkspaceDraftAgentTab({ modeOptions, availableModels, allProviderModels, + allProviderEntries, isAllModelsLoading, availableThinkingOptions, isModelLoading, @@ -250,6 +251,60 @@ export function WorkspaceDraftAgentTab({ addImagesRef.current = addImages; }, []); + const focusInputRef = useRef<(() => void) | null>(null); + + const handleFocusInputCallback = useCallback((focus: () => void) => { + focusInputRef.current = focus; + }, []); + + const handleProviderSelectWithFocus = useCallback( + (provider: string) => { + setProviderFromUser(provider); + focusInputRef.current?.(); + }, + [setProviderFromUser], + ); + + const handleModeSelectWithFocus = useCallback( + (modeId: string) => { + setModeFromUser(modeId); + focusInputRef.current?.(); + }, + [setModeFromUser], + ); + + const handleModelSelectWithFocus = useCallback( + (modelId: string) => { + setModelFromUser(modelId); + focusInputRef.current?.(); + }, + [setModelFromUser], + ); + + const handleProviderAndModelSelectWithFocus = useCallback( + (provider: string, modelId: string) => { + setProviderAndModelFromUser(provider, modelId); + focusInputRef.current?.(); + }, + [setProviderAndModelFromUser], + ); + + const handleThinkingOptionSelectWithFocus = useCallback( + (optionId: string) => { + setThinkingOptionFromUser(optionId); + focusInputRef.current?.(); + }, + [setThinkingOptionFromUser], + ); + + const handleSetFeatureWithFocus = useCallback( + (featureId: string, value: unknown) => { + setDraftFeatureValue(featureId, value); + focusInputRef.current?.(); + }, + [setDraftFeatureValue], + ); + return ( @@ -296,26 +351,28 @@ export function WorkspaceDraftAgentTab({ clearDraft={draftInput.clear} autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })} onAddImages={handleAddImagesCallback} + onFocusInput={handleFocusInputCallback} commandDraftConfig={draftCommandConfig} statusControls={{ providerDefinitions, selectedProvider, - onSelectProvider: setProviderFromUser, + onSelectProvider: handleProviderSelectWithFocus, modeOptions, selectedMode, - onSelectMode: setModeFromUser, + onSelectMode: handleModeSelectWithFocus, models: availableModels, selectedModel, - onSelectModel: setModelFromUser, + onSelectModel: handleModelSelectWithFocus, isModelLoading, allProviderModels, isAllModelsLoading, - onSelectProviderAndModel: setProviderAndModelFromUser, + onSelectProviderAndModel: handleProviderAndModelSelectWithFocus, thinkingOptions: availableThinkingOptions, selectedThinkingOptionId, - onSelectThinkingOption: setThinkingOptionFromUser, + onSelectThinkingOption: handleThinkingOptionSelectWithFocus, features: draftFeatures, - onSetFeature: setDraftFeatureValue, + onSetFeature: handleSetFeatureWithFocus, + onDropdownClose: () => focusInputRef.current?.(), disabled: isSubmitting, }} /> diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index e80908e99..11071eb16 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -21,6 +21,7 @@ import type { import type { FileDownloadTokenResponse, GitSetupOptions, + ServerInfoStatusPayload, ProjectPlacementPayload, ServerCapabilities, WorkspaceDescriptorPayload, @@ -190,6 +191,7 @@ export type DaemonServerInfo = { hostname: string | null; version: string | null; capabilities?: ServerCapabilities; + features?: ServerInfoStatusPayload["features"]; }; export interface AgentTimelineCursorState { @@ -412,6 +414,13 @@ function areServerCapabilitiesEqual( return JSON.stringify(current ?? null) === JSON.stringify(next ?? null); } +function areServerInfoFeaturesEqual( + current: ServerInfoStatusPayload["features"] | undefined, + next: ServerInfoStatusPayload["features"] | undefined, +): boolean { + return JSON.stringify(current ?? null) === JSON.stringify(next ?? null); +} + export const useSessionStore = create()( subscribeWithSelector((set, get) => { const commitActivityUpdates: AgentLastActivityCommitter = (updates) => { @@ -527,12 +536,15 @@ export const useSessionStore = create()( const prevVersion = session.serverInfo?.version?.trim() || null; const nextCapabilities = info.capabilities; const prevCapabilities = session.serverInfo?.capabilities; + const nextFeatures = info.features; + const prevFeatures = session.serverInfo?.features; if ( session.serverInfo?.serverId === info.serverId && prevHostname === nextHostname && prevVersion === nextVersion && - areServerCapabilitiesEqual(prevCapabilities, nextCapabilities) + areServerCapabilitiesEqual(prevCapabilities, nextCapabilities) && + areServerInfoFeaturesEqual(prevFeatures, nextFeatures) ) { return prev; } @@ -548,6 +560,7 @@ export const useSessionStore = create()( hostname: nextHostname, version: nextVersion, ...(nextCapabilities ? { capabilities: nextCapabilities } : {}), + ...(nextFeatures ? { features: nextFeatures } : {}), }, }, }, diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 9230d5fa1..942319554 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -43,6 +43,9 @@ import type { ListAvailableProvidersResponse, ListTerminalsResponse, CreateTerminalResponse, + GetProvidersSnapshotResponseMessage, + ProviderDiagnosticResponseMessage, + RefreshProvidersSnapshotResponseMessage, SubscribeTerminalResponse, TerminalState, CloseItemsResponse, @@ -147,6 +150,10 @@ export type DaemonEvent = requestId: string; resolution: AgentPermissionResponse; } + | { + type: "providers_snapshot_update"; + payload: Extract["payload"]; + } | { type: "error"; message: string }; export type DaemonEventHandler = (event: DaemonEvent) => void; @@ -222,6 +229,9 @@ type ListProviderFeaturesPayload = ListProviderFeaturesResponseMessage["payload" type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"]; type ListProviderModesPayload = ListProviderModesResponseMessage["payload"]; type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"]; +type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"]; +type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"]; +type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"]; type ListCommandsPayload = ListCommandsResponse["payload"]; type ListCommandsDraftConfig = Pick< AgentSessionConfig, @@ -2552,6 +2562,51 @@ export class DaemonClient { }); } + async getProvidersSnapshot(options?: { + cwd?: string; + requestId?: string; + }): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: options?.requestId, + message: { + type: "get_providers_snapshot_request", + cwd: options?.cwd, + }, + responseType: "get_providers_snapshot_response", + timeout: 10000, + }); + } + + async refreshProvidersSnapshot(options?: { + cwd?: string; + requestId?: string; + }): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: options?.requestId, + message: { + type: "refresh_providers_snapshot_request", + cwd: options?.cwd, + }, + responseType: "refresh_providers_snapshot_response", + timeout: 5000, + }); + } + + async getProviderDiagnostic( + provider: AgentProvider, + options?: { requestId?: string }, + ): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: options?.requestId, + message: { + type: "provider_diagnostic_request", + provider, + }, + responseType: "provider_diagnostic_response", + timeout: 30000, + }); + } + async listCommands(agentId: string, requestId?: string): Promise; async listCommands(agentId: string, options?: ListCommandsOptions): Promise; async listCommands( @@ -3637,6 +3692,11 @@ export class DaemonClient { requestId: msg.payload.requestId, resolution: msg.payload.resolution, }; + case "providers_snapshot_update": + return { + type: "providers_snapshot_update", + payload: msg.payload, + }; default: return null; } diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 7823ae02f..5b91fa1e2 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -45,6 +45,8 @@ export type AgentMode = { description?: string; }; +export type ProviderStatus = "ready" | "loading" | "error" | "unavailable"; + export type AgentModelDefinition = { provider: AgentProvider; id: string; @@ -64,6 +66,15 @@ export type AgentSelectOption = { metadata?: AgentMetadata; }; +export interface ProviderSnapshotEntry { + provider: AgentProvider; + status: ProviderStatus; + error?: string; + models?: AgentModelDefinition[]; + modes?: AgentMode[]; + fetchedAt?: string; +} + export type AgentFeatureToggle = { type: "toggle"; id: string; @@ -468,4 +479,5 @@ export interface AgentClient { * Returns true if available, false otherwise. */ isAvailable(): Promise; + getDiagnostic?(): Promise<{ diagnostic: string }>; } diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts new file mode 100644 index 000000000..4ea80bf3b --- /dev/null +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -0,0 +1,409 @@ +import { describe, expect, test, vi } from "vitest"; + +import { createTestLogger } from "../../test-utils/test-logger.js"; +import type { + AgentClient, + AgentMode, + AgentModelDefinition, + AgentProvider, + ProviderSnapshotEntry, +} from "./agent-sdk-types.js"; +import type { ProviderDefinition } from "./provider-registry.js"; +import { ProviderSnapshotManager } from "./provider-snapshot-manager.js"; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +}; + +type MockProviderOptions = { + provider: AgentProvider; + isAvailable?: () => Promise; + fetchModels?: (cwd?: string) => Promise; + fetchModes?: (cwd?: string) => Promise; +}; + +type MockProviderHandle = { + definition: ProviderDefinition; + isAvailable: ReturnType; + fetchModels: ReturnType; + fetchModes: ReturnType; +}; + +const TEST_CAPABILITIES = { + supportsStreaming: false, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, +} as const; + +describe("ProviderSnapshotManager", () => { + test("getSnapshot returns all providers in loading state initially and triggers warmUp", async () => { + const codexModels = deferred(); + const claudeModels = deferred(); + const { registry, handles } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async () => codexModels.promise, + }), + createMockProvider({ + provider: "claude", + fetchModels: async () => claudeModels.promise, + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + const snapshot = manager.getSnapshot("/tmp/project"); + + expect(snapshot).toEqual([ + { provider: "claude", status: "loading" }, + { provider: "codex", status: "loading" }, + ]); + + await vi.waitFor(() => { + expect(handles.claude?.isAvailable).toHaveBeenCalledTimes(1); + expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1); + }); + + manager.destroy(); + codexModels.resolve([]); + claudeModels.resolve([]); + }); + + test("after warmUp completes, getSnapshot returns ready entries with models", async () => { + const { registry } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async () => [createModel("codex", "gpt-5.2")], + fetchModes: async () => [createMode("auto")], + }), + createMockProvider({ + provider: "claude", + fetchModels: async () => [createModel("claude", "sonnet")], + fetchModes: async () => [createMode("default")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot("/tmp/project"); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "claude")?.status).toBe("ready"); + expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.status).toBe("ready"); + }); + + const snapshot = manager.getSnapshot("/tmp/project"); + expect(getProviderEntry(snapshot, "codex")).toMatchObject({ + provider: "codex", + status: "ready", + models: [createModel("codex", "gpt-5.2")], + modes: [createMode("auto")], + }); + expect(getProviderEntry(snapshot, "claude")).toMatchObject({ + provider: "claude", + status: "ready", + models: [createModel("claude", "sonnet")], + modes: [createMode("default")], + }); + expect(getProviderEntry(snapshot, "codex")?.fetchedAt).toEqual(expect.any(String)); + + manager.destroy(); + }); + + test("provider that fails isAvailable shows as unavailable", async () => { + const { registry, handles } = createRegistry([ + createMockProvider({ + provider: "codex", + isAvailable: async () => false, + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot("/tmp/project"); + + await vi.waitFor(() => { + expect(manager.getSnapshot("/tmp/project")).toEqual([ + { provider: "codex", status: "unavailable" }, + ]); + }); + + expect(handles.codex?.fetchModels).not.toHaveBeenCalled(); + expect(handles.codex?.fetchModes).not.toHaveBeenCalled(); + + manager.destroy(); + }); + + test("provider that fails fetchModels shows as error with error message", async () => { + const { registry } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async () => { + throw new Error("model lookup failed"); + }, + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot("/tmp/project"); + + await vi.waitFor(() => { + expect(manager.getSnapshot("/tmp/project")).toEqual([ + { + provider: "codex", + status: "error", + error: "model lookup failed", + }, + ]); + }); + + manager.destroy(); + }); + + test("change event fires for each provider as it resolves", async () => { + const codexModels = deferred(); + const claudeModels = deferred(); + const codexModes = deferred(); + const claudeModes = deferred(); + const { registry } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async () => codexModels.promise, + fetchModes: async () => codexModes.promise, + }), + createMockProvider({ + provider: "claude", + fetchModels: async () => claudeModels.promise, + fetchModes: async () => claudeModes.promise, + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + const changes: Array<{ cwd: string; entries: ProviderSnapshotEntry[] }> = []; + const listener = (entries: ProviderSnapshotEntry[], cwd: string) => { + changes.push({ cwd, entries }); + }; + manager.on("change", listener); + + manager.getSnapshot("/tmp/project"); + + claudeModels.resolve([createModel("claude", "sonnet")]); + claudeModes.resolve([createMode("default")]); + + await vi.waitFor(() => { + expect(changes).toHaveLength(1); + }); + + expect(changes[0]?.cwd).toBe("/tmp/project"); + expect(getProviderEntry(changes[0]?.entries ?? [], "claude")?.status).toBe("ready"); + expect(getProviderEntry(changes[0]?.entries ?? [], "codex")?.status).toBe("loading"); + + codexModels.resolve([createModel("codex", "gpt-5.2")]); + codexModes.resolve([createMode("auto")]); + + await vi.waitFor(() => { + expect(changes).toHaveLength(2); + }); + + expect(getProviderEntry(changes[1]?.entries ?? [], "codex")?.status).toBe("ready"); + expect(getProviderEntry(changes[1]?.entries ?? [], "claude")?.status).toBe("ready"); + + manager.off("change", listener); + manager.destroy(); + }); + + test("refresh re-fetches and updates entries", async () => { + const codexFetchModels = vi + .fn<(options?: { cwd?: string }) => Promise>() + .mockResolvedValueOnce([createModel("codex", "gpt-5.1")]) + .mockResolvedValueOnce([createModel("codex", "gpt-5.2")]); + const { registry } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async (cwd) => codexFetchModels({ cwd }), + fetchModes: async () => [createMode("auto")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot("/tmp/project"); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.models?.[0]?.id).toBe( + "gpt-5.1", + ); + }); + + manager.refresh("/tmp/project"); + expect(manager.getSnapshot("/tmp/project")).toEqual([ + { provider: "codex", status: "loading" }, + ]); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.models?.[0]?.id).toBe( + "gpt-5.2", + ); + }); + + expect(codexFetchModels).toHaveBeenCalledTimes(2); + + manager.destroy(); + }); + + test("multiple getSnapshot calls for same cwd do not trigger multiple warmUps", async () => { + const codexModels = deferred(); + const { registry, handles } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async () => codexModels.promise, + }), + createMockProvider({ + provider: "claude", + fetchModels: async () => [], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot("/tmp/project"); + manager.getSnapshot("/tmp/project"); + manager.getSnapshot("/tmp/project"); + + await vi.waitFor(() => { + expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1); + expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1); + expect(handles.claude?.isAvailable).toHaveBeenCalledTimes(1); + expect(handles.claude?.fetchModels).toHaveBeenCalledTimes(1); + }); + + codexModels.resolve([createModel("codex", "gpt-5.2")]); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.status).toBe("ready"); + }); + + manager.destroy(); + }); + + test("different cwd keys get independent snapshots", async () => { + const seenCwds: string[] = []; + const { registry } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async (cwd) => { + seenCwds.push(cwd ?? "__missing__"); + return [createModel("codex", `model:${cwd}`)]; + }, + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot("/tmp/project-a"); + manager.getSnapshot("/tmp/project-b"); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot("/tmp/project-a"), "codex")?.status).toBe("ready"); + expect(getProviderEntry(manager.getSnapshot("/tmp/project-b"), "codex")?.status).toBe("ready"); + }); + + expect(getProviderEntry(manager.getSnapshot("/tmp/project-a"), "codex")?.models?.[0]?.id).toBe( + "model:/tmp/project-a", + ); + expect(getProviderEntry(manager.getSnapshot("/tmp/project-b"), "codex")?.models?.[0]?.id).toBe( + "model:/tmp/project-b", + ); + expect(seenCwds).toEqual(["/tmp/project-a", "/tmp/project-b"]); + + manager.destroy(); + }); +}); + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function createRegistry(handles: MockProviderHandle[]): { + registry: Record; + handles: Record; +} { + return { + registry: Object.fromEntries( + handles.map((handle) => [handle.definition.id, handle.definition]), + ) as Record, + handles: Object.fromEntries( + handles.map((handle) => [handle.definition.id, handle]), + ) as Record, + }; +} + +function createMockProvider(options: MockProviderOptions): MockProviderHandle { + const isAvailable = vi.fn(async () => options.isAvailable?.() ?? true); + const fetchModels = vi.fn(async (listOptions?: { cwd?: string }) => + options.fetchModels?.(listOptions?.cwd) ?? [createModel(options.provider, `${options.provider}-default`)], + ); + const fetchModes = vi.fn(async (listOptions?: { cwd?: string }) => + options.fetchModes?.(listOptions?.cwd) ?? [createMode(`${options.provider}-mode`)], + ); + + const definition: ProviderDefinition = { + id: options.provider, + label: options.provider, + description: `${options.provider} test provider`, + defaultModeId: null, + modes: [], + createClient: () => + ({ + provider: options.provider, + capabilities: TEST_CAPABILITIES, + async createSession() { + throw new Error("not implemented"); + }, + async resumeSession() { + throw new Error("not implemented"); + }, + async listModels() { + return []; + }, + async isAvailable() { + return isAvailable(); + }, + }) satisfies AgentClient, + fetchModels, + fetchModes, + }; + + return { + definition, + isAvailable, + fetchModels, + fetchModes, + }; +} + +function createModel(provider: AgentProvider, id: string): AgentModelDefinition { + return { + provider, + id, + label: id, + }; +} + +function createMode(id: string): AgentMode { + return { + id, + label: id, + }; +} + +function getProviderEntry( + entries: ProviderSnapshotEntry[], + provider: AgentProvider, +): ProviderSnapshotEntry | undefined { + return entries.find((entry) => entry.provider === provider); +} diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts new file mode 100644 index 000000000..dded5a2b4 --- /dev/null +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -0,0 +1,217 @@ +import { EventEmitter } from "node:events"; +import { resolve } from "node:path"; + +import type { Logger } from "pino"; + +import type { + AgentProvider, + ProviderSnapshotEntry, +} from "./agent-sdk-types.js"; +import type { ProviderDefinition } from "./provider-registry.js"; +import { AGENT_PROVIDER_IDS } from "./provider-manifest.js"; + +const DEFAULT_CWD_KEY = "__default__"; + +type ProviderSnapshotChangeListener = ( + entries: ProviderSnapshotEntry[], + cwd?: string, +) => void; + +export class ProviderSnapshotManager { + private readonly snapshots = new Map>(); + private readonly warmUps = new Map>(); + private readonly events = new EventEmitter(); + private destroyed = false; + + constructor( + private readonly providerRegistry: Record, + private readonly logger: Logger, + ) {} + + getSnapshot(cwd?: string): ProviderSnapshotEntry[] { + const cwdKey = normalizeCwdKey(cwd); + const entries = this.snapshots.get(cwdKey); + if (!entries) { + const loadingEntries = this.createLoadingEntries(); + this.snapshots.set(cwdKey, loadingEntries); + void this.warmUp(cwd); + return entriesToArray(loadingEntries); + } + return entriesToArray(entries); + } + + refresh(cwd?: string): void { + const cwdKey = normalizeCwdKey(cwd); + this.snapshots.set(cwdKey, this.createLoadingEntries()); + void this.warmUp(cwd); + } + + on(event: "change", listener: ProviderSnapshotChangeListener): this { + this.events.on(event, listener); + return this; + } + + off(event: "change", listener: ProviderSnapshotChangeListener): this { + this.events.off(event, listener); + return this; + } + + destroy(): void { + this.destroyed = true; + this.events.removeAllListeners(); + this.snapshots.clear(); + this.warmUps.clear(); + } + + private createLoadingEntries(): Map { + const entries = new Map(); + for (const provider of this.getProviderIds()) { + entries.set(provider, { + provider, + status: "loading", + }); + } + return entries; + } + + private async warmUp(cwd?: string): Promise { + const cwdKey = normalizeCwdKey(cwd); + const inFlight = this.warmUps.get(cwdKey); + if (inFlight) { + return inFlight; + } + + const warmUpPromise = Promise.allSettled( + this.getProviderIds().map((provider) => this.refreshProvider(cwdKey, provider, cwd)), + ).then(() => undefined); + + this.warmUps.set(cwdKey, warmUpPromise); + + try { + await warmUpPromise; + } finally { + if (this.warmUps.get(cwdKey) === warmUpPromise) { + this.warmUps.delete(cwdKey); + } + } + } + + private async refreshProvider( + cwdKey: string, + provider: AgentProvider, + cwd?: string, + ): Promise { + const definition = this.providerRegistry[provider]; + if (!definition) { + return; + } + + const snapshot = this.getOrCreateSnapshot(cwdKey); + snapshot.set(provider, { + provider, + status: "loading", + }); + + try { + const client = definition.createClient(this.logger); + const available = await client.isAvailable(); + if (!available) { + snapshot.set(provider, { + provider, + status: "unavailable", + }); + this.emitChange(cwdKey); + return; + } + + const [models, modes] = await Promise.all([ + definition.fetchModels({ cwd }), + definition.fetchModes({ cwd }), + ]); + + snapshot.set(provider, { + provider, + status: "ready", + models, + modes, + fetchedAt: new Date().toISOString(), + }); + this.emitChange(cwdKey); + } catch (error) { + snapshot.set(provider, { + provider, + status: "error", + error: toErrorMessage(error), + }); + this.logger.warn({ err: error, provider, cwd: cwdKey }, "Failed to refresh provider snapshot"); + this.emitChange(cwdKey); + } + } + + private emitChange(cwdKey: string): void { + if (this.destroyed) { + return; + } + const snapshot = this.snapshots.get(cwdKey); + if (!snapshot) { + return; + } + this.events.emit("change", entriesToArray(snapshot), denormalizeCwdKey(cwdKey)); + } + + private getOrCreateSnapshot(cwdKey: string): Map { + const existing = this.snapshots.get(cwdKey); + if (existing) { + return existing; + } + + const created = this.createLoadingEntries(); + this.snapshots.set(cwdKey, created); + return created; + } + + private getProviderIds(): AgentProvider[] { + return AGENT_PROVIDER_IDS.filter((provider) => this.providerRegistry[provider]); + } +} + +function normalizeCwdKey(cwd?: string): string { + if (!cwd) { + return DEFAULT_CWD_KEY; + } + + const trimmed = cwd.trim(); + if (!trimmed) { + return DEFAULT_CWD_KEY; + } + + return resolve(trimmed); +} + +function denormalizeCwdKey(cwdKey: string): string | undefined { + return cwdKey === DEFAULT_CWD_KEY ? undefined : cwdKey; +} + +function entriesToArray( + entries: Map, +): ProviderSnapshotEntry[] { + return Array.from(entries.values(), cloneEntry); +} + +function cloneEntry(entry: ProviderSnapshotEntry): ProviderSnapshotEntry { + return { + ...entry, + models: entry.models?.map((model) => ({ ...model })), + modes: entry.modes?.map((mode) => ({ ...mode })), + }; +} + +function toErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + if (typeof error === "string" && error) { + return error; + } + return "Unknown error"; +} diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index e1b4e8b19..68ddee65e 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import fs from "node:fs"; import { promises } from "node:fs"; @@ -39,6 +39,12 @@ import { } from "./claude/claude-models.js"; import { parsePartialJsonObject } from "./claude/partial-json.js"; import { ClaudeSidechainTracker } from "./claude/sidechain-tracker.js"; +import { + formatDiagnosticStatus, + formatProviderDiagnostic, + formatProviderDiagnosticError, + toDiagnosticErrorMessage, +} from "./diagnostic-utils.js"; import type { AgentCapabilityFlags, @@ -1086,6 +1092,42 @@ export class ClaudeAgentClient implements AgentClient { return true; } + async getDiagnostic(): Promise<{ diagnostic: string }> { + try { + const resolvedBinary = findExecutable("claude") ?? "not found"; + const available = await this.isAvailable(); + const version = resolveClaudeVersion(this.runtimeSettings); + let modelsValue = "Not checked"; + let status = formatDiagnosticStatus(available); + + if (available) { + try { + const models = await this.listModels(); + modelsValue = String(models.length); + } catch (error) { + modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`; + status = formatDiagnosticStatus(available, { + source: "model fetch", + cause: error, + }); + } + } + + return { + diagnostic: formatProviderDiagnostic("Claude Code", [ + { label: "Binary", value: resolvedBinary }, + ...(version ? [{ label: "Version", value: version }] : []), + { label: "Models", value: modelsValue }, + { label: "Status", value: status }, + ]), + }; + } catch (error) { + return { + diagnostic: formatProviderDiagnosticError("Claude Code", error), + }; + } + } + private assertConfig(config: AgentSessionConfig): ClaudeAgentConfig { if (config.provider !== "claude") { throw new Error(`ClaudeAgentClient received config for provider '${config.provider}'`); @@ -1094,6 +1136,31 @@ export class ClaudeAgentClient implements AgentClient { } } +function resolveClaudeVersion(runtimeSettings?: ProviderRuntimeSettings): string | null { + const command = runtimeSettings?.command; + + try { + if (command?.mode === "replace") { + return execFileSync(command.argv[0]!, [...command.argv.slice(1), "--version"], { + encoding: "utf8", + timeout: 5_000, + }).trim() || null; + } + + const executable = findExecutable("claude"); + if (!executable) { + return null; + } + + return execFileSync(executable, ["--version"], { + encoding: "utf8", + timeout: 5_000, + }).trim() || null; + } catch { + return null; + } +} + class ClaudeAgentSession implements AgentSession { readonly provider: "claude" = "claude"; readonly capabilities = CLAUDE_CAPABILITIES; 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 bf8d3e086..1b49b62df 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 @@ -52,6 +52,13 @@ import { } from "../../../utils/executable.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js"; +import { + formatDiagnosticStatus, + formatProviderDiagnostic, + formatProviderDiagnosticError, + resolveBinaryVersion, + toDiagnosticErrorMessage, +} from "./diagnostic-utils.js"; const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000; const TURN_START_TIMEOUT_MS = 90 * 1000; @@ -3947,6 +3954,49 @@ export class CodexAppServerAgentClient implements AgentClient { } return true; } + + async getDiagnostic(): Promise<{ diagnostic: string }> { + try { + const available = await this.isAvailable(); + const resolvedBinary = findExecutable("codex"); + const entries: Array<{ label: string; value: string }> = [ + { + label: "Binary", + value: resolvedBinary ?? "not found", + }, + { label: "Version", value: resolvedBinary ? resolveBinaryVersion(resolvedBinary) : "unknown" }, + ]; + let status = formatDiagnosticStatus(available); + + if (!available) { + entries.push({ label: "Models", value: "Not checked" }); + } else { + try { + const models = await this.listModels(); + entries.push({ label: "Models", value: String(models.length) }); + } catch (error) { + entries.push({ + label: "Models", + value: `Error - ${toDiagnosticErrorMessage(error)}`, + }); + status = formatDiagnosticStatus(available, { + source: "model fetch", + cause: error, + }); + } + } + + entries.push({ label: "Status", value: status }); + + return { + diagnostic: formatProviderDiagnostic("Codex", entries), + }; + } catch (error) { + return { + diagnostic: formatProviderDiagnosticError("Codex", error), + }; + } + } } export const __codexAppServerInternals = { diff --git a/packages/server/src/server/agent/providers/copilot-acp-agent.ts b/packages/server/src/server/agent/providers/copilot-acp-agent.ts index e282c51cf..395508336 100644 --- a/packages/server/src/server/agent/providers/copilot-acp-agent.ts +++ b/packages/server/src/server/agent/providers/copilot-acp-agent.ts @@ -2,7 +2,15 @@ import type { Logger } from "pino"; import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js"; import type { ProviderRuntimeSettings } from "../provider-launch-config.js"; +import { findExecutable } from "../../../utils/executable.js"; import { ACPAgentClient } from "./acp-agent.js"; +import { + formatDiagnosticStatus, + formatProviderDiagnostic, + formatProviderDiagnosticError, + resolveBinaryVersion, + toDiagnosticErrorMessage, +} from "./diagnostic-utils.js"; const COPILOT_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, @@ -51,4 +59,53 @@ export class CopilotACPAgentClient extends ACPAgentClient { override async isAvailable(): Promise { return super.isAvailable(); } + + async getDiagnostic(): Promise<{ diagnostic: string }> { + try { + const available = await this.isAvailable(); + const resolvedBinary = findExecutable("copilot"); + let modelsValue = "Not checked"; + let status = formatDiagnosticStatus(available); + + if (available) { + try { + const models = await this.listModels(); + modelsValue = String(models.length); + } catch (error) { + modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`; + status = formatDiagnosticStatus(available, { + source: "model fetch", + cause: error, + }); + } + + if (!modelsValue.startsWith("Error -")) { + try { + await this.listModes(); + } catch (error) { + status = formatDiagnosticStatus(available, { + source: "mode fetch", + cause: error, + }); + } + } + } + + return { + diagnostic: formatProviderDiagnostic("Copilot", [ + { + label: "Binary", + value: resolvedBinary ?? "not found", + }, + { label: "Version", value: resolvedBinary ? resolveBinaryVersion(resolvedBinary) : "unknown" }, + { label: "Models", value: modelsValue }, + { label: "Status", value: status }, + ]), + }; + } catch (error) { + return { + diagnostic: formatProviderDiagnosticError("Copilot", error), + }; + } + } } diff --git a/packages/server/src/server/agent/providers/diagnostic-utils.ts b/packages/server/src/server/agent/providers/diagnostic-utils.ts new file mode 100644 index 000000000..af077b271 --- /dev/null +++ b/packages/server/src/server/agent/providers/diagnostic-utils.ts @@ -0,0 +1,80 @@ +import { execFileSync } from "node:child_process"; + +import type { ProviderRuntimeSettings } from "../provider-launch-config.js"; + +type DiagnosticEntry = { + label: string; + value: string; +}; + +export function formatProviderDiagnostic( + providerName: string, + entries: DiagnosticEntry[], +): string { + return [providerName, ...entries.map((entry) => ` ${entry.label}: ${entry.value}`)].join("\n"); +} + +export function formatProviderDiagnosticError( + providerName: string, + error: unknown, +): string { + return formatProviderDiagnostic(providerName, [ + { + label: "Error", + value: error instanceof Error ? error.message : String(error), + }, + ]); +} + +export function formatAvailabilityStatus(available: boolean): string { + return available ? "Available" : "Unavailable"; +} + +export function formatDiagnosticStatus( + available: boolean, + error?: { source: string; cause: unknown }, +): string { + if (error) { + return `Error (${error.source} failed: ${toDiagnosticErrorMessage(error.cause)})`; + } + return formatAvailabilityStatus(available); +} + +export function toDiagnosticErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + if (typeof error === "string" && error.trim().length > 0) { + return error; + } + return "Unknown error"; +} + +export function resolveBinaryVersion(binaryPath: string): string { + try { + return ( + execFileSync(binaryPath, ["--version"], { + encoding: "utf8", + timeout: 5_000, + }).trim() || "unknown" + ); + } catch { + return "unknown"; + } +} + +export function formatConfiguredCommand( + defaultArgv: readonly string[], + runtimeSettings?: ProviderRuntimeSettings, +): string { + const command = runtimeSettings?.command; + if (!command || command.mode === "default") { + return `${defaultArgv.join(" ")} (default)`; + } + + if (command.mode === "append") { + return [defaultArgv[0], ...(command.args ?? []), ...defaultArgv.slice(1)].join(" "); + } + + return command.argv.join(" "); +} diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index d0f47b3e5..c022caa57 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -43,6 +43,13 @@ import { quoteWindowsCommand, } from "../../../utils/executable.js"; import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js"; +import { + formatDiagnosticStatus, + formatProviderDiagnostic, + formatProviderDiagnosticError, + resolveBinaryVersion, + toDiagnosticErrorMessage, +} from "./diagnostic-utils.js"; const OPENCODE_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, @@ -615,6 +622,63 @@ export class OpenCodeAgentClient implements AgentClient { return true; } + async getDiagnostic(): Promise<{ diagnostic: string }> { + try { + const available = await this.isAvailable(); + const resolvedBinary = findExecutable("opencode"); + let serverStatus = "Not running"; + let modelsValue = "Not checked"; + let status = formatDiagnosticStatus(available); + + try { + const { url } = await this.serverManager.ensureRunning(); + serverStatus = `Running (${url})`; + } catch (error) { + serverStatus = `Unavailable (${normalizeTurnFailureError(error)})`; + } + + if (available) { + try { + const models = await this.listModels(); + modelsValue = String(models.length); + } catch (error) { + modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`; + status = formatDiagnosticStatus(available, { + source: "model fetch", + cause: error, + }); + } + + if (!modelsValue.startsWith("Error -")) { + try { + await this.listModes(); + } catch (error) { + status = formatDiagnosticStatus(available, { + source: "mode fetch", + cause: error, + }); + } + } + } + + return { + diagnostic: formatProviderDiagnostic("OpenCode", [ + { + label: "Binary", + value: resolvedBinary ?? "not found", + }, + { label: "Version", value: resolvedBinary ? resolveBinaryVersion(resolvedBinary) : "unknown" }, + { label: "Server", value: serverStatus }, + { label: "Models", value: modelsValue }, + { label: "Status", value: status }, + ]), + }; + } catch (error) { + return { + diagnostic: formatProviderDiagnosticError("OpenCode", error), + }; + } + } private assertConfig(config: AgentSessionConfig): OpenCodeAgentConfig { if (config.provider !== "opencode") { throw new Error(`OpenCodeAgentClient received config for provider '${config.provider}'`); diff --git a/packages/server/src/server/agent/providers/pi-acp-agent.ts b/packages/server/src/server/agent/providers/pi-acp-agent.ts index baab3b48a..7b3d99ad1 100644 --- a/packages/server/src/server/agent/providers/pi-acp-agent.ts +++ b/packages/server/src/server/agent/providers/pi-acp-agent.ts @@ -29,12 +29,19 @@ import type { AgentFeatureSelect, } from "../agent-sdk-types.js"; import type { ProviderRuntimeSettings } from "../provider-launch-config.js"; -import { isCommandAvailable } from "../../../utils/executable.js"; +import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; import { ACPAgentClient, type ACPToolSnapshot, type SessionStateResponse, } from "./acp-agent.js"; +import { + formatDiagnosticStatus, + formatProviderDiagnostic, + formatProviderDiagnosticError, + resolveBinaryVersion, + toDiagnosticErrorMessage, +} from "./diagnostic-utils.js"; const require = createRequire(import.meta.url); const resolvedPiAcpPath = require.resolve("pi-acp"); @@ -329,4 +336,85 @@ export class PiACPAgentClient extends ACPAgentClient { existsSync(join(homedir(), ".pi", "agent", "auth.json")) ); } + + async getDiagnostic(): Promise<{ diagnostic: string }> { + try { + const piCommand = process.env.PI_ACP_PI_COMMAND ?? "pi"; + const piCliPath = findExecutable(piCommand); + const piVersion = piCliPath ? resolveBinaryVersion(piCliPath) : "unknown"; + const authConfigPath = join(homedir(), ".pi", "agent", "auth.json"); + const available = await this.isAvailable(); + let modelsValue = "Not checked"; + let status = formatDiagnosticStatus(available); + + if (available) { + try { + const models = await this.listModels(); + modelsValue = String(models.length); + } catch (error) { + modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`; + status = formatDiagnosticStatus(available, { + source: "model fetch", + cause: error, + }); + } + + if (!modelsValue.startsWith("Error -")) { + try { + await this.listModes(); + } catch (error) { + status = formatDiagnosticStatus(available, { + source: "mode fetch", + cause: error, + }); + } + } + } + + return { + diagnostic: formatProviderDiagnostic("Pi", [ + { + label: "pi-acp module", + value: existsSync(resolvedPiAcpPath) ? "found" : "not found", + }, + { + label: "Binary", + value: piCliPath ?? "not found", + }, + { + label: "Version", + value: piVersion, + }, + { + label: "OPENAI_API_KEY", + value: process.env.OPENAI_API_KEY ? "set" : "not set", + }, + { + label: "ANTHROPIC_API_KEY", + value: process.env.ANTHROPIC_API_KEY ? "set" : "not set", + }, + { + label: "OPENROUTER_API_KEY", + value: process.env.OPENROUTER_API_KEY ? "set" : "not set", + }, + { + label: "Auth config (~/.pi/agent/auth.json)", + value: existsSync(authConfigPath) ? "found" : "not found", + }, + { + label: "Models", + value: modelsValue, + }, + { + label: "Status", + value: status, + }, + ]), + }; + } catch (error) { + return { + diagnostic: formatProviderDiagnosticError("Pi", error), + }; + } + } } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 37fa4ee4a..a647e6495 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -68,6 +68,7 @@ export type AgentMcpTransportFactory = () => Promise; import { buildProviderRegistry } from "./agent/provider-registry.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import { AgentManager } from "./agent/agent-manager.js"; +import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import type { AgentTimelineCursor, AgentTimelineFetchDirection, @@ -101,6 +102,7 @@ import type { AgentStreamEvent, AgentProvider, AgentPersistenceHandle, + ProviderSnapshotEntry, } from "./agent/agent-sdk-types.js"; import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js"; import { isValidAgentProvider, AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js"; @@ -398,6 +400,7 @@ export type SessionOptions = { stt: Resolvable; tts: Resolvable; terminalManager: TerminalManager | null; + providerSnapshotManager?: ProviderSnapshotManager; voice?: { voiceAgentMcpStdio?: VoiceMcpStdioConfig | null; turnDetection?: Resolvable; @@ -594,6 +597,8 @@ export class Session { } | null = null; private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000; private readonly terminalManager: TerminalManager | null; + private readonly providerSnapshotManager: ProviderSnapshotManager | null; + private unsubscribeProviderSnapshotEvents: (() => void) | null = null; private readonly subscribedTerminalDirectories = new Set(); private unsubscribeTerminalsChanged: (() => void) | null = null; private terminalExitSubscriptions: Map void> = new Map(); @@ -646,6 +651,7 @@ export class Session { stt, tts, terminalManager, + providerSnapshotManager, voice, voiceBridge, dictation, @@ -671,11 +677,32 @@ export class Session { this.checkoutDiffManager = checkoutDiffManager; this.createAgentMcpTransport = createAgentMcpTransport; this.terminalManager = terminalManager; + this.providerSnapshotManager = providerSnapshotManager ?? null; if (this.terminalManager) { this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) => this.handleTerminalsChanged(event), ); } + if (this.providerSnapshotManager) { + const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd?: string) => { + // COMPAT(providersSnapshot): keep provider visibility gating for older clients. + const visibleEntries = entries.filter((entry) => + this.isProviderVisibleToClient(entry.provider), + ); + this.emit({ + type: "providers_snapshot_update", + payload: { + cwd, + entries: visibleEntries, + generatedAt: new Date().toISOString(), + }, + }); + }; + this.providerSnapshotManager.on("change", handleProviderSnapshotChange); + this.unsubscribeProviderSnapshotEvents = () => { + this.providerSnapshotManager?.off("change", handleProviderSnapshotChange); + }; + } this.voiceAgentMcpStdio = voice?.voiceAgentMcpStdio ?? null; this.resolveVoiceTurnDetection = toResolver(voice?.turnDetection ?? null); this.registerVoiceSpeakHandler = voiceBridge?.registerVoiceSpeakHandler; @@ -1751,6 +1778,18 @@ export class Session { await this.handleListAvailableProvidersRequest(msg); break; + case "get_providers_snapshot_request": + await this.handleGetProvidersSnapshotRequest(msg); + break; + + case "refresh_providers_snapshot_request": + await this.handleRefreshProvidersSnapshotRequest(msg); + break; + + case "provider_diagnostic_request": + await this.handleProviderDiagnosticRequest(msg); + break; + case "clear_agent_attention": await this.handleClearAgentAttention(msg.agentId); break; @@ -3250,6 +3289,73 @@ export class Session { } } + private async handleGetProvidersSnapshotRequest( + msg: Extract, + ): Promise { + // COMPAT(providersSnapshot): keep legacy provider-list RPCs alongside snapshot flow. + const entries = this.providerSnapshotManager + ? this.providerSnapshotManager + .getSnapshot(msg.cwd ? expandTilde(msg.cwd) : undefined) + .filter((entry) => this.isProviderVisibleToClient(entry.provider)) + : []; + + this.emit({ + type: "get_providers_snapshot_response", + payload: { + entries, + generatedAt: new Date().toISOString(), + requestId: msg.requestId, + }, + }); + } + + private async handleRefreshProvidersSnapshotRequest( + msg: Extract, + ): Promise { + this.providerSnapshotManager?.refresh(msg.cwd ? expandTilde(msg.cwd) : undefined); + this.emit({ + type: "refresh_providers_snapshot_response", + payload: { + acknowledged: true, + requestId: msg.requestId, + }, + }); + } + + private async handleProviderDiagnosticRequest( + msg: Extract, + ): Promise { + try { + const client = this.providerRegistry[msg.provider].createClient(this.sessionLogger); + const diagnostic = client.getDiagnostic + ? (await client.getDiagnostic()).diagnostic + : "No diagnostic available for this provider."; + this.emit({ + type: "provider_diagnostic_response", + payload: { + provider: msg.provider, + diagnostic, + requestId: msg.requestId, + }, + }); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.sessionLogger.error( + { err, provider: msg.provider }, + `Failed to get provider diagnostic for ${msg.provider}`, + ); + this.emit({ + type: "rpc_error", + payload: { + requestId: msg.requestId, + requestType: msg.type, + error: `Failed to get provider diagnostic: ${err.message}`, + code: "provider_diagnostic_failed", + }, + }); + } + } + private assertSafeGitRef(ref: string, label: string): void { if (!/^[A-Za-z0-9._/-]+$/.test(ref)) { throw new Error(`Invalid ${label}: ${ref}`); @@ -7014,6 +7120,10 @@ export class Session { this.unsubscribeAgentEvents(); this.unsubscribeAgentEvents = null; } + if (this.unsubscribeProviderSnapshotEvents) { + this.unsubscribeProviderSnapshotEvents(); + this.unsubscribeProviderSnapshotEvents = null; + } // Abort any ongoing operations this.abortController.abort(); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 7b024e446..00c636e34 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -31,6 +31,8 @@ import { isHostAllowed } from "./allowed-hosts.js"; import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js"; import type { AgentProvider } from "./agent/agent-sdk-types.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; +import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; +import { buildProviderRegistry } from "./agent/provider-registry.js"; import { PushTokenStore } from "./push/token-store.js"; import { PushService } from "./push/push-service.js"; import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js"; @@ -253,6 +255,7 @@ export class VoiceAssistantWebSocketServer { private readonly voiceSpeakHandlers = new Map(); private readonly voiceCallerContexts = new Map(); private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; + private readonly providerSnapshotManager: ProviderSnapshotManager; private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null; private serverCapabilities: ServerCapabilities | undefined; private runtimeWindowStartedAt = Date.now(); @@ -343,6 +346,13 @@ export class VoiceAssistantWebSocketServer { this.voice = voice ?? null; this.dictation = dictation ?? null; this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; + const providerSnapshotLogger = this.logger.child({ module: "provider-snapshot-manager" }); + this.providerSnapshotManager = new ProviderSnapshotManager( + buildProviderRegistry(providerSnapshotLogger, { + runtimeSettings: this.agentProviderRuntimeSettings, + }), + providerSnapshotLogger, + ); this.onLifecycleIntent = onLifecycleIntent ?? null; this.serverCapabilities = buildServerCapabilities({ readiness: this.speech?.getReadiness() ?? null, @@ -496,6 +506,7 @@ export class VoiceAssistantWebSocketServer { } await Promise.all(cleanupPromises); + this.providerSnapshotManager.destroy(); this.checkoutDiffManager.dispose(); this.pendingConnections.clear(); this.sessions.clear(); @@ -646,6 +657,7 @@ export class VoiceAssistantWebSocketServer { stt: () => this.speech?.resolveStt() ?? null, tts: () => this.speech?.resolveTts() ?? null, terminalManager: this.terminalManager, + providerSnapshotManager: this.providerSnapshotManager, voice: { ...(this.voice ?? {}), turnDetection: () => this.speech?.resolveTurnDetection() ?? null, @@ -792,6 +804,10 @@ export class VoiceAssistantWebSocketServer { hostname: getHostname(), version: this.daemonVersion, ...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}), + features: { + // COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow. + providersSnapshot: true, + }, }; } diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 9e256dbf2..156ce6510 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -54,6 +54,8 @@ import type { AgentPermissionRequest, AgentPermissionResponse, AgentPersistenceHandle, + ProviderSnapshotEntry, + ProviderStatus, AgentRuntimeInfo, AgentTimelineItem, ToolCallDetail, @@ -69,6 +71,13 @@ const AgentModeSchema: z.ZodType = z.object({ description: z.string().optional(), }); +const ProviderStatusSchema: z.ZodType = z.enum([ + "ready", + "loading", + "error", + "unavailable", +]); + const AgentSelectOptionSchema = z.object({ id: z.string(), label: z.string(), @@ -114,6 +123,15 @@ const AgentModelDefinitionSchema: z.ZodType = z.object({ defaultThinkingOptionId: z.string().optional(), }); +const ProviderSnapshotEntrySchema: z.ZodType = z.object({ + provider: AgentProviderSchema, + status: ProviderStatusSchema, + error: z.string().optional(), + models: z.array(AgentModelDefinitionSchema).optional(), + modes: z.array(AgentModeSchema).optional(), + fetchedAt: z.string().optional(), +}); + const AgentCapabilityFlagsSchema: z.ZodType = z.object({ supportsStreaming: z.boolean(), supportsSessionPersistence: z.boolean(), @@ -772,6 +790,24 @@ export const ListAvailableProvidersRequestMessageSchema = z.object({ requestId: z.string(), }); +export const GetProvidersSnapshotRequestMessageSchema = z.object({ + type: z.literal("get_providers_snapshot_request"), + cwd: z.string().optional(), + requestId: z.string(), +}); + +export const RefreshProvidersSnapshotRequestMessageSchema = z.object({ + type: z.literal("refresh_providers_snapshot_request"), + cwd: z.string().optional(), + requestId: z.string(), +}); + +export const ProviderDiagnosticRequestMessageSchema = z.object({ + type: z.literal("provider_diagnostic_request"), + provider: AgentProviderSchema, + requestId: z.string(), +}); + export const ResumeAgentRequestMessageSchema = z.object({ type: z.literal("resume_agent_request"), handle: AgentPersistenceHandleSchema, @@ -1275,6 +1311,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ListProviderModesRequestMessageSchema, ListProviderFeaturesRequestMessageSchema, ListAvailableProvidersRequestMessageSchema, + GetProvidersSnapshotRequestMessageSchema, + RefreshProvidersSnapshotRequestMessageSchema, + ProviderDiagnosticRequestMessageSchema, ResumeAgentRequestMessageSchema, RefreshAgentRequestMessageSchema, CancelAgentRequestMessageSchema, @@ -1500,6 +1539,12 @@ export const ServerInfoStatusPayloadSchema = z hostname: ServerInfoHostnameSchema.optional(), version: ServerInfoVersionSchema.optional(), capabilities: ServerCapabilitiesFromUnknownSchema, + // COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot + features: z + .object({ + providersSnapshot: z.boolean().optional(), + }) + .optional(), }) .passthrough() .transform((payload) => ({ @@ -2235,6 +2280,45 @@ export const ListAvailableProvidersResponseSchema = z.object({ }), }); +// COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot +export const GetProvidersSnapshotResponseMessageSchema = z.object({ + type: z.literal("get_providers_snapshot_response"), + payload: z.object({ + entries: z.array(ProviderSnapshotEntrySchema), + generatedAt: z.string(), + requestId: z.string(), + }), +}); + +// COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot +export const ProvidersSnapshotUpdateMessageSchema = z.object({ + type: z.literal("providers_snapshot_update"), + payload: z.object({ + cwd: z.string().optional(), + entries: z.array(ProviderSnapshotEntrySchema), + generatedAt: z.string(), + }), +}); + +// COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot +export const RefreshProvidersSnapshotResponseMessageSchema = z.object({ + type: z.literal("refresh_providers_snapshot_response"), + payload: z.object({ + requestId: z.string(), + acknowledged: z.boolean(), + }), +}); + +// COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot +export const ProviderDiagnosticResponseMessageSchema = z.object({ + type: z.literal("provider_diagnostic_response"), + payload: z.object({ + provider: AgentProviderSchema, + diagnostic: z.string(), + requestId: z.string(), + }), +}); + const AgentSlashCommandSchema = z.object({ name: z.string(), description: z.string(), @@ -2428,6 +2512,10 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ListProviderModesResponseMessageSchema, ListProviderFeaturesResponseMessageSchema, ListAvailableProvidersResponseSchema, + GetProvidersSnapshotResponseMessageSchema, + ProvidersSnapshotUpdateMessageSchema, + RefreshProvidersSnapshotResponseMessageSchema, + ProviderDiagnosticResponseMessageSchema, ListCommandsResponseSchema, ListTerminalsResponseSchema, TerminalsChangedSchema, @@ -2507,6 +2595,16 @@ export type ListProviderFeaturesResponseMessage = z.infer< typeof ListProviderFeaturesResponseMessageSchema >; export type ListAvailableProvidersResponse = z.infer; +export type GetProvidersSnapshotResponseMessage = z.infer< + typeof GetProvidersSnapshotResponseMessageSchema +>; +export type ProvidersSnapshotUpdateMessage = z.infer; +export type RefreshProvidersSnapshotResponseMessage = z.infer< + typeof RefreshProvidersSnapshotResponseMessageSchema +>; +export type ProviderDiagnosticResponseMessage = z.infer< + typeof ProviderDiagnosticResponseMessageSchema +>; export type ChatCreateResponse = z.infer; export type ChatListResponse = z.infer; export type ChatInspectResponse = z.infer; @@ -2554,6 +2652,15 @@ export type ListProviderFeaturesRequestMessage = z.infer< export type ListAvailableProvidersRequestMessage = z.infer< typeof ListAvailableProvidersRequestMessageSchema >; +export type GetProvidersSnapshotRequestMessage = z.infer< + typeof GetProvidersSnapshotRequestMessageSchema +>; +export type RefreshProvidersSnapshotRequestMessage = z.infer< + typeof RefreshProvidersSnapshotRequestMessageSchema +>; +export type ProviderDiagnosticRequestMessage = z.infer< + typeof ProviderDiagnosticRequestMessageSchema +>; export type ChatCreateRequest = z.infer; export type ChatListRequest = z.infer; export type ChatInspectRequest = z.infer;