Rework provider selector and settings UX
Surface all enabled providers in the model selector with inline loading and error states instead of silently hiding non-ready ones. Tap any row to drill in; loading and error states show in the drill-down body, with a Retry button on error. Providers with no models render a synthetic "Default" row so every provider's drill-down has symmetric structure. Redesign the per-provider settings sheet: search lives in the header, discovered and custom models share one scrolling list, and the footer hosts action buttons. Diagnostic and "Add model" each open as their own sub-sheet, so neither expands inline or shifts the surrounding layout. Snapshot refresh now keeps cached data visible: setQueryData replaces the cache atomically and sibling-scope caches are invalidated (not removed), so refreshing a provider no longer blanks its row. Search inputs across the app are uncontrolled — onChangeText drives parent state, and resetKey remounts the field when an explicit reset is needed — to avoid RN's controlled-input flicker. Adds a dev-only mock-slow provider whose discovery never resolves, so the 30s snapshot timeout produces a real error and lets loading and timeout-error UI be exercised end to end.
This commit is contained in:
@@ -28,6 +28,7 @@ import { QuittingOverlay } from "@/components/quitting-overlay";
|
|||||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||||
import { LeftSidebar } from "@/components/left-sidebar";
|
import { LeftSidebar } from "@/components/left-sidebar";
|
||||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||||
|
import { ProviderSettingsHost } from "@/components/provider-settings-host";
|
||||||
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||||
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
||||||
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
|
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
|
||||||
@@ -466,6 +467,7 @@ function AppContainer({
|
|||||||
<WorktreeSetupCalloutSource />
|
<WorktreeSetupCalloutSource />
|
||||||
<CommandCenter />
|
<CommandCenter />
|
||||||
<ProjectPickerModal />
|
<ProjectPickerModal />
|
||||||
|
<ProviderSettingsHost />
|
||||||
<WorkspaceShortcutTargetsSubscriber
|
<WorkspaceShortcutTargetsSubscriber
|
||||||
enabled={keyboardShortcutsEnabled}
|
enabled={keyboardShortcutsEnabled}
|
||||||
serverId={activeServerId}
|
serverId={activeServerId}
|
||||||
|
|||||||
@@ -28,9 +28,7 @@ import { isNative, isWeb } from "@/constants/platform";
|
|||||||
export const SHEET_HORIZONTAL_PADDING_SCALE = 6;
|
export const SHEET_HORIZONTAL_PADDING_SCALE = 6;
|
||||||
|
|
||||||
export interface SheetHeaderSearch {
|
export interface SheetHeaderSearch {
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
initialValue?: string;
|
|
||||||
resetKey?: string | number;
|
resetKey?: string | number;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
@@ -167,6 +165,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderBottomColor: theme.colors.border,
|
borderBottomColor: theme.colors.border,
|
||||||
},
|
},
|
||||||
inlineTitle: {
|
inlineTitle: {
|
||||||
|
flex: 1,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.medium,
|
fontWeight: theme.fontWeight.medium,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
@@ -202,6 +201,16 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
|
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
|
||||||
gap: theme.spacing[4],
|
gap: theme.spacing[4],
|
||||||
},
|
},
|
||||||
|
footer: {
|
||||||
|
paddingHorizontal: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: theme.colors.surface2,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
adaptiveInputOutline: {
|
adaptiveInputOutline: {
|
||||||
outlineColor: theme.colors.accent,
|
outlineColor: theme.colors.accent,
|
||||||
},
|
},
|
||||||
@@ -352,9 +361,7 @@ export function SheetHeaderView({
|
|||||||
// @ts-expect-error - outlineStyle is web-only
|
// @ts-expect-error - outlineStyle is web-only
|
||||||
style={SEARCH_INPUT_STYLE}
|
style={SEARCH_INPUT_STYLE}
|
||||||
placeholder={search.placeholder ?? "Search"}
|
placeholder={search.placeholder ?? "Search"}
|
||||||
initialValue={search.initialValue}
|
|
||||||
resetKey={search.resetKey}
|
resetKey={search.resetKey}
|
||||||
value={search.value}
|
|
||||||
onChangeText={handleSearchChange}
|
onChangeText={handleSearchChange}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
@@ -371,7 +378,7 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const back = header.back;
|
const back = header.back;
|
||||||
const handleBackPress = back?.onPress;
|
const handleBackPress = back?.onPress;
|
||||||
const hasInlineRow = Boolean(handleBackPress || header.leading);
|
const hasInlineRow = Boolean(handleBackPress || header.leading || header.actions);
|
||||||
if (!hasInlineRow && !header.search) return null;
|
if (!hasInlineRow && !header.search) return null;
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
@@ -398,6 +405,7 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
|
|||||||
<Text style={styles.inlineTitle} numberOfLines={1}>
|
<Text style={styles.inlineTitle} numberOfLines={1}>
|
||||||
{header.title}
|
{header.title}
|
||||||
</Text>
|
</Text>
|
||||||
|
{header.actions ? <View style={styles.headerActions}>{header.actions}</View> : null}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
{header.search ? (
|
{header.search ? (
|
||||||
@@ -407,9 +415,7 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
|
|||||||
// @ts-expect-error - outlineStyle is web-only
|
// @ts-expect-error - outlineStyle is web-only
|
||||||
style={SEARCH_INPUT_STYLE}
|
style={SEARCH_INPUT_STYLE}
|
||||||
placeholder={header.search.placeholder ?? "Search"}
|
placeholder={header.search.placeholder ?? "Search"}
|
||||||
initialValue={header.search.initialValue}
|
|
||||||
resetKey={header.search.resetKey}
|
resetKey={header.search.resetKey}
|
||||||
value={header.search.value}
|
|
||||||
onChangeText={header.search.onChange}
|
onChangeText={header.search.onChange}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
@@ -427,6 +433,8 @@ export interface AdaptiveModalSheetProps {
|
|||||||
visible: boolean;
|
visible: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
/** Sticky footer rendered below the scrollable content. */
|
||||||
|
footer?: ReactNode;
|
||||||
snapPoints?: string[];
|
snapPoints?: string[];
|
||||||
testID?: string;
|
testID?: string;
|
||||||
/** Override the max width of the desktop card. */
|
/** Override the max width of the desktop card. */
|
||||||
@@ -441,6 +449,7 @@ export function AdaptiveModalSheet({
|
|||||||
visible,
|
visible,
|
||||||
onClose,
|
onClose,
|
||||||
children,
|
children,
|
||||||
|
footer,
|
||||||
snapPoints,
|
snapPoints,
|
||||||
testID,
|
testID,
|
||||||
desktopMaxWidth,
|
desktopMaxWidth,
|
||||||
@@ -506,6 +515,7 @@ export function AdaptiveModalSheet({
|
|||||||
) : (
|
) : (
|
||||||
<View style={styles.bottomSheetStaticContent}>{children}</View>
|
<View style={styles.bottomSheetStaticContent}>{children}</View>
|
||||||
)}
|
)}
|
||||||
|
{footer ? <View style={styles.footer}>{footer}</View> : null}
|
||||||
</IsolatedBottomSheetModal>
|
</IsolatedBottomSheetModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -518,13 +528,13 @@ export function AdaptiveModalSheet({
|
|||||||
style={styles.desktopScroll}
|
style={styles.desktopScroll}
|
||||||
contentContainerStyle={styles.desktopContent}
|
contentContainerStyle={styles.desktopContent}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.desktopStaticContent}>{children}</View>
|
<View style={styles.desktopStaticContent}>{children}</View>
|
||||||
)}
|
)}
|
||||||
|
{footer ? <View style={styles.footer}>{footer}</View> : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,19 @@ import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
|
|||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
|
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
|
||||||
import { ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Search,
|
||||||
|
Settings,
|
||||||
|
Star,
|
||||||
|
} from "lucide-react-native";
|
||||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
|
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||||
|
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
const IS_WEB = platformIsWeb;
|
const IS_WEB = platformIsWeb;
|
||||||
|
|
||||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
@@ -48,7 +58,6 @@ import {
|
|||||||
buildSelectedTriggerLabel,
|
buildSelectedTriggerLabel,
|
||||||
filterAndRankModelRows,
|
filterAndRankModelRows,
|
||||||
getAllProviderModelRows,
|
getAllProviderModelRows,
|
||||||
getProviderDefaultLabel,
|
|
||||||
getProviderModelRows,
|
getProviderModelRows,
|
||||||
resolveSelectedModelLabel,
|
resolveSelectedModelLabel,
|
||||||
type ProviderSelectionModelRow,
|
type ProviderSelectionModelRow,
|
||||||
@@ -79,6 +88,7 @@ interface CombinedModelSelectorProps {
|
|||||||
onOpen?: () => void;
|
onOpen?: () => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
serverId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SelectorContentProps {
|
interface SelectorContentProps {
|
||||||
@@ -91,6 +101,7 @@ interface SelectorContentProps {
|
|||||||
onSelect: (provider: string, modelId: string) => void;
|
onSelect: (provider: string, modelId: string) => void;
|
||||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||||
|
serverId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSearchQuery(value: string): string {
|
function normalizeSearchQuery(value: string): string {
|
||||||
@@ -268,42 +279,60 @@ function FavoritesSection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface GroupProviderButtonProps {
|
interface GroupProviderButtonProps {
|
||||||
providerId: string;
|
provider: ProviderSelectorProvider;
|
||||||
providerLabel: string;
|
|
||||||
rowCount: number;
|
|
||||||
defaultLabel: string | null;
|
|
||||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||||
onSelectDefault: (providerId: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupProviderButton({
|
function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) {
|
||||||
providerId,
|
return [
|
||||||
providerLabel,
|
styles.rowIconButton,
|
||||||
rowCount,
|
Boolean(hovered) && styles.rowIconButtonHovered,
|
||||||
defaultLabel,
|
pressed && styles.rowIconButtonPressed,
|
||||||
onDrillDown,
|
];
|
||||||
onSelectDefault,
|
}
|
||||||
}: GroupProviderButtonProps) {
|
|
||||||
|
function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const ProvIcon = getProviderIcon(providerId);
|
const ProvIcon = getProviderIcon(provider.id);
|
||||||
|
const selection = provider.modelSelection;
|
||||||
|
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
if (defaultLabel) {
|
onDrillDown(provider.id, provider.label);
|
||||||
onSelectDefault(providerId);
|
}, [onDrillDown, provider.id, provider.label]);
|
||||||
return;
|
|
||||||
}
|
let stateNode: React.ReactNode;
|
||||||
onDrillDown(providerId, providerLabel);
|
if (selection.kind === "models") {
|
||||||
}, [defaultLabel, onDrillDown, onSelectDefault, providerId, providerLabel]);
|
const count = selection.rows.length;
|
||||||
|
stateNode = (
|
||||||
|
<Text style={styles.drillDownCount}>{`${count} ${count === 1 ? "model" : "models"}`}</Text>
|
||||||
|
);
|
||||||
|
} else if (selection.kind === "loading") {
|
||||||
|
stateNode = (
|
||||||
|
<View style={styles.rowStateInline}>
|
||||||
|
<ActivityIndicator
|
||||||
|
size="small"
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
style={styles.rowSpinner}
|
||||||
|
/>
|
||||||
|
<Text style={styles.drillDownCount}>Loading</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
stateNode = (
|
||||||
|
<View style={styles.rowStateInline}>
|
||||||
|
<AlertTriangle size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={styles.drillDownCount}>Error</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={handlePress} style={drillDownRowStyle}>
|
<Pressable onPress={handlePress} style={drillDownRowStyle}>
|
||||||
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.drillDownText}>{providerLabel}</Text>
|
<Text style={styles.drillDownText}>{provider.label}</Text>
|
||||||
<View style={styles.drillDownTrailing}>
|
<View style={styles.drillDownTrailing}>
|
||||||
<Text style={styles.drillDownCount}>
|
{stateNode}
|
||||||
{defaultLabel ?? `${rowCount} ${rowCount === 1 ? "model" : "models"}`}
|
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
</Text>
|
|
||||||
{defaultLabel ? null : (
|
|
||||||
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
@@ -312,64 +341,22 @@ function GroupProviderButton({
|
|||||||
function GroupedProviderRows({
|
function GroupedProviderRows({
|
||||||
providers,
|
providers,
|
||||||
onDrillDown,
|
onDrillDown,
|
||||||
onSelectDefault,
|
|
||||||
}: {
|
}: {
|
||||||
providers: ProviderSelectorProvider[];
|
providers: ProviderSelectorProvider[];
|
||||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||||
onSelectDefault: (providerId: string) => void;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
{providers.map((provider, index) => {
|
{providers.map((provider, index) => (
|
||||||
const rows = getProviderModelRows(provider);
|
<View key={provider.id}>
|
||||||
const defaultLabel = getProviderDefaultLabel(provider);
|
{index > 0 ? <View style={styles.separator} /> : null}
|
||||||
return (
|
<GroupProviderButton provider={provider} onDrillDown={onDrillDown} />
|
||||||
<View key={provider.id}>
|
</View>
|
||||||
{index > 0 ? <View style={styles.separator} /> : null}
|
))}
|
||||||
<GroupProviderButton
|
|
||||||
providerId={provider.id}
|
|
||||||
providerLabel={provider.label}
|
|
||||||
rowCount={rows.length}
|
|
||||||
defaultLabel={defaultLabel}
|
|
||||||
onDrillDown={onDrillDown}
|
|
||||||
onSelectDefault={onSelectDefault}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DefaultProviderRow({
|
|
||||||
providerId,
|
|
||||||
isSelected,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
providerId: string;
|
|
||||||
isSelected: boolean;
|
|
||||||
onSelect: (provider: string, modelId: string) => void;
|
|
||||||
}) {
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const ProviderIcon = getProviderIcon(providerId);
|
|
||||||
const handlePress = useCallback(() => {
|
|
||||||
onSelect(providerId, "");
|
|
||||||
}, [onSelect, providerId]);
|
|
||||||
const leadingSlot = useMemo(
|
|
||||||
() => <ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
|
||||||
[ProviderIcon, theme.iconSize.sm, theme.colors.foregroundMuted],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ComboboxItem
|
|
||||||
label="Default"
|
|
||||||
selected={isSelected}
|
|
||||||
onPress={handlePress}
|
|
||||||
leadingSlot={leadingSlot}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProviderModelRows({
|
function ProviderModelRows({
|
||||||
rows,
|
rows,
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
@@ -430,6 +417,31 @@ function ProviderModelRows({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProviderErrorEmptyState({
|
||||||
|
serverId,
|
||||||
|
providerId,
|
||||||
|
message,
|
||||||
|
}: {
|
||||||
|
serverId: string | null;
|
||||||
|
providerId: string;
|
||||||
|
message: string;
|
||||||
|
}) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const { refresh, isRefreshing } = useProvidersSnapshot(serverId);
|
||||||
|
const handleRetry = useCallback(() => {
|
||||||
|
void refresh([providerId]);
|
||||||
|
}, [refresh, providerId]);
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<AlertTriangle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={styles.emptyStateText}>{message}</Text>
|
||||||
|
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRefreshing}>
|
||||||
|
{isRefreshing ? "Retrying…" : "Retry"}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SelectorContent({
|
function SelectorContent({
|
||||||
view,
|
view,
|
||||||
providers,
|
providers,
|
||||||
@@ -440,6 +452,7 @@ function SelectorContent({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
onDrillDown,
|
onDrillDown,
|
||||||
|
serverId,
|
||||||
}: SelectorContentProps) {
|
}: SelectorContentProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
|
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
|
||||||
@@ -461,12 +474,6 @@ function SelectorContent({
|
|||||||
() => getAllProviderModelRows(providers).filter((row) => favoriteKeys.has(row.favoriteKey)),
|
() => getAllProviderModelRows(providers).filter((row) => favoriteKeys.has(row.favoriteKey)),
|
||||||
[favoriteKeys, providers],
|
[favoriteKeys, providers],
|
||||||
);
|
);
|
||||||
const handleSelectDefaultProvider = useCallback(
|
|
||||||
(providerId: string) => {
|
|
||||||
onSelect(providerId, "");
|
|
||||||
},
|
|
||||||
[onSelect],
|
|
||||||
);
|
|
||||||
const hasResults = favoriteRows.length > 0 || providers.length > 0;
|
const hasResults = favoriteRows.length > 0 || providers.length > 0;
|
||||||
const emptyState = (
|
const emptyState = (
|
||||||
<View style={styles.emptyState}>
|
<View style={styles.emptyState}>
|
||||||
@@ -479,17 +486,28 @@ function SelectorContent({
|
|||||||
if (!selectedViewProvider) {
|
if (!selectedViewProvider) {
|
||||||
return emptyState;
|
return emptyState;
|
||||||
}
|
}
|
||||||
|
const drillSelection = selectedViewProvider.modelSelection;
|
||||||
if (getProviderDefaultLabel(selectedViewProvider) && !normalizedQuery) {
|
if (drillSelection.kind === "loading") {
|
||||||
return (
|
return (
|
||||||
<DefaultProviderRow
|
<View style={styles.emptyState}>
|
||||||
|
<ActivityIndicator
|
||||||
|
size="small"
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
style={styles.rowSpinner}
|
||||||
|
/>
|
||||||
|
<Text style={styles.emptyStateText}>Loading</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (drillSelection.kind === "error") {
|
||||||
|
return (
|
||||||
|
<ProviderErrorEmptyState
|
||||||
|
serverId={serverId}
|
||||||
providerId={view.providerId}
|
providerId={view.providerId}
|
||||||
isSelected={view.providerId === selectedProvider && !selectedModel}
|
message={drillSelection.message}
|
||||||
onSelect={onSelect}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (visibleRows.length === 0) {
|
if (visibleRows.length === 0) {
|
||||||
return emptyState;
|
return emptyState;
|
||||||
}
|
}
|
||||||
@@ -519,11 +537,7 @@ function SelectorContent({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{providers.length > 0 ? (
|
{providers.length > 0 ? (
|
||||||
<GroupedProviderRows
|
<GroupedProviderRows providers={providers} onDrillDown={onDrillDown} />
|
||||||
providers={providers}
|
|
||||||
onDrillDown={onDrillDown}
|
|
||||||
onSelectDefault={handleSelectDefaultProvider}
|
|
||||||
/>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!hasResults ? emptyState : null}
|
{!hasResults ? emptyState : null}
|
||||||
@@ -543,6 +557,7 @@ export function CombinedModelSelector({
|
|||||||
onOpen,
|
onOpen,
|
||||||
onClose,
|
onClose,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
serverId = null,
|
||||||
}: CombinedModelSelectorProps) {
|
}: CombinedModelSelectorProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const anchorRef = useRef<View>(null);
|
const anchorRef = useRef<View>(null);
|
||||||
@@ -648,6 +663,10 @@ export function CombinedModelSelector({
|
|||||||
handleOpenChange(!isOpen);
|
handleOpenChange(!isOpen);
|
||||||
}, [handleOpenChange, isOpen]);
|
}, [handleOpenChange, isOpen]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
handleOpenChange(false);
|
||||||
|
}, [handleOpenChange]);
|
||||||
|
|
||||||
const triggerStyle = useCallback(
|
const triggerStyle = useCallback(
|
||||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
styles.trigger,
|
styles.trigger,
|
||||||
@@ -673,21 +692,42 @@ export function CombinedModelSelector({
|
|||||||
setSearchQuery(value);
|
setSearchQuery(value);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const openHeaderProviderSettings = useCallback(() => {
|
||||||
|
if (!serverId || view.kind !== "provider") return;
|
||||||
|
useProviderSettingsStore.getState().open({ serverId, provider: view.providerId });
|
||||||
|
handleClose();
|
||||||
|
}, [serverId, view, handleClose]);
|
||||||
|
|
||||||
const sheetHeader = useMemo<SheetHeader>(() => {
|
const sheetHeader = useMemo<SheetHeader>(() => {
|
||||||
if (view.kind === "all") {
|
if (view.kind === "all") {
|
||||||
return { title: "Select provider" };
|
return { title: "Select provider" };
|
||||||
}
|
}
|
||||||
const ProviderIconForView = getProviderIcon(view.providerId);
|
const ProviderIconForView = getProviderIcon(view.providerId);
|
||||||
|
const headerActions = (
|
||||||
|
<Pressable
|
||||||
|
onPress={openHeaderProviderSettings}
|
||||||
|
disabled={!serverId}
|
||||||
|
hitSlop={8}
|
||||||
|
style={iconButtonStyle}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Open ${view.providerLabel} settings`}
|
||||||
|
testID={`selector-header-settings-${view.providerId}`}
|
||||||
|
>
|
||||||
|
<Settings
|
||||||
|
size={theme.iconSize.sm}
|
||||||
|
color={!serverId ? theme.colors.border : theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
title: view.providerLabel,
|
title: view.providerLabel,
|
||||||
leading: ProviderIconForView ? (
|
leading: ProviderIconForView ? (
|
||||||
<ProviderIconForView size={theme.iconSize.md} color={theme.colors.foreground} />
|
<ProviderIconForView size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||||
) : undefined,
|
) : undefined,
|
||||||
back: singleProviderView ? undefined : { onPress: handleBackToAll },
|
back: singleProviderView ? undefined : { onPress: handleBackToAll },
|
||||||
|
actions: headerActions,
|
||||||
search: {
|
search: {
|
||||||
value: searchQuery,
|
|
||||||
onChange: handleSearchQueryChange,
|
onChange: handleSearchQueryChange,
|
||||||
initialValue: searchQuery,
|
|
||||||
resetKey: `${view.providerId}:${searchResetKey}`,
|
resetKey: `${view.providerId}:${searchResetKey}`,
|
||||||
placeholder: "Search models...",
|
placeholder: "Search models...",
|
||||||
autoFocus: platformIsWeb,
|
autoFocus: platformIsWeb,
|
||||||
@@ -697,11 +737,15 @@ export function CombinedModelSelector({
|
|||||||
}, [
|
}, [
|
||||||
view,
|
view,
|
||||||
singleProviderView,
|
singleProviderView,
|
||||||
|
serverId,
|
||||||
|
openHeaderProviderSettings,
|
||||||
|
theme.colors.border,
|
||||||
|
theme.colors.foregroundMuted,
|
||||||
handleBackToAll,
|
handleBackToAll,
|
||||||
handleSearchQueryChange,
|
handleSearchQueryChange,
|
||||||
searchQuery,
|
|
||||||
searchResetKey,
|
searchResetKey,
|
||||||
theme.iconSize.md,
|
theme.iconSize.md,
|
||||||
|
theme.iconSize.sm,
|
||||||
theme.colors.foreground,
|
theme.colors.foreground,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -760,6 +804,7 @@ export function CombinedModelSelector({
|
|||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
onDrillDown={handleDrillDown}
|
onDrillDown={handleDrillDown}
|
||||||
|
serverId={serverId}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.sheetLoadingState}>
|
<View style={styles.sheetLoadingState}>
|
||||||
@@ -857,6 +902,34 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
|
rowStateInline: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
rowErrorText: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
maxWidth: 140,
|
||||||
|
},
|
||||||
|
rowIconButton: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
rowSpinner: {
|
||||||
|
transform: [{ scale: 0.7 }],
|
||||||
|
},
|
||||||
|
rowIconButtonHovered: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
rowIconButtonPressed: {
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
},
|
||||||
emptyState: {
|
emptyState: {
|
||||||
paddingVertical: theme.spacing[4],
|
paddingVertical: theme.spacing[4],
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
||||||
|
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||||
|
|
||||||
|
export function ProviderSettingsHost() {
|
||||||
|
const serverId = useProviderSettingsStore((state) => state.serverId);
|
||||||
|
const provider = useProviderSettingsStore((state) => state.provider);
|
||||||
|
const close = useProviderSettingsStore((state) => state.close);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
close();
|
||||||
|
}, [close]);
|
||||||
|
|
||||||
|
if (!serverId || !provider) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProviderDiagnosticSheet
|
||||||
|
provider={provider}
|
||||||
|
serverId={serverId}
|
||||||
|
visible
|
||||||
|
onClose={handleClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -156,7 +156,6 @@ function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
|
|||||||
|
|
||||||
export interface SearchInputProps {
|
export interface SearchInputProps {
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
value: string;
|
|
||||||
onChangeText: (text: string) => void;
|
onChangeText: (text: string) => void;
|
||||||
onSubmitEditing?: () => void;
|
onSubmitEditing?: () => void;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
@@ -166,7 +165,6 @@ export interface SearchInputProps {
|
|||||||
|
|
||||||
export function SearchInput({
|
export function SearchInput({
|
||||||
placeholder,
|
placeholder,
|
||||||
value,
|
|
||||||
onChangeText,
|
onChangeText,
|
||||||
onSubmitEditing,
|
onSubmitEditing,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
@@ -194,9 +192,7 @@ export function SearchInput({
|
|||||||
// @ts-expect-error - outlineStyle is web-only
|
// @ts-expect-error - outlineStyle is web-only
|
||||||
style={SEARCH_INPUT_STYLE}
|
style={SEARCH_INPUT_STYLE}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
initialValue={value}
|
|
||||||
resetKey={resetKey}
|
resetKey={resetKey}
|
||||||
value={value}
|
|
||||||
onChangeText={onChangeText}
|
onChangeText={onChangeText}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
@@ -204,12 +200,12 @@ export function SearchInput({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TextInput
|
<TextInput
|
||||||
|
key={resetKey}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
// @ts-expect-error - outlineStyle is web-only
|
// @ts-expect-error - outlineStyle is web-only
|
||||||
style={SEARCH_INPUT_STYLE}
|
style={SEARCH_INPUT_STYLE}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
placeholderTextColor={theme.colors.foregroundMuted}
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
value={value}
|
|
||||||
onChangeText={onChangeText}
|
onChangeText={onChangeText}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
@@ -1041,7 +1037,6 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
|
|||||||
{!props.hasChildren && props.searchable ? (
|
{!props.hasChildren && props.searchable ? (
|
||||||
<SearchInput
|
<SearchInput
|
||||||
placeholder={props.searchPlaceholder}
|
placeholder={props.searchPlaceholder}
|
||||||
value={props.searchQuery}
|
|
||||||
onChangeText={props.setSearchQueryWithCallback}
|
onChangeText={props.setSearchQueryWithCallback}
|
||||||
onSubmitEditing={props.handleSubmitSearch}
|
onSubmitEditing={props.handleSubmitSearch}
|
||||||
autoFocus={false}
|
autoFocus={false}
|
||||||
@@ -1150,7 +1145,6 @@ function DesktopComboboxOptionsBody(props: {
|
|||||||
{props.header || !props.searchable ? null : (
|
{props.header || !props.searchable ? null : (
|
||||||
<SearchInput
|
<SearchInput
|
||||||
placeholder={props.searchPlaceholder}
|
placeholder={props.searchPlaceholder}
|
||||||
value={props.searchQuery}
|
|
||||||
onChangeText={props.setSearchQueryWithCallback}
|
onChangeText={props.setSearchQueryWithCallback}
|
||||||
onSubmitEditing={props.handleSubmitSearch}
|
onSubmitEditing={props.handleSubmitSearch}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ interface ControlledAgentControlsProps {
|
|||||||
onModelSelectorOpen?: () => void;
|
onModelSelectorOpen?: () => void;
|
||||||
/** Extra elements rendered inline with the agent controls (desktop only). */
|
/** Extra elements rendered inline with the agent controls (desktop only). */
|
||||||
desktopExtras?: ReactNode;
|
desktopExtras?: ReactNode;
|
||||||
|
modelSelectorServerId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DraftAgentControlsProps {
|
export interface DraftAgentControlsProps {
|
||||||
@@ -117,6 +118,7 @@ export interface DraftAgentControlsProps {
|
|||||||
onDropdownClose?: () => void;
|
onDropdownClose?: () => void;
|
||||||
onModelSelectorOpen?: () => void;
|
onModelSelectorOpen?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
modelSelectorServerId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AgentControlsProps {
|
interface AgentControlsProps {
|
||||||
@@ -410,6 +412,7 @@ function ControlledAgentControls({
|
|||||||
onDropdownClose,
|
onDropdownClose,
|
||||||
onModelSelectorOpen,
|
onModelSelectorOpen,
|
||||||
desktopExtras,
|
desktopExtras,
|
||||||
|
modelSelectorServerId = null,
|
||||||
}: ControlledAgentControlsProps) {
|
}: ControlledAgentControlsProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
@@ -613,6 +616,7 @@ function ControlledAgentControls({
|
|||||||
handleOpenChange={handleOpenChange}
|
handleOpenChange={handleOpenChange}
|
||||||
renderThinkingOption={renderThinkingOption}
|
renderThinkingOption={renderThinkingOption}
|
||||||
extras={desktopExtras}
|
extras={desktopExtras}
|
||||||
|
modelSelectorServerId={modelSelectorServerId}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SheetAgentControlsContent
|
<SheetAgentControlsContent
|
||||||
@@ -641,6 +645,7 @@ function ControlledAgentControls({
|
|||||||
handleSelectThinkingAndClose={handleSelectThinkingAndClose}
|
handleSelectThinkingAndClose={handleSelectThinkingAndClose}
|
||||||
handleOpenChange={handleOpenChange}
|
handleOpenChange={handleOpenChange}
|
||||||
renderThinkingOption={renderThinkingOption}
|
renderThinkingOption={renderThinkingOption}
|
||||||
|
modelSelectorServerId={modelSelectorServerId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -693,6 +698,7 @@ interface DesktopAgentControlsContentProps {
|
|||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
}) => ReactElement;
|
}) => ReactElement;
|
||||||
extras?: ReactNode;
|
extras?: ReactNode;
|
||||||
|
modelSelectorServerId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DESKTOP_SEARCH_THRESHOLD = 6;
|
const DESKTOP_SEARCH_THRESHOLD = 6;
|
||||||
@@ -739,6 +745,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
|
|||||||
handleOpenChange,
|
handleOpenChange,
|
||||||
renderThinkingOption,
|
renderThinkingOption,
|
||||||
extras,
|
extras,
|
||||||
|
modelSelectorServerId,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -791,6 +798,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
|
|||||||
disabled={modelDisabled}
|
disabled={modelDisabled}
|
||||||
onOpen={onModelSelectorOpen}
|
onOpen={onModelSelectorOpen}
|
||||||
onClose={onDropdownClose}
|
onClose={onDropdownClose}
|
||||||
|
serverId={modelSelectorServerId}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -884,6 +892,7 @@ interface SheetAgentControlsContentProps {
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
}) => ReactElement;
|
}) => ReactElement;
|
||||||
|
modelSelectorServerId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
||||||
@@ -914,6 +923,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
|||||||
handleSelectThinkingAndClose,
|
handleSelectThinkingAndClose,
|
||||||
handleOpenChange,
|
handleOpenChange,
|
||||||
renderThinkingOption,
|
renderThinkingOption,
|
||||||
|
modelSelectorServerId,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const thinkingAnchorRef = useRef<View | null>(null);
|
const thinkingAnchorRef = useRef<View | null>(null);
|
||||||
@@ -983,6 +993,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
|||||||
onOpen={onModelSelectorOpen}
|
onOpen={onModelSelectorOpen}
|
||||||
onClose={onDropdownClose}
|
onClose={onDropdownClose}
|
||||||
renderTrigger={renderModelTrigger}
|
renderTrigger={renderModelTrigger}
|
||||||
|
serverId={modelSelectorServerId}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -1521,6 +1532,7 @@ export const AgentControls = memo(function AgentControls({
|
|||||||
onDropdownClose={onDropdownClose}
|
onDropdownClose={onDropdownClose}
|
||||||
disabled={!client}
|
disabled={!client}
|
||||||
desktopExtras={modeChip}
|
desktopExtras={modeChip}
|
||||||
|
modelSelectorServerId={serverId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1547,6 +1559,7 @@ export function DraftAgentControls({
|
|||||||
onDropdownClose,
|
onDropdownClose,
|
||||||
onModelSelectorOpen,
|
onModelSelectorOpen,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
modelSelectorServerId = null,
|
||||||
}: DraftAgentControlsProps) {
|
}: DraftAgentControlsProps) {
|
||||||
const { preferences, updatePreferences } = useFormPreferences();
|
const { preferences, updatePreferences } = useFormPreferences();
|
||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
@@ -1614,6 +1627,7 @@ export function DraftAgentControls({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onOpen={onModelSelectorOpen}
|
onOpen={onModelSelectorOpen}
|
||||||
onClose={onDropdownClose}
|
onClose={onDropdownClose}
|
||||||
|
serverId={modelSelectorServerId}
|
||||||
/>
|
/>
|
||||||
{selectedProvider ? (
|
{selectedProvider ? (
|
||||||
<ControlledAgentControls
|
<ControlledAgentControls
|
||||||
@@ -1650,6 +1664,7 @@ export function DraftAgentControls({
|
|||||||
onSetFeature={onSetFeature}
|
onSetFeature={onSetFeature}
|
||||||
onModelSelectorOpen={onModelSelectorOpen}
|
onModelSelectorOpen={onModelSelectorOpen}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
modelSelectorServerId={modelSelectorServerId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,13 +187,12 @@ function AgentModeControlView({
|
|||||||
() => ({
|
() => ({
|
||||||
title: "Mode",
|
title: "Mode",
|
||||||
search: {
|
search: {
|
||||||
value: searchQuery,
|
|
||||||
onChange: setSearchQuery,
|
onChange: setSearchQuery,
|
||||||
placeholder: "Search modes...",
|
placeholder: "Search modes...",
|
||||||
testID: "mode-search-input",
|
testID: "mode-search-input",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[searchQuery],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!selectedMode) return null;
|
if (!selectedMode) return null;
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export function buildDraftAgentControls(input: {
|
|||||||
onSetFeature,
|
onSetFeature,
|
||||||
onDropdownClose,
|
onDropdownClose,
|
||||||
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
|
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
|
||||||
|
modelSelectorServerId: formState.selectedServerId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,11 +94,11 @@ export function useProvidersSnapshot(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await refreshSnapshot(providers);
|
await refreshSnapshot(providers);
|
||||||
if (isProvidersSnapshotHomeScope(cwd)) {
|
|
||||||
queryClient.removeQueries({ queryKey: queryRoot, exact: false });
|
|
||||||
}
|
|
||||||
const snapshot = await client.getProvidersSnapshot(requestOptions);
|
const snapshot = await client.getProvidersSnapshot(requestOptions);
|
||||||
queryClient.setQueryData(queryKey, snapshot);
|
queryClient.setQueryData(queryKey, snapshot);
|
||||||
|
if (isProvidersSnapshotHomeScope(cwd)) {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryRoot, exact: false });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[client, cwd, queryClient, queryKey, queryRoot, refreshSnapshot, requestOptions],
|
[client, cwd, queryClient, queryKey, queryRoot, refreshSnapshot, requestOptions],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -99,14 +99,39 @@ describe("combined model selector data", () => {
|
|||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("excludes providers that are not ready", () => {
|
it("surfaces non-ready providers with their state-specific selection", () => {
|
||||||
expect(
|
expect(
|
||||||
buildSelectableProviderSelectorProviders([
|
buildSelectableProviderSelectorProviders([
|
||||||
snapshotEntry({ provider: "loading-provider", status: "loading", models: [] }),
|
snapshotEntry({ provider: "loading-provider", status: "loading", models: [] }),
|
||||||
snapshotEntry({ provider: "error-provider", status: "error", models: [] }),
|
snapshotEntry({
|
||||||
snapshotEntry({ provider: "unavailable-provider", status: "unavailable", models: [] }),
|
provider: "error-provider",
|
||||||
|
status: "error",
|
||||||
|
error: "boom",
|
||||||
|
models: [],
|
||||||
|
}),
|
||||||
|
snapshotEntry({
|
||||||
|
provider: "unavailable-provider",
|
||||||
|
status: "unavailable",
|
||||||
|
models: [],
|
||||||
|
}),
|
||||||
]),
|
]),
|
||||||
).toEqual([]);
|
).toEqual([
|
||||||
|
{
|
||||||
|
id: "loading-provider",
|
||||||
|
label: "loading-provider",
|
||||||
|
modelSelection: { kind: "loading" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "error-provider",
|
||||||
|
label: "error-provider",
|
||||||
|
modelSelection: { kind: "error", message: "boom" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "unavailable-provider",
|
||||||
|
label: "unavailable-provider",
|
||||||
|
modelSelection: { kind: "error", message: "Unavailable" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds selector providers from an already-curated provider list", () => {
|
it("builds selector providers from an already-curated provider list", () => {
|
||||||
|
|||||||
@@ -13,9 +13,8 @@ export type ProviderSelectionModelRow = FavoriteModelRow & { isDefault?: boolean
|
|||||||
|
|
||||||
export type ProviderModelSelection =
|
export type ProviderModelSelection =
|
||||||
| { kind: "models"; rows: ProviderSelectionModelRow[] }
|
| { kind: "models"; rows: ProviderSelectionModelRow[] }
|
||||||
| { kind: "providerDefault"; label: string }
|
|
||||||
| { kind: "loading" }
|
| { kind: "loading" }
|
||||||
| { kind: "unavailable"; reason: string };
|
| { kind: "error"; message: string };
|
||||||
|
|
||||||
export interface ProviderSelectorProvider {
|
export interface ProviderSelectorProvider {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -53,6 +52,21 @@ function buildModelRows(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSyntheticDefaultRow(
|
||||||
|
provider: string,
|
||||||
|
providerLabel: string,
|
||||||
|
): ProviderSelectionModelRow {
|
||||||
|
return {
|
||||||
|
favoriteKey: buildFavoriteModelKey({ provider, modelId: "" }),
|
||||||
|
provider,
|
||||||
|
providerLabel,
|
||||||
|
modelId: "",
|
||||||
|
modelLabel: "Default",
|
||||||
|
description: undefined,
|
||||||
|
isDefault: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildModelSelection(
|
function buildModelSelection(
|
||||||
provider: string,
|
provider: string,
|
||||||
providerLabel: string,
|
providerLabel: string,
|
||||||
@@ -62,13 +76,28 @@ function buildModelSelection(
|
|||||||
return { kind: "loading" };
|
return { kind: "loading" };
|
||||||
}
|
}
|
||||||
if (models.length === 0) {
|
if (models.length === 0) {
|
||||||
return { kind: "providerDefault", label: "Default" };
|
return { kind: "models", rows: [buildSyntheticDefaultRow(provider, providerLabel)] };
|
||||||
}
|
}
|
||||||
return { kind: "models", rows: buildModelRows(provider, providerLabel, models) };
|
return { kind: "models", rows: buildModelRows(provider, providerLabel, models) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSelectableProvider(entry: ProviderSnapshotEntry): boolean {
|
function buildEntryModelSelection(
|
||||||
return entry.enabled && entry.status === "ready";
|
entry: ProviderSnapshotEntry,
|
||||||
|
label: string,
|
||||||
|
): ProviderModelSelection {
|
||||||
|
if ((entry.models?.length ?? 0) > 0) {
|
||||||
|
return buildModelSelection(entry.provider, label, entry.models ?? null);
|
||||||
|
}
|
||||||
|
if (entry.status === "ready") {
|
||||||
|
return buildModelSelection(entry.provider, label, entry.models ?? null);
|
||||||
|
}
|
||||||
|
if (entry.status === "loading") {
|
||||||
|
return { kind: "loading" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "error",
|
||||||
|
message: entry.error ?? (entry.status === "unavailable" ? "Unavailable" : "Unknown error"),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildProviderSelectorProviders(input: {
|
export function buildProviderSelectorProviders(input: {
|
||||||
@@ -91,14 +120,16 @@ export function buildProviderSelectorProviders(input: {
|
|||||||
export function buildSelectableProviderSelectorProviders(
|
export function buildSelectableProviderSelectorProviders(
|
||||||
entries: ProviderSnapshotEntry[] | undefined,
|
entries: ProviderSnapshotEntry[] | undefined,
|
||||||
): ProviderSelectorProvider[] {
|
): ProviderSelectorProvider[] {
|
||||||
return (entries ?? []).filter(isSelectableProvider).map((entry) => {
|
return (entries ?? [])
|
||||||
const label = entry.label ?? entry.provider;
|
.filter((entry) => entry.enabled)
|
||||||
return {
|
.map((entry) => {
|
||||||
id: entry.provider,
|
const label = entry.label ?? entry.provider;
|
||||||
label,
|
return {
|
||||||
modelSelection: buildModelSelection(entry.provider, label, entry.models ?? []),
|
id: entry.provider,
|
||||||
};
|
label,
|
||||||
});
|
modelSelection: buildEntryModelSelection(entry, label),
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProviderModelRows(
|
export function getProviderModelRows(
|
||||||
@@ -113,10 +144,6 @@ export function getAllProviderModelRows(
|
|||||||
return providers.flatMap(getProviderModelRows);
|
return providers.flatMap(getProviderModelRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProviderDefaultLabel(provider: ProviderSelectorProvider): string | null {
|
|
||||||
return provider.modelSelection.kind === "providerDefault" ? provider.modelSelection.label : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveSelectedModelLabel(input: {
|
export function resolveSelectedModelLabel(input: {
|
||||||
providers: ProviderSelectorProvider[];
|
providers: ProviderSelectorProvider[];
|
||||||
selectedProvider: string;
|
selectedProvider: string;
|
||||||
@@ -129,23 +156,14 @@ export function resolveSelectedModelLabel(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const provider = input.providers.find((entry) => entry.id === selectedProvider);
|
const provider = input.providers.find((entry) => entry.id === selectedProvider);
|
||||||
if (!input.selectedModel) {
|
|
||||||
if (provider?.modelSelection.kind === "providerDefault") {
|
|
||||||
return provider.modelSelection.label;
|
|
||||||
}
|
|
||||||
if (provider?.modelSelection.kind === "loading") {
|
|
||||||
return "Loading...";
|
|
||||||
}
|
|
||||||
return input.isLoading ? "Loading..." : "Select model";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
return input.isLoading ? "Loading..." : "Select model";
|
return input.isLoading ? "Loading..." : "Select model";
|
||||||
}
|
}
|
||||||
|
if (provider.modelSelection.kind === "loading") {
|
||||||
|
return "Loading...";
|
||||||
|
}
|
||||||
if (provider.modelSelection.kind !== "models") {
|
if (provider.modelSelection.kind !== "models") {
|
||||||
return provider.modelSelection.kind === "providerDefault"
|
return "Select model";
|
||||||
? provider.modelSelection.label
|
|
||||||
: "Select model";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const model = provider.modelSelection.rows.find((entry) => entry.modelId === input.selectedModel);
|
const model = provider.modelSelection.rows.find((entry) => entry.modelId === input.selectedModel);
|
||||||
|
|||||||
@@ -7,39 +7,41 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
||||||
import type { MutableDaemonConfig } from "@server/shared/messages";
|
import type { MutableDaemonConfig } from "@server/shared/messages";
|
||||||
|
|
||||||
const { theme, snapshotState, configState, patchConfigMock, refreshMock } = vi.hoisted(() => ({
|
const { theme, snapshotState, configState, patchConfigMock, openProviderSettingsMock } = vi.hoisted(
|
||||||
theme: {
|
() => ({
|
||||||
spacing: { 1: 4, "1.5": 6, 2: 8, 3: 12, 4: 16, 6: 24 },
|
theme: {
|
||||||
iconSize: { sm: 14, md: 20 },
|
spacing: { 1: 4, "1.5": 6, 2: 8, 3: 12, 4: 16, 6: 24 },
|
||||||
fontSize: { xs: 11, sm: 13, base: 15 },
|
iconSize: { sm: 14, md: 20 },
|
||||||
fontWeight: { normal: "400" },
|
fontSize: { xs: 11, sm: 13, base: 15 },
|
||||||
borderRadius: { lg: 8 },
|
fontWeight: { normal: "400" },
|
||||||
opacity: { 50: 0.5 },
|
borderRadius: { lg: 8 },
|
||||||
colors: {
|
opacity: { 50: 0.5 },
|
||||||
surface1: "#111",
|
colors: {
|
||||||
surface2: "#222",
|
surface1: "#111",
|
||||||
surface3: "#333",
|
surface2: "#222",
|
||||||
foreground: "#fff",
|
surface3: "#333",
|
||||||
foregroundMuted: "#aaa",
|
foreground: "#fff",
|
||||||
border: "#555",
|
foregroundMuted: "#aaa",
|
||||||
accent: "#0a84ff",
|
border: "#555",
|
||||||
statusSuccess: "#00ff00",
|
accent: "#0a84ff",
|
||||||
statusWarning: "#ff9500",
|
statusSuccess: "#00ff00",
|
||||||
statusDanger: "#ff0000",
|
statusWarning: "#ff9500",
|
||||||
palette: { red: { 300: "#ff6b6b" }, white: "#fff" },
|
statusDanger: "#ff0000",
|
||||||
|
palette: { red: { 300: "#ff6b6b" }, white: "#fff" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
snapshotState: {
|
||||||
snapshotState: {
|
entries: undefined as ProviderSnapshotEntry[] | undefined,
|
||||||
entries: undefined as ProviderSnapshotEntry[] | undefined,
|
isLoading: false,
|
||||||
isLoading: false,
|
isRefreshing: false,
|
||||||
isRefreshing: false,
|
},
|
||||||
},
|
configState: {
|
||||||
configState: {
|
config: null as MutableDaemonConfig | null,
|
||||||
config: null as MutableDaemonConfig | null,
|
},
|
||||||
},
|
patchConfigMock: vi.fn(async () => undefined),
|
||||||
patchConfigMock: vi.fn(async () => undefined),
|
openProviderSettingsMock: vi.fn(),
|
||||||
refreshMock: vi.fn(async () => {}),
|
}),
|
||||||
}));
|
);
|
||||||
|
|
||||||
vi.mock("react-native", () => ({
|
vi.mock("react-native", () => ({
|
||||||
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
|
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
|
||||||
@@ -96,7 +98,6 @@ vi.mock("lucide-react-native", () => {
|
|||||||
return {
|
return {
|
||||||
ChevronRight: icon("ChevronRight"),
|
ChevronRight: icon("ChevronRight"),
|
||||||
Plus: icon("Plus"),
|
Plus: icon("Plus"),
|
||||||
RotateCw: icon("RotateCw"),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,12 +138,9 @@ vi.mock("@/components/provider-icons", () => ({
|
|||||||
React.createElement("span", { "data-icon": `provider-${provider}` }),
|
React.createElement("span", { "data-icon": `provider-${provider}` }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/provider-diagnostic-sheet", () => ({
|
vi.mock("@/stores/provider-settings-store", () => ({
|
||||||
ProviderDiagnosticSheet: ({ provider }: { provider: string }) =>
|
useProviderSettingsStore: (selector: (state: unknown) => unknown) =>
|
||||||
React.createElement("div", {
|
selector({ open: openProviderSettingsMock }),
|
||||||
"data-testid": "provider-diagnostic-sheet",
|
|
||||||
"data-provider": provider,
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/add-provider-modal", () => ({
|
vi.mock("@/components/add-provider-modal", () => ({
|
||||||
@@ -157,7 +155,7 @@ vi.mock("@/hooks/use-providers-snapshot", () => ({
|
|||||||
isRefreshing: snapshotState.isRefreshing,
|
isRefreshing: snapshotState.isRefreshing,
|
||||||
error: null,
|
error: null,
|
||||||
supportsSnapshot: true,
|
supportsSnapshot: true,
|
||||||
refresh: refreshMock,
|
refresh: vi.fn(async () => {}),
|
||||||
refetchIfStale: vi.fn(),
|
refetchIfStale: vi.fn(),
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
@@ -240,8 +238,7 @@ describe("ProvidersSection", () => {
|
|||||||
configState.config = null;
|
configState.config = null;
|
||||||
patchConfigMock.mockReset();
|
patchConfigMock.mockReset();
|
||||||
patchConfigMock.mockResolvedValue(undefined);
|
patchConfigMock.mockResolvedValue(undefined);
|
||||||
refreshMock.mockReset();
|
openProviderSettingsMock.mockReset();
|
||||||
refreshMock.mockResolvedValue(undefined);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -321,16 +318,18 @@ describe("ProvidersSection", () => {
|
|||||||
|
|
||||||
render();
|
render();
|
||||||
|
|
||||||
expect(container?.querySelector('[data-testid="provider-diagnostic-sheet"]')).toBeNull();
|
expect(openProviderSettingsMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
const row = findRow("Codex provider details");
|
const row = findRow("Codex provider details");
|
||||||
act(() => {
|
act(() => {
|
||||||
row.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
row.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
const sheet = container?.querySelector('[data-testid="provider-diagnostic-sheet"]');
|
expect(openProviderSettingsMock).toHaveBeenCalledTimes(1);
|
||||||
expect(sheet).not.toBeNull();
|
expect(openProviderSettingsMock).toHaveBeenCalledWith({
|
||||||
expect(sheet?.getAttribute("data-provider")).toBe("codex");
|
serverId: "server-1",
|
||||||
|
provider: "codex",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("toggles the provider enabled flag through patchConfig when the switch is pressed", async () => {
|
it("toggles the provider enabled flag through patchConfig when the switch is pressed", async () => {
|
||||||
@@ -352,26 +351,5 @@ describe("ProvidersSection", () => {
|
|||||||
expect(patchConfigMock).toHaveBeenCalledWith({
|
expect(patchConfigMock).toHaveBeenCalledWith({
|
||||||
providers: { claude: { enabled: false } },
|
providers: { claude: { enabled: false } },
|
||||||
});
|
});
|
||||||
expect(refreshMock).not.toHaveBeenCalled();
|
|
||||||
expect(container?.querySelector('[data-testid="provider-diagnostic-sheet"]')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("forces a provider snapshot refresh from the settings refresh action", async () => {
|
|
||||||
snapshotState.entries = [claudeEntry];
|
|
||||||
configState.config = makeConfig();
|
|
||||||
|
|
||||||
render();
|
|
||||||
|
|
||||||
const refreshButton = container?.querySelector<HTMLElement>(
|
|
||||||
'[role="button"][aria-label="Refresh providers"]',
|
|
||||||
);
|
|
||||||
expect(refreshButton).not.toBeNull();
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
refreshButton?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(refreshMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(patchConfigMock).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
|||||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||||
import { AddProviderModal } from "@/components/add-provider-modal";
|
import { AddProviderModal } from "@/components/add-provider-modal";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||||
import { ChevronRight, Plus, RotateCw } from "lucide-react-native";
|
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||||
|
import { ChevronRight, Plus } from "lucide-react-native";
|
||||||
|
|
||||||
type ProviderDefinition = ReturnType<typeof buildProviderDefinitions>[number];
|
type ProviderDefinition = ReturnType<typeof buildProviderDefinitions>[number];
|
||||||
type ProviderEntry = NonNullable<ReturnType<typeof useProvidersSnapshot>["entries"]>[number];
|
type ProviderEntry = NonNullable<ReturnType<typeof useProvidersSnapshot>["entries"]>[number];
|
||||||
@@ -179,22 +179,21 @@ export interface ProvidersSectionProps {
|
|||||||
export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const { entries, isLoading, isRefreshing, refresh } = useProvidersSnapshot(serverId);
|
const { entries, isLoading } = useProvidersSnapshot(serverId);
|
||||||
const { patchConfig } = useDaemonConfig(serverId);
|
const { patchConfig } = useDaemonConfig(serverId);
|
||||||
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null);
|
const openProviderSettings = useProviderSettingsStore((state) => state.open);
|
||||||
const [isAddProviderOpen, setIsAddProviderOpen] = useState(false);
|
const [isAddProviderOpen, setIsAddProviderOpen] = useState(false);
|
||||||
const [pendingProviderId, setPendingProviderId] = useState<string | null>(null);
|
const [pendingProviderId, setPendingProviderId] = useState<string | null>(null);
|
||||||
|
|
||||||
const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]);
|
const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]);
|
||||||
const providerRefreshInFlight =
|
|
||||||
isRefreshing || (entries?.some((entry) => entry.status === "loading") ?? false);
|
|
||||||
const hasServer = serverId.length > 0;
|
const hasServer = serverId.length > 0;
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleOpenProviderSettings = useCallback(
|
||||||
void refresh();
|
(providerId: string) => {
|
||||||
}, [refresh]);
|
openProviderSettings({ serverId, provider: providerId });
|
||||||
|
},
|
||||||
const handleCloseDiagnostic = useCallback(() => setDiagnosticProvider(null), []);
|
[openProviderSettings, serverId],
|
||||||
|
);
|
||||||
const handleOpenAddProvider = useCallback(() => setIsAddProviderOpen(true), []);
|
const handleOpenAddProvider = useCallback(() => setIsAddProviderOpen(true), []);
|
||||||
const handleCloseAddProvider = useCallback(() => setIsAddProviderOpen(false), []);
|
const handleCloseAddProvider = useCallback(() => setIsAddProviderOpen(false), []);
|
||||||
const handleToggleEnabled = useCallback(
|
const handleToggleEnabled = useCallback(
|
||||||
@@ -229,30 +228,12 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
|||||||
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
<Text style={settingsStyles.sectionHeaderLinkText}>Add provider</Text>
|
<Text style={settingsStyles.sectionHeaderLinkText}>Add provider</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
|
||||||
onPress={handleRefresh}
|
|
||||||
disabled={providerRefreshInFlight}
|
|
||||||
hitSlop={8}
|
|
||||||
style={settingsStyles.sectionHeaderLink}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={
|
|
||||||
providerRefreshInFlight ? "Refreshing providers" : "Refresh providers"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{providerRefreshInFlight ? (
|
|
||||||
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
||||||
) : (
|
|
||||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
</View>
|
||||||
) : undefined,
|
) : undefined,
|
||||||
[
|
[
|
||||||
hasServer,
|
hasServer,
|
||||||
isConnected,
|
isConnected,
|
||||||
handleOpenAddProvider,
|
handleOpenAddProvider,
|
||||||
handleRefresh,
|
|
||||||
providerRefreshInFlight,
|
|
||||||
theme.iconSize.sm,
|
theme.iconSize.sm,
|
||||||
theme.colors.foregroundMuted,
|
theme.colors.foregroundMuted,
|
||||||
],
|
],
|
||||||
@@ -289,7 +270,7 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
|||||||
enabled={entry.enabled ?? true}
|
enabled={entry.enabled ?? true}
|
||||||
isToggling={pendingProviderId === def.id}
|
isToggling={pendingProviderId === def.id}
|
||||||
isFirst={index === 0}
|
isFirst={index === 0}
|
||||||
onPress={setDiagnosticProvider}
|
onPress={handleOpenProviderSettings}
|
||||||
onToggleEnabled={handleToggleEnabled}
|
onToggleEnabled={handleToggleEnabled}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -298,14 +279,6 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
{diagnosticProvider ? (
|
|
||||||
<ProviderDiagnosticSheet
|
|
||||||
provider={diagnosticProvider}
|
|
||||||
visible
|
|
||||||
onClose={handleCloseDiagnostic}
|
|
||||||
serverId={serverId}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{hasServer && isConnected && isAddProviderOpen ? (
|
{hasServer && isConnected && isAddProviderOpen ? (
|
||||||
<AddProviderModal serverId={serverId} visible onClose={handleCloseAddProvider} />
|
<AddProviderModal serverId={serverId} visible onClose={handleCloseAddProvider} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
interface ProviderSettingsTarget {
|
||||||
|
serverId: string;
|
||||||
|
provider: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderSettingsStoreState {
|
||||||
|
serverId: string | null;
|
||||||
|
provider: string | null;
|
||||||
|
open: (target: ProviderSettingsTarget) => void;
|
||||||
|
close: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useProviderSettingsStore = create<ProviderSettingsStoreState>()((set) => ({
|
||||||
|
serverId: null,
|
||||||
|
provider: null,
|
||||||
|
open: ({ serverId, provider }) => {
|
||||||
|
set({ serverId, provider });
|
||||||
|
},
|
||||||
|
close: () => {
|
||||||
|
set({ serverId: null, provider: null });
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -151,6 +151,16 @@ const MOCK_LOAD_TEST_MODES: AgentProviderModeDefinition[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const MOCK_SLOW_MODES: AgentProviderModeDefinition[] = [
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
label: "Default",
|
||||||
|
description: "Dev-only mode for the mock slow provider",
|
||||||
|
icon: "ShieldOff",
|
||||||
|
colorTier: "dangerous",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
||||||
{
|
{
|
||||||
id: "claude",
|
id: "claude",
|
||||||
@@ -212,6 +222,13 @@ export const DEV_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
|||||||
defaultModeId: "load-test",
|
defaultModeId: "load-test",
|
||||||
modes: MOCK_LOAD_TEST_MODES,
|
modes: MOCK_LOAD_TEST_MODES,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "mock-slow",
|
||||||
|
label: "Mock Slow Provider",
|
||||||
|
description: "Dev-only: hangs during model discovery to test loading and timeout UI",
|
||||||
|
defaultModeId: "default",
|
||||||
|
modes: MOCK_SLOW_MODES,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getAgentProviderDefinition(
|
export function getAgentProviderDefinition(
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
|
|||||||
import { OpenCodeServerManager } from "./providers/opencode/server-manager.js";
|
import { OpenCodeServerManager } from "./providers/opencode/server-manager.js";
|
||||||
import { PiRpcAgentClient } from "./providers/pi/agent.js";
|
import { PiRpcAgentClient } from "./providers/pi/agent.js";
|
||||||
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
|
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
|
||||||
|
import { MockSlowProviderClient } from "./providers/mock-slow-provider.js";
|
||||||
import {
|
import {
|
||||||
AGENT_PROVIDER_DEFINITIONS,
|
AGENT_PROVIDER_DEFINITIONS,
|
||||||
BUILTIN_PROVIDER_IDS,
|
BUILTIN_PROVIDER_IDS,
|
||||||
@@ -126,6 +127,7 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
|||||||
runtimeSettings,
|
runtimeSettings,
|
||||||
}),
|
}),
|
||||||
mock: (logger) => new MockLoadTestAgentClient(logger),
|
mock: (logger) => new MockLoadTestAgentClient(logger),
|
||||||
|
"mock-slow": () => new MockSlowProviderClient(),
|
||||||
};
|
};
|
||||||
|
|
||||||
function getCursorACPCommand(
|
function getCursorACPCommand(
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type {
|
||||||
|
AgentCapabilityFlags,
|
||||||
|
AgentClient,
|
||||||
|
AgentLaunchContext,
|
||||||
|
AgentMode,
|
||||||
|
AgentModelDefinition,
|
||||||
|
AgentPersistenceHandle,
|
||||||
|
AgentProvider,
|
||||||
|
AgentSession,
|
||||||
|
AgentSessionConfig,
|
||||||
|
ListModelsOptions,
|
||||||
|
ListModesOptions,
|
||||||
|
ListPersistedAgentsOptions,
|
||||||
|
PersistedAgentDescriptor,
|
||||||
|
} from "../agent-sdk-types.js";
|
||||||
|
|
||||||
|
export const MOCK_SLOW_PROVIDER_ID = "mock-slow";
|
||||||
|
|
||||||
|
const CAPABILITIES: AgentCapabilityFlags = {
|
||||||
|
supportsStreaming: false,
|
||||||
|
supportsSessionPersistence: false,
|
||||||
|
supportsDynamicModes: false,
|
||||||
|
supportsMcpServers: false,
|
||||||
|
supportsReasoningStream: false,
|
||||||
|
supportsToolInvocations: false,
|
||||||
|
supportsRewindConversation: false,
|
||||||
|
supportsRewindFiles: false,
|
||||||
|
supportsRewindBoth: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function neverResolves<T>(): Promise<T> {
|
||||||
|
return new Promise<T>(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MockSlowProviderClient implements AgentClient {
|
||||||
|
readonly provider: AgentProvider = MOCK_SLOW_PROVIDER_ID;
|
||||||
|
readonly capabilities = CAPABILITIES;
|
||||||
|
|
||||||
|
async isAvailable(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||||
|
return neverResolves<AgentModelDefinition[]>();
|
||||||
|
}
|
||||||
|
|
||||||
|
listModes(_options: ListModesOptions): Promise<AgentMode[]> {
|
||||||
|
return neverResolves<AgentMode[]>();
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPersistedAgents(
|
||||||
|
_options?: ListPersistedAgentsOptions,
|
||||||
|
): Promise<PersistedAgentDescriptor[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDiagnostic(): Promise<{ diagnostic: string }> {
|
||||||
|
return {
|
||||||
|
diagnostic:
|
||||||
|
"Mock slow provider: dev-only. listModels() never resolves so the snapshot manager will time out.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
createSession(
|
||||||
|
_config: AgentSessionConfig,
|
||||||
|
_launchContext?: AgentLaunchContext,
|
||||||
|
): Promise<AgentSession> {
|
||||||
|
throw new Error("Mock slow provider is dev-only; sessions are not supported.");
|
||||||
|
}
|
||||||
|
|
||||||
|
resumeSession(
|
||||||
|
_handle: AgentPersistenceHandle,
|
||||||
|
_overrides?: Partial<AgentSessionConfig>,
|
||||||
|
_launchContext?: AgentLaunchContext,
|
||||||
|
): Promise<AgentSession> {
|
||||||
|
throw new Error("Mock slow provider is dev-only; sessions are not supported.");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user