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:
@@ -213,7 +213,13 @@ export function AgentInputArea({
|
|||||||
|
|
||||||
const focusInput = useCallback(() => {
|
const focusInput = useCallback(() => {
|
||||||
if (Platform.OS !== "web") return;
|
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(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ type ControlledAgentStatusBarProps = {
|
|||||||
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
|
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
|
||||||
features?: AgentFeature[];
|
features?: AgentFeature[];
|
||||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||||
|
onDropdownClose?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface DraftAgentStatusBarProps {
|
export interface DraftAgentStatusBarProps {
|
||||||
@@ -108,6 +109,7 @@ export interface DraftAgentStatusBarProps {
|
|||||||
onSelectThinkingOption: (thinkingOptionId: string) => void;
|
onSelectThinkingOption: (thinkingOptionId: string) => void;
|
||||||
features?: AgentFeature[];
|
features?: AgentFeature[];
|
||||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||||
|
onDropdownClose?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +214,7 @@ function ControlledStatusBar({
|
|||||||
onToggleFavoriteModel,
|
onToggleFavoriteModel,
|
||||||
features,
|
features,
|
||||||
onSetFeature,
|
onSetFeature,
|
||||||
|
onDropdownClose,
|
||||||
}: ControlledAgentStatusBarProps) {
|
}: ControlledAgentStatusBarProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isWeb = Platform.OS === "web";
|
const isWeb = Platform.OS === "web";
|
||||||
@@ -331,8 +334,11 @@ function ControlledStatusBar({
|
|||||||
const handleOpenChange = useCallback(
|
const handleOpenChange = useCallback(
|
||||||
(selector: StatusSelector) => (nextOpen: boolean) => {
|
(selector: StatusSelector) => (nextOpen: boolean) => {
|
||||||
setOpenSelector(nextOpen ? selector : null);
|
setOpenSelector(nextOpen ? selector : null);
|
||||||
|
if (!nextOpen) {
|
||||||
|
onDropdownClose?.();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[],
|
[onDropdownClose],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectorPress = useCallback(
|
const handleSelectorPress = useCallback(
|
||||||
@@ -555,9 +561,7 @@ function ControlledStatusBar({
|
|||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
key={`feature-${feature.id}`}
|
key={`feature-${feature.id}`}
|
||||||
open={openSelector === `feature-${feature.id}`}
|
open={openSelector === `feature-${feature.id}`}
|
||||||
onOpenChange={(open) =>
|
onOpenChange={handleOpenChange(`feature-${feature.id}`)}
|
||||||
setOpenSelector(open ? `feature-${feature.id}` : null)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger asChild triggerRefProp="ref">
|
<TooltipTrigger asChild triggerRefProp="ref">
|
||||||
@@ -784,9 +788,7 @@ function ControlledStatusBar({
|
|||||||
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
|
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
open={openSelector === `feature-${feature.id}`}
|
open={openSelector === `feature-${feature.id}`}
|
||||||
onOpenChange={(open) =>
|
onOpenChange={handleOpenChange(`feature-${feature.id}`)}
|
||||||
setOpenSelector(open ? `feature-${feature.id}` : null)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -1048,6 +1050,7 @@ export function DraftAgentStatusBar({
|
|||||||
onSelectThinkingOption,
|
onSelectThinkingOption,
|
||||||
features,
|
features,
|
||||||
onSetFeature,
|
onSetFeature,
|
||||||
|
onDropdownClose,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: DraftAgentStatusBarProps) {
|
}: DraftAgentStatusBarProps) {
|
||||||
const isWeb = Platform.OS === "web";
|
const isWeb = Platform.OS === "web";
|
||||||
@@ -1092,6 +1095,7 @@ export function DraftAgentStatusBar({
|
|||||||
}}
|
}}
|
||||||
isLoading={isAllModelsLoading}
|
isLoading={isAllModelsLoading}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
onClose={onDropdownClose}
|
||||||
/>
|
/>
|
||||||
<ControlledStatusBar
|
<ControlledStatusBar
|
||||||
provider={selectedProvider}
|
provider={selectedProvider}
|
||||||
@@ -1103,6 +1107,7 @@ export function DraftAgentStatusBar({
|
|||||||
onSelectThinkingOption={onSelectThinkingOption}
|
onSelectThinkingOption={onSelectThinkingOption}
|
||||||
features={features}
|
features={features}
|
||||||
onSetFeature={onSetFeature}
|
onSetFeature={onSetFeature}
|
||||||
|
onDropdownClose={onDropdownClose}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -1115,30 +1120,32 @@ export function DraftAgentStatusBar({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ControlledStatusBar
|
<>
|
||||||
provider={selectedProvider}
|
<ControlledStatusBar
|
||||||
providerDefinitions={providerDefinitions}
|
provider={selectedProvider}
|
||||||
allProviderModels={allProviderModels}
|
providerDefinitions={providerDefinitions}
|
||||||
modeOptions={mappedModeOptions}
|
allProviderModels={allProviderModels}
|
||||||
selectedModeId={effectiveSelectedMode}
|
modeOptions={mappedModeOptions}
|
||||||
onSelectMode={onSelectMode}
|
selectedModeId={effectiveSelectedMode}
|
||||||
modelOptions={modelOptions}
|
onSelectMode={onSelectMode}
|
||||||
selectedModelId={selectedModel}
|
modelOptions={modelOptions}
|
||||||
onSelectModel={(modelId) => onSelectModel(modelId)}
|
selectedModelId={selectedModel}
|
||||||
isModelLoading={isAllModelsLoading}
|
onSelectModel={(modelId) => onSelectModel(modelId)}
|
||||||
favoriteKeys={favoriteKeys}
|
isModelLoading={isAllModelsLoading}
|
||||||
onToggleFavoriteModel={(provider, modelId) => {
|
favoriteKeys={favoriteKeys}
|
||||||
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
|
onToggleFavoriteModel={(provider, modelId) => {
|
||||||
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
|
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
|
||||||
});
|
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
|
||||||
}}
|
});
|
||||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
}}
|
||||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||||
onSelectThinkingOption={onSelectThinkingOption}
|
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||||
features={features}
|
onSelectThinkingOption={onSelectThinkingOption}
|
||||||
onSetFeature={onSetFeature}
|
features={features}
|
||||||
disabled={disabled}
|
onSetFeature={onSetFeature}
|
||||||
/>
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
|
TextInput,
|
||||||
Pressable,
|
Pressable,
|
||||||
Platform,
|
Platform,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
type GestureResponderEvent,
|
type GestureResponderEvent,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -15,9 +17,12 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Star,
|
Star,
|
||||||
} from "lucide-react-native";
|
} 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 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 { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import type { FavoriteModelRow } from "@/hooks/use-form-preferences";
|
import type { FavoriteModelRow } from "@/hooks/use-form-preferences";
|
||||||
@@ -29,8 +34,6 @@ import {
|
|||||||
type SelectorModelRow,
|
type SelectorModelRow,
|
||||||
} from "./combined-model-selector.utils";
|
} from "./combined-model-selector.utils";
|
||||||
|
|
||||||
const INLINE_MODEL_THRESHOLD = Number.POSITIVE_INFINITY;
|
|
||||||
|
|
||||||
type SelectorView =
|
type SelectorView =
|
||||||
| { kind: "all" }
|
| { kind: "all" }
|
||||||
| { kind: "provider"; providerId: string; providerLabel: string };
|
| { kind: "provider"; providerId: string; providerLabel: string };
|
||||||
@@ -51,6 +54,7 @@ interface CombinedModelSelectorProps {
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
}) => React.ReactNode;
|
}) => React.ReactNode;
|
||||||
|
onClose?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +71,6 @@ interface SelectorContentProps {
|
|||||||
canSelectProvider: (provider: string) => boolean;
|
canSelectProvider: (provider: string) => boolean;
|
||||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||||
onBack?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
|
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
|
||||||
@@ -99,6 +102,22 @@ function partitionRows(
|
|||||||
return { favoriteRows, regularRows };
|
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(
|
function groupRowsByProvider(
|
||||||
rows: SelectorModelRow[],
|
rows: SelectorModelRow[],
|
||||||
): Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }> {
|
): Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }> {
|
||||||
@@ -126,6 +145,7 @@ function ModelRow({
|
|||||||
isSelected,
|
isSelected,
|
||||||
isFavorite,
|
isFavorite,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
elevated = false,
|
||||||
onPress,
|
onPress,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
}: {
|
}: {
|
||||||
@@ -133,6 +153,7 @@ function ModelRow({
|
|||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
isFavorite: boolean;
|
isFavorite: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
elevated?: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -153,8 +174,9 @@ function ModelRow({
|
|||||||
label={row.modelLabel}
|
label={row.modelLabel}
|
||||||
selected={isSelected}
|
selected={isSelected}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
elevated={elevated}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
leadingSlot={<ProviderIcon size={14} color={theme.colors.foregroundMuted} />}
|
leadingSlot={<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
|
||||||
trailingSlot={
|
trailingSlot={
|
||||||
onToggleFavorite && !disabled ? (
|
onToggleFavorite && !disabled ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -228,7 +250,7 @@ function FavoritesSection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View style={styles.favoritesContainer}>
|
||||||
<View style={styles.sectionHeading}>
|
<View style={styles.sectionHeading}>
|
||||||
<Text style={styles.sectionHeadingText}>Favorites</Text>
|
<Text style={styles.sectionHeadingText}>Favorites</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -239,11 +261,11 @@ function FavoritesSection({
|
|||||||
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
|
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
|
||||||
isFavorite={favoriteKeys.has(row.favoriteKey)}
|
isFavorite={favoriteKeys.has(row.favoriteKey)}
|
||||||
disabled={!canSelectProvider(row.provider)}
|
disabled={!canSelectProvider(row.provider)}
|
||||||
|
elevated
|
||||||
onPress={() => onSelect(row.provider, row.modelId)}
|
onPress={() => onSelect(row.provider, row.modelId)}
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<View style={styles.separator} />
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -258,6 +280,7 @@ function GroupedProviderRows({
|
|||||||
canSelectProvider,
|
canSelectProvider,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
onDrillDown,
|
onDrillDown,
|
||||||
|
viewKind,
|
||||||
}: {
|
}: {
|
||||||
providerDefinitions: AgentProviderDefinition[];
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>;
|
groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>;
|
||||||
@@ -268,6 +291,7 @@ function GroupedProviderRows({
|
|||||||
canSelectProvider: (provider: string) => boolean;
|
canSelectProvider: (provider: string) => boolean;
|
||||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||||
|
viewKind: SelectorView["kind"];
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
@@ -276,19 +300,14 @@ function GroupedProviderRows({
|
|||||||
{groupedRows.map((group, index) => {
|
{groupedRows.map((group, index) => {
|
||||||
const providerDefinition = providerDefinitions.find((definition) => definition.id === group.providerId);
|
const providerDefinition = providerDefinitions.find((definition) => definition.id === group.providerId);
|
||||||
const ProvIcon = getProviderIcon(group.providerId);
|
const ProvIcon = getProviderIcon(group.providerId);
|
||||||
const isInline = group.rows.length <= INLINE_MODEL_THRESHOLD;
|
const isInline = viewKind === "provider";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View key={group.providerId}>
|
<View key={group.providerId}>
|
||||||
{index > 0 ? <View style={styles.separator} /> : null}
|
{index > 0 ? <View style={styles.separator} /> : null}
|
||||||
{isInline ? (
|
{isInline ? (
|
||||||
<>
|
<>
|
||||||
<View style={styles.sectionHeading}>
|
{sortFavoritesFirst(group.rows, favoriteKeys).map((row) => (
|
||||||
<Text style={styles.sectionHeadingText}>
|
|
||||||
{providerDefinition?.label ?? group.providerLabel}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{group.rows.map((row) => (
|
|
||||||
<ModelRow
|
<ModelRow
|
||||||
key={row.favoriteKey}
|
key={row.favoriteKey}
|
||||||
row={row}
|
row={row}
|
||||||
@@ -309,11 +328,13 @@ function GroupedProviderRows({
|
|||||||
pressed && styles.drillDownRowPressed,
|
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>
|
<Text style={styles.drillDownText}>{group.providerLabel}</Text>
|
||||||
<View style={styles.drillDownTrailing}>
|
<View style={styles.drillDownTrailing}>
|
||||||
<Text style={styles.drillDownCount}>{group.rows.length}</Text>
|
<Text style={styles.drillDownCount}>
|
||||||
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
|
{group.rows.length} {group.rows.length === 1 ? "model" : "models"}
|
||||||
|
</Text>
|
||||||
|
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</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({
|
function SelectorContent({
|
||||||
view,
|
view,
|
||||||
providerDefinitions,
|
providerDefinitions,
|
||||||
@@ -337,8 +398,8 @@ function SelectorContent({
|
|||||||
canSelectProvider,
|
canSelectProvider,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
onDrillDown,
|
onDrillDown,
|
||||||
onBack,
|
|
||||||
}: SelectorContentProps) {
|
}: SelectorContentProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
const allRows = useMemo(
|
const allRows = useMemo(
|
||||||
() => buildModelRows(providerDefinitions, allProviderModels),
|
() => buildModelRows(providerDefinitions, allProviderModels),
|
||||||
[allProviderModels, providerDefinitions],
|
[allProviderModels, providerDefinitions],
|
||||||
@@ -363,35 +424,41 @@ function SelectorContent({
|
|||||||
[favoriteKeys, visibleRows],
|
[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 (
|
return (
|
||||||
<View>
|
<View>
|
||||||
{view.kind === "provider" ? (
|
{view.kind === "all" ? (
|
||||||
<ProviderBackButton providerId={view.providerId} providerLabel={view.providerLabel} onBack={onBack} />
|
<FavoritesSection
|
||||||
|
favoriteRows={favoriteRows}
|
||||||
|
selectedProvider={selectedProvider}
|
||||||
|
selectedModel={selectedModel}
|
||||||
|
favoriteKeys={favoriteKeys}
|
||||||
|
onSelect={onSelect}
|
||||||
|
canSelectProvider={canSelectProvider}
|
||||||
|
onToggleFavorite={onToggleFavorite}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<SearchInput
|
{filteredGroupedRows.length > 0 ? (
|
||||||
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 ? (
|
|
||||||
<GroupedProviderRows
|
<GroupedProviderRows
|
||||||
providerDefinitions={providerDefinitions}
|
providerDefinitions={providerDefinitions}
|
||||||
groupedRows={groupedRegularRows}
|
groupedRows={filteredGroupedRows}
|
||||||
selectedProvider={selectedProvider}
|
selectedProvider={selectedProvider}
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
favoriteKeys={favoriteKeys}
|
favoriteKeys={favoriteKeys}
|
||||||
@@ -399,12 +466,13 @@ function SelectorContent({
|
|||||||
canSelectProvider={canSelectProvider}
|
canSelectProvider={canSelectProvider}
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
onDrillDown={onDrillDown}
|
onDrillDown={onDrillDown}
|
||||||
|
viewKind={view.kind}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{favoriteRows.length === 0 && groupedRegularRows.length === 0 ? (
|
{!hasResults ? (
|
||||||
<View style={styles.emptyState}>
|
<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>
|
<Text style={styles.emptyStateText}>No models match your search</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -437,8 +505,8 @@ function ProviderBackButton({
|
|||||||
pressed && styles.backButtonPressed,
|
pressed && styles.backButtonPressed,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<ArrowLeft size={14} color={theme.colors.foregroundMuted} />
|
<ArrowLeft size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
<ProviderIcon size={14} color={theme.colors.foregroundMuted} />
|
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.backButtonText}>{providerLabel}</Text>
|
<Text style={styles.backButtonText}>{providerLabel}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
@@ -455,6 +523,7 @@ export function CombinedModelSelector({
|
|||||||
favoriteKeys = new Set<string>(),
|
favoriteKeys = new Set<string>(),
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
renderTrigger,
|
renderTrigger,
|
||||||
|
onClose,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: CombinedModelSelectorProps) {
|
}: CombinedModelSelectorProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
@@ -465,25 +534,35 @@ export function CombinedModelSelector({
|
|||||||
const [view, setView] = useState<SelectorView>({ kind: "all" });
|
const [view, setView] = useState<SelectorView>({ kind: "all" });
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
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(
|
const handleOpenChange = useCallback(
|
||||||
(open: boolean) => {
|
(open: boolean) => {
|
||||||
setIsOpen(open);
|
setIsOpen(open);
|
||||||
setView({ kind: "all" });
|
setView(singleProviderView ?? { kind: "all" });
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
|
onClose?.();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[onClose, singleProviderView],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(provider: string, modelId: string) => {
|
(provider: string, modelId: string) => {
|
||||||
onSelect(provider as AgentProvider, modelId);
|
onSelect(provider as AgentProvider, modelId);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setView({ kind: "all" });
|
setView(singleProviderView ?? { kind: "all" });
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
},
|
},
|
||||||
[onSelect],
|
[onSelect, singleProviderView],
|
||||||
);
|
);
|
||||||
|
|
||||||
const ProviderIcon = getProviderIcon(selectedProvider);
|
const ProviderIcon = getProviderIcon(selectedProvider);
|
||||||
@@ -501,6 +580,15 @@ export function CombinedModelSelector({
|
|||||||
return model?.label ?? resolveDefaultModelLabel(models);
|
return model?.label ?? resolveDefaultModelLabel(models);
|
||||||
}, [allProviderModels, isLoading, selectedModel, selectedProvider]);
|
}, [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(() => {
|
const triggerLabel = useMemo(() => {
|
||||||
if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") {
|
if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") {
|
||||||
return selectedModelLabel;
|
return selectedModelLabel;
|
||||||
@@ -568,7 +656,30 @@ export function CombinedModelSelector({
|
|||||||
stackBehavior="push"
|
stackBehavior="push"
|
||||||
anchorRef={anchorRef}
|
anchorRef={anchorRef}
|
||||||
desktopPlacement="top-start"
|
desktopPlacement="top-start"
|
||||||
|
desktopMinWidth={360}
|
||||||
|
desktopFixedHeight={desktopFixedHeight}
|
||||||
title="Select model"
|
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 ? (
|
{isContentReady ? (
|
||||||
<SelectorContent
|
<SelectorContent
|
||||||
@@ -586,13 +697,6 @@ export function CombinedModelSelector({
|
|||||||
onDrillDown={(providerId, providerLabel) => {
|
onDrillDown={(providerId, providerLabel) => {
|
||||||
setView({ kind: "provider", providerId, providerLabel });
|
setView({ kind: "provider", providerId, providerLabel });
|
||||||
}}
|
}}
|
||||||
onBack={
|
|
||||||
view.kind === "provider"
|
|
||||||
? () => {
|
|
||||||
setView({ kind: "all" });
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.sheetLoadingState}>
|
<View style={styles.sheetLoadingState}>
|
||||||
@@ -634,17 +738,22 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingVertical: 0,
|
paddingVertical: 0,
|
||||||
height: "auto",
|
height: "auto",
|
||||||
},
|
},
|
||||||
|
favoritesContainer: {
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
|
},
|
||||||
separator: {
|
separator: {
|
||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: theme.colors.border,
|
backgroundColor: theme.colors.border,
|
||||||
marginVertical: theme.spacing[1],
|
|
||||||
},
|
},
|
||||||
sectionHeading: {
|
sectionHeading: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingVertical: theme.spacing[1],
|
paddingTop: theme.spacing[2],
|
||||||
|
paddingBottom: theme.spacing[1],
|
||||||
},
|
},
|
||||||
sectionHeadingText: {
|
sectionHeadingText: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
@@ -667,8 +776,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
drillDownText: {
|
drillDownText: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.sm,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foreground,
|
||||||
},
|
},
|
||||||
drillDownTrailing: {
|
drillDownTrailing: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -679,6 +788,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
|
level2Header: {
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
|
},
|
||||||
backButton: {
|
backButton: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -689,7 +803,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderBottomColor: theme.colors.border,
|
borderBottomColor: theme.colors.border,
|
||||||
},
|
},
|
||||||
backButtonHovered: {
|
backButtonHovered: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
backButtonPressed: {
|
backButtonPressed: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
@@ -734,4 +848,16 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -73,6 +73,12 @@ export interface ComboboxProps {
|
|||||||
* for that combobox instance to avoid animation overriding hidden opacity.
|
* for that combobox instance to avoid animation overriding hidden opacity.
|
||||||
*/
|
*/
|
||||||
desktopPreventInitialFlash?: boolean;
|
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>;
|
anchorRef: React.RefObject<View | null>;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -150,6 +156,8 @@ export interface ComboboxItemProps {
|
|||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** When true, bumps hover/pressed colors up one surface level (for items on elevated backgrounds). */
|
||||||
|
elevated?: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
}
|
}
|
||||||
@@ -163,6 +171,7 @@ export function ComboboxItem({
|
|||||||
selected,
|
selected,
|
||||||
active,
|
active,
|
||||||
disabled,
|
disabled,
|
||||||
|
elevated,
|
||||||
onPress,
|
onPress,
|
||||||
testID,
|
testID,
|
||||||
}: ComboboxItemProps): ReactElement {
|
}: ComboboxItemProps): ReactElement {
|
||||||
@@ -187,8 +196,8 @@ export function ComboboxItem({
|
|||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
style={({ pressed, hovered = false }) => [
|
style={({ pressed, hovered = false }) => [
|
||||||
styles.comboboxItem,
|
styles.comboboxItem,
|
||||||
hovered && styles.comboboxItemHovered,
|
hovered && (elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
|
||||||
pressed && styles.comboboxItemPressed,
|
pressed && (elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
|
||||||
active && styles.comboboxItemActive,
|
active && styles.comboboxItemActive,
|
||||||
disabled && styles.comboboxItemDisabled,
|
disabled && styles.comboboxItemDisabled,
|
||||||
]}
|
]}
|
||||||
@@ -246,6 +255,9 @@ export function Combobox({
|
|||||||
stackBehavior,
|
stackBehavior,
|
||||||
desktopPlacement = "top-start",
|
desktopPlacement = "top-start",
|
||||||
desktopPreventInitialFlash = true,
|
desktopPreventInitialFlash = true,
|
||||||
|
desktopMinWidth,
|
||||||
|
desktopFixedHeight,
|
||||||
|
stickyHeader,
|
||||||
anchorRef,
|
anchorRef,
|
||||||
children,
|
children,
|
||||||
}: ComboboxProps): ReactElement {
|
}: ComboboxProps): ReactElement {
|
||||||
@@ -390,12 +402,27 @@ export function Combobox({
|
|||||||
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin);
|
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin);
|
||||||
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
|
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
|
||||||
const shouldUseDesktopFade = !desktopPreventInitialFlash;
|
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
|
const desktopPositionStyle = isDesktopAboveSearch
|
||||||
? {
|
? {
|
||||||
left: floatingLeft ?? 0,
|
left: floatingLeft ?? 0,
|
||||||
bottom: desktopAboveSearchBottom ?? 0,
|
bottom: desktopAboveSearchBottom ?? 0,
|
||||||
}
|
}
|
||||||
: floatingStyles;
|
: useStableBottom
|
||||||
|
? {
|
||||||
|
left: floatingLeft ?? 0,
|
||||||
|
bottom: Math.max(windowHeight - referenceTop!, collisionPadding),
|
||||||
|
}
|
||||||
|
: floatingStyles;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMobile) return;
|
if (!isMobile) return;
|
||||||
@@ -662,6 +689,7 @@ export function Combobox({
|
|||||||
<View style={styles.bottomSheetHeader}>
|
<View style={styles.bottomSheetHeader}>
|
||||||
<Text style={styles.comboboxTitle}>{title}</Text>
|
<Text style={styles.comboboxTitle}>{title}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
{stickyHeader}
|
||||||
<BottomSheetScrollView
|
<BottomSheetScrollView
|
||||||
contentContainerStyle={styles.comboboxScrollContent}
|
contentContainerStyle={styles.comboboxScrollContent}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
@@ -687,13 +715,16 @@ export function Combobox({
|
|||||||
styles.desktopContainer,
|
styles.desktopContainer,
|
||||||
{
|
{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
minWidth: referenceWidth ?? 200,
|
minWidth: desktopMinWidth ?? referenceWidth ?? 200,
|
||||||
maxWidth: 400,
|
maxWidth: Math.max(400, desktopMinWidth ?? 0),
|
||||||
},
|
},
|
||||||
|
desktopFixedHeight != null
|
||||||
|
? { minHeight: desktopFixedHeight, maxHeight: desktopFixedHeight }
|
||||||
|
: null,
|
||||||
desktopPositionStyle,
|
desktopPositionStyle,
|
||||||
shouldHideDesktopContent ? { opacity: 0 } : null,
|
shouldHideDesktopContent ? { opacity: 0 } : null,
|
||||||
typeof availableSize?.height === "number"
|
typeof availableSize?.height === "number"
|
||||||
? { maxHeight: Math.min(availableSize.height, 400) }
|
? { maxHeight: Math.min(availableSize.height, desktopFixedHeight ?? 400) }
|
||||||
: null,
|
: null,
|
||||||
]}
|
]}
|
||||||
ref={refs.setFloating}
|
ref={refs.setFloating}
|
||||||
@@ -701,14 +732,17 @@ export function Combobox({
|
|||||||
onLayout={() => update()}
|
onLayout={() => update()}
|
||||||
>
|
>
|
||||||
{children ? (
|
{children ? (
|
||||||
<ScrollView
|
<>
|
||||||
contentContainerStyle={styles.desktopScrollContent}
|
{stickyHeader}
|
||||||
keyboardShouldPersistTaps="handled"
|
<ScrollView
|
||||||
showsVerticalScrollIndicator={false}
|
contentContainerStyle={styles.desktopChildrenScrollContent}
|
||||||
style={styles.desktopScroll}
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
showsVerticalScrollIndicator={false}
|
||||||
{content}
|
style={styles.desktopScroll}
|
||||||
</ScrollView>
|
>
|
||||||
|
{content}
|
||||||
|
</ScrollView>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{effectiveOptionsPosition === "above-search" ? (
|
{effectiveOptionsPosition === "above-search" ? (
|
||||||
@@ -783,9 +817,15 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
comboboxItemHovered: {
|
comboboxItemHovered: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
|
comboboxItemHoveredElevated: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
comboboxItemPressed: {
|
comboboxItemPressed: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
|
comboboxItemPressedElevated: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
comboboxItemActive: {
|
comboboxItemActive: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
@@ -876,6 +916,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
desktopScrollContent: {
|
desktopScrollContent: {
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
},
|
},
|
||||||
|
desktopChildrenScrollContent: {
|
||||||
|
// No padding — custom children (e.g. model selector) control their own spacing
|
||||||
|
},
|
||||||
desktopScrollContentAboveSearch: {
|
desktopScrollContentAboveSearch: {
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
justifyContent: "flex-end",
|
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,
|
hostname: serverInfo.hostname,
|
||||||
version: serverInfo.version,
|
version: serverInfo.version,
|
||||||
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
|
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
|
||||||
|
...(serverInfo.features ? { features: serverInfo.features } : {}),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useQuery, useQueries } from "@tanstack/react-query";
|
import { useQuery, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
AGENT_PROVIDER_DEFINITIONS,
|
AGENT_PROVIDER_DEFINITIONS,
|
||||||
type AgentProviderDefinition,
|
type AgentProviderDefinition,
|
||||||
@@ -8,9 +8,11 @@ import type {
|
|||||||
AgentMode,
|
AgentMode,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
|
ProviderSnapshotEntry,
|
||||||
} from "@server/server/agent/agent-sdk-types";
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
|
import { useSessionForServer } from "./use-session-directory";
|
||||||
import {
|
import {
|
||||||
useFormPreferences,
|
useFormPreferences,
|
||||||
mergeProviderPreferences,
|
mergeProviderPreferences,
|
||||||
@@ -84,6 +86,7 @@ type UseAgentFormStateResult = {
|
|||||||
providerDefinitions: AgentProviderDefinition[];
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
providerDefinitionMap: Map<AgentProvider, AgentProviderDefinition>;
|
providerDefinitionMap: Map<AgentProvider, AgentProviderDefinition>;
|
||||||
agentDefinition?: AgentProviderDefinition;
|
agentDefinition?: AgentProviderDefinition;
|
||||||
|
allProviderEntries?: ProviderSnapshotEntry[];
|
||||||
modeOptions: AgentMode[];
|
modeOptions: AgentMode[];
|
||||||
availableModels: AgentModelDefinition[];
|
availableModels: AgentModelDefinition[];
|
||||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||||
@@ -319,6 +322,10 @@ function combineInitialValues(
|
|||||||
return initialValues;
|
return initialValues;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function providersSnapshotQueryKey(serverId: string | null, cwd: string | undefined) {
|
||||||
|
return ["providersSnapshot", serverId, cwd ?? ""] as const;
|
||||||
|
}
|
||||||
|
|
||||||
export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult {
|
export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult {
|
||||||
const {
|
const {
|
||||||
initialServerId = null,
|
initialServerId = null,
|
||||||
@@ -336,6 +343,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
} = useFormPreferences();
|
} = useFormPreferences();
|
||||||
|
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Build a set of valid server IDs for preference validation
|
// Build a set of valid server IDs for preference validation
|
||||||
const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]);
|
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
|
// Session state for provider model listing
|
||||||
const client = useHostRuntimeClient(formState.serverId ?? "");
|
const client = useHostRuntimeClient(formState.serverId ?? "");
|
||||||
const isConnected = useHostRuntimeIsConnected(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({
|
const availableProvidersQuery = useQuery({
|
||||||
queryKey: ["availableProviders", formState.serverId],
|
queryKey: ["availableProviders", formState.serverId],
|
||||||
enabled: Boolean(
|
enabled: Boolean(
|
||||||
isVisible && isTargetDaemonReady && formState.serverId && client && isConnected,
|
!supportsProvidersSnapshot &&
|
||||||
|
isVisible &&
|
||||||
|
isTargetDaemonReady &&
|
||||||
|
formState.serverId &&
|
||||||
|
client &&
|
||||||
|
isConnected,
|
||||||
),
|
),
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -389,43 +524,33 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
return payload.providers.filter((entry) => entry.available).map((entry) => entry.provider);
|
return payload.providers.filter((entry) => entry.available).map((entry) => entry.provider);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const legacyProviderDefinitions = useMemo(() => {
|
||||||
const providerDefinitions = useMemo(() => {
|
|
||||||
const availableProviders = availableProvidersQuery.data;
|
const availableProviders = availableProvidersQuery.data;
|
||||||
if (!availableProviders) {
|
if (!availableProviders) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const available = new Set(availableProviders);
|
const available = new Set(availableProviders);
|
||||||
return allProviderDefinitions.filter((definition) =>
|
return allProviderDefinitions.filter((definition) => available.has(definition.id));
|
||||||
available.has(definition.id as AgentProvider),
|
|
||||||
);
|
|
||||||
}, [availableProvidersQuery.data]);
|
}, [availableProvidersQuery.data]);
|
||||||
|
const legacyProviderDefinitionMap = useMemo(
|
||||||
const providerDefinitionMap = useMemo(
|
|
||||||
() =>
|
() =>
|
||||||
new Map<AgentProvider, AgentProviderDefinition>(
|
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);
|
// COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots
|
||||||
useEffect(() => {
|
const legacySelectedProviderModelsQuery = useQuery({
|
||||||
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({
|
|
||||||
queryKey: ["providerModels", formState.serverId, formState.provider],
|
queryKey: ["providerModels", formState.serverId, formState.provider],
|
||||||
enabled: Boolean(
|
enabled: Boolean(
|
||||||
|
!supportsProvidersSnapshot &&
|
||||||
isVisible &&
|
isVisible &&
|
||||||
isTargetDaemonReady &&
|
isTargetDaemonReady &&
|
||||||
formState.serverId &&
|
formState.serverId &&
|
||||||
client &&
|
client &&
|
||||||
isConnected &&
|
isConnected &&
|
||||||
providerDefinitionMap.has(formState.provider),
|
legacyProviderDefinitionMap.has(formState.provider),
|
||||||
),
|
),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -441,18 +566,19 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
return payload.models ?? [];
|
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({
|
const providerModesQuery = useQuery({
|
||||||
queryKey: ["providerModes", formState.serverId, formState.provider, debouncedCwd],
|
queryKey: ["providerModes", formState.serverId, formState.provider, debouncedCwd],
|
||||||
enabled: Boolean(
|
enabled: Boolean(
|
||||||
isVisible &&
|
!supportsProvidersSnapshot &&
|
||||||
|
isVisible &&
|
||||||
isTargetDaemonReady &&
|
isTargetDaemonReady &&
|
||||||
formState.serverId &&
|
formState.serverId &&
|
||||||
client &&
|
client &&
|
||||||
isConnected &&
|
isConnected &&
|
||||||
providerDefinitionMap.has(formState.provider),
|
legacyProviderDefinitionMap.has(formState.provider),
|
||||||
),
|
),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
queryFn: async () => {
|
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({
|
const allProviderModelQueries = useQueries({
|
||||||
queries: providerDefinitions.map((def) => ({
|
queries: (supportsProvidersSnapshot ? [] : legacyProviderDefinitions).map((def) => ({
|
||||||
queryKey: ["providerModels", formState.serverId, def.id],
|
queryKey: ["providerModels", formState.serverId, def.id],
|
||||||
enabled: Boolean(
|
enabled: Boolean(
|
||||||
isVisible && isTargetDaemonReady && formState.serverId && client && isConnected,
|
isVisible && isTargetDaemonReady && formState.serverId && client && isConnected,
|
||||||
@@ -490,19 +617,40 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
const legacyAllProviderModels = useMemo(() => {
|
||||||
const allProviderModels = useMemo(() => {
|
|
||||||
const map = new Map<string, AgentModelDefinition[]>();
|
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];
|
const query = allProviderModelQueries[i];
|
||||||
if (query?.data) {
|
if (query?.data) {
|
||||||
map.set(providerDefinitions[i]!.id, query.data);
|
map.set(legacyProviderDefinitions[i]!.id, query.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map;
|
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
|
// Combine initialValues with initialServerId for resolution
|
||||||
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
|
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
|
||||||
@@ -527,7 +675,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
userModified,
|
userModified,
|
||||||
formStateRef.current,
|
formStateRef.current,
|
||||||
validServerIds,
|
validServerIds,
|
||||||
providerDefinitionMap,
|
selectableProviderDefinitionMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only update if something changed
|
// Only update if something changed
|
||||||
@@ -552,7 +700,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
availableModels,
|
availableModels,
|
||||||
userModified,
|
userModified,
|
||||||
validServerIds,
|
validServerIds,
|
||||||
providerDefinitionMap,
|
selectableProviderDefinitionMap,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Auto-select the first online host when:
|
// Auto-select the first online host when:
|
||||||
@@ -592,8 +740,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
|
|
||||||
const setProviderFromUser = useCallback(
|
const setProviderFromUser = useCallback(
|
||||||
(provider: AgentProvider) => {
|
(provider: AgentProvider) => {
|
||||||
|
if (!selectableProviderDefinitionMap.has(provider)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const providerModels = allProviderModels.get(provider) ?? null;
|
const providerModels = allProviderModels.get(provider) ?? null;
|
||||||
const providerDef = providerDefinitionMap.get(provider);
|
const providerDef = selectableProviderDefinitionMap.get(provider);
|
||||||
const providerPrefs = preferences?.providerPreferences?.[provider];
|
const providerPrefs = preferences?.providerPreferences?.[provider];
|
||||||
|
|
||||||
const isValidModel = (m: string) =>
|
const isValidModel = (m: string) =>
|
||||||
@@ -631,12 +782,20 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
thinkingOptionId: nextThinkingOptionId,
|
thinkingOptionId: nextThinkingOptionId,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
[allProviderModels, preferences?.providerPreferences, providerDefinitionMap, updatePreferences],
|
[
|
||||||
|
allProviderModels,
|
||||||
|
preferences?.providerPreferences,
|
||||||
|
selectableProviderDefinitionMap,
|
||||||
|
updatePreferences,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setProviderAndModelFromUser = useCallback(
|
const setProviderAndModelFromUser = useCallback(
|
||||||
(provider: AgentProvider, modelId: string) => {
|
(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 providerModels = allProviderModels.get(provider) ?? null;
|
||||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||||
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
|
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
|
||||||
@@ -656,7 +815,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
|
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
|
||||||
void updatePreferences({ provider });
|
void updatePreferences({ provider });
|
||||||
},
|
},
|
||||||
[allProviderModels, providerDefinitionMap, updatePreferences],
|
[allProviderModels, selectableProviderDefinitionMap, updatePreferences],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setModeFromUser = useCallback(
|
const setModeFromUser = useCallback(
|
||||||
@@ -713,8 +872,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refreshProviderModels = useCallback(() => {
|
const refreshProviderModels = useCallback(() => {
|
||||||
void providerModelsQuery.refetch();
|
if (supportsProvidersSnapshot) {
|
||||||
}, [providerModelsQuery]);
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void client.refreshProvidersSnapshot({ cwd: debouncedCwd });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void legacySelectedProviderModelsQuery.refetch();
|
||||||
|
}, [client, debouncedCwd, legacySelectedProviderModelsQuery, supportsProvidersSnapshot]);
|
||||||
|
|
||||||
const persistFormPreferences = useCallback(async () => {
|
const persistFormPreferences = useCallback(async () => {
|
||||||
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
||||||
@@ -747,13 +913,20 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const agentDefinition = providerDefinitionMap.get(formState.provider);
|
const agentDefinition = providerDefinitionMap.get(formState.provider);
|
||||||
const modeOptions = providerModesQuery.data ?? agentDefinition?.modes ?? [];
|
|
||||||
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
||||||
const resolvedModelId = effectiveModel?.id ?? formState.model;
|
const resolvedModelId = effectiveModel?.id ?? formState.model;
|
||||||
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||||
const isModelLoading = providerModelsQuery.isLoading || providerModelsQuery.isFetching;
|
const isModelLoading = supportsProvidersSnapshot
|
||||||
|
? providersSnapshotQuery.isLoading || providersSnapshotQuery.isFetching
|
||||||
|
: legacySelectedProviderModelsQuery.isLoading || legacySelectedProviderModelsQuery.isFetching;
|
||||||
const modelError =
|
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();
|
const workingDirIsEmpty = !formState.workingDir.trim();
|
||||||
|
|
||||||
@@ -776,6 +949,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
providerDefinitions,
|
providerDefinitions,
|
||||||
providerDefinitionMap,
|
providerDefinitionMap,
|
||||||
agentDefinition,
|
agentDefinition,
|
||||||
|
allProviderEntries,
|
||||||
modeOptions,
|
modeOptions,
|
||||||
availableModels: availableModels ?? [],
|
availableModels: availableModels ?? [],
|
||||||
allProviderModels,
|
allProviderModels,
|
||||||
@@ -806,6 +980,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
providerDefinitions,
|
providerDefinitions,
|
||||||
providerDefinitionMap,
|
providerDefinitionMap,
|
||||||
agentDefinition,
|
agentDefinition,
|
||||||
|
allProviderEntries,
|
||||||
modeOptions,
|
modeOptions,
|
||||||
availableModels,
|
availableModels,
|
||||||
allProviderModels,
|
allProviderModels,
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ function DraftAgentScreenContent({
|
|||||||
modeOptions,
|
modeOptions,
|
||||||
availableModels,
|
availableModels,
|
||||||
allProviderModels,
|
allProviderModels,
|
||||||
|
allProviderEntries,
|
||||||
isAllModelsLoading,
|
isAllModelsLoading,
|
||||||
availableThinkingOptions,
|
availableThinkingOptions,
|
||||||
isModelLoading,
|
isModelLoading,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
Shield,
|
Shield,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
|
Blocks,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||||
import type { HostProfile, HostConnection } from "@/types/host-connection";
|
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 { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
||||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||||
import { isCompactFormFactor } from "@/constants/layout";
|
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
|
// Section definitions
|
||||||
@@ -72,6 +78,7 @@ type SettingsSectionId =
|
|||||||
| "appearance"
|
| "appearance"
|
||||||
| "shortcuts"
|
| "shortcuts"
|
||||||
| "integrations"
|
| "integrations"
|
||||||
|
| "providers"
|
||||||
| "diagnostics"
|
| "diagnostics"
|
||||||
| "about"
|
| "about"
|
||||||
| "permissions"
|
| "permissions"
|
||||||
@@ -99,6 +106,7 @@ function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectio
|
|||||||
}
|
}
|
||||||
|
|
||||||
sections.push(
|
sections.push(
|
||||||
|
{ id: "providers", label: "Providers", icon: Blocks },
|
||||||
{ id: "diagnostics", label: "Diagnostics", icon: Stethoscope },
|
{ id: "diagnostics", label: "Diagnostics", icon: Stethoscope },
|
||||||
{ id: "about", label: "About", icon: Info },
|
{ 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 {
|
interface DiagnosticsSectionProps {
|
||||||
voiceAudioEngine: ReturnType<typeof useVoiceAudioEngineOptional>;
|
voiceAudioEngine: ReturnType<typeof useVoiceAudioEngineOptional>;
|
||||||
isPlaybackTestRunning: boolean;
|
isPlaybackTestRunning: boolean;
|
||||||
@@ -486,6 +604,7 @@ interface SettingsSectionContentProps {
|
|||||||
sectionId: SettingsSectionId;
|
sectionId: SettingsSectionId;
|
||||||
hostsProps: HostsSectionProps;
|
hostsProps: HostsSectionProps;
|
||||||
appearanceProps: AppearanceSectionProps;
|
appearanceProps: AppearanceSectionProps;
|
||||||
|
providersProps: ProvidersSectionProps;
|
||||||
diagnosticsProps: DiagnosticsSectionProps;
|
diagnosticsProps: DiagnosticsSectionProps;
|
||||||
aboutProps: AboutSectionProps;
|
aboutProps: AboutSectionProps;
|
||||||
appVersion: string | null;
|
appVersion: string | null;
|
||||||
@@ -497,6 +616,7 @@ function SettingsSectionContent({
|
|||||||
sectionId,
|
sectionId,
|
||||||
hostsProps,
|
hostsProps,
|
||||||
appearanceProps,
|
appearanceProps,
|
||||||
|
providersProps,
|
||||||
diagnosticsProps,
|
diagnosticsProps,
|
||||||
aboutProps,
|
aboutProps,
|
||||||
appVersion,
|
appVersion,
|
||||||
@@ -510,6 +630,8 @@ function SettingsSectionContent({
|
|||||||
return <AppearanceSection {...appearanceProps} />;
|
return <AppearanceSection {...appearanceProps} />;
|
||||||
case "shortcuts":
|
case "shortcuts":
|
||||||
return <KeyboardShortcutsSection />;
|
return <KeyboardShortcutsSection />;
|
||||||
|
case "providers":
|
||||||
|
return <ProvidersSection {...providersProps} />;
|
||||||
case "diagnostics":
|
case "diagnostics":
|
||||||
return <DiagnosticsSection {...diagnosticsProps} />;
|
return <DiagnosticsSection {...diagnosticsProps} />;
|
||||||
case "about":
|
case "about":
|
||||||
@@ -567,7 +689,7 @@ function SettingsDesktopLayout({ sections, sectionContentProps }: SettingsLayout
|
|||||||
const isSelected = section.id === selectedSectionId;
|
const isSelected = section.id === selectedSectionId;
|
||||||
const IconComponent = section.icon;
|
const IconComponent = section.icon;
|
||||||
const showSeparator =
|
const showSeparator =
|
||||||
section.id === "integrations" || section.id === "diagnostics";
|
section.id === "integrations" || section.id === "providers";
|
||||||
return (
|
return (
|
||||||
<View key={section.id}>
|
<View key={section.id}>
|
||||||
{showSeparator ? <View style={desktopStyles.sidebarSeparator} /> : null}
|
{showSeparator ? <View style={desktopStyles.sidebarSeparator} /> : null}
|
||||||
@@ -951,9 +1073,14 @@ export default function SettingsScreen() {
|
|||||||
isDesktopApp,
|
isDesktopApp,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const providersProps: ProvidersSectionProps = {
|
||||||
|
routeServerId,
|
||||||
|
};
|
||||||
|
|
||||||
const sectionContentProps: Omit<SettingsSectionContentProps, "sectionId"> = {
|
const sectionContentProps: Omit<SettingsSectionContentProps, "sectionId"> = {
|
||||||
hostsProps,
|
hostsProps,
|
||||||
appearanceProps,
|
appearanceProps,
|
||||||
|
providersProps,
|
||||||
diagnosticsProps,
|
diagnosticsProps,
|
||||||
aboutProps,
|
aboutProps,
|
||||||
appVersion,
|
appVersion,
|
||||||
@@ -1761,6 +1888,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
},
|
},
|
||||||
|
providerActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
aboutValue: {
|
aboutValue: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
modeOptions,
|
modeOptions,
|
||||||
availableModels,
|
availableModels,
|
||||||
allProviderModels,
|
allProviderModels,
|
||||||
|
allProviderEntries,
|
||||||
isAllModelsLoading,
|
isAllModelsLoading,
|
||||||
availableThinkingOptions,
|
availableThinkingOptions,
|
||||||
isModelLoading,
|
isModelLoading,
|
||||||
@@ -250,6 +251,60 @@ export function WorkspaceDraftAgentTab({
|
|||||||
addImagesRef.current = addImages;
|
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 (
|
return (
|
||||||
<FileDropZone onFilesDropped={handleFilesDropped}>
|
<FileDropZone onFilesDropped={handleFilesDropped}>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -296,26 +351,28 @@ export function WorkspaceDraftAgentTab({
|
|||||||
clearDraft={draftInput.clear}
|
clearDraft={draftInput.clear}
|
||||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||||
onAddImages={handleAddImagesCallback}
|
onAddImages={handleAddImagesCallback}
|
||||||
|
onFocusInput={handleFocusInputCallback}
|
||||||
commandDraftConfig={draftCommandConfig}
|
commandDraftConfig={draftCommandConfig}
|
||||||
statusControls={{
|
statusControls={{
|
||||||
providerDefinitions,
|
providerDefinitions,
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
onSelectProvider: setProviderFromUser,
|
onSelectProvider: handleProviderSelectWithFocus,
|
||||||
modeOptions,
|
modeOptions,
|
||||||
selectedMode,
|
selectedMode,
|
||||||
onSelectMode: setModeFromUser,
|
onSelectMode: handleModeSelectWithFocus,
|
||||||
models: availableModels,
|
models: availableModels,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
onSelectModel: setModelFromUser,
|
onSelectModel: handleModelSelectWithFocus,
|
||||||
isModelLoading,
|
isModelLoading,
|
||||||
allProviderModels,
|
allProviderModels,
|
||||||
isAllModelsLoading,
|
isAllModelsLoading,
|
||||||
onSelectProviderAndModel: setProviderAndModelFromUser,
|
onSelectProviderAndModel: handleProviderAndModelSelectWithFocus,
|
||||||
thinkingOptions: availableThinkingOptions,
|
thinkingOptions: availableThinkingOptions,
|
||||||
selectedThinkingOptionId,
|
selectedThinkingOptionId,
|
||||||
onSelectThinkingOption: setThinkingOptionFromUser,
|
onSelectThinkingOption: handleThinkingOptionSelectWithFocus,
|
||||||
features: draftFeatures,
|
features: draftFeatures,
|
||||||
onSetFeature: setDraftFeatureValue,
|
onSetFeature: handleSetFeatureWithFocus,
|
||||||
|
onDropdownClose: () => focusInputRef.current?.(),
|
||||||
disabled: isSubmitting,
|
disabled: isSubmitting,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
FileDownloadTokenResponse,
|
FileDownloadTokenResponse,
|
||||||
GitSetupOptions,
|
GitSetupOptions,
|
||||||
|
ServerInfoStatusPayload,
|
||||||
ProjectPlacementPayload,
|
ProjectPlacementPayload,
|
||||||
ServerCapabilities,
|
ServerCapabilities,
|
||||||
WorkspaceDescriptorPayload,
|
WorkspaceDescriptorPayload,
|
||||||
@@ -190,6 +191,7 @@ export type DaemonServerInfo = {
|
|||||||
hostname: string | null;
|
hostname: string | null;
|
||||||
version: string | null;
|
version: string | null;
|
||||||
capabilities?: ServerCapabilities;
|
capabilities?: ServerCapabilities;
|
||||||
|
features?: ServerInfoStatusPayload["features"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AgentTimelineCursorState {
|
export interface AgentTimelineCursorState {
|
||||||
@@ -412,6 +414,13 @@ function areServerCapabilitiesEqual(
|
|||||||
return JSON.stringify(current ?? null) === JSON.stringify(next ?? null);
|
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>()(
|
export const useSessionStore = create<SessionStore>()(
|
||||||
subscribeWithSelector((set, get) => {
|
subscribeWithSelector((set, get) => {
|
||||||
const commitActivityUpdates: AgentLastActivityCommitter = (updates) => {
|
const commitActivityUpdates: AgentLastActivityCommitter = (updates) => {
|
||||||
@@ -527,12 +536,15 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
const prevVersion = session.serverInfo?.version?.trim() || null;
|
const prevVersion = session.serverInfo?.version?.trim() || null;
|
||||||
const nextCapabilities = info.capabilities;
|
const nextCapabilities = info.capabilities;
|
||||||
const prevCapabilities = session.serverInfo?.capabilities;
|
const prevCapabilities = session.serverInfo?.capabilities;
|
||||||
|
const nextFeatures = info.features;
|
||||||
|
const prevFeatures = session.serverInfo?.features;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
session.serverInfo?.serverId === info.serverId &&
|
session.serverInfo?.serverId === info.serverId &&
|
||||||
prevHostname === nextHostname &&
|
prevHostname === nextHostname &&
|
||||||
prevVersion === nextVersion &&
|
prevVersion === nextVersion &&
|
||||||
areServerCapabilitiesEqual(prevCapabilities, nextCapabilities)
|
areServerCapabilitiesEqual(prevCapabilities, nextCapabilities) &&
|
||||||
|
areServerInfoFeaturesEqual(prevFeatures, nextFeatures)
|
||||||
) {
|
) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
@@ -548,6 +560,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
hostname: nextHostname,
|
hostname: nextHostname,
|
||||||
version: nextVersion,
|
version: nextVersion,
|
||||||
...(nextCapabilities ? { capabilities: nextCapabilities } : {}),
|
...(nextCapabilities ? { capabilities: nextCapabilities } : {}),
|
||||||
|
...(nextFeatures ? { features: nextFeatures } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ import type {
|
|||||||
ListAvailableProvidersResponse,
|
ListAvailableProvidersResponse,
|
||||||
ListTerminalsResponse,
|
ListTerminalsResponse,
|
||||||
CreateTerminalResponse,
|
CreateTerminalResponse,
|
||||||
|
GetProvidersSnapshotResponseMessage,
|
||||||
|
ProviderDiagnosticResponseMessage,
|
||||||
|
RefreshProvidersSnapshotResponseMessage,
|
||||||
SubscribeTerminalResponse,
|
SubscribeTerminalResponse,
|
||||||
TerminalState,
|
TerminalState,
|
||||||
CloseItemsResponse,
|
CloseItemsResponse,
|
||||||
@@ -147,6 +150,10 @@ export type DaemonEvent =
|
|||||||
requestId: string;
|
requestId: string;
|
||||||
resolution: AgentPermissionResponse;
|
resolution: AgentPermissionResponse;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: "providers_snapshot_update";
|
||||||
|
payload: Extract<SessionOutboundMessage, { type: "providers_snapshot_update" }>["payload"];
|
||||||
|
}
|
||||||
| { type: "error"; message: string };
|
| { type: "error"; message: string };
|
||||||
|
|
||||||
export type DaemonEventHandler = (event: DaemonEvent) => void;
|
export type DaemonEventHandler = (event: DaemonEvent) => void;
|
||||||
@@ -222,6 +229,9 @@ type ListProviderFeaturesPayload = ListProviderFeaturesResponseMessage["payload"
|
|||||||
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
|
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
|
||||||
type ListProviderModesPayload = ListProviderModesResponseMessage["payload"];
|
type ListProviderModesPayload = ListProviderModesResponseMessage["payload"];
|
||||||
type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
|
type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
|
||||||
|
type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"];
|
||||||
|
type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"];
|
||||||
|
type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"];
|
||||||
type ListCommandsPayload = ListCommandsResponse["payload"];
|
type ListCommandsPayload = ListCommandsResponse["payload"];
|
||||||
type ListCommandsDraftConfig = Pick<
|
type ListCommandsDraftConfig = Pick<
|
||||||
AgentSessionConfig,
|
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, requestId?: string): Promise<ListCommandsPayload>;
|
||||||
async listCommands(agentId: string, options?: ListCommandsOptions): Promise<ListCommandsPayload>;
|
async listCommands(agentId: string, options?: ListCommandsOptions): Promise<ListCommandsPayload>;
|
||||||
async listCommands(
|
async listCommands(
|
||||||
@@ -3637,6 +3692,11 @@ export class DaemonClient {
|
|||||||
requestId: msg.payload.requestId,
|
requestId: msg.payload.requestId,
|
||||||
resolution: msg.payload.resolution,
|
resolution: msg.payload.resolution,
|
||||||
};
|
};
|
||||||
|
case "providers_snapshot_update":
|
||||||
|
return {
|
||||||
|
type: "providers_snapshot_update",
|
||||||
|
payload: msg.payload,
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export type AgentMode = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProviderStatus = "ready" | "loading" | "error" | "unavailable";
|
||||||
|
|
||||||
export type AgentModelDefinition = {
|
export type AgentModelDefinition = {
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -64,6 +66,15 @@ export type AgentSelectOption = {
|
|||||||
metadata?: AgentMetadata;
|
metadata?: AgentMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface ProviderSnapshotEntry {
|
||||||
|
provider: AgentProvider;
|
||||||
|
status: ProviderStatus;
|
||||||
|
error?: string;
|
||||||
|
models?: AgentModelDefinition[];
|
||||||
|
modes?: AgentMode[];
|
||||||
|
fetchedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type AgentFeatureToggle = {
|
export type AgentFeatureToggle = {
|
||||||
type: "toggle";
|
type: "toggle";
|
||||||
id: string;
|
id: string;
|
||||||
@@ -468,4 +479,5 @@ export interface AgentClient {
|
|||||||
* Returns true if available, false otherwise.
|
* Returns true if available, false otherwise.
|
||||||
*/
|
*/
|
||||||
isAvailable(): Promise<boolean>;
|
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 { randomUUID } from "node:crypto";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { promises } from "node:fs";
|
import { promises } from "node:fs";
|
||||||
@@ -39,6 +39,12 @@ import {
|
|||||||
} from "./claude/claude-models.js";
|
} from "./claude/claude-models.js";
|
||||||
import { parsePartialJsonObject } from "./claude/partial-json.js";
|
import { parsePartialJsonObject } from "./claude/partial-json.js";
|
||||||
import { ClaudeSidechainTracker } from "./claude/sidechain-tracker.js";
|
import { ClaudeSidechainTracker } from "./claude/sidechain-tracker.js";
|
||||||
|
import {
|
||||||
|
formatDiagnosticStatus,
|
||||||
|
formatProviderDiagnostic,
|
||||||
|
formatProviderDiagnosticError,
|
||||||
|
toDiagnosticErrorMessage,
|
||||||
|
} from "./diagnostic-utils.js";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
@@ -1086,6 +1092,42 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
return true;
|
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 {
|
private assertConfig(config: AgentSessionConfig): ClaudeAgentConfig {
|
||||||
if (config.provider !== "claude") {
|
if (config.provider !== "claude") {
|
||||||
throw new Error(`ClaudeAgentClient received config for provider '${config.provider}'`);
|
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 {
|
class ClaudeAgentSession implements AgentSession {
|
||||||
readonly provider: "claude" = "claude";
|
readonly provider: "claude" = "claude";
|
||||||
readonly capabilities = CLAUDE_CAPABILITIES;
|
readonly capabilities = CLAUDE_CAPABILITIES;
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ import {
|
|||||||
} from "../../../utils/executable.js";
|
} from "../../../utils/executable.js";
|
||||||
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
||||||
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.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 DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
|
||||||
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
||||||
@@ -3947,6 +3954,49 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
}
|
}
|
||||||
return true;
|
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 = {
|
export const __codexAppServerInternals = {
|
||||||
|
|||||||
@@ -2,7 +2,15 @@ import type { Logger } from "pino";
|
|||||||
|
|
||||||
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
|
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
|
||||||
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||||
|
import { findExecutable } from "../../../utils/executable.js";
|
||||||
import { ACPAgentClient } from "./acp-agent.js";
|
import { ACPAgentClient } from "./acp-agent.js";
|
||||||
|
import {
|
||||||
|
formatDiagnosticStatus,
|
||||||
|
formatProviderDiagnostic,
|
||||||
|
formatProviderDiagnosticError,
|
||||||
|
resolveBinaryVersion,
|
||||||
|
toDiagnosticErrorMessage,
|
||||||
|
} from "./diagnostic-utils.js";
|
||||||
|
|
||||||
const COPILOT_CAPABILITIES: AgentCapabilityFlags = {
|
const COPILOT_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
supportsStreaming: true,
|
supportsStreaming: true,
|
||||||
@@ -51,4 +59,53 @@ export class CopilotACPAgentClient extends ACPAgentClient {
|
|||||||
override async isAvailable(): Promise<boolean> {
|
override async isAvailable(): Promise<boolean> {
|
||||||
return super.isAvailable();
|
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,
|
quoteWindowsCommand,
|
||||||
} from "../../../utils/executable.js";
|
} from "../../../utils/executable.js";
|
||||||
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
||||||
|
import {
|
||||||
|
formatDiagnosticStatus,
|
||||||
|
formatProviderDiagnostic,
|
||||||
|
formatProviderDiagnosticError,
|
||||||
|
resolveBinaryVersion,
|
||||||
|
toDiagnosticErrorMessage,
|
||||||
|
} from "./diagnostic-utils.js";
|
||||||
|
|
||||||
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
|
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
supportsStreaming: true,
|
supportsStreaming: true,
|
||||||
@@ -615,6 +622,63 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
return true;
|
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 {
|
private assertConfig(config: AgentSessionConfig): OpenCodeAgentConfig {
|
||||||
if (config.provider !== "opencode") {
|
if (config.provider !== "opencode") {
|
||||||
throw new Error(`OpenCodeAgentClient received config for provider '${config.provider}'`);
|
throw new Error(`OpenCodeAgentClient received config for provider '${config.provider}'`);
|
||||||
|
|||||||
@@ -29,12 +29,19 @@ import type {
|
|||||||
AgentFeatureSelect,
|
AgentFeatureSelect,
|
||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||||
import { isCommandAvailable } from "../../../utils/executable.js";
|
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
||||||
import {
|
import {
|
||||||
ACPAgentClient,
|
ACPAgentClient,
|
||||||
type ACPToolSnapshot,
|
type ACPToolSnapshot,
|
||||||
type SessionStateResponse,
|
type SessionStateResponse,
|
||||||
} from "./acp-agent.js";
|
} from "./acp-agent.js";
|
||||||
|
import {
|
||||||
|
formatDiagnosticStatus,
|
||||||
|
formatProviderDiagnostic,
|
||||||
|
formatProviderDiagnosticError,
|
||||||
|
resolveBinaryVersion,
|
||||||
|
toDiagnosticErrorMessage,
|
||||||
|
} from "./diagnostic-utils.js";
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const resolvedPiAcpPath = require.resolve("pi-acp");
|
const resolvedPiAcpPath = require.resolve("pi-acp");
|
||||||
@@ -329,4 +336,85 @@ export class PiACPAgentClient extends ACPAgentClient {
|
|||||||
existsSync(join(homedir(), ".pi", "agent", "auth.json"))
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export type AgentMcpTransportFactory = () => Promise<Transport>;
|
|||||||
import { buildProviderRegistry } from "./agent/provider-registry.js";
|
import { buildProviderRegistry } from "./agent/provider-registry.js";
|
||||||
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
|
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
|
||||||
import { AgentManager } from "./agent/agent-manager.js";
|
import { AgentManager } from "./agent/agent-manager.js";
|
||||||
|
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||||
import type {
|
import type {
|
||||||
AgentTimelineCursor,
|
AgentTimelineCursor,
|
||||||
AgentTimelineFetchDirection,
|
AgentTimelineFetchDirection,
|
||||||
@@ -101,6 +102,7 @@ import type {
|
|||||||
AgentStreamEvent,
|
AgentStreamEvent,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
AgentPersistenceHandle,
|
AgentPersistenceHandle,
|
||||||
|
ProviderSnapshotEntry,
|
||||||
} from "./agent/agent-sdk-types.js";
|
} from "./agent/agent-sdk-types.js";
|
||||||
import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
|
import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
|
||||||
import { isValidAgentProvider, AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
|
import { isValidAgentProvider, AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
|
||||||
@@ -398,6 +400,7 @@ export type SessionOptions = {
|
|||||||
stt: Resolvable<SpeechToTextProvider | null>;
|
stt: Resolvable<SpeechToTextProvider | null>;
|
||||||
tts: Resolvable<TextToSpeechProvider | null>;
|
tts: Resolvable<TextToSpeechProvider | null>;
|
||||||
terminalManager: TerminalManager | null;
|
terminalManager: TerminalManager | null;
|
||||||
|
providerSnapshotManager?: ProviderSnapshotManager;
|
||||||
voice?: {
|
voice?: {
|
||||||
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
|
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
|
||||||
turnDetection?: Resolvable<TurnDetectionProvider | null>;
|
turnDetection?: Resolvable<TurnDetectionProvider | null>;
|
||||||
@@ -594,6 +597,8 @@ export class Session {
|
|||||||
} | null = null;
|
} | null = null;
|
||||||
private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000;
|
private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000;
|
||||||
private readonly terminalManager: TerminalManager | null;
|
private readonly terminalManager: TerminalManager | null;
|
||||||
|
private readonly providerSnapshotManager: ProviderSnapshotManager | null;
|
||||||
|
private unsubscribeProviderSnapshotEvents: (() => void) | null = null;
|
||||||
private readonly subscribedTerminalDirectories = new Set<string>();
|
private readonly subscribedTerminalDirectories = new Set<string>();
|
||||||
private unsubscribeTerminalsChanged: (() => void) | null = null;
|
private unsubscribeTerminalsChanged: (() => void) | null = null;
|
||||||
private terminalExitSubscriptions: Map<string, () => void> = new Map();
|
private terminalExitSubscriptions: Map<string, () => void> = new Map();
|
||||||
@@ -646,6 +651,7 @@ export class Session {
|
|||||||
stt,
|
stt,
|
||||||
tts,
|
tts,
|
||||||
terminalManager,
|
terminalManager,
|
||||||
|
providerSnapshotManager,
|
||||||
voice,
|
voice,
|
||||||
voiceBridge,
|
voiceBridge,
|
||||||
dictation,
|
dictation,
|
||||||
@@ -671,11 +677,32 @@ export class Session {
|
|||||||
this.checkoutDiffManager = checkoutDiffManager;
|
this.checkoutDiffManager = checkoutDiffManager;
|
||||||
this.createAgentMcpTransport = createAgentMcpTransport;
|
this.createAgentMcpTransport = createAgentMcpTransport;
|
||||||
this.terminalManager = terminalManager;
|
this.terminalManager = terminalManager;
|
||||||
|
this.providerSnapshotManager = providerSnapshotManager ?? null;
|
||||||
if (this.terminalManager) {
|
if (this.terminalManager) {
|
||||||
this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) =>
|
this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) =>
|
||||||
this.handleTerminalsChanged(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.voiceAgentMcpStdio = voice?.voiceAgentMcpStdio ?? null;
|
||||||
this.resolveVoiceTurnDetection = toResolver(voice?.turnDetection ?? null);
|
this.resolveVoiceTurnDetection = toResolver(voice?.turnDetection ?? null);
|
||||||
this.registerVoiceSpeakHandler = voiceBridge?.registerVoiceSpeakHandler;
|
this.registerVoiceSpeakHandler = voiceBridge?.registerVoiceSpeakHandler;
|
||||||
@@ -1751,6 +1778,18 @@ export class Session {
|
|||||||
await this.handleListAvailableProvidersRequest(msg);
|
await this.handleListAvailableProvidersRequest(msg);
|
||||||
break;
|
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":
|
case "clear_agent_attention":
|
||||||
await this.handleClearAgentAttention(msg.agentId);
|
await this.handleClearAgentAttention(msg.agentId);
|
||||||
break;
|
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 {
|
private assertSafeGitRef(ref: string, label: string): void {
|
||||||
if (!/^[A-Za-z0-9._/-]+$/.test(ref)) {
|
if (!/^[A-Za-z0-9._/-]+$/.test(ref)) {
|
||||||
throw new Error(`Invalid ${label}: ${ref}`);
|
throw new Error(`Invalid ${label}: ${ref}`);
|
||||||
@@ -7014,6 +7120,10 @@ export class Session {
|
|||||||
this.unsubscribeAgentEvents();
|
this.unsubscribeAgentEvents();
|
||||||
this.unsubscribeAgentEvents = null;
|
this.unsubscribeAgentEvents = null;
|
||||||
}
|
}
|
||||||
|
if (this.unsubscribeProviderSnapshotEvents) {
|
||||||
|
this.unsubscribeProviderSnapshotEvents();
|
||||||
|
this.unsubscribeProviderSnapshotEvents = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Abort any ongoing operations
|
// Abort any ongoing operations
|
||||||
this.abortController.abort();
|
this.abortController.abort();
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import { isHostAllowed } from "./allowed-hosts.js";
|
|||||||
import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js";
|
import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js";
|
||||||
import type { AgentProvider } from "./agent/agent-sdk-types.js";
|
import type { AgentProvider } from "./agent/agent-sdk-types.js";
|
||||||
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.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 { PushTokenStore } from "./push/token-store.js";
|
||||||
import { PushService } from "./push/push-service.js";
|
import { PushService } from "./push/push-service.js";
|
||||||
import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.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 voiceSpeakHandlers = new Map<string, VoiceSpeakHandler>();
|
||||||
private readonly voiceCallerContexts = new Map<string, VoiceCallerContext>();
|
private readonly voiceCallerContexts = new Map<string, VoiceCallerContext>();
|
||||||
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
|
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
|
||||||
|
private readonly providerSnapshotManager: ProviderSnapshotManager;
|
||||||
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
|
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
|
||||||
private serverCapabilities: ServerCapabilities | undefined;
|
private serverCapabilities: ServerCapabilities | undefined;
|
||||||
private runtimeWindowStartedAt = Date.now();
|
private runtimeWindowStartedAt = Date.now();
|
||||||
@@ -343,6 +346,13 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
this.voice = voice ?? null;
|
this.voice = voice ?? null;
|
||||||
this.dictation = dictation ?? null;
|
this.dictation = dictation ?? null;
|
||||||
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
|
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.onLifecycleIntent = onLifecycleIntent ?? null;
|
||||||
this.serverCapabilities = buildServerCapabilities({
|
this.serverCapabilities = buildServerCapabilities({
|
||||||
readiness: this.speech?.getReadiness() ?? null,
|
readiness: this.speech?.getReadiness() ?? null,
|
||||||
@@ -496,6 +506,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(cleanupPromises);
|
await Promise.all(cleanupPromises);
|
||||||
|
this.providerSnapshotManager.destroy();
|
||||||
this.checkoutDiffManager.dispose();
|
this.checkoutDiffManager.dispose();
|
||||||
this.pendingConnections.clear();
|
this.pendingConnections.clear();
|
||||||
this.sessions.clear();
|
this.sessions.clear();
|
||||||
@@ -646,6 +657,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
stt: () => this.speech?.resolveStt() ?? null,
|
stt: () => this.speech?.resolveStt() ?? null,
|
||||||
tts: () => this.speech?.resolveTts() ?? null,
|
tts: () => this.speech?.resolveTts() ?? null,
|
||||||
terminalManager: this.terminalManager,
|
terminalManager: this.terminalManager,
|
||||||
|
providerSnapshotManager: this.providerSnapshotManager,
|
||||||
voice: {
|
voice: {
|
||||||
...(this.voice ?? {}),
|
...(this.voice ?? {}),
|
||||||
turnDetection: () => this.speech?.resolveTurnDetection() ?? null,
|
turnDetection: () => this.speech?.resolveTurnDetection() ?? null,
|
||||||
@@ -792,6 +804,10 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
hostname: getHostname(),
|
hostname: getHostname(),
|
||||||
version: this.daemonVersion,
|
version: this.daemonVersion,
|
||||||
...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}),
|
...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}),
|
||||||
|
features: {
|
||||||
|
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
|
||||||
|
providersSnapshot: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ import type {
|
|||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
AgentPermissionResponse,
|
AgentPermissionResponse,
|
||||||
AgentPersistenceHandle,
|
AgentPersistenceHandle,
|
||||||
|
ProviderSnapshotEntry,
|
||||||
|
ProviderStatus,
|
||||||
AgentRuntimeInfo,
|
AgentRuntimeInfo,
|
||||||
AgentTimelineItem,
|
AgentTimelineItem,
|
||||||
ToolCallDetail,
|
ToolCallDetail,
|
||||||
@@ -69,6 +71,13 @@ const AgentModeSchema: z.ZodType<AgentMode> = z.object({
|
|||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ProviderStatusSchema: z.ZodType<ProviderStatus> = z.enum([
|
||||||
|
"ready",
|
||||||
|
"loading",
|
||||||
|
"error",
|
||||||
|
"unavailable",
|
||||||
|
]);
|
||||||
|
|
||||||
const AgentSelectOptionSchema = z.object({
|
const AgentSelectOptionSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
label: z.string(),
|
label: z.string(),
|
||||||
@@ -114,6 +123,15 @@ const AgentModelDefinitionSchema: z.ZodType<AgentModelDefinition> = z.object({
|
|||||||
defaultThinkingOptionId: z.string().optional(),
|
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({
|
const AgentCapabilityFlagsSchema: z.ZodType<AgentCapabilityFlags> = z.object({
|
||||||
supportsStreaming: z.boolean(),
|
supportsStreaming: z.boolean(),
|
||||||
supportsSessionPersistence: z.boolean(),
|
supportsSessionPersistence: z.boolean(),
|
||||||
@@ -772,6 +790,24 @@ export const ListAvailableProvidersRequestMessageSchema = z.object({
|
|||||||
requestId: z.string(),
|
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({
|
export const ResumeAgentRequestMessageSchema = z.object({
|
||||||
type: z.literal("resume_agent_request"),
|
type: z.literal("resume_agent_request"),
|
||||||
handle: AgentPersistenceHandleSchema,
|
handle: AgentPersistenceHandleSchema,
|
||||||
@@ -1275,6 +1311,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
ListProviderModesRequestMessageSchema,
|
ListProviderModesRequestMessageSchema,
|
||||||
ListProviderFeaturesRequestMessageSchema,
|
ListProviderFeaturesRequestMessageSchema,
|
||||||
ListAvailableProvidersRequestMessageSchema,
|
ListAvailableProvidersRequestMessageSchema,
|
||||||
|
GetProvidersSnapshotRequestMessageSchema,
|
||||||
|
RefreshProvidersSnapshotRequestMessageSchema,
|
||||||
|
ProviderDiagnosticRequestMessageSchema,
|
||||||
ResumeAgentRequestMessageSchema,
|
ResumeAgentRequestMessageSchema,
|
||||||
RefreshAgentRequestMessageSchema,
|
RefreshAgentRequestMessageSchema,
|
||||||
CancelAgentRequestMessageSchema,
|
CancelAgentRequestMessageSchema,
|
||||||
@@ -1500,6 +1539,12 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
hostname: ServerInfoHostnameSchema.optional(),
|
hostname: ServerInfoHostnameSchema.optional(),
|
||||||
version: ServerInfoVersionSchema.optional(),
|
version: ServerInfoVersionSchema.optional(),
|
||||||
capabilities: ServerCapabilitiesFromUnknownSchema,
|
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()
|
.passthrough()
|
||||||
.transform((payload) => ({
|
.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({
|
const AgentSlashCommandSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
@@ -2428,6 +2512,10 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
ListProviderModesResponseMessageSchema,
|
ListProviderModesResponseMessageSchema,
|
||||||
ListProviderFeaturesResponseMessageSchema,
|
ListProviderFeaturesResponseMessageSchema,
|
||||||
ListAvailableProvidersResponseSchema,
|
ListAvailableProvidersResponseSchema,
|
||||||
|
GetProvidersSnapshotResponseMessageSchema,
|
||||||
|
ProvidersSnapshotUpdateMessageSchema,
|
||||||
|
RefreshProvidersSnapshotResponseMessageSchema,
|
||||||
|
ProviderDiagnosticResponseMessageSchema,
|
||||||
ListCommandsResponseSchema,
|
ListCommandsResponseSchema,
|
||||||
ListTerminalsResponseSchema,
|
ListTerminalsResponseSchema,
|
||||||
TerminalsChangedSchema,
|
TerminalsChangedSchema,
|
||||||
@@ -2507,6 +2595,16 @@ export type ListProviderFeaturesResponseMessage = z.infer<
|
|||||||
typeof ListProviderFeaturesResponseMessageSchema
|
typeof ListProviderFeaturesResponseMessageSchema
|
||||||
>;
|
>;
|
||||||
export type ListAvailableProvidersResponse = z.infer<typeof ListAvailableProvidersResponseSchema>;
|
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 ChatCreateResponse = z.infer<typeof ChatCreateResponseSchema>;
|
||||||
export type ChatListResponse = z.infer<typeof ChatListResponseSchema>;
|
export type ChatListResponse = z.infer<typeof ChatListResponseSchema>;
|
||||||
export type ChatInspectResponse = z.infer<typeof ChatInspectResponseSchema>;
|
export type ChatInspectResponse = z.infer<typeof ChatInspectResponseSchema>;
|
||||||
@@ -2554,6 +2652,15 @@ export type ListProviderFeaturesRequestMessage = z.infer<
|
|||||||
export type ListAvailableProvidersRequestMessage = z.infer<
|
export type ListAvailableProvidersRequestMessage = z.infer<
|
||||||
typeof ListAvailableProvidersRequestMessageSchema
|
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 ChatCreateRequest = z.infer<typeof ChatCreateRequestSchema>;
|
||||||
export type ChatListRequest = z.infer<typeof ChatListRequestSchema>;
|
export type ChatListRequest = z.infer<typeof ChatListRequestSchema>;
|
||||||
export type ChatInspectRequest = z.infer<typeof ChatInspectRequestSchema>;
|
export type ChatInspectRequest = z.infer<typeof ChatInspectRequestSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user