Refine model selector UX and mobile sheet behavior

Closes #173
This commit is contained in:
Mohamed Boudra
2026-04-02 22:58:59 +07:00
parent 994ee488b9
commit 0bf8e8b5b2
4 changed files with 140 additions and 141 deletions
@@ -6,7 +6,7 @@ import { useStoreWithEqualityFn } from "zustand/traditional";
import { Brain, ChevronDown, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react-native"; import { Brain, ChevronDown, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector"; import { CombinedModelSelector } from "@/components/combined-model-selector";
import { useQueries, useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { import {
buildFavoriteModelKey, buildFavoriteModelKey,
@@ -481,41 +481,6 @@ function ControlledStatusBar({
stackBehavior="replace" stackBehavior="replace"
testID="agent-preferences-sheet" testID="agent-preferences-sheet"
> >
{providerOptions && providerOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === "provider"}
onOpenChange={handleOpenChange("provider")}
>
<DropdownMenuTrigger
disabled={disabled || !canSelectProvider}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
(disabled || !canSelectProvider) && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel="Select agent provider"
testID="agent-preferences-provider"
>
<Text style={styles.sheetSelectText}>{displayProvider}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{providerOptions.map((provider) => (
<DropdownMenuItem
key={provider.id}
selected={provider.id === selectedProviderId}
onSelect={() => onSelectProvider?.(provider.id)}
>
{provider.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
) : null}
{canSelectModel ? ( {canSelectModel ? (
<View style={styles.sheetSection}> <View style={styles.sheetSection}>
<CombinedModelSelector <CombinedModelSelector
@@ -525,9 +490,10 @@ function ControlledStatusBar({
selectedModel={selectedModelId ?? ""} selectedModel={selectedModelId ?? ""}
canSelectProvider={canSelectProviderInModelMenu} canSelectProvider={canSelectProviderInModelMenu}
onSelect={(selectedProviderId, modelId) => { onSelect={(selectedProviderId, modelId) => {
if (selectedProviderId === provider) { if (selectedProviderId !== provider) {
onSelectModel?.(modelId); onSelectProvider?.(selectedProviderId);
} }
onSelectModel?.(modelId);
}} }}
favoriteKeys={favoriteKeys} favoriteKeys={favoriteKeys}
onToggleFavorite={onToggleFavoriteModel} onToggleFavorite={onToggleFavoriteModel}
@@ -676,59 +642,34 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
}, },
}); });
const availableProvidersQuery = useQuery({ const agentProviderDefinitions = useMemo(() => {
queryKey: ["availableProviders", serverId], const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider);
enabled: Boolean(client), return definition ? [definition] : [];
staleTime: 60 * 1000, }, [agent?.provider]);
const agentProviderModelQuery = useQuery({
queryKey: ["providerModels", serverId, agent?.provider, agent?.cwd ?? ""],
enabled: Boolean(client && agent?.cwd && agent?.provider),
staleTime: 5 * 60 * 1000,
queryFn: async () => { queryFn: async () => {
if (!client) { if (!client || !agent) {
throw new Error("Daemon client unavailable"); throw new Error("Daemon client unavailable");
} }
const payload = await client.listAvailableProviders(); const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
if (payload.error) { if (payload.error) {
throw new Error(payload.error); throw new Error(payload.error);
} }
return payload.providers.filter((entry) => entry.available).map((entry) => entry.provider); return payload.models ?? [];
}, },
}); });
const availableProviderDefinitions = useMemo(() => { const agentProviderModels = useMemo(() => {
const availableProviders = availableProvidersQuery.data;
if (!availableProviders) {
return [];
}
const available = new Set(availableProviders);
return AGENT_PROVIDER_DEFINITIONS.filter((definition) => available.has(definition.id));
}, [availableProvidersQuery.data]);
const allProviderModelQueries = useQueries({
queries: availableProviderDefinitions.map((definition) => ({
queryKey: ["providerModels", serverId, definition.id, agent?.cwd ?? ""],
enabled: Boolean(client && agent?.cwd),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client || !agent) {
throw new Error("Daemon client unavailable");
}
const payload = await client.listProviderModels(definition.id, { cwd: agent.cwd });
if (payload.error) {
throw new Error(payload.error);
}
return payload.models ?? [];
},
})),
});
const liveAllProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>(); const map = new Map<string, AgentModelDefinition[]>();
for (let i = 0; i < availableProviderDefinitions.length; i++) { if (agent?.provider && agentProviderModelQuery.data) {
const query = allProviderModelQueries[i]; map.set(agent.provider, agentProviderModelQuery.data);
if (query?.data) {
map.set(availableProviderDefinitions[i]!.id, query.data);
}
} }
return map; return map;
}, [allProviderModelQueries, availableProviderDefinitions]); }, [agent?.provider, agentProviderModelQuery.data]);
const models = modelsQuery.data ?? null; const models = modelsQuery.data ?? null;
@@ -777,9 +718,8 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
modeOptions.length > 0 ? modeOptions : [{ id: agent.currentModeId ?? "", label: displayMode }] modeOptions.length > 0 ? modeOptions : [{ id: agent.currentModeId ?? "", label: displayMode }]
} }
selectedModeId={agent.currentModeId ?? undefined} selectedModeId={agent.currentModeId ?? undefined}
providerDefinitions={availableProviderDefinitions} providerDefinitions={agentProviderDefinitions}
allProviderModels={liveAllProviderModels} allProviderModels={agentProviderModels}
canSelectModelProvider={(providerId) => providerId === agent.provider}
onSelectMode={(modeId) => { onSelectMode={(modeId) => {
if (!client) { if (!client) {
return; return;
@@ -924,29 +864,23 @@ export function DraftAgentStatusBar({
); );
} }
const providerOptions = providerDefinitions.map((definition) => ({ const modelOptions: StatusOption[] = models.map((model) => ({
id: definition.id, id: model.id,
label: definition.label, label: model.label,
})); }));
const modelOptions: StatusOption[] = [];
for (const model of models) {
modelOptions.push({ id: model.id, label: model.label });
}
return ( return (
<ControlledStatusBar <ControlledStatusBar
provider={selectedProvider} provider={selectedProvider}
providerOptions={providerOptions} providerDefinitions={providerDefinitions}
selectedProviderId={selectedProvider} allProviderModels={allProviderModels}
onSelectProvider={(providerId) => onSelectProvider(providerId as AgentProvider)}
modeOptions={mappedModeOptions} modeOptions={mappedModeOptions}
selectedModeId={effectiveSelectedMode} selectedModeId={effectiveSelectedMode}
onSelectMode={onSelectMode} onSelectMode={onSelectMode}
modelOptions={modelOptions} modelOptions={modelOptions}
selectedModelId={selectedModel} selectedModelId={selectedModel}
onSelectModel={onSelectModel} onSelectModel={(modelId) => onSelectModel(modelId)}
isModelLoading={isModelLoading} isModelLoading={isAllModelsLoading}
favoriteKeys={favoriteKeys} favoriteKeys={favoriteKeys}
onToggleFavoriteModel={(provider, modelId) => { onToggleFavoriteModel={(provider, modelId) => {
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => { void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
@@ -1,5 +1,12 @@
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { View, Text, Pressable, Platform, type GestureResponderEvent } from "react-native"; import {
View,
Text,
Pressable,
Platform,
ActivityIndicator,
type GestureResponderEvent,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { import {
ArrowLeft, ArrowLeft,
@@ -11,6 +18,7 @@ import {
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types"; import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { Combobox, ComboboxItem, SearchInput } from "@/components/ui/combobox"; import { Combobox, ComboboxItem, SearchInput } from "@/components/ui/combobox";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
import type { FavoriteModelRow } from "@/hooks/use-form-preferences"; import type { FavoriteModelRow } from "@/hooks/use-form-preferences";
import { import {
@@ -130,6 +138,7 @@ function ModelRow({
}) { }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const ProviderIcon = getProviderIcon(row.provider); const ProviderIcon = getProviderIcon(row.provider);
const isWeb = Platform.OS === "web";
const handleToggleFavorite = useCallback( const handleToggleFavorite = useCallback(
(event: GestureResponderEvent) => { (event: GestureResponderEvent) => {
@@ -139,16 +148,9 @@ function ModelRow({
[onToggleFavorite, row.modelId, row.provider], [onToggleFavorite, row.modelId, row.provider],
); );
return ( const item = (
<ComboboxItem <ComboboxItem
label={row.modelLabel} label={row.modelLabel}
description={
disabled
? `${row.providerLabel} · Available when creating a ${row.providerLabel} agent`
: row.description
? `${row.providerLabel} · ${row.description}`
: row.providerLabel
}
selected={isSelected} selected={isSelected}
disabled={disabled} disabled={disabled}
onPress={onPress} onPress={onPress}
@@ -167,16 +169,39 @@ function ModelRow({
accessibilityLabel={isFavorite ? "Unfavorite model" : "Favorite model"} accessibilityLabel={isFavorite ? "Unfavorite model" : "Favorite model"}
testID={`favorite-model-${row.provider}-${row.modelId}`} testID={`favorite-model-${row.provider}-${row.modelId}`}
> >
<Star {({ hovered }) => (
size={16} <Star
color={isFavorite ? theme.colors.palette.amber[500] : theme.colors.foregroundMuted} size={16}
fill={isFavorite ? theme.colors.palette.amber[500] : "transparent"} color={
/> isFavorite
? theme.colors.palette.amber[500]
: hovered
? theme.colors.foregroundMuted
: theme.colors.border
}
fill={isFavorite ? theme.colors.palette.amber[500] : "transparent"}
/>
)}
</Pressable> </Pressable>
) : null ) : null
} }
/> />
); );
if (!isWeb || !row.description) {
return item;
}
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<View>{item}</View>
</TooltipTrigger>
<TooltipContent side="right" align="center" offset={4}>
<Text style={styles.tooltipText}>{row.description}</Text>
</TooltipContent>
</Tooltip>
);
} }
function FavoritesSection({ function FavoritesSection({
@@ -205,7 +230,6 @@ function FavoritesSection({
return ( return (
<View> <View>
<View style={styles.sectionHeading}> <View style={styles.sectionHeading}>
<Star size={14} color={theme.colors.palette.amber[500]} fill={theme.colors.palette.amber[500]} />
<Text style={styles.sectionHeadingText}>Favorites</Text> <Text style={styles.sectionHeadingText}>Favorites</Text>
</View> </View>
{favoriteRows.map((row) => ( {favoriteRows.map((row) => (
@@ -260,7 +284,6 @@ function GroupedProviderRows({
{isInline ? ( {isInline ? (
<> <>
<View style={styles.sectionHeading}> <View style={styles.sectionHeading}>
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.sectionHeadingText}> <Text style={styles.sectionHeadingText}>
{providerDefinition?.label ?? group.providerLabel} {providerDefinition?.label ?? group.providerLabel}
</Text> </Text>
@@ -435,8 +458,10 @@ export function CombinedModelSelector({
disabled = false, disabled = false,
}: CombinedModelSelectorProps) { }: CombinedModelSelectorProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isWeb = Platform.OS === "web";
const anchorRef = useRef<View>(null); const anchorRef = useRef<View>(null);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isContentReady, setIsContentReady] = useState(isWeb);
const [view, setView] = useState<SelectorView>({ kind: "all" }); const [view, setView] = useState<SelectorView>({ kind: "all" });
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@@ -484,6 +509,23 @@ export function CombinedModelSelector({
return buildSelectedTriggerLabel(selectedProviderLabel, selectedModelLabel); return buildSelectedTriggerLabel(selectedProviderLabel, selectedModelLabel);
}, [selectedModelLabel, selectedProviderLabel]); }, [selectedModelLabel, selectedProviderLabel]);
useEffect(() => {
if (isWeb) {
return;
}
if (!isOpen) {
setIsContentReady(false);
return;
}
const frame = requestAnimationFrame(() => {
setIsContentReady(true);
});
return () => cancelAnimationFrame(frame);
}, [isOpen, isWeb]);
return ( return (
<> <>
<Pressable <Pressable
@@ -523,33 +565,41 @@ export function CombinedModelSelector({
onSelect={() => {}} onSelect={() => {}}
open={isOpen} open={isOpen}
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
stackBehavior="push"
anchorRef={anchorRef} anchorRef={anchorRef}
desktopPlacement="top-start" desktopPlacement="top-start"
title="Select model" title="Select model"
> >
<SelectorContent {isContentReady ? (
view={view} <SelectorContent
providerDefinitions={providerDefinitions} view={view}
allProviderModels={allProviderModels} providerDefinitions={providerDefinitions}
selectedProvider={selectedProvider} allProviderModels={allProviderModels}
selectedModel={selectedModel} selectedProvider={selectedProvider}
searchQuery={searchQuery} selectedModel={selectedModel}
onSearchChange={setSearchQuery} searchQuery={searchQuery}
favoriteKeys={favoriteKeys} onSearchChange={setSearchQuery}
onSelect={handleSelect} favoriteKeys={favoriteKeys}
canSelectProvider={canSelectProvider} onSelect={handleSelect}
onToggleFavorite={onToggleFavorite} canSelectProvider={canSelectProvider}
onDrillDown={(providerId, providerLabel) => { onToggleFavorite={onToggleFavorite}
setView({ kind: "provider", providerId, providerLabel }); onDrillDown={(providerId, providerLabel) => {
}} setView({ kind: "provider", providerId, providerLabel });
onBack={ }}
view.kind === "provider" onBack={
? () => { view.kind === "provider"
setView({ kind: "all" }); ? () => {
} setView({ kind: "all" });
: undefined }
} : undefined
/> }
/>
) : (
<View style={styles.sheetLoadingState}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={styles.sheetLoadingText}>Loading model selector…</Text>
</View>
)}
</Combobox> </Combobox>
</> </>
); );
@@ -670,4 +720,18 @@ const styles = StyleSheet.create((theme) => ({
favoriteButtonPressed: { favoriteButtonPressed: {
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface1,
}, },
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
},
sheetLoadingState: {
minHeight: 160,
justifyContent: "center",
alignItems: "center",
gap: theme.spacing[2],
},
sheetLoadingText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
})); }));
@@ -12,7 +12,7 @@ export function resolveProviderLabel(
} }
export function buildSelectedTriggerLabel(providerLabel: string, modelLabel: string): string { export function buildSelectedTriggerLabel(providerLabel: string, modelLabel: string): string {
return `${providerLabel}: ${modelLabel}`; return modelLabel;
} }
export function buildModelRows( export function buildModelRows(
+6 -5
View File
@@ -65,6 +65,7 @@ export interface ComboboxProps {
open?: boolean; open?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
enableDismissOnClose?: boolean; enableDismissOnClose?: boolean;
stackBehavior?: "push" | "switch" | "replace";
desktopPlacement?: "top-start" | "bottom-start"; desktopPlacement?: "top-start" | "bottom-start";
/** /**
* Prevents an initial frame at 0,0 by hiding desktop content until floating * Prevents an initial frame at 0,0 by hiding desktop content until floating
@@ -205,12 +206,10 @@ export function ComboboxItem({
</View> </View>
{selected || trailingSlot ? ( {selected || trailingSlot ? (
<View style={styles.comboboxItemTrailingContainer}> <View style={styles.comboboxItemTrailingContainer}>
<View style={styles.comboboxItemTrailingSlot}>
{selected ? <Check size={16} color={theme.colors.foregroundMuted} /> : null}
</View>
{trailingSlot} {trailingSlot}
{selected ? (
<View style={styles.comboboxItemTrailingSlot}>
<Check size={16} color={theme.colors.foregroundMuted} />
</View>
) : null}
</View> </View>
) : null} ) : null}
</Pressable> </Pressable>
@@ -244,6 +243,7 @@ export function Combobox({
open, open,
onOpenChange, onOpenChange,
enableDismissOnClose, enableDismissOnClose,
stackBehavior,
desktopPlacement = "top-start", desktopPlacement = "top-start",
desktopPreventInitialFlash = true, desktopPreventInitialFlash = true,
anchorRef, anchorRef,
@@ -653,6 +653,7 @@ export function Combobox({
backdropComponent={renderBackdrop} backdropComponent={renderBackdrop}
enablePanDownToClose enablePanDownToClose
enableDismissOnClose={enableDismissOnClose} enableDismissOnClose={enableDismissOnClose}
stackBehavior={stackBehavior}
backgroundComponent={ComboboxSheetBackground} backgroundComponent={ComboboxSheetBackground}
handleIndicatorStyle={styles.bottomSheetHandle} handleIndicatorStyle={styles.bottomSheetHandle}
keyboardBehavior="extend" keyboardBehavior="extend"