diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index d15f3aea9..015f19c9b 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -6,9 +6,14 @@ 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 { useQuery } from "@tanstack/react-query"; +import { useQueries, useQuery } from "@tanstack/react-query"; import { useSessionStore } from "@/stores/session-store"; -import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences"; +import { + buildFavoriteModelKey, + mergeProviderPreferences, + toggleFavoriteModel, + useFormPreferences, +} from "@/hooks/use-form-preferences"; import { DropdownMenu, DropdownMenuContent, @@ -25,6 +30,7 @@ import type { } from "@server/server/agent/agent-sdk-types"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; import { + AGENT_PROVIDER_DEFINITIONS, getModeVisuals, type AgentModeColorTier, type AgentModeIcon, @@ -42,6 +48,10 @@ type StatusOption = { type StatusSelector = "provider" | "mode" | "model" | "thinking"; +const PROVIDER_DEFINITION_MAP = new Map( + AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]), +); + type ControlledAgentStatusBarProps = { provider: string; providerOptions?: StatusOption[]; @@ -58,6 +68,11 @@ type ControlledAgentStatusBarProps = { onSelectThinkingOption?: (thinkingOptionId: string) => void; disabled?: boolean; isModelLoading?: boolean; + providerDefinitions?: AgentProviderDefinition[]; + allProviderModels?: Map; + canSelectModelProvider?: (providerId: string) => boolean; + favoriteKeys?: Set; + onToggleFavoriteModel?: (provider: string, modelId: string) => void; }; export interface DraftAgentStatusBarProps { @@ -142,6 +157,11 @@ function ControlledStatusBar({ onSelectThinkingOption, disabled = false, isModelLoading = false, + providerDefinitions, + allProviderModels, + canSelectModelProvider, + favoriteKeys = new Set(), + onToggleFavoriteModel, }: ControlledAgentStatusBarProps) { const { theme } = useUnistyles(); const isWeb = Platform.OS === "web"; @@ -205,6 +225,26 @@ function ControlledStatusBar({ () => (modelOptions ?? []).map((o) => ({ id: o.id, label: o.label })), [modelOptions], ); + const fallbackAllProviderModels = useMemo(() => { + const map = new Map(); + if (!modelOptions || modelOptions.length === 0) { + return map; + } + + map.set( + provider, + modelOptions.map((option) => ({ + provider: provider as AgentProvider, + id: option.id, + label: option.label, + })), + ); + return map; + }, [modelOptions, provider]); + const effectiveProviderDefinitions = providerDefinitions ?? + (PROVIDER_DEFINITION_MAP.has(provider) ? [PROVIDER_DEFINITION_MAP.get(provider)!] : []); + const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels; + const canSelectProviderInModelMenu = canSelectModelProvider ?? (() => true); const comboboxThinkingOptions = useMemo( () => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })), [thinkingOptions], @@ -289,49 +329,36 @@ function ControlledStatusBar({ ) : null} {canSelectModel ? ( - <> - - - + + + { + if (selectedProviderId === provider) { + onSelectModel?.(modelId); + } + }} + favoriteKeys={favoriteKeys} + onToggleFavorite={onToggleFavoriteModel} + isLoading={isModelLoading} disabled={modelDisabled} - onPress={() => handleSelectorPress("model")} - style={({ pressed, hovered }) => [ - styles.modeBadge, - hovered && styles.modeBadgeHovered, - (pressed || openSelector === "model") && styles.modeBadgePressed, - modelDisabled && styles.disabledBadge, - ]} - accessibilityRole="button" - accessibilityLabel="Select agent model" - testID="agent-model-selector" - > - - {displayModel} - - - - - {getStatusSelectorHint("model")} - - - onSelectModel?.(id)} - searchable={comboboxModelOptions.length > SEARCH_THRESHOLD} - open={openSelector === "model"} - onOpenChange={handleOpenChange("model")} - anchorRef={modelAnchorRef} - desktopPlacement="top-start" - /> - + /> + + + + {getStatusSelectorHint("model")} + + ) : null} {thinkingOptions && thinkingOptions.length > 0 ? ( @@ -491,36 +518,35 @@ function ControlledStatusBar({ {canSelectModel ? ( - - [ - styles.sheetSelect, - pressed && styles.sheetSelectPressed, - modelDisabled && styles.disabledSheetSelect, - ]} - accessibilityRole="button" - accessibilityLabel="Select agent model" - testID="agent-preferences-model" - > - {displayModel} - - - - {(modelOptions ?? []).map((model) => ( - onSelectModel?.(model.id)} - > - {model.label} - - ))} - - + { + if (selectedProviderId === provider) { + onSelectModel?.(modelId); + } + }} + favoriteKeys={favoriteKeys} + onToggleFavorite={onToggleFavoriteModel} + isLoading={isModelLoading} + disabled={modelDisabled} + renderTrigger={({ selectedModelLabel }) => ( + + {selectedModelLabel} + + + )} + /> ) : null} @@ -650,6 +676,60 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { }, }); + const availableProvidersQuery = useQuery({ + queryKey: ["availableProviders", serverId], + enabled: Boolean(client), + staleTime: 60 * 1000, + queryFn: async () => { + if (!client) { + throw new Error("Daemon client unavailable"); + } + const payload = await client.listAvailableProviders(); + if (payload.error) { + throw new Error(payload.error); + } + return payload.providers.filter((entry) => entry.available).map((entry) => entry.provider); + }, + }); + + 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 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); + } + } + return map; + }, [allProviderModelQueries, availableProviderDefinitions]); + const models = modelsQuery.data ?? null; const displayMode = @@ -674,6 +754,10 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { const modelOptions = useMemo(() => { return (models ?? []).map((model) => ({ id: model.id, label: model.label })); }, [models]); + const favoriteKeys = useMemo( + () => new Set((preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite))), + [preferences.favoriteModels], + ); const thinkingOptions = useMemo(() => { return (modelSelection.thinkingOptions ?? []).map((option) => ({ @@ -693,6 +777,9 @@ 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} onSelectMode={(modeId) => { if (!client) { return; @@ -722,6 +809,12 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { console.warn("[AgentStatusBar] setAgentModel failed", error); }); }} + favoriteKeys={favoriteKeys} + onToggleFavoriteModel={(provider, modelId) => { + void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => { + console.warn("[AgentStatusBar] toggle favorite model failed", error); + }); + }} thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined} selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined} onSelectThinkingOption={(thinkingOptionId) => { @@ -775,6 +868,7 @@ export function DraftAgentStatusBar({ disabled = false, }: DraftAgentStatusBarProps) { const isWeb = Platform.OS === "web"; + const { preferences, updatePreferences } = useFormPreferences(); const mappedModeOptions = useMemo(() => { if (modeOptions.length === 0) { @@ -789,6 +883,10 @@ export function DraftAgentStatusBar({ const mappedThinkingOptions = useMemo(() => { return thinkingOptions.map((option) => ({ id: option.id, label: option.label })); }, [thinkingOptions]); + const favoriteKeys = useMemo( + () => new Set((preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite))), + [preferences.favoriteModels], + ); const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || ""; const effectiveSelectedThinkingOption = @@ -803,6 +901,12 @@ export function DraftAgentStatusBar({ selectedProvider={selectedProvider} selectedModel={selectedModel} onSelect={onSelectProviderAndModel} + favoriteKeys={favoriteKeys} + onToggleFavorite={(provider, modelId) => { + void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => { + console.warn("[DraftAgentStatusBar] toggle favorite model failed", error); + }); + }} isLoading={isAllModelsLoading} disabled={disabled} /> @@ -843,6 +947,12 @@ export function DraftAgentStatusBar({ selectedModelId={selectedModel} onSelectModel={onSelectModel} isModelLoading={isModelLoading} + favoriteKeys={favoriteKeys} + onToggleFavoriteModel={(provider, modelId) => { + void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => { + console.warn("[DraftAgentStatusBar] toggle favorite model failed", error); + }); + }} thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined} selectedThinkingOptionId={effectiveSelectedThinkingOption} onSelectThinkingOption={onSelectThinkingOption} diff --git a/packages/app/src/components/combined-model-selector.test.ts b/packages/app/src/components/combined-model-selector.test.ts new file mode 100644 index 000000000..9da2b44c1 --- /dev/null +++ b/packages/app/src/components/combined-model-selector.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types"; +import { + buildModelRows, + buildSelectedTriggerLabel, + matchesSearch, + resolveProviderLabel, +} from "./combined-model-selector.utils"; + +describe("combined model selector helpers", () => { + const providerDefinitions = [ + { + id: "claude", + label: "Claude", + description: "Claude provider", + defaultModeId: "default", + modes: [], + }, + { + id: "codex", + label: "Codex", + description: "Codex provider", + defaultModeId: "auto", + modes: [], + }, + ]; + + const claudeModels: AgentModelDefinition[] = [ + { + provider: "claude", + id: "sonnet-4.6", + label: "Sonnet 4.6", + }, + ]; + + const codexModels: AgentModelDefinition[] = [ + { + provider: "codex", + id: "gpt-5.4", + label: "GPT-5.4", + }, + ]; + + it("keeps enough data to search by model and provider name", async () => { + const rows = buildModelRows(providerDefinitions, new Map([ + ["claude", claudeModels], + ["codex", codexModels], + ])); + + expect(rows).toEqual([ + expect.objectContaining({ providerLabel: "Claude", modelLabel: "Sonnet 4.6", modelId: "sonnet-4.6" }), + expect.objectContaining({ providerLabel: "Codex", modelLabel: "GPT-5.4", modelId: "gpt-5.4" }), + ]); + + expect(matchesSearch(rows[0]!, "claude")).toBe(true); + expect(matchesSearch(rows[1]!, "gpt-5.4")).toBe(true); + }); + + it("builds an explicit trigger label for the selected provider and model", () => { + expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex"); + expect(buildSelectedTriggerLabel("Codex", "GPT-5.4")).toBe("Codex: GPT-5.4"); + }); +}); diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index d9edea21f..159bd774d 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -1,22 +1,31 @@ import { useCallback, useMemo, useRef, useState } from "react"; -import { View, Text, Pressable, Platform } from "react-native"; +import { View, Text, Pressable, Platform, type GestureResponderEvent } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { ArrowLeft, Check, ChevronDown, ChevronRight } from "lucide-react-native"; +import { + ArrowLeft, + ChevronDown, + ChevronRight, + Search, + Star, +} from "lucide-react-native"; 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 { getProviderIcon } from "@/components/provider-icons"; +import type { FavoriteModelRow } from "@/hooks/use-form-preferences"; +import { + buildModelRows, + buildSelectedTriggerLabel, + matchesSearch, + resolveProviderLabel, + type SelectorModelRow, +} from "./combined-model-selector.utils"; -const INLINE_MODEL_THRESHOLD = 8; +const INLINE_MODEL_THRESHOLD = Number.POSITIVE_INFINITY; -type DrillDownView = { provider: string }; - -function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string { - if (!models || models.length === 0) { - return "Select model"; - } - return (models.find((model) => model.isDefault) ?? models[0])?.label ?? "Select model"; -} +type SelectorView = + | { kind: "all" } + | { kind: "provider"; providerId: string; providerLabel: string }; interface CombinedModelSelectorProps { providerDefinitions: AgentProviderDefinition[]; @@ -25,9 +34,393 @@ interface CombinedModelSelectorProps { selectedModel: string; onSelect: (provider: AgentProvider, modelId: string) => void; isLoading: boolean; + canSelectProvider?: (provider: string) => boolean; + favoriteKeys?: Set; + onToggleFavorite?: (provider: string, modelId: string) => void; + renderTrigger?: (input: { + selectedModelLabel: string; + onPress: () => void; + disabled: boolean; + isOpen: boolean; + }) => React.ReactNode; disabled?: boolean; } +interface SelectorContentProps { + view: SelectorView; + providerDefinitions: AgentProviderDefinition[]; + allProviderModels: Map; + selectedProvider: string; + selectedModel: string; + searchQuery: string; + onSearchChange: (query: string) => void; + favoriteKeys: Set; + onSelect: (provider: string, modelId: string) => void; + canSelectProvider: (provider: string) => boolean; + onToggleFavorite?: (provider: string, modelId: string) => void; + onDrillDown: (providerId: string, providerLabel: string) => void; + onBack?: () => void; +} + +function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string { + if (!models || models.length === 0) { + return "Select model"; + } + return (models.find((model) => model.isDefault) ?? models[0])?.label ?? "Select model"; +} + +function normalizeSearchQuery(value: string): string { + return value.trim().toLowerCase(); +} + +function partitionRows( + rows: SelectorModelRow[], + favoriteKeys: Set, +): { favoriteRows: SelectorModelRow[]; regularRows: SelectorModelRow[] } { + const favoriteRows: SelectorModelRow[] = []; + const regularRows: SelectorModelRow[] = []; + + for (const row of rows) { + if (favoriteKeys.has(row.favoriteKey)) { + favoriteRows.push(row); + continue; + } + regularRows.push(row); + } + + return { favoriteRows, regularRows }; +} + +function groupRowsByProvider( + rows: SelectorModelRow[], +): Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }> { + const grouped = new Map(); + + for (const row of rows) { + const existing = grouped.get(row.provider); + if (existing) { + existing.rows.push(row); + continue; + } + + grouped.set(row.provider, { + providerId: row.provider, + providerLabel: row.providerLabel, + rows: [row], + }); + } + + return Array.from(grouped.values()); +} + +function ModelRow({ + row, + isSelected, + isFavorite, + disabled = false, + onPress, + onToggleFavorite, +}: { + row: SelectorModelRow; + isSelected: boolean; + isFavorite: boolean; + disabled?: boolean; + onPress: () => void; + onToggleFavorite?: (provider: string, modelId: string) => void; +}) { + const { theme } = useUnistyles(); + const ProviderIcon = getProviderIcon(row.provider); + + const handleToggleFavorite = useCallback( + (event: GestureResponderEvent) => { + event.stopPropagation(); + onToggleFavorite?.(row.provider, row.modelId); + }, + [onToggleFavorite, row.modelId, row.provider], + ); + + return ( + } + trailingSlot={ + onToggleFavorite && !disabled ? ( + [ + styles.favoriteButton, + hovered && styles.favoriteButtonHovered, + pressed && styles.favoriteButtonPressed, + ]} + accessibilityRole="button" + accessibilityLabel={isFavorite ? "Unfavorite model" : "Favorite model"} + testID={`favorite-model-${row.provider}-${row.modelId}`} + > + + + ) : null + } + /> + ); +} + +function FavoritesSection({ + favoriteRows, + selectedProvider, + selectedModel, + favoriteKeys, + onSelect, + canSelectProvider, + onToggleFavorite, +}: { + favoriteRows: SelectorModelRow[]; + selectedProvider: string; + selectedModel: string; + favoriteKeys: Set; + onSelect: (provider: string, modelId: string) => void; + canSelectProvider: (provider: string) => boolean; + onToggleFavorite?: (provider: string, modelId: string) => void; +}) { + const { theme } = useUnistyles(); + + if (favoriteRows.length === 0) { + return null; + } + + return ( + + + + Favorites + + {favoriteRows.map((row) => ( + onSelect(row.provider, row.modelId)} + onToggleFavorite={onToggleFavorite} + /> + ))} + + + ); +} + +function GroupedProviderRows({ + providerDefinitions, + groupedRows, + selectedProvider, + selectedModel, + favoriteKeys, + onSelect, + canSelectProvider, + onToggleFavorite, + onDrillDown, +}: { + providerDefinitions: AgentProviderDefinition[]; + groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>; + selectedProvider: string; + selectedModel: string; + favoriteKeys: Set; + onSelect: (provider: string, modelId: string) => void; + canSelectProvider: (provider: string) => boolean; + onToggleFavorite?: (provider: string, modelId: string) => void; + onDrillDown: (providerId: string, providerLabel: string) => void; +}) { + const { theme } = useUnistyles(); + + return ( + + {groupedRows.map((group, index) => { + const providerDefinition = providerDefinitions.find((definition) => definition.id === group.providerId); + const ProvIcon = getProviderIcon(group.providerId); + const isInline = group.rows.length <= INLINE_MODEL_THRESHOLD; + + return ( + + {index > 0 ? : null} + {isInline ? ( + <> + + + + {providerDefinition?.label ?? group.providerLabel} + + + {group.rows.map((row) => ( + onSelect(row.provider, row.modelId)} + onToggleFavorite={onToggleFavorite} + /> + ))} + + ) : ( + onDrillDown(group.providerId, group.providerLabel)} + style={({ pressed, hovered }) => [ + styles.drillDownRow, + hovered && styles.drillDownRowHovered, + pressed && styles.drillDownRowPressed, + ]} + > + + {group.providerLabel} + + {group.rows.length} + + + + )} + + ); + })} + + ); +} + +function SelectorContent({ + view, + providerDefinitions, + allProviderModels, + selectedProvider, + selectedModel, + searchQuery, + onSearchChange, + favoriteKeys, + onSelect, + canSelectProvider, + onToggleFavorite, + onDrillDown, + onBack, +}: SelectorContentProps) { + const allRows = useMemo( + () => buildModelRows(providerDefinitions, allProviderModels), + [allProviderModels, providerDefinitions], + ); + + const scopedRows = useMemo(() => { + if (view.kind === "provider") { + return allRows.filter((row) => row.provider === view.providerId); + } + return allRows; + }, [allRows, view]); + + const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]); + + const visibleRows = useMemo( + () => scopedRows.filter((row) => matchesSearch(row, normalizedQuery)), + [normalizedQuery, scopedRows], + ); + + const { favoriteRows, regularRows } = useMemo( + () => partitionRows(visibleRows, favoriteKeys), + [favoriteKeys, visibleRows], + ); + + const groupedRegularRows = useMemo(() => groupRowsByProvider(regularRows), [regularRows]); + + return ( + + {view.kind === "provider" ? ( + + ) : null} + + + + + + {groupedRegularRows.length > 0 ? ( + + ) : null} + + {favoriteRows.length === 0 && groupedRegularRows.length === 0 ? ( + + + No models match your search + + ) : null} + + ); +} + +function ProviderBackButton({ + providerId, + providerLabel, + onBack, +}: { + providerId: string; + providerLabel: string; + onBack?: () => void; +}) { + const { theme } = useUnistyles(); + const ProviderIcon = getProviderIcon(providerId); + + if (!onBack) { + return null; + } + + return ( + [ + styles.backButton, + hovered && styles.backButtonHovered, + pressed && styles.backButtonPressed, + ]} + > + + + {providerLabel} + + ); +} + export function CombinedModelSelector({ providerDefinitions, allProviderModels, @@ -35,48 +428,61 @@ export function CombinedModelSelector({ selectedModel, onSelect, isLoading, + canSelectProvider = () => true, + favoriteKeys = new Set(), + onToggleFavorite, + renderTrigger, disabled = false, }: CombinedModelSelectorProps) { const { theme } = useUnistyles(); const anchorRef = useRef(null); const [isOpen, setIsOpen] = useState(false); - const [view, setView] = useState<"groups" | DrillDownView>("groups"); + const [view, setView] = useState({ kind: "all" }); const [searchQuery, setSearchQuery] = useState(""); const handleOpenChange = useCallback( (open: boolean) => { setIsOpen(open); - if (open) { - const models = allProviderModels.get(selectedProvider); - if (models && models.length > INLINE_MODEL_THRESHOLD) { - setView({ provider: selectedProvider }); - } - } else { - setView("groups"); + setView({ kind: "all" }); + if (!open) { setSearchQuery(""); } }, - [allProviderModels, selectedProvider], + [], ); const handleSelect = useCallback( (provider: string, modelId: string) => { onSelect(provider as AgentProvider, modelId); setIsOpen(false); - setView("groups"); + setView({ kind: "all" }); setSearchQuery(""); }, [onSelect], ); const ProviderIcon = getProviderIcon(selectedProvider); + const selectedProviderLabel = useMemo( + () => resolveProviderLabel(providerDefinitions, selectedProvider), + [providerDefinitions, selectedProvider], + ); const selectedModelLabel = useMemo(() => { const models = allProviderModels.get(selectedProvider); - if (!models) return isLoading ? "Loading..." : "Select model"; - const model = models.find((m) => m.id === selectedModel); + if (!models) { + return isLoading ? "Loading..." : "Select model"; + } + const model = models.find((entry) => entry.id === selectedModel); return model?.label ?? resolveDefaultModelLabel(models); - }, [allProviderModels, selectedProvider, selectedModel, isLoading]); + }, [allProviderModels, isLoading, selectedModel, selectedProvider]); + + const triggerLabel = useMemo(() => { + if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") { + return selectedModelLabel; + } + + return buildSelectedTriggerLabel(selectedProviderLabel, selectedModelLabel); + }, [selectedModelLabel, selectedProviderLabel]); return ( <> @@ -90,14 +496,26 @@ export function CombinedModelSelector({ hovered && styles.triggerHovered, (pressed || isOpen) && styles.triggerPressed, disabled && styles.triggerDisabled, + renderTrigger ? styles.customTriggerWrapper : null, ]} accessibilityRole="button" accessibilityLabel={`Select model (${selectedModelLabel})`} testID="combined-model-selector" > - - {selectedModelLabel} - + {renderTrigger ? ( + renderTrigger({ + selectedModelLabel: triggerLabel, + onPress: () => handleOpenChange(!isOpen), + disabled, + isOpen, + }) + ) : ( + <> + + {triggerLabel} + + + )} - {view === "groups" ? ( - { - setView({ provider }); - setSearchQuery(""); - }} - /> - ) : ( - { - setView("groups"); - setSearchQuery(""); - }} - /> - )} + { + setView({ kind: "provider", providerId, providerLabel }); + }} + onBack={ + view.kind === "provider" + ? () => { + setView({ kind: "all" }); + } + : undefined + } + /> ); } -function GroupsView({ - providerDefinitions, - allProviderModels, - selectedProvider, - selectedModel, - onSelect, - onDrillDown, -}: { - providerDefinitions: AgentProviderDefinition[]; - allProviderModels: Map; - selectedProvider: string; - selectedModel: string; - onSelect: (provider: string, modelId: string) => void; - onDrillDown: (provider: string) => void; -}) { - const { theme } = useUnistyles(); - - return ( - - {providerDefinitions.map((def, index) => { - const models = allProviderModels.get(def.id) ?? []; - const isInline = models.length <= INLINE_MODEL_THRESHOLD; - const ProvIcon = getProviderIcon(def.id); - - return ( - - {index > 0 ? : null} - - {isInline ? ( - <> - - - {def.label} - - {models.map((model) => ( - onSelect(def.id, model.id)} - /> - ))} - - ) : ( - onDrillDown(def.id)} - style={({ pressed, hovered }) => [ - styles.drillDownRow, - hovered && styles.drillDownRowHovered, - pressed && styles.drillDownRowPressed, - ]} - > - - {def.label} - - {models.length} - - - - )} - - ); - })} - - ); -} - -function DrillDownModelView({ - provider, - providerDefinitions, - models, - selectedProvider, - selectedModel, - searchQuery, - onSearchChange, - onSelect, - onBack, -}: { - provider: string; - providerDefinitions: AgentProviderDefinition[]; - models: AgentModelDefinition[]; - selectedProvider: string; - selectedModel: string; - searchQuery: string; - onSearchChange: (query: string) => void; - onSelect: (provider: string, modelId: string) => void; - onBack: () => void; -}) { - const { theme } = useUnistyles(); - const ProvIcon = getProviderIcon(provider); - const providerLabel = providerDefinitions.find((d) => d.id === provider)?.label ?? provider; - - const filteredModels = useMemo(() => { - if (!searchQuery.trim()) return models; - const q = searchQuery.toLowerCase(); - return models.filter( - (m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q), - ); - }, [models, searchQuery]); - - return ( - - [ - styles.backButton, - hovered && styles.backButtonHovered, - pressed && styles.backButtonPressed, - ]} - > - - - {providerLabel} - - - - - {filteredModels.map((model) => ( - onSelect(provider, model.id)} - /> - ))} - - {filteredModels.length === 0 ? ( - - No models match your search - - ) : null} - - ); -} - const styles = StyleSheet.create((theme) => ({ trigger: { height: 28, @@ -307,6 +579,11 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, }, + customTriggerWrapper: { + paddingHorizontal: 0, + paddingVertical: 0, + height: "auto", + }, separator: { height: 1, backgroundColor: theme.colors.border, @@ -374,9 +651,23 @@ const styles = StyleSheet.create((theme) => ({ emptyState: { paddingVertical: theme.spacing[4], alignItems: "center", + gap: theme.spacing[2], }, emptyStateText: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, }, + favoriteButton: { + width: 24, + height: 24, + borderRadius: theme.borderRadius.full, + alignItems: "center", + justifyContent: "center", + }, + favoriteButtonHovered: { + backgroundColor: theme.colors.surface2, + }, + favoriteButtonPressed: { + backgroundColor: theme.colors.surface1, + }, })); diff --git a/packages/app/src/components/combined-model-selector.utils.ts b/packages/app/src/components/combined-model-selector.utils.ts new file mode 100644 index 000000000..fd1f07144 --- /dev/null +++ b/packages/app/src/components/combined-model-selector.utils.ts @@ -0,0 +1,50 @@ +import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types"; +import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; +import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences"; + +export type SelectorModelRow = FavoriteModelRow; + +export function resolveProviderLabel( + providerDefinitions: AgentProviderDefinition[], + providerId: string, +): string { + return providerDefinitions.find((definition) => definition.id === providerId)?.label ?? providerId; +} + +export function buildSelectedTriggerLabel(providerLabel: string, modelLabel: string): string { + return `${providerLabel}: ${modelLabel}`; +} + +export function buildModelRows( + providerDefinitions: AgentProviderDefinition[], + allProviderModels: Map, +): SelectorModelRow[] { + const providerLabelMap = new Map(providerDefinitions.map((definition) => [definition.id, definition.label])); + const rows: SelectorModelRow[] = []; + + for (const definition of providerDefinitions) { + const providerLabel = providerLabelMap.get(definition.id) ?? definition.label; + for (const model of allProviderModels.get(definition.id) ?? []) { + rows.push({ + favoriteKey: buildFavoriteModelKey({ provider: definition.id, modelId: model.id }), + provider: definition.id, + providerLabel, + modelId: model.id, + modelLabel: model.label, + description: model.description, + }); + } + } + + return rows; +} + +export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean { + if (!normalizedQuery) { + return true; + } + + return [row.modelLabel, row.modelId, row.providerLabel].some((value) => + value.toLowerCase().includes(normalizedQuery), + ); +} diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 450c46597..cffec42fd 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -145,8 +145,10 @@ export interface ComboboxItemProps { description?: string; kind?: "directory" | "file"; leadingSlot?: ReactNode; + trailingSlot?: ReactNode; selected?: boolean; active?: boolean; + disabled?: boolean; onPress: () => void; testID?: string; } @@ -156,8 +158,10 @@ export function ComboboxItem({ description, kind, leadingSlot, + trailingSlot, selected, active, + disabled, onPress, testID, }: ComboboxItemProps): ReactElement { @@ -178,12 +182,14 @@ export function ComboboxItem({ return ( [ styles.comboboxItem, hovered && styles.comboboxItemHovered, pressed && styles.comboboxItemPressed, active && styles.comboboxItemActive, + disabled && styles.comboboxItemDisabled, ]} > {leadingContent} @@ -197,9 +203,14 @@ export function ComboboxItem({ ) : null} - {selected ? ( - - + {selected || trailingSlot ? ( + + {trailingSlot} + {selected ? ( + + + + ) : null} ) : null} @@ -777,10 +788,18 @@ const styles = StyleSheet.create((theme) => ({ comboboxItemActive: { backgroundColor: theme.colors.surface1, }, + comboboxItemDisabled: { + opacity: 0.55, + }, comboboxItemTrailingSlot: { width: 16, alignItems: "center", justifyContent: "center", + }, + comboboxItemTrailingContainer: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], marginLeft: "auto", }, comboboxItemContent: { diff --git a/packages/app/src/hooks/use-form-preferences.test.ts b/packages/app/src/hooks/use-form-preferences.test.ts index da73e4be2..55bb8fc56 100644 --- a/packages/app/src/hooks/use-form-preferences.test.ts +++ b/packages/app/src/hooks/use-form-preferences.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { mergeProviderPreferences } from "./use-form-preferences"; +import { + buildFavoriteModelKey, + isFavoriteModel, + mergeProviderPreferences, + toggleFavoriteModel, +} from "./use-form-preferences"; describe("mergeProviderPreferences", () => { it("stores the selected model for a provider", () => { @@ -55,3 +60,92 @@ describe("mergeProviderPreferences", () => { }); }); }); + +describe("favorite model preferences", () => { + it("builds a stable favorite key from provider and model", () => { + expect(buildFavoriteModelKey({ provider: "claude", modelId: "sonnet-4.6" })).toBe( + "claude:sonnet-4.6", + ); + }); + + it("adds a model to favorites without dropping other preferences", () => { + expect( + toggleFavoriteModel({ + preferences: { + provider: "claude", + providerPreferences: { + claude: { + model: "claude-sonnet-4-6", + }, + }, + }, + provider: "codex", + modelId: "gpt-5.4", + }), + ).toEqual({ + provider: "claude", + providerPreferences: { + claude: { + model: "claude-sonnet-4-6", + }, + }, + favoriteModels: [ + { + provider: "codex", + modelId: "gpt-5.4", + }, + ], + }); + }); + + it("removes a model from favorites when toggled again", () => { + expect( + toggleFavoriteModel({ + preferences: { + favoriteModels: [ + { + provider: "codex", + modelId: "gpt-5.4", + }, + ], + }, + provider: "codex", + modelId: "gpt-5.4", + }), + ).toEqual({ + favoriteModels: [], + }); + }); + + it("reports whether a model is favorited", () => { + expect( + isFavoriteModel({ + preferences: { + favoriteModels: [ + { + provider: "codex", + modelId: "gpt-5.4", + }, + ], + }, + provider: "codex", + modelId: "gpt-5.4", + }), + ).toBe(true); + + expect( + isFavoriteModel({ + preferences: { + favoriteModels: [ + { + provider: "codex", + modelId: "gpt-5.4", + }, + ], + }, + provider: "claude", + modelId: "sonnet-4.6", + }), + ).toBe(false); + }); +}); diff --git a/packages/app/src/hooks/use-form-preferences.ts b/packages/app/src/hooks/use-form-preferences.ts index 6031e16bc..ff04c8e84 100644 --- a/packages/app/src/hooks/use-form-preferences.ts +++ b/packages/app/src/hooks/use-form-preferences.ts @@ -7,6 +7,20 @@ import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences"; const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"]; +export interface FavoriteModelPreference { + provider: string; + modelId: string; +} + +export interface FavoriteModelRow { + favoriteKey: string; + provider: string; + providerLabel: string; + modelId: string; + modelLabel: string; + description?: string; +} + const providerPreferencesSchema = z.object({ model: z.string().optional(), mode: z.string().optional(), @@ -16,6 +30,12 @@ const providerPreferencesSchema = z.object({ const formPreferencesSchema = z.object({ provider: z.string().optional(), providerPreferences: z.record(providerPreferencesSchema).optional(), + favoriteModels: z.array( + z.object({ + provider: z.string(), + modelId: z.string(), + }), + ).optional(), }); export type ProviderPreferences = z.infer; @@ -66,6 +86,41 @@ export function mergeProviderPreferences(args: { }; } +export function buildFavoriteModelKey(input: FavoriteModelPreference): string { + return `${input.provider}:${input.modelId}`; +} + +export function isFavoriteModel(args: { + preferences: FormPreferences; + provider: string; + modelId: string; +}): boolean { + const favoriteKey = buildFavoriteModelKey({ provider: args.provider, modelId: args.modelId }); + return (args.preferences.favoriteModels ?? []).some( + (favorite) => buildFavoriteModelKey(favorite) === favoriteKey, + ); +} + +export function toggleFavoriteModel(args: { + preferences: FormPreferences; + provider: string; + modelId: string; +}): FormPreferences { + const favorite = { provider: args.provider, modelId: args.modelId }; + const favoriteKey = buildFavoriteModelKey(favorite); + const existingFavorites = args.preferences.favoriteModels ?? []; + const hasFavorite = existingFavorites.some( + (entry) => buildFavoriteModelKey(entry) === favoriteKey, + ); + + return { + ...args.preferences, + favoriteModels: hasFavorite + ? existingFavorites.filter((entry) => buildFavoriteModelKey(entry) !== favoriteKey) + : [...existingFavorites, favorite], + }; +} + export function useFormPreferences(): UseFormPreferencesReturn { const queryClient = useQueryClient(); const { data, isPending } = useQuery({