Fix provider refresh scope and self-healing
This commit is contained in:
@@ -92,7 +92,7 @@ type ControlledAgentStatusBarProps = {
|
||||
|
||||
export interface DraftAgentStatusBarProps {
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
selectedProvider: AgentProvider;
|
||||
selectedProvider: AgentProvider | null;
|
||||
onSelectProvider: (provider: AgentProvider) => void;
|
||||
modeOptions: AgentMode[];
|
||||
selectedMode: string;
|
||||
@@ -255,7 +255,8 @@ function ControlledStatusBar({
|
||||
: undefined;
|
||||
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
|
||||
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
|
||||
const ProviderIcon = getProviderIcon(provider);
|
||||
const hasSelectedProvider = provider.trim().length > 0;
|
||||
const ProviderIcon = hasSelectedProvider ? getProviderIcon(provider) : null;
|
||||
|
||||
const hasAnyControl =
|
||||
Boolean(providerOptions?.length) ||
|
||||
@@ -620,7 +621,9 @@ function ControlledStatusBar({
|
||||
accessibilityLabel="Agent preferences"
|
||||
testID="agent-preferences-button"
|
||||
>
|
||||
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
||||
{ProviderIcon ? (
|
||||
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
||||
) : null}
|
||||
<Text style={styles.prefsButtonText} numberOfLines={1}>
|
||||
{displayModel}
|
||||
</Text>
|
||||
@@ -662,7 +665,12 @@ function ControlledStatusBar({
|
||||
pointerEvents="none"
|
||||
testID="agent-preferences-model"
|
||||
>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
{ProviderIcon ? (
|
||||
<ProviderIcon
|
||||
size={theme.iconSize.md}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
) : null}
|
||||
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
@@ -1096,6 +1104,7 @@ export function DraftAgentStatusBar({
|
||||
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
|
||||
const effectiveSelectedThinkingOption =
|
||||
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
|
||||
const hasSelectedProvider = selectedProvider !== null;
|
||||
|
||||
if (platformIsWeb) {
|
||||
return (
|
||||
@@ -1103,7 +1112,7 @@ export function DraftAgentStatusBar({
|
||||
<CombinedModelSelector
|
||||
providerDefinitions={providerDefinitions}
|
||||
allProviderModels={allProviderModels}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedProvider={selectedProvider ?? ""}
|
||||
selectedModel={selectedModel}
|
||||
onSelect={onSelectProviderAndModel}
|
||||
favoriteKeys={favoriteKeys}
|
||||
@@ -1119,20 +1128,22 @@ export function DraftAgentStatusBar({
|
||||
onOpen={onModelSelectorOpen}
|
||||
onClose={onDropdownClose}
|
||||
/>
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider}
|
||||
providerDefinitions={providerDefinitions}
|
||||
modeOptions={mappedModeOptions}
|
||||
selectedModeId={effectiveSelectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||
onSelectThinkingOption={onSelectThinkingOption}
|
||||
features={features}
|
||||
onSetFeature={onSetFeature}
|
||||
onDropdownClose={onDropdownClose}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{selectedProvider ? (
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider}
|
||||
providerDefinitions={providerDefinitions}
|
||||
modeOptions={mappedModeOptions}
|
||||
selectedModeId={effectiveSelectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||
onSelectThinkingOption={onSelectThinkingOption}
|
||||
features={features}
|
||||
onSetFeature={onSetFeature}
|
||||
onDropdownClose={onDropdownClose}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1145,10 +1156,10 @@ export function DraftAgentStatusBar({
|
||||
return (
|
||||
<>
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider}
|
||||
provider={selectedProvider ?? ""}
|
||||
providerDefinitions={providerDefinitions}
|
||||
allProviderModels={allProviderModels}
|
||||
modeOptions={mappedModeOptions}
|
||||
modeOptions={hasSelectedProvider ? mappedModeOptions : undefined}
|
||||
selectedModeId={effectiveSelectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
modelOptions={modelOptions}
|
||||
|
||||
@@ -568,10 +568,14 @@ export function CombinedModelSelector({
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
const ProviderIcon = getProviderIcon(selectedProvider);
|
||||
const hasSelectedProvider = selectedProvider.trim().length > 0;
|
||||
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
|
||||
|
||||
const selectedModelLabel = useMemo(() => {
|
||||
if (!selectedModel) {
|
||||
if (!hasSelectedProvider) {
|
||||
return "Select model";
|
||||
}
|
||||
return isLoading ? "Loading..." : "Select model";
|
||||
}
|
||||
const models = allProviderModels.get(selectedProvider);
|
||||
@@ -580,7 +584,7 @@ export function CombinedModelSelector({
|
||||
}
|
||||
const model = models.find((entry) => entry.id === selectedModel);
|
||||
return model?.label ?? resolveDefaultModelLabel(models);
|
||||
}, [allProviderModels, isLoading, selectedModel, selectedProvider]);
|
||||
}, [allProviderModels, hasSelectedProvider, isLoading, selectedModel, selectedProvider]);
|
||||
|
||||
const desktopFixedHeight = useMemo(() => {
|
||||
if (view.kind !== "provider") {
|
||||
@@ -643,7 +647,9 @@ export function CombinedModelSelector({
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
{ProviderIcon ? (
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
) : null}
|
||||
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
|
||||
{triggerLabel}
|
||||
</Text>
|
||||
|
||||
@@ -8,18 +8,8 @@ import {
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
@@ -34,6 +24,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react-native";
|
||||
import { getFileIconSvg } from "@/components/material-file-icons";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -312,48 +303,6 @@ export function FileExplorerPane({
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refetchExplorer();
|
||||
}, [refetchExplorer]);
|
||||
const refreshIconRotation = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRefreshFetching) {
|
||||
refreshIconRotation.value = 0;
|
||||
refreshIconRotation.value = withRepeat(
|
||||
withTiming(360, {
|
||||
duration: 700,
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
-1,
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
cancelAnimation(refreshIconRotation);
|
||||
const remainder = refreshIconRotation.value % 360;
|
||||
if (Math.abs(remainder) < 0.001) {
|
||||
refreshIconRotation.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const remaining = 360 - remainder;
|
||||
const duration = Math.max(80, Math.round((remaining / 360) * 700));
|
||||
refreshIconRotation.value = withTiming(
|
||||
360,
|
||||
{
|
||||
duration,
|
||||
easing: Easing.linear,
|
||||
},
|
||||
(finished) => {
|
||||
if (finished) {
|
||||
refreshIconRotation.value = 0;
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [isRefreshFetching, refreshIconRotation]);
|
||||
|
||||
const refreshIconAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ rotate: `${refreshIconRotation.value}deg` }],
|
||||
}));
|
||||
|
||||
const currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name";
|
||||
|
||||
@@ -563,11 +512,15 @@ export function FileExplorerPane({
|
||||
(hovered || pressed) && styles.iconButtonHovered,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Refresh files"
|
||||
accessibilityLabel={isRefreshFetching ? "Refreshing files" : "Refresh files"}
|
||||
>
|
||||
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
|
||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Animated.View>
|
||||
<View style={styles.refreshIcon}>
|
||||
{isRefreshFetching ? (
|
||||
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
<FlatList
|
||||
|
||||
@@ -118,6 +118,26 @@ function useDisableOuterSpacing(disableOuterSpacing: boolean | undefined) {
|
||||
|
||||
const WEB_TOOLCALL_SHIMMER_KEYFRAME_ID = "paseo-toolcall-shimmer-keyframes";
|
||||
const WEB_TOOLCALL_SHIMMER_ANIMATION_NAME = "paseo-toolcall-shimmer";
|
||||
const MARKDOWN_ALLOWED_IMAGE_HANDLERS = [
|
||||
"data:image/png;base64",
|
||||
"data:image/gif;base64",
|
||||
"data:image/jpeg;base64",
|
||||
"https://",
|
||||
"http://",
|
||||
] as const;
|
||||
const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = <Text key="dotdotdot">...</Text>;
|
||||
|
||||
type MarkdownWithStableRendererProps = {
|
||||
children: ReactNode;
|
||||
style: ReturnType<typeof createMarkdownStyles>;
|
||||
rules: RenderRules;
|
||||
markdownit: MarkdownIt;
|
||||
onLinkPress: (url: string) => boolean;
|
||||
allowedImageHandlers: readonly string[];
|
||||
topLevelMaxExceededItem: ReactNode;
|
||||
};
|
||||
|
||||
const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>;
|
||||
const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
|
||||
@keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} {
|
||||
0% {
|
||||
@@ -947,9 +967,16 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
|
||||
onLinkPress,
|
||||
}: MemoizedMarkdownBlockProps) {
|
||||
return (
|
||||
<Markdown style={styles} rules={rules} markdownit={parser} onLinkPress={onLinkPress}>
|
||||
<MarkdownWithStableRenderer
|
||||
style={styles}
|
||||
rules={rules}
|
||||
markdownit={parser}
|
||||
onLinkPress={onLinkPress}
|
||||
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
|
||||
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
|
||||
>
|
||||
{text}
|
||||
</Markdown>
|
||||
</MarkdownWithStableRenderer>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { AlertCircle, Search } from "lucide-react-native";
|
||||
import { AlertCircle, RotateCw, 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, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||
import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
@@ -82,8 +82,12 @@ export function ProviderDiagnosticSheet({
|
||||
);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refresh([provider as AgentProvider]);
|
||||
void fetchDiagnostic({ keepCurrent: true });
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
void Promise.all([refresh([provider as AgentProvider]), fetchDiagnostic()]).catch((err) => {
|
||||
setDiagnostic(err instanceof Error ? err.message : "Failed to refresh provider");
|
||||
});
|
||||
}, [fetchDiagnostic, provider, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -157,13 +161,15 @@ export function ProviderDiagnosticSheet({
|
||||
refreshInFlight ? sheetStyles.disabled : null,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Refresh ${providerLabel}`}
|
||||
accessibilityLabel={
|
||||
refreshInFlight ? `Refreshing ${providerLabel}` : `Refresh ${providerLabel}`
|
||||
}
|
||||
>
|
||||
<SpinningRefreshIcon
|
||||
spinning={refreshInFlight}
|
||||
size={theme.iconSize.sm}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
{refreshInFlight ? (
|
||||
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ActivityIndicator, type ActivityIndicatorProps } from "react-native";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
color: string;
|
||||
size?: ActivityIndicatorProps["size"];
|
||||
}
|
||||
|
||||
export function LoadingSpinner({ color, size = "small" }: LoadingSpinnerProps) {
|
||||
return <ActivityIndicator size={size} color={color} />;
|
||||
}
|
||||
@@ -179,6 +179,9 @@ export function WorkspaceSetupDialog() {
|
||||
if (!composerState) {
|
||||
throw new Error("Workspace setup composer state is required");
|
||||
}
|
||||
if (!composerState.selectedProvider) {
|
||||
throw new Error("Select a model");
|
||||
}
|
||||
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
|
||||
const encodedImages = await encodeImages(wirePayload.images);
|
||||
|
||||
Reference in New Issue
Block a user