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:
@@ -16,6 +16,35 @@ import {
|
||||
import { X } from "lucide-react-native";
|
||||
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) => ({
|
||||
desktopOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
@@ -48,10 +77,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderBottomColor: theme.colors.surface2,
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
headerActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
marginLeft: theme.spacing[3],
|
||||
marginRight: theme.spacing[2],
|
||||
},
|
||||
closeButton: {
|
||||
padding: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
@@ -83,6 +120,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[6],
|
||||
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) {
|
||||
@@ -106,9 +155,11 @@ export interface AdaptiveModalSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
headerActions?: ReactNode;
|
||||
snapPoints?: string[];
|
||||
stackBehavior?: "push" | "switch" | "replace";
|
||||
testID?: string;
|
||||
scrollable?: boolean;
|
||||
}
|
||||
|
||||
export function AdaptiveModalSheet({
|
||||
@@ -116,9 +167,11 @@ export function AdaptiveModalSheet({
|
||||
visible,
|
||||
onClose,
|
||||
children,
|
||||
headerActions,
|
||||
snapPoints,
|
||||
stackBehavior,
|
||||
testID,
|
||||
scrollable = true,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
@@ -157,6 +210,11 @@ export function AdaptiveModalSheet({
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb || isMobile || !visible) return;
|
||||
return pushEscHandler(onClose);
|
||||
}, [visible, isMobile, onClose]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<BottomSheetModal
|
||||
@@ -174,18 +232,25 @@ export function AdaptiveModalSheet({
|
||||
keyboardBlurBehavior="restore"
|
||||
>
|
||||
<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}>
|
||||
<X size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.bottomSheetContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</BottomSheetScrollView>
|
||||
{scrollable ? (
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.bottomSheetContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</BottomSheetScrollView>
|
||||
) : (
|
||||
<View style={styles.bottomSheetStaticContent}>{children}</View>
|
||||
)}
|
||||
</BottomSheetModal>
|
||||
);
|
||||
}
|
||||
@@ -199,19 +264,26 @@ export function AdaptiveModalSheet({
|
||||
/>
|
||||
<View style={styles.desktopCard}>
|
||||
<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}>
|
||||
<X size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView
|
||||
style={styles.desktopScroll}
|
||||
contentContainerStyle={styles.desktopContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
{scrollable ? (
|
||||
<ScrollView
|
||||
style={styles.desktopScroll}
|
||||
contentContainerStyle={styles.desktopContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View style={styles.desktopStaticContent}>{children}</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { View, Text, ActivityIndicator, ScrollView } from "react-native";
|
||||
import { AlertCircle, Search } from "lucide-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 { 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 { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
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 {
|
||||
provider: string;
|
||||
@@ -22,83 +27,333 @@ export function ProviderDiagnosticSheet({
|
||||
}: ProviderDiagnosticSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const { entries: snapshotEntries } = useProvidersSnapshot(serverId);
|
||||
const { entries: snapshotEntries, refresh, isRefreshing } = useProvidersSnapshot(serverId);
|
||||
const [diagnostic, setDiagnostic] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
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 () => {
|
||||
if (!client || !provider) return;
|
||||
const [clockTick, setClockTick] = useState(0);
|
||||
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);
|
||||
setDiagnostic(null);
|
||||
const q = query.trim().toLowerCase();
|
||||
const filteredModels = q
|
||||
? models.filter((m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q))
|
||||
: models;
|
||||
|
||||
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 fetchDiagnostic = useCallback(
|
||||
async (options?: { keepCurrent?: boolean }) => {
|
||||
if (!client || !provider) return;
|
||||
|
||||
setLoading(true);
|
||||
if (!options?.keepCurrent) {
|
||||
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],
|
||||
);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refresh([provider as AgentProvider]);
|
||||
void fetchDiagnostic({ keepCurrent: true });
|
||||
}, [fetchDiagnostic, provider, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
fetchDiagnostic();
|
||||
} else {
|
||||
setDiagnostic(null);
|
||||
setQuery("");
|
||||
}
|
||||
}, [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 (
|
||||
<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}
|
||||
scrollable={false}
|
||||
headerActions={
|
||||
<Pressable
|
||||
onPress={handleRefresh}
|
||||
disabled={refreshInFlight}
|
||||
hitSlop={8}
|
||||
style={({ hovered, pressed }) => [
|
||||
sheetStyles.iconButton,
|
||||
(hovered || pressed) && sheetStyles.iconButtonHovered,
|
||||
refreshInFlight ? sheetStyles.disabled : null,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Refresh ${providerLabel}`}
|
||||
>
|
||||
<Text style={sheetStyles.diagnosticText} selectable>
|
||||
{diagnostic}
|
||||
</Text>
|
||||
<SpinningRefreshIcon
|
||||
spinning={refreshInFlight}
|
||||
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>
|
||||
) : null}
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
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],
|
||||
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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user