From 0bf8e8b5b2a6d38ace19e67b2639d9232a45a30a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 2 Apr 2026 22:58:10 +0700 Subject: [PATCH] Refine model selector UX and mobile sheet behavior Closes #173 --- .../app/src/components/agent-status-bar.tsx | 124 ++++----------- .../components/combined-model-selector.tsx | 144 +++++++++++++----- .../combined-model-selector.utils.ts | 2 +- packages/app/src/components/ui/combobox.tsx | 11 +- 4 files changed, 140 insertions(+), 141 deletions(-) diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 015f19c9b..afbd7d866 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -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 ? ( - - - [ - styles.sheetSelect, - pressed && styles.sheetSelectPressed, - (disabled || !canSelectProvider) && styles.disabledSheetSelect, - ]} - accessibilityRole="button" - accessibilityLabel="Select agent provider" - testID="agent-preferences-provider" - > - {displayProvider} - - - - {providerOptions.map((provider) => ( - onSelectProvider?.(provider.id)} - > - {provider.label} - - ))} - - - - ) : null} - {canSelectModel ? ( { - 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(); - 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 ( 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) => { diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index 159bd774d..1d62aa555 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -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 = ( - + {({ hovered }) => ( + + )} ) : null } /> ); + + if (!isWeb || !row.description) { + return item; + } + + return ( + + + {item} + + + {row.description} + + + ); } function FavoritesSection({ @@ -205,7 +230,6 @@ function FavoritesSection({ return ( - Favorites {favoriteRows.map((row) => ( @@ -260,7 +284,6 @@ function GroupedProviderRows({ {isInline ? ( <> - {providerDefinition?.label ?? group.providerLabel} @@ -435,8 +458,10 @@ export function CombinedModelSelector({ disabled = false, }: CombinedModelSelectorProps) { const { theme } = useUnistyles(); + const isWeb = Platform.OS === "web"; const anchorRef = useRef(null); const [isOpen, setIsOpen] = useState(false); + const [isContentReady, setIsContentReady] = useState(isWeb); const [view, setView] = useState({ 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 ( <> {}} open={isOpen} onOpenChange={handleOpenChange} + stackBehavior="push" anchorRef={anchorRef} desktopPlacement="top-start" title="Select model" > - { - setView({ kind: "provider", providerId, providerLabel }); - }} - onBack={ - view.kind === "provider" - ? () => { - setView({ kind: "all" }); - } - : undefined - } - /> + {isContentReady ? ( + { + setView({ kind: "provider", providerId, providerLabel }); + }} + onBack={ + view.kind === "provider" + ? () => { + setView({ kind: "all" }); + } + : undefined + } + /> + ) : ( + + + Loading model selector… + + )} ); @@ -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, + }, })); diff --git a/packages/app/src/components/combined-model-selector.utils.ts b/packages/app/src/components/combined-model-selector.utils.ts index fd1f07144..fa7f102d3 100644 --- a/packages/app/src/components/combined-model-selector.utils.ts +++ b/packages/app/src/components/combined-model-selector.utils.ts @@ -12,7 +12,7 @@ export function resolveProviderLabel( } export function buildSelectedTriggerLabel(providerLabel: string, modelLabel: string): string { - return `${providerLabel}: ${modelLabel}`; + return modelLabel; } export function buildModelRows( diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index cffec42fd..179f94c15 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -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({ {selected || trailingSlot ? ( + + {selected ? : null} + {trailingSlot} - {selected ? ( - - - - ) : null} ) : null} @@ -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"