feat(app): provider system overhaul with model selector, diagnostics, and UX polish

- Add CombinedModelSelector with two-level drill-down (providers → models),
  favorites section, search, and single-provider auto-drill mode
- Add provider snapshot system (ProviderSnapshotManager) for cached provider/model state
- Add provider diagnostics with resolved binary paths and versions across all providers
- Add StatusBadge shared component for consistent status visual language
- Add ProviderDiagnosticSheet for detailed provider health inspection
- Add desktop auto-focus on input after any status bar interaction (model, mode,
  thinking, features) using focusWithRetries
- Fix Combobox flicker on height change by switching to bottom-based CSS positioning
  once initial floating-ui position resolves
- Add dynamic dropdown height: Level 1 fits content, Level 2 calculates from model count
- Remove "Other providers" section from model selector (only show available providers)
This commit is contained in:
Mohamed Boudra
2026-04-05 15:32:13 +07:00
parent 6907f6e71d
commit 0d88ce2270
25 changed files with 2225 additions and 160 deletions
@@ -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(() => {
@@ -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({
<DropdownMenu
key={`feature-${feature.id}`}
open={openSelector === `feature-${feature.id}`}
onOpenChange={(open) =>
setOpenSelector(open ? `feature-${feature.id}` : null)
}
onOpenChange={handleOpenChange(`feature-${feature.id}`)}
>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
@@ -784,9 +788,7 @@ function ControlledStatusBar({
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
<DropdownMenu
open={openSelector === `feature-${feature.id}`}
onOpenChange={(open) =>
setOpenSelector(open ? `feature-${feature.id}` : null)
}
onOpenChange={handleOpenChange(`feature-${feature.id}`)}
>
<DropdownMenuTrigger
disabled={disabled}
@@ -1048,6 +1050,7 @@ export function DraftAgentStatusBar({
onSelectThinkingOption,
features,
onSetFeature,
onDropdownClose,
disabled = false,
}: DraftAgentStatusBarProps) {
const isWeb = Platform.OS === "web";
@@ -1092,6 +1095,7 @@ export function DraftAgentStatusBar({
}}
isLoading={isAllModelsLoading}
disabled={disabled}
onClose={onDropdownClose}
/>
<ControlledStatusBar
provider={selectedProvider}
@@ -1103,6 +1107,7 @@ export function DraftAgentStatusBar({
onSelectThinkingOption={onSelectThinkingOption}
features={features}
onSetFeature={onSetFeature}
onDropdownClose={onDropdownClose}
disabled={disabled}
/>
</View>
@@ -1115,30 +1120,32 @@ export function DraftAgentStatusBar({
}));
return (
<ControlledStatusBar
provider={selectedProvider}
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
modeOptions={mappedModeOptions}
selectedModeId={effectiveSelectedMode}
onSelectMode={onSelectMode}
modelOptions={modelOptions}
selectedModelId={selectedModel}
onSelectModel={(modelId) => 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}
/>
<>
<ControlledStatusBar
provider={selectedProvider}
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
modeOptions={mappedModeOptions}
selectedModeId={effectiveSelectedMode}
onSelectMode={onSelectMode}
modelOptions={modelOptions}
selectedModelId={selectedModel}
onSelectModel={(modelId) => 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}
/>
</>
);
}
@@ -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<string>,
): 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={<ProviderIcon size={14} color={theme.colors.foregroundMuted} />}
leadingSlot={<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
trailingSlot={
onToggleFavorite && !disabled ? (
<Pressable
@@ -228,7 +250,7 @@ function FavoritesSection({
}
return (
<View>
<View style={styles.favoritesContainer}>
<View style={styles.sectionHeading}>
<Text style={styles.sectionHeadingText}>Favorites</Text>
</View>
@@ -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}
/>
))}
<View style={styles.separator} />
</View>
);
}
@@ -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 (
<View key={group.providerId}>
{index > 0 ? <View style={styles.separator} /> : null}
{isInline ? (
<>
<View style={styles.sectionHeading}>
<Text style={styles.sectionHeadingText}>
{providerDefinition?.label ?? group.providerLabel}
</Text>
</View>
{group.rows.map((row) => (
{sortFavoritesFirst(group.rows, favoriteKeys).map((row) => (
<ModelRow
key={row.favoriteKey}
row={row}
@@ -309,11 +328,13 @@ function GroupedProviderRows({
pressed && styles.drillDownRowPressed,
]}
>
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownText}>{group.providerLabel}</Text>
<View style={styles.drillDownTrailing}>
<Text style={styles.drillDownCount}>{group.rows.length}</Text>
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownCount}>
{group.rows.length} {group.rows.length === 1 ? "model" : "models"}
</Text>
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
</Pressable>
)}
@@ -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<TextInput>(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 (
<View style={styles.providerSearchContainer}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<InputComponent
ref={inputRef as any}
// @ts-expect-error - outlineStyle is web-only
style={[styles.providerSearchInput, Platform.OS === "web" && { outlineStyle: "none" }]}
placeholder="Search models..."
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
onChangeText={onChangeText}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
);
}
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>
{view.kind === "provider" ? (
<ProviderBackButton providerId={view.providerId} providerLabel={view.providerLabel} onBack={onBack} />
{view.kind === "all" ? (
<FavoritesSection
favoriteRows={favoriteRows}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
onSelect={onSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
/>
) : null}
<SearchInput
placeholder={view.kind === "provider" ? "Search models..." : "Search models or providers..."}
value={searchQuery}
onChangeText={onSearchChange}
autoFocus={Platform.OS === "web"}
/>
<FavoritesSection
favoriteRows={favoriteRows}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
onSelect={onSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
/>
{groupedRegularRows.length > 0 ? (
{filteredGroupedRows.length > 0 ? (
<GroupedProviderRows
providerDefinitions={providerDefinitions}
groupedRows={groupedRegularRows}
groupedRows={filteredGroupedRows}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
@@ -399,12 +466,13 @@ function SelectorContent({
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
onDrillDown={onDrillDown}
viewKind={view.kind}
/>
) : null}
{favoriteRows.length === 0 && groupedRegularRows.length === 0 ? (
{!hasResults ? (
<View style={styles.emptyState}>
<Search size={16} color="#777" />
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.emptyStateText}>No models match your search</Text>
</View>
) : null}
@@ -437,8 +505,8 @@ function ProviderBackButton({
pressed && styles.backButtonPressed,
]}
>
<ArrowLeft size={14} color={theme.colors.foregroundMuted} />
<ProviderIcon size={14} color={theme.colors.foregroundMuted} />
<ArrowLeft size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.backButtonText}>{providerLabel}</Text>
</Pressable>
);
@@ -455,6 +523,7 @@ export function CombinedModelSelector({
favoriteKeys = new Set<string>(),
onToggleFavorite,
renderTrigger,
onClose,
disabled = false,
}: CombinedModelSelectorProps) {
const { theme } = useUnistyles();
@@ -465,25 +534,35 @@ export function CombinedModelSelector({
const [view, setView] = useState<SelectorView>({ kind: "all" });
const [searchQuery, setSearchQuery] = useState("");
// Single-provider mode: only one provider with models → skip Level 1 entirely
const singleProviderView = useMemo<SelectorView | null>(() => {
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" ? (
<View style={styles.level2Header}>
{!singleProviderView ? (
<ProviderBackButton
providerId={view.providerId}
providerLabel={view.providerLabel}
onBack={() => {
setView({ kind: "all" });
setSearchQuery("");
}}
/>
) : null}
<ProviderSearchInput
value={searchQuery}
onChangeText={setSearchQuery}
autoFocus={Platform.OS === "web"}
/>
</View>
) : undefined
}
>
{isContentReady ? (
<SelectorContent
@@ -586,13 +697,6 @@ export function CombinedModelSelector({
onDrillDown={(providerId, providerLabel) => {
setView({ kind: "provider", providerId, providerLabel });
}}
onBack={
view.kind === "provider"
? () => {
setView({ kind: "all" });
}
: undefined
}
/>
) : (
<View style={styles.sheetLoadingState}>
@@ -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,
},
}));
@@ -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<string | null>(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 (
<AdaptiveModalSheet
title={providerLabel}
visible={visible}
onClose={onClose}
snapPoints={["50%", "85%"]}
>
{loading ? (
<View style={sheetStyles.loadingContainer}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.loadingText}>Fetching diagnostic…</Text>
</View>
) : diagnostic ? (
<ScrollView
horizontal
style={sheetStyles.scrollContainer}
contentContainerStyle={sheetStyles.scrollContent}
>
<Text style={sheetStyles.diagnosticText} selectable>
{diagnostic}
</Text>
</ScrollView>
) : null}
</AdaptiveModalSheet>
);
}
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,
},
}));
+57 -14
View File
@@ -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<View | null>;
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({
<View style={styles.bottomSheetHeader}>
<Text style={styles.comboboxTitle}>{title}</Text>
</View>
{stickyHeader}
<BottomSheetScrollView
contentContainerStyle={styles.comboboxScrollContent}
keyboardShouldPersistTaps="handled"
@@ -687,13 +715,16 @@ export function Combobox({
styles.desktopContainer,
{
position: "absolute",
minWidth: referenceWidth ?? 200,
maxWidth: 400,
minWidth: desktopMinWidth ?? referenceWidth ?? 200,
maxWidth: Math.max(400, desktopMinWidth ?? 0),
},
desktopFixedHeight != null
? { minHeight: desktopFixedHeight, maxHeight: desktopFixedHeight }
: null,
desktopPositionStyle,
shouldHideDesktopContent ? { opacity: 0 } : null,
typeof availableSize?.height === "number"
? { maxHeight: Math.min(availableSize.height, 400) }
? { maxHeight: Math.min(availableSize.height, desktopFixedHeight ?? 400) }
: null,
]}
ref={refs.setFloating}
@@ -701,14 +732,17 @@ export function Combobox({
onLayout={() => update()}
>
{children ? (
<ScrollView
contentContainerStyle={styles.desktopScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
style={styles.desktopScroll}
>
{content}
</ScrollView>
<>
{stickyHeader}
<ScrollView
contentContainerStyle={styles.desktopChildrenScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
style={styles.desktopScroll}
>
{content}
</ScrollView>
</>
) : (
<>
{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",
@@ -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 (
<View
style={[
styles.pill,
variant === "success" && styles.pillSuccess,
variant === "error" && styles.pillError,
]}
>
<Text
style={[
styles.pillText,
variant === "success" && styles.pillTextSuccess,
variant === "error" && styles.pillTextError,
]}
>
{label}
</Text>
</View>
);
}
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],
},
}));
@@ -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;
}
+218 -43
View File
@@ -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<AgentProvider, AgentProviderDefinition>;
agentDefinition?: AgentProviderDefinition;
allProviderEntries?: ProviderSnapshotEntry[];
modeOptions: AgentMode[];
availableModels: AgentModelDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]>;
@@ -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<string | undefined>(undefined);
useEffect(() => {
const trimmed = formState.workingDir.trim();
const next = trimmed.length > 0 ? trimmed : undefined;
const timer = setTimeout(() => setDebouncedCwd(next), 180);
return () => clearTimeout(timer);
}, [formState.workingDir]);
const snapshotQueryKey = useMemo(
() => providersSnapshotQueryKey(formState.serverId, debouncedCwd),
[debouncedCwd, formState.serverId],
);
const providersSnapshotQuery = useQuery({
queryKey: snapshotQueryKey,
enabled: Boolean(
supportsProvidersSnapshot &&
isVisible &&
isTargetDaemonReady &&
formState.serverId &&
client &&
isConnected,
),
staleTime: 60 * 1000,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
return client.getProvidersSnapshot({ cwd: debouncedCwd });
},
});
useEffect(() => {
if (
!supportsProvidersSnapshot ||
!client ||
!isConnected ||
!isVisible ||
!isTargetDaemonReady ||
!formState.serverId
) {
return;
}
return client.on("providers_snapshot_update", (message) => {
if (message.type !== "providers_snapshot_update") {
return;
}
if (message.payload.cwd !== undefined && message.payload.cwd !== debouncedCwd) {
return;
}
queryClient.setQueryData(snapshotQueryKey, {
entries: message.payload.entries,
generatedAt: message.payload.generatedAt,
requestId: "providers_snapshot_update",
});
});
}, [
client,
debouncedCwd,
formState.serverId,
isConnected,
isTargetDaemonReady,
isVisible,
queryClient,
snapshotQueryKey,
supportsProvidersSnapshot,
]);
const snapshotEntries = providersSnapshotQuery.data?.entries ?? undefined;
const allProviderEntries = useMemo(
() => (supportsProvidersSnapshot ? snapshotEntries ?? [] : undefined),
[snapshotEntries, supportsProvidersSnapshot],
);
const 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<AgentProvider, AgentProviderDefinition>(
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<AgentProvider, AgentProviderDefinition>(
snapshotProviderDefinitions
.filter((definition) => readyProviders.has(definition.id))
.map((definition) => [definition.id, definition]),
);
}, [snapshotEntries, snapshotProviderDefinitions]);
const snapshotAllProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>();
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<AgentProvider, AgentProviderDefinition>(
providerDefinitions.map((definition) => [definition.id as AgentProvider, definition]),
legacyProviderDefinitions.map((definition) => [definition.id, definition]),
),
[providerDefinitions],
[legacyProviderDefinitions],
);
const [debouncedCwd, setDebouncedCwd] = useState<string | undefined>(undefined);
useEffect(() => {
const trimmed = formState.workingDir.trim();
const next = trimmed.length > 0 ? trimmed : undefined;
const timer = setTimeout(() => setDebouncedCwd(next), 180);
return () => clearTimeout(timer);
}, [formState.workingDir]);
const 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<string, AgentModelDefinition[]>();
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,
@@ -220,6 +220,7 @@ function DraftAgentScreenContent({
modeOptions,
availableModels,
allProviderModels,
allProviderEntries,
isAllModelsLoading,
availableThinkingOptions,
isModelLoading,
+133 -1
View File
@@ -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<ProviderSnapshotEntry[]>([]);
const [loading, setLoading] = useState(false);
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null);
useEffect(() => {
if (!client || !isConnected) {
setEntries([]);
return;
}
let cancelled = false;
setLoading(true);
client
.getProvidersSnapshot()
.then((result) => {
if (!cancelled) setEntries(result.entries);
})
.catch(() => {
if (!cancelled) setEntries([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [client, isConnected]);
const hasServer = routeServerId.length > 0;
return (
<>
<View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>Providers</Text>
{!hasServer || !isConnected ? (
<View style={[settingsStyles.card, styles.emptyCard]}>
<Text style={styles.emptyText}>Connect to a host to see providers</Text>
</View>
) : loading ? (
<View style={[settingsStyles.card, styles.emptyCard]}>
<Text style={styles.emptyText}>Loading...</Text>
</View>
) : (
<View style={[settingsStyles.card, styles.audioCard]}>
{AGENT_PROVIDER_DEFINITIONS.map((def) => {
const entry = entries.find((e) => e.provider === def.id);
const status = entry?.status ?? "unavailable";
const ProviderIcon = getProviderIcon(def.id);
return (
<View key={def.id} style={styles.audioRow}>
<View style={[styles.audioRowContent, { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }]}>
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foreground} />
<Text style={styles.audioRowTitle}>{def.label}</Text>
</View>
<View style={styles.providerActions}>
<StatusBadge
label={
status === "ready"
? "Available"
: status === "error"
? "Error"
: status === "loading"
? "Loading..."
: "Not installed"
}
variant={
status === "ready"
? "success"
: status === "error"
? "error"
: "muted"
}
/>
<Button
variant="secondary"
size="sm"
onPress={() => setDiagnosticProvider(def.id)}
>
Diagnostic
</Button>
</View>
</View>
);
})}
</View>
)}
</View>
{diagnosticProvider ? (
<ProviderDiagnosticSheet
provider={diagnosticProvider}
visible
onClose={() => setDiagnosticProvider(null)}
serverId={routeServerId}
/>
) : null}
</>
);
}
interface DiagnosticsSectionProps {
voiceAudioEngine: ReturnType<typeof useVoiceAudioEngineOptional>;
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 <AppearanceSection {...appearanceProps} />;
case "shortcuts":
return <KeyboardShortcutsSection />;
case "providers":
return <ProvidersSection {...providersProps} />;
case "diagnostics":
return <DiagnosticsSection {...diagnosticsProps} />;
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 (
<View key={section.id}>
{showSeparator ? <View style={desktopStyles.sidebarSeparator} /> : null}
@@ -951,9 +1073,14 @@ export default function SettingsScreen() {
isDesktopApp,
};
const providersProps: ProvidersSectionProps = {
routeServerId,
};
const sectionContentProps: Omit<SettingsSectionContentProps, "sectionId"> = {
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,
@@ -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 (
<FileDropZone onFilesDropped={handleFilesDropped}>
<View style={styles.container}>
@@ -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,
}}
/>
+14 -1
View File
@@ -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<SessionStore>()(
subscribeWithSelector((set, get) => {
const commitActivityUpdates: AgentLastActivityCommitter = (updates) => {
@@ -527,12 +536,15 @@ export const useSessionStore = create<SessionStore>()(
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<SessionStore>()(
hostname: nextHostname,
version: nextVersion,
...(nextCapabilities ? { capabilities: nextCapabilities } : {}),
...(nextFeatures ? { features: nextFeatures } : {}),
},
},
},
@@ -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<SessionOutboundMessage, { type: "providers_snapshot_update" }>["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<GetProvidersSnapshotPayload> {
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<RefreshProvidersSnapshotPayload> {
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<ProviderDiagnosticPayload> {
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<ListCommandsPayload>;
async listCommands(agentId: string, options?: ListCommandsOptions): Promise<ListCommandsPayload>;
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;
}
@@ -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<boolean>;
getDiagnostic?(): Promise<{ diagnostic: string }>;
}
@@ -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<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
};
type MockProviderOptions = {
provider: AgentProvider;
isAvailable?: () => Promise<boolean>;
fetchModels?: (cwd?: string) => Promise<AgentModelDefinition[]>;
fetchModes?: (cwd?: string) => Promise<AgentMode[]>;
};
type MockProviderHandle = {
definition: ProviderDefinition;
isAvailable: ReturnType<typeof vi.fn>;
fetchModels: ReturnType<typeof vi.fn>;
fetchModes: ReturnType<typeof vi.fn>;
};
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<AgentModelDefinition[]>();
const claudeModels = deferred<AgentModelDefinition[]>();
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<AgentModelDefinition[]>();
const claudeModels = deferred<AgentModelDefinition[]>();
const codexModes = deferred<AgentMode[]>();
const claudeModes = deferred<AgentMode[]>();
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<AgentModelDefinition[]>>()
.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<AgentModelDefinition[]>();
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<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function createRegistry(handles: MockProviderHandle[]): {
registry: Record<AgentProvider, ProviderDefinition>;
handles: Record<AgentProvider, MockProviderHandle>;
} {
return {
registry: Object.fromEntries(
handles.map((handle) => [handle.definition.id, handle.definition]),
) as Record<AgentProvider, ProviderDefinition>,
handles: Object.fromEntries(
handles.map((handle) => [handle.definition.id, handle]),
) as Record<AgentProvider, MockProviderHandle>,
};
}
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);
}
@@ -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<string, Map<AgentProvider, ProviderSnapshotEntry>>();
private readonly warmUps = new Map<string, Promise<void>>();
private readonly events = new EventEmitter();
private destroyed = false;
constructor(
private readonly providerRegistry: Record<AgentProvider, ProviderDefinition>,
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<AgentProvider, ProviderSnapshotEntry> {
const entries = new Map<AgentProvider, ProviderSnapshotEntry>();
for (const provider of this.getProviderIds()) {
entries.set(provider, {
provider,
status: "loading",
});
}
return entries;
}
private async warmUp(cwd?: string): Promise<void> {
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<void> {
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<AgentProvider, ProviderSnapshotEntry> {
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<AgentProvider, ProviderSnapshotEntry>,
): 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";
}
@@ -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;
@@ -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 = {
@@ -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<boolean> {
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),
};
}
}
}
@@ -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(" ");
}
@@ -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}'`);
@@ -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),
};
}
}
}
+110
View File
@@ -68,6 +68,7 @@ export type AgentMcpTransportFactory = () => Promise<Transport>;
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<SpeechToTextProvider | null>;
tts: Resolvable<TextToSpeechProvider | null>;
terminalManager: TerminalManager | null;
providerSnapshotManager?: ProviderSnapshotManager;
voice?: {
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
turnDetection?: Resolvable<TurnDetectionProvider | null>;
@@ -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<string>();
private unsubscribeTerminalsChanged: (() => void) | null = null;
private terminalExitSubscriptions: Map<string, () => 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<SessionInboundMessage, { type: "get_providers_snapshot_request" }>,
): Promise<void> {
// 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<SessionInboundMessage, { type: "refresh_providers_snapshot_request" }>,
): Promise<void> {
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<SessionInboundMessage, { type: "provider_diagnostic_request" }>,
): Promise<void> {
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();
@@ -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<string, VoiceSpeakHandler>();
private readonly voiceCallerContexts = new Map<string, VoiceCallerContext>();
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,
},
};
}
+107
View File
@@ -54,6 +54,8 @@ import type {
AgentPermissionRequest,
AgentPermissionResponse,
AgentPersistenceHandle,
ProviderSnapshotEntry,
ProviderStatus,
AgentRuntimeInfo,
AgentTimelineItem,
ToolCallDetail,
@@ -69,6 +71,13 @@ const AgentModeSchema: z.ZodType<AgentMode> = z.object({
description: z.string().optional(),
});
const ProviderStatusSchema: z.ZodType<ProviderStatus> = z.enum([
"ready",
"loading",
"error",
"unavailable",
]);
const AgentSelectOptionSchema = z.object({
id: z.string(),
label: z.string(),
@@ -114,6 +123,15 @@ const AgentModelDefinitionSchema: z.ZodType<AgentModelDefinition> = z.object({
defaultThinkingOptionId: z.string().optional(),
});
const ProviderSnapshotEntrySchema: z.ZodType<ProviderSnapshotEntry> = 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<AgentCapabilityFlags> = 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<typeof ListAvailableProvidersResponseSchema>;
export type GetProvidersSnapshotResponseMessage = z.infer<
typeof GetProvidersSnapshotResponseMessageSchema
>;
export type ProvidersSnapshotUpdateMessage = z.infer<typeof ProvidersSnapshotUpdateMessageSchema>;
export type RefreshProvidersSnapshotResponseMessage = z.infer<
typeof RefreshProvidersSnapshotResponseMessageSchema
>;
export type ProviderDiagnosticResponseMessage = z.infer<
typeof ProviderDiagnosticResponseMessageSchema
>;
export type ChatCreateResponse = z.infer<typeof ChatCreateResponseSchema>;
export type ChatListResponse = z.infer<typeof ChatListResponseSchema>;
export type ChatInspectResponse = z.infer<typeof ChatInspectResponseSchema>;
@@ -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<typeof ChatCreateRequestSchema>;
export type ChatListRequest = z.infer<typeof ChatListRequestSchema>;
export type ChatInspectRequest = z.infer<typeof ChatInspectRequestSchema>;