feat: provider model freshness TTL and diagnostic UI (#426)
* feat: add server-side TTL for provider snapshots and model list UI Provider snapshots cached forever, causing newly available models (e.g. OpenCode Go minimax, glm) to never appear in the picker. Server: ProviderSnapshotManager now returns stale data immediately and kicks off a background re-warm when the snapshot is older than 5 minutes. Injectable TTL/clock for testing. App: Provider settings rows show model count and are tappable to open a diagnostic sheet with a read-only model list (label + ID). * feat: add per-provider refresh for models and diagnostic * refactor: await refresh completion and clean up diagnostic sheet state - ProviderSnapshotManager.refresh is now async; session.ts awaits it so the RPC ACK reflects real completion instead of just queuing work. - Preserve models/modes/fetchedAt on entries during targeted refresh so the list no longer flashes empty mid-refresh. - Show "Updated Xs ago" next to the Models count, plus loading and error states for the list body. - Split resetSnapshotToLoading into full vs targeted branches. - Flatten nested ternary in model list rendering into renderModelsBody. - Drop redundant local refreshing state and cargo useMemo wrappers. * fix(app): widen isImeComposingKeyboardEvent type to accept optional fields TextInputKeyPressEventData has isComposing and keyCode as optional, so Pick<KeyboardEvent, ...> was too narrow and broke typecheck on main.
This commit is contained in:
@@ -51,6 +51,17 @@
|
|||||||
[contenteditable='true'] {
|
[contenteditable='true'] {
|
||||||
-webkit-app-region: no-drag !important;
|
-webkit-app-region: no-drag !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Suppress the browser's default focus outline — it appears on mouse
|
||||||
|
clicks and looks out of place against our themed surfaces. Keep a
|
||||||
|
themed ring for keyboard users via :focus-visible. */
|
||||||
|
*:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
*:focus-visible {
|
||||||
|
outline: 2px solid #20744A;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -16,6 +16,35 @@ import {
|
|||||||
import { X } from "lucide-react-native";
|
import { X } from "lucide-react-native";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
|
type EscHandler = () => void;
|
||||||
|
const escStack: EscHandler[] = [];
|
||||||
|
let escListenerAttached = false;
|
||||||
|
|
||||||
|
function handleEscKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key !== "Escape") return;
|
||||||
|
const top = escStack[escStack.length - 1];
|
||||||
|
if (!top) return;
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
top();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushEscHandler(handler: EscHandler): () => void {
|
||||||
|
escStack.push(handler);
|
||||||
|
if (!escListenerAttached && typeof window !== "undefined") {
|
||||||
|
window.addEventListener("keydown", handleEscKeyDown, true);
|
||||||
|
escListenerAttached = true;
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
const index = escStack.lastIndexOf(handler);
|
||||||
|
if (index !== -1) escStack.splice(index, 1);
|
||||||
|
if (escStack.length === 0 && escListenerAttached && typeof window !== "undefined") {
|
||||||
|
window.removeEventListener("keydown", handleEscKeyDown, true);
|
||||||
|
escListenerAttached = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
desktopOverlay: {
|
desktopOverlay: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFillObject,
|
||||||
@@ -48,10 +77,18 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderBottomColor: theme.colors.surface2,
|
borderBottomColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
|
flex: 1,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.lg,
|
fontSize: theme.fontSize.lg,
|
||||||
fontWeight: theme.fontWeight.medium,
|
fontWeight: theme.fontWeight.medium,
|
||||||
},
|
},
|
||||||
|
headerActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
marginLeft: theme.spacing[3],
|
||||||
|
marginRight: theme.spacing[2],
|
||||||
|
},
|
||||||
closeButton: {
|
closeButton: {
|
||||||
padding: theme.spacing[2],
|
padding: theme.spacing[2],
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
@@ -83,6 +120,18 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
padding: theme.spacing[6],
|
padding: theme.spacing[6],
|
||||||
gap: theme.spacing[4],
|
gap: theme.spacing[4],
|
||||||
},
|
},
|
||||||
|
bottomSheetStaticContent: {
|
||||||
|
flex: 1,
|
||||||
|
padding: theme.spacing[6],
|
||||||
|
gap: theme.spacing[4],
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
|
desktopStaticContent: {
|
||||||
|
flexShrink: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
padding: theme.spacing[6],
|
||||||
|
gap: theme.spacing[4],
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||||
@@ -106,9 +155,11 @@ export interface AdaptiveModalSheetProps {
|
|||||||
visible: boolean;
|
visible: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
headerActions?: ReactNode;
|
||||||
snapPoints?: string[];
|
snapPoints?: string[];
|
||||||
stackBehavior?: "push" | "switch" | "replace";
|
stackBehavior?: "push" | "switch" | "replace";
|
||||||
testID?: string;
|
testID?: string;
|
||||||
|
scrollable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdaptiveModalSheet({
|
export function AdaptiveModalSheet({
|
||||||
@@ -116,9 +167,11 @@ export function AdaptiveModalSheet({
|
|||||||
visible,
|
visible,
|
||||||
onClose,
|
onClose,
|
||||||
children,
|
children,
|
||||||
|
headerActions,
|
||||||
snapPoints,
|
snapPoints,
|
||||||
stackBehavior,
|
stackBehavior,
|
||||||
testID,
|
testID,
|
||||||
|
scrollable = true,
|
||||||
}: AdaptiveModalSheetProps) {
|
}: AdaptiveModalSheetProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
@@ -157,6 +210,11 @@ export function AdaptiveModalSheet({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isWeb || isMobile || !visible) return;
|
||||||
|
return pushEscHandler(onClose);
|
||||||
|
}, [visible, isMobile, onClose]);
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
@@ -174,18 +232,25 @@ export function AdaptiveModalSheet({
|
|||||||
keyboardBlurBehavior="restore"
|
keyboardBlurBehavior="restore"
|
||||||
>
|
>
|
||||||
<View style={styles.bottomSheetHeader}>
|
<View style={styles.bottomSheetHeader}>
|
||||||
<Text style={styles.title}>{title}</Text>
|
<Text style={styles.title} numberOfLines={1}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
|
||||||
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
||||||
<X size={16} color={theme.colors.foregroundMuted} />
|
<X size={16} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<BottomSheetScrollView
|
{scrollable ? (
|
||||||
contentContainerStyle={styles.bottomSheetContent}
|
<BottomSheetScrollView
|
||||||
keyboardShouldPersistTaps="handled"
|
contentContainerStyle={styles.bottomSheetContent}
|
||||||
showsVerticalScrollIndicator={false}
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
showsVerticalScrollIndicator={false}
|
||||||
{children}
|
>
|
||||||
</BottomSheetScrollView>
|
{children}
|
||||||
|
</BottomSheetScrollView>
|
||||||
|
) : (
|
||||||
|
<View style={styles.bottomSheetStaticContent}>{children}</View>
|
||||||
|
)}
|
||||||
</BottomSheetModal>
|
</BottomSheetModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -199,19 +264,26 @@ export function AdaptiveModalSheet({
|
|||||||
/>
|
/>
|
||||||
<View style={styles.desktopCard}>
|
<View style={styles.desktopCard}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.title}>{title}</Text>
|
<Text style={styles.title} numberOfLines={1}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
|
||||||
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
||||||
<X size={16} color={theme.colors.foregroundMuted} />
|
<X size={16} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView
|
{scrollable ? (
|
||||||
style={styles.desktopScroll}
|
<ScrollView
|
||||||
contentContainerStyle={styles.desktopContent}
|
style={styles.desktopScroll}
|
||||||
keyboardShouldPersistTaps="handled"
|
contentContainerStyle={styles.desktopContent}
|
||||||
showsVerticalScrollIndicator={false}
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
showsVerticalScrollIndicator={false}
|
||||||
{children}
|
>
|
||||||
</ScrollView>
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
) : (
|
||||||
|
<View style={styles.desktopStaticContent}>{children}</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { AlertCircle, Search } from "lucide-react-native";
|
||||||
import { View, Text, ActivityIndicator, ScrollView } from "react-native";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||||
|
import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
import { Fonts } from "@/constants/theme";
|
||||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||||
import { resolveProviderLabel } from "@/utils/provider-definitions";
|
import { resolveProviderLabel } from "@/utils/provider-definitions";
|
||||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
|
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
|
|
||||||
interface ProviderDiagnosticSheetProps {
|
interface ProviderDiagnosticSheetProps {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -22,83 +27,333 @@ export function ProviderDiagnosticSheet({
|
|||||||
}: ProviderDiagnosticSheetProps) {
|
}: ProviderDiagnosticSheetProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const { entries: snapshotEntries } = useProvidersSnapshot(serverId);
|
const { entries: snapshotEntries, refresh, isRefreshing } = useProvidersSnapshot(serverId);
|
||||||
const [diagnostic, setDiagnostic] = useState<string | null>(null);
|
const [diagnostic, setDiagnostic] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
const providerLabel = resolveProviderLabel(provider, snapshotEntries);
|
const providerLabel = resolveProviderLabel(provider, snapshotEntries);
|
||||||
|
const providerEntry = useMemo(
|
||||||
|
() => snapshotEntries?.find((entry) => entry.provider === provider),
|
||||||
|
[snapshotEntries, provider],
|
||||||
|
);
|
||||||
|
const models = providerEntry?.models ?? [];
|
||||||
|
const providerSnapshotRefreshing = providerEntry?.status === "loading";
|
||||||
|
const providerErrorMessage =
|
||||||
|
providerEntry?.status === "error" ? (providerEntry.error ?? "Unknown error") : null;
|
||||||
|
const refreshInFlight = isRefreshing || providerSnapshotRefreshing || loading;
|
||||||
|
|
||||||
const fetchDiagnostic = useCallback(async () => {
|
const [clockTick, setClockTick] = useState(0);
|
||||||
if (!client || !provider) return;
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
const id = setInterval(() => setClockTick((t) => t + 1), 10_000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [visible]);
|
||||||
|
const fetchedAtLabel = useMemo(() => {
|
||||||
|
if (!providerEntry?.fetchedAt) return null;
|
||||||
|
return formatTimeAgo(new Date(providerEntry.fetchedAt));
|
||||||
|
// clockTick triggers re-computation on timer
|
||||||
|
}, [providerEntry?.fetchedAt, clockTick]);
|
||||||
|
|
||||||
setLoading(true);
|
const q = query.trim().toLowerCase();
|
||||||
setDiagnostic(null);
|
const filteredModels = q
|
||||||
|
? models.filter((m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q))
|
||||||
|
: models;
|
||||||
|
|
||||||
try {
|
const fetchDiagnostic = useCallback(
|
||||||
const result = await client.getProviderDiagnostic(provider as AgentProvider);
|
async (options?: { keepCurrent?: boolean }) => {
|
||||||
setDiagnostic(result.diagnostic);
|
if (!client || !provider) return;
|
||||||
} catch (err) {
|
|
||||||
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic");
|
setLoading(true);
|
||||||
} finally {
|
if (!options?.keepCurrent) {
|
||||||
setLoading(false);
|
setDiagnostic(null);
|
||||||
}
|
}
|
||||||
}, [client, provider]);
|
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
void refresh([provider as AgentProvider]);
|
||||||
|
void fetchDiagnostic({ keepCurrent: true });
|
||||||
|
}, [fetchDiagnostic, provider, refresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
fetchDiagnostic();
|
fetchDiagnostic();
|
||||||
} else {
|
} else {
|
||||||
setDiagnostic(null);
|
setDiagnostic(null);
|
||||||
|
setQuery("");
|
||||||
}
|
}
|
||||||
}, [visible, fetchDiagnostic]);
|
}, [visible, fetchDiagnostic]);
|
||||||
|
|
||||||
|
function renderModelsBody() {
|
||||||
|
if (models.length === 0 && providerSnapshotRefreshing) {
|
||||||
|
return (
|
||||||
|
<View style={sheetStyles.emptyState}>
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={sheetStyles.mutedText}>Loading models…</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (models.length === 0 && providerErrorMessage) {
|
||||||
|
return (
|
||||||
|
<View style={sheetStyles.emptyState}>
|
||||||
|
<AlertCircle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={sheetStyles.mutedText}>{providerErrorMessage}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (models.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={sheetStyles.emptyState}>
|
||||||
|
<Text style={sheetStyles.mutedText}>No models detected.</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filteredModels.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={sheetStyles.emptyState}>
|
||||||
|
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={sheetStyles.mutedText}>No models match your search</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return filteredModels.map((model: AgentModelDefinition, index) => (
|
||||||
|
<View key={model.id} style={[sheetStyles.modelRow, index > 0 && sheetStyles.modelRowBorder]}>
|
||||||
|
<Text style={sheetStyles.modelLabel} numberOfLines={1}>
|
||||||
|
{model.label}
|
||||||
|
</Text>
|
||||||
|
<Text style={sheetStyles.modelId} numberOfLines={1} selectable>
|
||||||
|
{model.id}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdaptiveModalSheet
|
<AdaptiveModalSheet
|
||||||
title={providerLabel}
|
title={providerLabel}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
snapPoints={["50%", "85%"]}
|
snapPoints={["50%", "85%"]}
|
||||||
>
|
scrollable={false}
|
||||||
{loading ? (
|
headerActions={
|
||||||
<View style={sheetStyles.loadingContainer}>
|
<Pressable
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
onPress={handleRefresh}
|
||||||
<Text style={sheetStyles.loadingText}>Fetching diagnostic…</Text>
|
disabled={refreshInFlight}
|
||||||
</View>
|
hitSlop={8}
|
||||||
) : diagnostic ? (
|
style={({ hovered, pressed }) => [
|
||||||
<ScrollView
|
sheetStyles.iconButton,
|
||||||
horizontal
|
(hovered || pressed) && sheetStyles.iconButtonHovered,
|
||||||
style={sheetStyles.scrollContainer}
|
refreshInFlight ? sheetStyles.disabled : null,
|
||||||
contentContainerStyle={sheetStyles.scrollContent}
|
]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Refresh ${providerLabel}`}
|
||||||
>
|
>
|
||||||
<Text style={sheetStyles.diagnosticText} selectable>
|
<SpinningRefreshIcon
|
||||||
{diagnostic}
|
spinning={refreshInFlight}
|
||||||
</Text>
|
size={theme.iconSize.sm}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View style={sheetStyles.section}>
|
||||||
|
<Text style={sheetStyles.sectionTitle}>Diagnostic</Text>
|
||||||
|
<View style={sheetStyles.codeBlock}>
|
||||||
|
{loading && !diagnostic ? (
|
||||||
|
<View style={sheetStyles.codeBlockLoading}>
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={sheetStyles.mutedText}>Running diagnostic…</Text>
|
||||||
|
</View>
|
||||||
|
) : diagnostic ? (
|
||||||
|
<ScrollView
|
||||||
|
style={sheetStyles.codeScroll}
|
||||||
|
contentContainerStyle={sheetStyles.codeContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<Text style={sheetStyles.codeText} selectable>
|
||||||
|
{diagnostic}
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</ScrollView>
|
||||||
|
) : (
|
||||||
|
<View style={sheetStyles.codeBlockLoading}>
|
||||||
|
<Text style={sheetStyles.mutedText}>No diagnostic available.</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={sheetStyles.modelsSection}>
|
||||||
|
<View style={sheetStyles.modelsHeader}>
|
||||||
|
<Text style={sheetStyles.sectionTitle}>Models</Text>
|
||||||
|
<View style={sheetStyles.modelsHeaderMeta}>
|
||||||
|
<Text style={sheetStyles.countText}>{models.length}</Text>
|
||||||
|
{fetchedAtLabel ? (
|
||||||
|
<>
|
||||||
|
<Text style={sheetStyles.metaDot}>·</Text>
|
||||||
|
<Text style={sheetStyles.countText}>Updated {fetchedAtLabel}</Text>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{models.length > 0 ? (
|
||||||
|
<View style={sheetStyles.searchContainer}>
|
||||||
|
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
|
<AdaptiveTextInput
|
||||||
|
value={query}
|
||||||
|
onChangeText={setQuery}
|
||||||
|
placeholder="Search models"
|
||||||
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
// @ts-expect-error - outlineStyle is web-only
|
||||||
|
style={[sheetStyles.searchInput, isWeb && { outlineStyle: "none" }]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
<ScrollView
|
||||||
|
style={sheetStyles.modelsScroll}
|
||||||
|
contentContainerStyle={sheetStyles.modelsScrollContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{renderModelsBody()}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
) : null}
|
</View>
|
||||||
</AdaptiveModalSheet>
|
</AdaptiveModalSheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sheetStyles = StyleSheet.create((theme) => ({
|
const sheetStyles = StyleSheet.create((theme) => ({
|
||||||
loadingContainer: {
|
section: {
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
},
|
||||||
|
mutedText: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
iconButton: {
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
iconButtonHovered: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
codeBlock: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
borderRadius: theme.borderRadius.base,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
overflow: "hidden",
|
||||||
|
maxHeight: 180,
|
||||||
|
},
|
||||||
|
codeScroll: {
|
||||||
|
maxHeight: 180,
|
||||||
|
},
|
||||||
|
codeContent: {
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
},
|
||||||
|
codeText: {
|
||||||
|
fontFamily: Fonts.mono,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
codeBlockLoading: {
|
||||||
|
paddingVertical: theme.spacing[4],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
modelsSection: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
modelsHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
modelsHeaderMeta: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
metaDot: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
countText: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
searchContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
},
|
||||||
|
searchInput: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
modelsScroll: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
|
modelsScrollContent: {
|
||||||
|
paddingBottom: theme.spacing[2],
|
||||||
|
},
|
||||||
|
modelRow: {
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
},
|
||||||
|
modelRowBorder: {
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
modelLabel: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
modelId: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontFamily: Fonts.mono,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
paddingVertical: theme.spacing[6],
|
paddingVertical: theme.spacing[6],
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
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,
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { RefreshCw } from "lucide-react-native";
|
||||||
|
import Animated, {
|
||||||
|
cancelAnimation,
|
||||||
|
Easing,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withRepeat,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
|
||||||
|
interface SpinningRefreshIconProps {
|
||||||
|
spinning: boolean;
|
||||||
|
size: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpinningRefreshIcon({ spinning, size, color }: SpinningRefreshIconProps) {
|
||||||
|
const rotation = useSharedValue(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (spinning) {
|
||||||
|
rotation.value = 0;
|
||||||
|
rotation.value = withRepeat(
|
||||||
|
withTiming(360, {
|
||||||
|
duration: 1000,
|
||||||
|
easing: Easing.linear,
|
||||||
|
}),
|
||||||
|
-1,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelAnimation(rotation);
|
||||||
|
const remainder = rotation.value % 360;
|
||||||
|
if (Math.abs(remainder) < 0.001) {
|
||||||
|
rotation.value = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rotation.value = withTiming(360, {
|
||||||
|
duration: Math.max(80, Math.round(((360 - remainder) / 360) * 1000)),
|
||||||
|
easing: Easing.linear,
|
||||||
|
});
|
||||||
|
}, [rotation, spinning]);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ rotate: `${rotation.value}deg` }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
animatedStyle,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<RefreshCw size={size} color={color} />
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
import type { AgentProvider, ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { useSessionForServer } from "./use-session-directory";
|
import { useSessionForServer } from "./use-session-directory";
|
||||||
@@ -14,9 +14,10 @@ interface UseProvidersSnapshotResult {
|
|||||||
entries: ProviderSnapshotEntry[] | undefined;
|
entries: ProviderSnapshotEntry[] | undefined;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
|
isRefreshing: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
supportsSnapshot: boolean;
|
supportsSnapshot: boolean;
|
||||||
refresh: () => void;
|
refresh: (providers?: AgentProvider[]) => Promise<void>;
|
||||||
invalidate: () => void;
|
invalidate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +44,16 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const refreshMutation = useMutation({
|
||||||
|
mutationFn: async (providers?: AgentProvider[]) => {
|
||||||
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await client.refreshProvidersSnapshot({ providers });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!supportsSnapshot || !client || !isConnected || !serverId) {
|
if (!supportsSnapshot || !client || !isConnected || !serverId) {
|
||||||
return;
|
return;
|
||||||
@@ -60,12 +71,12 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps
|
|||||||
});
|
});
|
||||||
}, [client, isConnected, serverId, queryClient, queryKey, supportsSnapshot]);
|
}, [client, isConnected, serverId, queryClient, queryKey, supportsSnapshot]);
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(
|
||||||
if (!client) {
|
async (providers?: AgentProvider[]) => {
|
||||||
return;
|
await refreshSnapshot(providers);
|
||||||
}
|
},
|
||||||
void client.refreshProvidersSnapshot();
|
[refreshSnapshot],
|
||||||
}, [client]);
|
);
|
||||||
|
|
||||||
const invalidate = useCallback(() => {
|
const invalidate = useCallback(() => {
|
||||||
void queryClient.invalidateQueries({ queryKey });
|
void queryClient.invalidateQueries({ queryKey });
|
||||||
@@ -75,6 +86,7 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps
|
|||||||
entries: snapshotQuery.data?.entries ?? undefined,
|
entries: snapshotQuery.data?.entries ?? undefined,
|
||||||
isLoading: snapshotQuery.isLoading,
|
isLoading: snapshotQuery.isLoading,
|
||||||
isFetching: snapshotQuery.isFetching,
|
isFetching: snapshotQuery.isFetching,
|
||||||
|
isRefreshing,
|
||||||
error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null,
|
error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null,
|
||||||
supportsSnapshot,
|
supportsSnapshot,
|
||||||
refresh,
|
refresh,
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
|||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
||||||
|
import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon";
|
||||||
import { StatusBadge } from "@/components/ui/status-badge";
|
import { StatusBadge } from "@/components/ui/status-badge";
|
||||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
@@ -521,9 +522,11 @@ interface ProvidersSectionProps {
|
|||||||
function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isConnected = useHostRuntimeIsConnected(routeServerId);
|
const isConnected = useHostRuntimeIsConnected(routeServerId);
|
||||||
const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId);
|
const { entries, isLoading, isRefreshing, refresh } = useProvidersSnapshot(routeServerId);
|
||||||
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null);
|
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null);
|
||||||
const providerDefinitions = buildProviderDefinitions(entries);
|
const providerDefinitions = buildProviderDefinitions(entries);
|
||||||
|
const providerRefreshInFlight =
|
||||||
|
isRefreshing || (entries?.some((entry) => entry.status === "loading") ?? false);
|
||||||
|
|
||||||
const hasServer = routeServerId.length > 0;
|
const hasServer = routeServerId.length > 0;
|
||||||
|
|
||||||
@@ -534,18 +537,25 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
|||||||
<Text style={settingsStyles.sectionHeaderTitle}>Providers</Text>
|
<Text style={settingsStyles.sectionHeaderTitle}>Providers</Text>
|
||||||
{hasServer && isConnected ? (
|
{hasServer && isConnected ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={refresh}
|
onPress={() => {
|
||||||
disabled={isFetching}
|
void refresh();
|
||||||
style={[settingsStyles.sectionHeaderLink, isFetching ? { opacity: 0.5 } : null]}
|
}}
|
||||||
|
disabled={providerRefreshInFlight}
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ hovered, pressed }) => [
|
||||||
|
settingsStyles.sectionHeaderLink,
|
||||||
|
styles.providerRefreshButton,
|
||||||
|
(hovered || pressed) && styles.providerRefreshButtonHovered,
|
||||||
|
providerRefreshInFlight ? styles.providerRefreshButtonDisabled : null,
|
||||||
|
]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Refresh providers"
|
||||||
>
|
>
|
||||||
<Text
|
<SpinningRefreshIcon
|
||||||
style={{
|
spinning={providerRefreshInFlight}
|
||||||
color: theme.colors.primary,
|
size={theme.iconSize.sm}
|
||||||
fontSize: theme.fontSize.xs,
|
color={theme.colors.foregroundMuted}
|
||||||
}}
|
/>
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
@@ -570,8 +580,15 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
|||||||
? entry.error.trim()
|
? entry.error.trim()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const modelCount = entry?.models?.length ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View key={def.id} style={styles.audioRow}>
|
<Pressable
|
||||||
|
key={def.id}
|
||||||
|
style={styles.audioRow}
|
||||||
|
onPress={() => setDiagnosticProvider(def.id)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
>
|
||||||
<View style={styles.audioRowContent}>
|
<View style={styles.audioRowContent}>
|
||||||
<View
|
<View
|
||||||
style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }}
|
style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }}
|
||||||
@@ -584,31 +601,27 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
|||||||
{providerError}
|
{providerError}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
{status === "ready" && modelCount > 0 ? (
|
||||||
|
<Text style={styles.audioRowSubtitle}>
|
||||||
|
{modelCount === 1 ? "1 model" : `${modelCount} models`}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.providerActions}>
|
<StatusBadge
|
||||||
<StatusBadge
|
label={
|
||||||
label={
|
status === "ready"
|
||||||
status === "ready"
|
? "Available"
|
||||||
? "Available"
|
: status === "error"
|
||||||
: status === "error"
|
? "Error"
|
||||||
? "Error"
|
: status === "loading"
|
||||||
: status === "loading"
|
? "Loading..."
|
||||||
? "Loading..."
|
: "Not installed"
|
||||||
: "Not installed"
|
}
|
||||||
}
|
variant={
|
||||||
variant={
|
status === "ready" ? "success" : status === "error" ? "error" : "muted"
|
||||||
status === "ready" ? "success" : status === "error" ? "error" : "muted"
|
}
|
||||||
}
|
/>
|
||||||
/>
|
</Pressable>
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onPress={() => setDiagnosticProvider(def.id)}
|
|
||||||
>
|
|
||||||
Diagnostic
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
@@ -1952,6 +1965,18 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
formButtonPrimaryText: {
|
formButtonPrimaryText: {
|
||||||
color: theme.colors.palette.white,
|
color: theme.colors.palette.white,
|
||||||
},
|
},
|
||||||
|
providerRefreshButton: {
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
providerRefreshButtonHovered: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
providerRefreshButtonDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
// Audio settings card
|
// Audio settings card
|
||||||
audioCard: {
|
audioCard: {
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
@@ -1980,11 +2005,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
marginTop: theme.spacing[1],
|
marginTop: theme.spacing[1],
|
||||||
},
|
},
|
||||||
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,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export function isImeComposingKeyboardEvent(
|
export function isImeComposingKeyboardEvent(event: {
|
||||||
event: Pick<KeyboardEvent, "isComposing" | "keyCode">,
|
isComposing?: boolean;
|
||||||
): boolean {
|
keyCode?: number;
|
||||||
return event.isComposing || event.keyCode === 229;
|
}): boolean {
|
||||||
|
return Boolean(event.isComposing) || event.keyCode === 229;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2756,6 +2756,7 @@ export class DaemonClient {
|
|||||||
|
|
||||||
async refreshProvidersSnapshot(options?: {
|
async refreshProvidersSnapshot(options?: {
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
providers?: AgentProvider[];
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
}): Promise<RefreshProvidersSnapshotPayload> {
|
}): Promise<RefreshProvidersSnapshotPayload> {
|
||||||
return this.sendCorrelatedSessionRequest({
|
return this.sendCorrelatedSessionRequest({
|
||||||
@@ -2763,6 +2764,7 @@ export class DaemonClient {
|
|||||||
message: {
|
message: {
|
||||||
type: "refresh_providers_snapshot_request",
|
type: "refresh_providers_snapshot_request",
|
||||||
cwd: options?.cwd,
|
cwd: options?.cwd,
|
||||||
|
providers: options?.providers,
|
||||||
},
|
},
|
||||||
responseType: "refresh_providers_snapshot_response",
|
responseType: "refresh_providers_snapshot_response",
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const TEST_CAPABILITIES = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
describe("ProviderSnapshotManager", () => {
|
describe("ProviderSnapshotManager", () => {
|
||||||
|
const ttlMs = 5 * 60 * 1_000;
|
||||||
const projectCwd = resolve("/tmp/project");
|
const projectCwd = resolve("/tmp/project");
|
||||||
const projectACwd = resolve("/tmp/project-a");
|
const projectACwd = resolve("/tmp/project-a");
|
||||||
const projectBCwd = resolve("/tmp/project-b");
|
const projectBCwd = resolve("/tmp/project-b");
|
||||||
@@ -269,7 +270,7 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.refresh(projectCwd);
|
manager.refresh({ cwd: projectCwd });
|
||||||
expect(manager.getSnapshot(projectCwd)).toEqual([
|
expect(manager.getSnapshot(projectCwd)).toEqual([
|
||||||
{
|
{
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
@@ -291,6 +292,155 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
manager.destroy();
|
manager.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("refresh with providers only re-fetches matching providers", async () => {
|
||||||
|
const codexFetchModels = vi
|
||||||
|
.fn<() => Promise<AgentModelDefinition[]>>()
|
||||||
|
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
|
||||||
|
.mockResolvedValueOnce([createModel("codex", "gpt-5.2")]);
|
||||||
|
const claudeFetchModels = vi
|
||||||
|
.fn<() => Promise<AgentModelDefinition[]>>()
|
||||||
|
.mockResolvedValueOnce([createModel("claude", "sonnet-4")]);
|
||||||
|
const { registry } = createRegistry([
|
||||||
|
createMockProvider({
|
||||||
|
provider: "codex",
|
||||||
|
fetchModels: codexFetchModels,
|
||||||
|
fetchModes: async () => [createMode("auto")],
|
||||||
|
}),
|
||||||
|
createMockProvider({
|
||||||
|
provider: "claude",
|
||||||
|
fetchModels: claudeFetchModels,
|
||||||
|
fetchModes: async () => [createMode("default")],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||||
|
|
||||||
|
manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
|
"gpt-5.1",
|
||||||
|
);
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe(
|
||||||
|
"sonnet-4",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.refresh({ cwd: projectCwd, providers: ["codex"] });
|
||||||
|
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("loading");
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")).toMatchObject({
|
||||||
|
provider: "claude",
|
||||||
|
status: "ready",
|
||||||
|
models: [createModel("claude", "sonnet-4")],
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
|
"gpt-5.2",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(codexFetchModels).toHaveBeenCalledTimes(2);
|
||||||
|
expect(claudeFetchModels).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refresh treats an empty providers list as a full refresh", async () => {
|
||||||
|
const codexFetchModels = vi
|
||||||
|
.fn<() => Promise<AgentModelDefinition[]>>()
|
||||||
|
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
|
||||||
|
.mockResolvedValueOnce([createModel("codex", "gpt-5.2")]);
|
||||||
|
const claudeFetchModels = vi
|
||||||
|
.fn<() => Promise<AgentModelDefinition[]>>()
|
||||||
|
.mockResolvedValueOnce([createModel("claude", "sonnet-4")])
|
||||||
|
.mockResolvedValueOnce([createModel("claude", "sonnet-4.5")]);
|
||||||
|
const { registry } = createRegistry([
|
||||||
|
createMockProvider({
|
||||||
|
provider: "codex",
|
||||||
|
fetchModels: codexFetchModels,
|
||||||
|
fetchModes: async () => [createMode("auto")],
|
||||||
|
}),
|
||||||
|
createMockProvider({
|
||||||
|
provider: "claude",
|
||||||
|
fetchModels: claudeFetchModels,
|
||||||
|
fetchModes: async () => [createMode("default")],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||||
|
|
||||||
|
manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("ready");
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.refresh({ cwd: projectCwd, providers: [] });
|
||||||
|
|
||||||
|
expect(manager.getSnapshot(projectCwd)).toEqual([
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
status: "loading",
|
||||||
|
label: "codex",
|
||||||
|
description: "codex test provider",
|
||||||
|
defaultModeId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
status: "loading",
|
||||||
|
label: "claude",
|
||||||
|
description: "claude test provider",
|
||||||
|
defaultModeId: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
|
"gpt-5.2",
|
||||||
|
);
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe(
|
||||||
|
"sonnet-4.5",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(codexFetchModels).toHaveBeenCalledTimes(2);
|
||||||
|
expect(claudeFetchModels).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refresh ignores provider filters that are not in the registry", async () => {
|
||||||
|
const codexFetchModels = vi
|
||||||
|
.fn<() => Promise<AgentModelDefinition[]>>()
|
||||||
|
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")]);
|
||||||
|
const { registry } = createRegistry([
|
||||||
|
createMockProvider({
|
||||||
|
provider: "codex",
|
||||||
|
fetchModels: codexFetchModels,
|
||||||
|
fetchModes: async () => [createMode("auto")],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||||
|
|
||||||
|
manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.refresh({ cwd: projectCwd, providers: ["zai"] });
|
||||||
|
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")).toMatchObject({
|
||||||
|
provider: "codex",
|
||||||
|
status: "ready",
|
||||||
|
models: [createModel("codex", "gpt-5.1")],
|
||||||
|
});
|
||||||
|
expect(codexFetchModels).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
test("refresh during an in-flight refresh is a no-op", async () => {
|
test("refresh during an in-flight refresh is a no-op", async () => {
|
||||||
const fetchModels = deferred<AgentModelDefinition[]>();
|
const fetchModels = deferred<AgentModelDefinition[]>();
|
||||||
const fetchModes = deferred<AgentMode[]>();
|
const fetchModes = deferred<AgentMode[]>();
|
||||||
@@ -305,7 +455,7 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
const changes: ProviderSnapshotEntry[][] = [];
|
const changes: ProviderSnapshotEntry[][] = [];
|
||||||
manager.on("change", (entries) => changes.push(entries));
|
manager.on("change", (entries) => changes.push(entries));
|
||||||
|
|
||||||
manager.refresh(projectCwd);
|
manager.refresh({ cwd: projectCwd });
|
||||||
|
|
||||||
expect(manager.getSnapshot(projectCwd)).toEqual([
|
expect(manager.getSnapshot(projectCwd)).toEqual([
|
||||||
{
|
{
|
||||||
@@ -317,9 +467,9 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
manager.refresh(projectCwd);
|
manager.refresh({ cwd: projectCwd });
|
||||||
manager.refresh(projectCwd);
|
manager.refresh({ cwd: projectCwd });
|
||||||
manager.refresh(projectCwd);
|
manager.refresh({ cwd: projectCwd });
|
||||||
|
|
||||||
expect(changes).toHaveLength(1);
|
expect(changes).toHaveLength(1);
|
||||||
expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1);
|
expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1);
|
||||||
@@ -342,6 +492,247 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
manager.destroy();
|
manager.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("getSnapshot returns stale ready entries and starts background warm-up when snapshot is older than TTL", async () => {
|
||||||
|
let now = 1_000;
|
||||||
|
const fetchModels = vi
|
||||||
|
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
|
||||||
|
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
|
||||||
|
.mockResolvedValueOnce([createModel("codex", "gpt-5.2")]);
|
||||||
|
const { registry, handles } = createRegistry([
|
||||||
|
createMockProvider({
|
||||||
|
provider: "codex",
|
||||||
|
fetchModels: async (cwd) => fetchModels(cwd),
|
||||||
|
fetchModes: async () => [createMode("auto")],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
|
||||||
|
ttlMs,
|
||||||
|
now: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
|
"gpt-5.1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
now += ttlMs + 1;
|
||||||
|
|
||||||
|
const staleSnapshot = manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
expect(getProviderEntry(staleSnapshot, "codex")).toMatchObject({
|
||||||
|
provider: "codex",
|
||||||
|
status: "ready",
|
||||||
|
models: [createModel("codex", "gpt-5.1")],
|
||||||
|
modes: [createMode("auto")],
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
|
"gpt-5.2",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSnapshot does not trigger a second warm-up while a stale re-warm is already in flight", async () => {
|
||||||
|
let now = 2_000;
|
||||||
|
const staleRefreshModels = deferred<AgentModelDefinition[]>();
|
||||||
|
const fetchModels = vi
|
||||||
|
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
|
||||||
|
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
|
||||||
|
.mockImplementationOnce(async () => staleRefreshModels.promise);
|
||||||
|
const { registry, handles } = createRegistry([
|
||||||
|
createMockProvider({
|
||||||
|
provider: "codex",
|
||||||
|
fetchModels: async (cwd) => fetchModels(cwd),
|
||||||
|
fetchModes: async () => [createMode("auto")],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
|
||||||
|
ttlMs,
|
||||||
|
now: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
|
"gpt-5.1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
now += ttlMs + 1;
|
||||||
|
|
||||||
|
const firstStaleSnapshot = manager.getSnapshot(projectCwd);
|
||||||
|
const secondStaleSnapshot = manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
expect(getProviderEntry(firstStaleSnapshot, "codex")?.models?.[0]?.id).toBe("gpt-5.1");
|
||||||
|
expect(getProviderEntry(secondStaleSnapshot, "codex")?.models?.[0]?.id).toBe("gpt-5.1");
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
staleRefreshModels.resolve([createModel("codex", "gpt-5.2")]);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
|
"gpt-5.2",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSnapshot does not re-warm when the cached snapshot is still fresh", async () => {
|
||||||
|
let now = 3_000;
|
||||||
|
const { registry, handles } = createRegistry([
|
||||||
|
createMockProvider({
|
||||||
|
provider: "codex",
|
||||||
|
fetchModels: async () => [createModel("codex", "gpt-5.1")],
|
||||||
|
fetchModes: async () => [createMode("auto")],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
|
||||||
|
ttlMs,
|
||||||
|
now: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
|
||||||
|
});
|
||||||
|
|
||||||
|
now += ttlMs - 1;
|
||||||
|
|
||||||
|
const freshSnapshot = manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
expect(getProviderEntry(freshSnapshot, "codex")?.models?.[0]?.id).toBe("gpt-5.1");
|
||||||
|
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSnapshot re-warms snapshots in error and unavailable states after TTL", async () => {
|
||||||
|
let now = 4_000;
|
||||||
|
const unavailableFetchModels = vi
|
||||||
|
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
|
||||||
|
.mockResolvedValue([createModel("codex", "gpt-5.2")]);
|
||||||
|
const unavailableIsAvailable = vi
|
||||||
|
.fn<() => Promise<boolean>>()
|
||||||
|
.mockResolvedValueOnce(false)
|
||||||
|
.mockResolvedValueOnce(true);
|
||||||
|
const errorFetchModels = vi
|
||||||
|
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
|
||||||
|
.mockRejectedValueOnce(new Error("model lookup failed"))
|
||||||
|
.mockResolvedValueOnce([createModel("claude", "sonnet")]);
|
||||||
|
const { registry } = createRegistry([
|
||||||
|
createMockProvider({
|
||||||
|
provider: "codex",
|
||||||
|
isAvailable: unavailableIsAvailable,
|
||||||
|
fetchModels: async (cwd) => unavailableFetchModels(cwd),
|
||||||
|
fetchModes: async () => [createMode("auto")],
|
||||||
|
}),
|
||||||
|
createMockProvider({
|
||||||
|
provider: "claude",
|
||||||
|
fetchModels: async (cwd) => errorFetchModels(cwd),
|
||||||
|
fetchModes: async () => [createMode("default")],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
|
||||||
|
ttlMs,
|
||||||
|
now: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe(
|
||||||
|
"unavailable",
|
||||||
|
);
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
now += ttlMs + 1;
|
||||||
|
|
||||||
|
const staleSnapshot = manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
expect(getProviderEntry(staleSnapshot, "codex")?.status).toBe("unavailable");
|
||||||
|
expect(getProviderEntry(staleSnapshot, "claude")?.status).toBe("error");
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("ready");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
|
"gpt-5.2",
|
||||||
|
);
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe(
|
||||||
|
"sonnet",
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getSnapshot respects an injected TTL", async () => {
|
||||||
|
let now = 5_000;
|
||||||
|
const customTtlMs = 100;
|
||||||
|
const fetchModels = vi
|
||||||
|
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
|
||||||
|
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
|
||||||
|
.mockResolvedValueOnce([createModel("codex", "gpt-5.2")]);
|
||||||
|
const { registry, handles } = createRegistry([
|
||||||
|
createMockProvider({
|
||||||
|
provider: "codex",
|
||||||
|
fetchModels: async (cwd) => fetchModels(cwd),
|
||||||
|
fetchModes: async () => [createMode("auto")],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
|
||||||
|
ttlMs: customTtlMs,
|
||||||
|
now: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
|
"gpt-5.1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
now += customTtlMs - 1;
|
||||||
|
manager.getSnapshot(projectCwd);
|
||||||
|
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
now += 2;
|
||||||
|
manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
|
"gpt-5.2",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
test("multiple getSnapshot calls for same cwd do not trigger multiple warmUps", async () => {
|
test("multiple getSnapshot calls for same cwd do not trigger multiple warmUps", async () => {
|
||||||
const codexModels = deferred<AgentModelDefinition[]>();
|
const codexModels = deferred<AgentModelDefinition[]>();
|
||||||
const { registry, handles } = createRegistry([
|
const { registry, handles } = createRegistry([
|
||||||
|
|||||||
@@ -7,19 +7,35 @@ import type { AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js"
|
|||||||
import type { ProviderDefinition } from "./provider-registry.js";
|
import type { ProviderDefinition } from "./provider-registry.js";
|
||||||
|
|
||||||
const DEFAULT_CWD_KEY = "__default__";
|
const DEFAULT_CWD_KEY = "__default__";
|
||||||
|
const DEFAULT_SNAPSHOT_TTL_MS = 300_000;
|
||||||
|
|
||||||
type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd?: string) => void;
|
type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd?: string) => void;
|
||||||
|
type ProviderSnapshotManagerOptions = {
|
||||||
|
ttlMs?: number;
|
||||||
|
now?: () => number;
|
||||||
|
};
|
||||||
|
type ProviderSnapshotRefreshOptions = {
|
||||||
|
cwd?: string;
|
||||||
|
providers?: AgentProvider[];
|
||||||
|
};
|
||||||
|
|
||||||
export class ProviderSnapshotManager {
|
export class ProviderSnapshotManager {
|
||||||
private readonly snapshots = new Map<string, Map<AgentProvider, ProviderSnapshotEntry>>();
|
private readonly snapshots = new Map<string, Map<AgentProvider, ProviderSnapshotEntry>>();
|
||||||
|
private readonly lastCheckedAts = new Map<string, number>();
|
||||||
private readonly warmUps = new Map<string, Promise<void>>();
|
private readonly warmUps = new Map<string, Promise<void>>();
|
||||||
private readonly events = new EventEmitter();
|
private readonly events = new EventEmitter();
|
||||||
private destroyed = false;
|
private destroyed = false;
|
||||||
|
private readonly ttlMs: number;
|
||||||
|
private readonly now: () => number;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly providerRegistry: Record<AgentProvider, ProviderDefinition>,
|
private readonly providerRegistry: Record<AgentProvider, ProviderDefinition>,
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
) {}
|
options: ProviderSnapshotManagerOptions = {},
|
||||||
|
) {
|
||||||
|
this.ttlMs = options.ttlMs ?? DEFAULT_SNAPSHOT_TTL_MS;
|
||||||
|
this.now = options.now ?? Date.now;
|
||||||
|
}
|
||||||
|
|
||||||
getSnapshot(cwd?: string): ProviderSnapshotEntry[] {
|
getSnapshot(cwd?: string): ProviderSnapshotEntry[] {
|
||||||
const cwdKey = normalizeCwdKey(cwd);
|
const cwdKey = normalizeCwdKey(cwd);
|
||||||
@@ -29,17 +45,24 @@ export class ProviderSnapshotManager {
|
|||||||
void this.warmUp(cwd);
|
void this.warmUp(cwd);
|
||||||
return entriesToArray(loadingEntries);
|
return entriesToArray(loadingEntries);
|
||||||
}
|
}
|
||||||
|
if (this.shouldRevalidate(cwdKey)) {
|
||||||
|
void this.warmUp(cwd);
|
||||||
|
}
|
||||||
return entriesToArray(entries);
|
return entriesToArray(entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh(cwd?: string): void {
|
async refresh(options: ProviderSnapshotRefreshOptions = {}): Promise<void> {
|
||||||
|
const { cwd } = options;
|
||||||
const cwdKey = normalizeCwdKey(cwd);
|
const cwdKey = normalizeCwdKey(cwd);
|
||||||
if (this.warmUps.has(cwdKey)) {
|
const inFlight = this.warmUps.get(cwdKey);
|
||||||
|
if (inFlight) {
|
||||||
|
await inFlight;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.resetSnapshotToLoading(cwdKey);
|
const providers = this.resolveRefreshProviders(options.providers);
|
||||||
|
this.resetSnapshotToLoading(cwdKey, providers);
|
||||||
this.emitChange(cwdKey);
|
this.emitChange(cwdKey);
|
||||||
void this.warmUp(cwd);
|
await this.warmUp(cwd, providers);
|
||||||
}
|
}
|
||||||
|
|
||||||
on(event: "change", listener: ProviderSnapshotChangeListener): this {
|
on(event: "change", listener: ProviderSnapshotChangeListener): this {
|
||||||
@@ -56,6 +79,7 @@ export class ProviderSnapshotManager {
|
|||||||
this.destroyed = true;
|
this.destroyed = true;
|
||||||
this.events.removeAllListeners();
|
this.events.removeAllListeners();
|
||||||
this.snapshots.clear();
|
this.snapshots.clear();
|
||||||
|
this.lastCheckedAts.clear();
|
||||||
this.warmUps.clear();
|
this.warmUps.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,16 +98,21 @@ export class ProviderSnapshotManager {
|
|||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async warmUp(cwd?: string): Promise<void> {
|
private async warmUp(cwd?: string, providers?: AgentProvider[]): Promise<void> {
|
||||||
const cwdKey = normalizeCwdKey(cwd);
|
const cwdKey = normalizeCwdKey(cwd);
|
||||||
const inFlight = this.warmUps.get(cwdKey);
|
const inFlight = this.warmUps.get(cwdKey);
|
||||||
if (inFlight) {
|
if (inFlight) {
|
||||||
return inFlight;
|
return inFlight;
|
||||||
}
|
}
|
||||||
|
const providersToRefresh = providers ?? this.getProviderIds();
|
||||||
|
|
||||||
const warmUpPromise = Promise.allSettled(
|
const warmUpPromise = Promise.allSettled(
|
||||||
this.getProviderIds().map((provider) => this.refreshProvider(cwdKey, provider, cwd)),
|
providersToRefresh.map((provider) => this.refreshProvider(cwdKey, provider, cwd)),
|
||||||
).then(() => undefined);
|
).then(() => {
|
||||||
|
if (!providers) {
|
||||||
|
this.lastCheckedAts.set(cwdKey, this.now());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.warmUps.set(cwdKey, warmUpPromise);
|
this.warmUps.set(cwdKey, warmUpPromise);
|
||||||
|
|
||||||
@@ -107,13 +136,6 @@ export class ProviderSnapshotManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = this.getOrCreateSnapshot(cwdKey);
|
const snapshot = this.getOrCreateSnapshot(cwdKey);
|
||||||
snapshot.set(provider, {
|
|
||||||
provider,
|
|
||||||
status: "loading",
|
|
||||||
label: definition.label,
|
|
||||||
description: definition.description,
|
|
||||||
defaultModeId: definition.defaultModeId,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = definition.createClient(this.logger);
|
const client = definition.createClient(this.logger);
|
||||||
@@ -174,6 +196,17 @@ export class ProviderSnapshotManager {
|
|||||||
this.events.emit("change", entriesToArray(snapshot), denormalizeCwdKey(cwdKey));
|
this.events.emit("change", entriesToArray(snapshot), denormalizeCwdKey(cwdKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shouldRevalidate(cwdKey: string): boolean {
|
||||||
|
if (this.warmUps.has(cwdKey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const lastCheckedAt = this.lastCheckedAts.get(cwdKey);
|
||||||
|
if (lastCheckedAt === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.now() - lastCheckedAt > this.ttlMs;
|
||||||
|
}
|
||||||
|
|
||||||
private getOrCreateSnapshot(cwdKey: string): Map<AgentProvider, ProviderSnapshotEntry> {
|
private getOrCreateSnapshot(cwdKey: string): Map<AgentProvider, ProviderSnapshotEntry> {
|
||||||
const existing = this.snapshots.get(cwdKey);
|
const existing = this.snapshots.get(cwdKey);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -185,11 +218,31 @@ export class ProviderSnapshotManager {
|
|||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetSnapshotToLoading(cwdKey: string): Map<AgentProvider, ProviderSnapshotEntry> {
|
private resetSnapshotToLoading(
|
||||||
|
cwdKey: string,
|
||||||
|
providers?: AgentProvider[],
|
||||||
|
): Map<AgentProvider, ProviderSnapshotEntry> {
|
||||||
const snapshot = this.getOrCreateSnapshot(cwdKey);
|
const snapshot = this.getOrCreateSnapshot(cwdKey);
|
||||||
snapshot.clear();
|
const loadingEntries = this.createLoadingEntries();
|
||||||
for (const [provider, entry] of this.createLoadingEntries()) {
|
|
||||||
snapshot.set(provider, entry);
|
if (!providers) {
|
||||||
|
snapshot.clear();
|
||||||
|
for (const [provider, entry] of loadingEntries) {
|
||||||
|
snapshot.set(provider, entry);
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
const loadingEntry = loadingEntries.get(provider);
|
||||||
|
if (!loadingEntry) continue;
|
||||||
|
const existing = snapshot.get(provider);
|
||||||
|
snapshot.set(provider, {
|
||||||
|
...loadingEntry,
|
||||||
|
models: existing?.models,
|
||||||
|
modes: existing?.modes,
|
||||||
|
fetchedAt: existing?.fetchedAt,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
@@ -197,6 +250,15 @@ export class ProviderSnapshotManager {
|
|||||||
private getProviderIds(): AgentProvider[] {
|
private getProviderIds(): AgentProvider[] {
|
||||||
return Object.keys(this.providerRegistry) as AgentProvider[];
|
return Object.keys(this.providerRegistry) as AgentProvider[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveRefreshProviders(providers?: AgentProvider[]): AgentProvider[] | undefined {
|
||||||
|
if (!providers || providers.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerIds = new Set(this.getProviderIds());
|
||||||
|
return Array.from(new Set(providers)).filter((provider) => providerIds.has(provider));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCwdKey(cwd?: string): string {
|
function normalizeCwdKey(cwd?: string): string {
|
||||||
|
|||||||
@@ -3218,7 +3218,10 @@ export class Session {
|
|||||||
private async handleRefreshProvidersSnapshotRequest(
|
private async handleRefreshProvidersSnapshotRequest(
|
||||||
msg: Extract<SessionInboundMessage, { type: "refresh_providers_snapshot_request" }>,
|
msg: Extract<SessionInboundMessage, { type: "refresh_providers_snapshot_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.providerSnapshotManager?.refresh(msg.cwd ? expandTilde(msg.cwd) : undefined);
|
await this.providerSnapshotManager?.refresh({
|
||||||
|
cwd: msg.cwd ? expandTilde(msg.cwd) : undefined,
|
||||||
|
providers: msg.providers,
|
||||||
|
});
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "refresh_providers_snapshot_response",
|
type: "refresh_providers_snapshot_response",
|
||||||
payload: {
|
payload: {
|
||||||
|
|||||||
@@ -852,6 +852,7 @@ export const GetProvidersSnapshotRequestMessageSchema = z.object({
|
|||||||
export const RefreshProvidersSnapshotRequestMessageSchema = z.object({
|
export const RefreshProvidersSnapshotRequestMessageSchema = z.object({
|
||||||
type: z.literal("refresh_providers_snapshot_request"),
|
type: z.literal("refresh_providers_snapshot_request"),
|
||||||
cwd: z.string().optional(),
|
cwd: z.string().optional(),
|
||||||
|
providers: z.array(AgentProviderSchema).optional(),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user