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 { getProviderIcon } from "@/components/provider-icons";
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 {
buildFavoriteModelKey,
@@ -481,41 +481,6 @@ function ControlledStatusBar({
stackBehavior="replace"
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 ? (
<View style={styles.sheetSection}>
<CombinedModelSelector
@@ -525,9 +490,10 @@ function ControlledStatusBar({
selectedModel={selectedModelId ?? ""}
canSelectProvider={canSelectProviderInModelMenu}
onSelect={(selectedProviderId, modelId) => {
if (selectedProviderId === provider) {
onSelectModel?.(modelId);
if (selectedProviderId !== provider) {
onSelectProvider?.(selectedProviderId);
}
onSelectModel?.(modelId);
}}
favoriteKeys={favoriteKeys}
onToggleFavorite={onToggleFavoriteModel}
@@ -676,59 +642,34 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
},
});
const availableProvidersQuery = useQuery({
queryKey: ["availableProviders", serverId],
enabled: Boolean(client),
staleTime: 60 * 1000,
const agentProviderDefinitions = useMemo(() => {
const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider);
return definition ? [definition] : [];
}, [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 () => {
if (!client) {
if (!client || !agent) {
throw new Error("Daemon client unavailable");
}
const payload = await client.listAvailableProviders();
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
if (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 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 agentProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>();
for (let i = 0; i < availableProviderDefinitions.length; i++) {
const query = allProviderModelQueries[i];
if (query?.data) {
map.set(availableProviderDefinitions[i]!.id, query.data);
}
if (agent?.provider && agentProviderModelQuery.data) {
map.set(agent.provider, agentProviderModelQuery.data);
}
return map;
}, [allProviderModelQueries, availableProviderDefinitions]);
}, [agent?.provider, agentProviderModelQuery.data]);
const models = modelsQuery.data ?? null;
@@ -777,9 +718,8 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
modeOptions.length > 0 ? modeOptions : [{ id: agent.currentModeId ?? "", label: displayMode }]
}
selectedModeId={agent.currentModeId ?? undefined}
providerDefinitions={availableProviderDefinitions}
allProviderModels={liveAllProviderModels}
canSelectModelProvider={(providerId) => providerId === agent.provider}
providerDefinitions={agentProviderDefinitions}
allProviderModels={agentProviderModels}
onSelectMode={(modeId) => {
if (!client) {
return;
@@ -924,29 +864,23 @@ export function DraftAgentStatusBar({
);
}
const providerOptions = providerDefinitions.map((definition) => ({
id: definition.id,
label: definition.label,
const modelOptions: StatusOption[] = models.map((model) => ({
id: model.id,
label: model.label,
}));
const modelOptions: StatusOption[] = [];
for (const model of models) {
modelOptions.push({ id: model.id, label: model.label });
}
return (
<ControlledStatusBar
provider={selectedProvider}
providerOptions={providerOptions}
selectedProviderId={selectedProvider}
onSelectProvider={(providerId) => onSelectProvider(providerId as AgentProvider)}
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
modeOptions={mappedModeOptions}
selectedModeId={effectiveSelectedMode}
onSelectMode={onSelectMode}
modelOptions={modelOptions}
selectedModelId={selectedModel}
onSelectModel={onSelectModel}
isModelLoading={isModelLoading}
onSelectModel={(modelId) => onSelectModel(modelId)}
isModelLoading={isAllModelsLoading}
favoriteKeys={favoriteKeys}
onToggleFavoriteModel={(provider, modelId) => {
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
@@ -1,5 +1,12 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { View, Text, Pressable, Platform, type GestureResponderEvent } from "react-native";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
View,
Text,
Pressable,
Platform,
ActivityIndicator,
type GestureResponderEvent,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
ArrowLeft,
@@ -11,6 +18,7 @@ import {
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { Combobox, ComboboxItem, SearchInput } from "@/components/ui/combobox";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { getProviderIcon } from "@/components/provider-icons";
import type { FavoriteModelRow } from "@/hooks/use-form-preferences";
import {
@@ -130,6 +138,7 @@ function ModelRow({
}) {
const { theme } = useUnistyles();
const ProviderIcon = getProviderIcon(row.provider);
const isWeb = Platform.OS === "web";
const handleToggleFavorite = useCallback(
(event: GestureResponderEvent) => {
@@ -139,16 +148,9 @@ function ModelRow({
[onToggleFavorite, row.modelId, row.provider],
);
return (
const item = (
<ComboboxItem
label={row.modelLabel}
description={
disabled
? `${row.providerLabel} · Available when creating a ${row.providerLabel} agent`
: row.description
? `${row.providerLabel} · ${row.description}`
: row.providerLabel
}
selected={isSelected}
disabled={disabled}
onPress={onPress}
@@ -167,16 +169,39 @@ function ModelRow({
accessibilityLabel={isFavorite ? "Unfavorite model" : "Favorite model"}
testID={`favorite-model-${row.provider}-${row.modelId}`}
>
<Star
size={16}
color={isFavorite ? theme.colors.palette.amber[500] : theme.colors.foregroundMuted}
fill={isFavorite ? theme.colors.palette.amber[500] : "transparent"}
/>
{({ hovered }) => (
<Star
size={16}
color={
isFavorite
? theme.colors.palette.amber[500]
: hovered
? theme.colors.foregroundMuted
: theme.colors.border
}
fill={isFavorite ? theme.colors.palette.amber[500] : "transparent"}
/>
)}
</Pressable>
) : 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({
@@ -205,7 +230,6 @@ function FavoritesSection({
return (
<View>
<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>
</View>
{favoriteRows.map((row) => (
@@ -260,7 +284,6 @@ function GroupedProviderRows({
{isInline ? (
<>
<View style={styles.sectionHeading}>
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.sectionHeadingText}>
{providerDefinition?.label ?? group.providerLabel}
</Text>
@@ -435,8 +458,10 @@ export function CombinedModelSelector({
disabled = false,
}: CombinedModelSelectorProps) {
const { theme } = useUnistyles();
const isWeb = Platform.OS === "web";
const anchorRef = useRef<View>(null);
const [isOpen, setIsOpen] = useState(false);
const [isContentReady, setIsContentReady] = useState(isWeb);
const [view, setView] = useState<SelectorView>({ kind: "all" });
const [searchQuery, setSearchQuery] = useState("");
@@ -484,6 +509,23 @@ export function CombinedModelSelector({
return buildSelectedTriggerLabel(selectedProviderLabel, selectedModelLabel);
}, [selectedModelLabel, selectedProviderLabel]);
useEffect(() => {
if (isWeb) {
return;
}
if (!isOpen) {
setIsContentReady(false);
return;
}
const frame = requestAnimationFrame(() => {
setIsContentReady(true);
});
return () => cancelAnimationFrame(frame);
}, [isOpen, isWeb]);
return (
<>
<Pressable
@@ -523,33 +565,41 @@ export function CombinedModelSelector({
onSelect={() => {}}
open={isOpen}
onOpenChange={handleOpenChange}
stackBehavior="push"
anchorRef={anchorRef}
desktopPlacement="top-start"
title="Select model"
>
<SelectorContent
view={view}
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
favoriteKeys={favoriteKeys}
onSelect={handleSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
onDrillDown={(providerId, providerLabel) => {
setView({ kind: "provider", providerId, providerLabel });
}}
onBack={
view.kind === "provider"
? () => {
setView({ kind: "all" });
}
: undefined
}
/>
{isContentReady ? (
<SelectorContent
view={view}
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
favoriteKeys={favoriteKeys}
onSelect={handleSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
onDrillDown={(providerId, providerLabel) => {
setView({ kind: "provider", providerId, providerLabel });
}}
onBack={
view.kind === "provider"
? () => {
setView({ kind: "all" });
}
: undefined
}
/>
) : (
<View style={styles.sheetLoadingState}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={styles.sheetLoadingText}>Loading model selector…</Text>
</View>
)}
</Combobox>
</>
);
@@ -670,4 +720,18 @@ const styles = StyleSheet.create((theme) => ({
favoriteButtonPressed: {
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 {
return `${providerLabel}: ${modelLabel}`;
return modelLabel;
}
export function buildModelRows(
+6 -5
View File
@@ -65,6 +65,7 @@ export interface ComboboxProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
enableDismissOnClose?: boolean;
stackBehavior?: "push" | "switch" | "replace";
desktopPlacement?: "top-start" | "bottom-start";
/**
* Prevents an initial frame at 0,0 by hiding desktop content until floating
@@ -205,12 +206,10 @@ export function ComboboxItem({
</View>
{selected || trailingSlot ? (
<View style={styles.comboboxItemTrailingContainer}>
<View style={styles.comboboxItemTrailingSlot}>
{selected ? <Check size={16} color={theme.colors.foregroundMuted} /> : null}
</View>
{trailingSlot}
{selected ? (
<View style={styles.comboboxItemTrailingSlot}>
<Check size={16} color={theme.colors.foregroundMuted} />
</View>
) : null}
</View>
) : null}
</Pressable>
@@ -244,6 +243,7 @@ export function Combobox({
open,
onOpenChange,
enableDismissOnClose,
stackBehavior,
desktopPlacement = "top-start",
desktopPreventInitialFlash = true,
anchorRef,
@@ -653,6 +653,7 @@ export function Combobox({
backdropComponent={renderBackdrop}
enablePanDownToClose
enableDismissOnClose={enableDismissOnClose}
stackBehavior={stackBehavior}
backgroundComponent={ComboboxSheetBackground}
handleIndicatorStyle={styles.bottomSheetHandle}
keyboardBehavior="extend"