Merge branch 'main' of github.com:getpaseo/paseo

This commit is contained in:
Mohamed Boudra
2026-04-02 21:49:21 +07:00
7 changed files with 956 additions and 274 deletions

View File

@@ -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<string, AgentModelDefinition[]>;
canSelectModelProvider?: (providerId: string) => boolean;
favoriteKeys?: Set<string>;
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<string>(),
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<string, AgentModelDefinition[]>();
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<ComboboxOption[]>(
() => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[thinkingOptions],
@@ -289,49 +329,36 @@ function ControlledStatusBar({
) : null}
{canSelectModel ? (
<>
<Tooltip
key={`model-${openSelector === "model" ? "open" : "closed"}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
ref={modelAnchorRef}
collapsable={false}
<Tooltip
key={`model-${displayModel}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<TooltipTrigger asChild triggerRefProp="ref">
<View>
<CombinedModelSelector
providerDefinitions={effectiveProviderDefinitions}
allProviderModels={effectiveAllProviderModels}
selectedProvider={provider}
selectedModel={selectedModelId ?? ""}
canSelectProvider={canSelectProviderInModelMenu}
onSelect={(selectedProviderId, modelId) => {
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"
>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.modeBadgeText}>{displayModel}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint("model")}</Text>
</TooltipContent>
</Tooltip>
<Combobox
options={comboboxModelOptions}
value={selectedModelId ?? ""}
onSelect={(id) => onSelectModel?.(id)}
searchable={comboboxModelOptions.length > SEARCH_THRESHOLD}
open={openSelector === "model"}
onOpenChange={handleOpenChange("model")}
anchorRef={modelAnchorRef}
desktopPlacement="top-start"
/>
</>
/>
</View>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint("model")}</Text>
</TooltipContent>
</Tooltip>
) : null}
{thinkingOptions && thinkingOptions.length > 0 ? (
@@ -491,36 +518,35 @@ function ControlledStatusBar({
{canSelectModel ? (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === "model"}
onOpenChange={handleOpenChange("model")}
>
<DropdownMenuTrigger
disabled={modelDisabled}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
modelDisabled && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel="Select agent model"
testID="agent-preferences-model"
>
<Text style={styles.sheetSelectText}>{displayModel}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{(modelOptions ?? []).map((model) => (
<DropdownMenuItem
key={model.id}
selected={model.id === selectedModelId}
onSelect={() => onSelectModel?.(model.id)}
>
{model.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<CombinedModelSelector
providerDefinitions={effectiveProviderDefinitions}
allProviderModels={effectiveAllProviderModels}
selectedProvider={provider}
selectedModel={selectedModelId ?? ""}
canSelectProvider={canSelectProviderInModelMenu}
onSelect={(selectedProviderId, modelId) => {
if (selectedProviderId === provider) {
onSelectModel?.(modelId);
}
}}
favoriteKeys={favoriteKeys}
onToggleFavorite={onToggleFavoriteModel}
isLoading={isModelLoading}
disabled={modelDisabled}
renderTrigger={({ selectedModelLabel }) => (
<View
style={[
styles.sheetSelect,
modelDisabled && styles.disabledSheetSelect,
]}
pointerEvents="none"
testID="agent-preferences-model"
>
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</View>
)}
/>
</View>
) : 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<string, AgentModelDefinition[]>();
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<StatusOption[]>(() => {
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<StatusOption[]>(() => {
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<StatusOption[]>(() => {
if (modeOptions.length === 0) {
@@ -789,6 +883,10 @@ export function DraftAgentStatusBar({
const mappedThinkingOptions = useMemo<StatusOption[]>(() => {
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}

View File

@@ -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");
});
});

View File

@@ -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<string>;
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<string, AgentModelDefinition[]>;
selectedProvider: string;
selectedModel: string;
searchQuery: string;
onSearchChange: (query: string) => void;
favoriteKeys: Set<string>;
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<string>,
): { 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<string, { providerId: string; providerLabel: string; rows: SelectorModelRow[] }>();
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 (
<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}
leadingSlot={<ProviderIcon size={14} color={theme.colors.foregroundMuted} />}
trailingSlot={
onToggleFavorite && !disabled ? (
<Pressable
onPress={handleToggleFavorite}
hitSlop={8}
style={({ pressed, hovered }) => [
styles.favoriteButton,
hovered && styles.favoriteButtonHovered,
pressed && styles.favoriteButtonPressed,
]}
accessibilityRole="button"
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"}
/>
</Pressable>
) : null
}
/>
);
}
function FavoritesSection({
favoriteRows,
selectedProvider,
selectedModel,
favoriteKeys,
onSelect,
canSelectProvider,
onToggleFavorite,
}: {
favoriteRows: SelectorModelRow[];
selectedProvider: string;
selectedModel: string;
favoriteKeys: Set<string>;
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 (
<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) => (
<ModelRow
key={row.favoriteKey}
row={row}
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
isFavorite={favoriteKeys.has(row.favoriteKey)}
disabled={!canSelectProvider(row.provider)}
onPress={() => onSelect(row.provider, row.modelId)}
onToggleFavorite={onToggleFavorite}
/>
))}
<View style={styles.separator} />
</View>
);
}
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<string>;
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 (
<View>
{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 (
<View key={group.providerId}>
{index > 0 ? <View style={styles.separator} /> : null}
{isInline ? (
<>
<View style={styles.sectionHeading}>
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.sectionHeadingText}>
{providerDefinition?.label ?? group.providerLabel}
</Text>
</View>
{group.rows.map((row) => (
<ModelRow
key={row.favoriteKey}
row={row}
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
isFavorite={favoriteKeys.has(row.favoriteKey)}
disabled={!canSelectProvider(row.provider)}
onPress={() => onSelect(row.provider, row.modelId)}
onToggleFavorite={onToggleFavorite}
/>
))}
</>
) : (
<Pressable
onPress={() => onDrillDown(group.providerId, group.providerLabel)}
style={({ pressed, hovered }) => [
styles.drillDownRow,
hovered && styles.drillDownRowHovered,
pressed && styles.drillDownRowPressed,
]}
>
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownText}>{group.providerLabel}</Text>
<View style={styles.drillDownTrailing}>
<Text style={styles.drillDownCount}>{group.rows.length}</Text>
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
</View>
</Pressable>
)}
</View>
);
})}
</View>
);
}
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>
{view.kind === "provider" ? (
<ProviderBackButton providerId={view.providerId} providerLabel={view.providerLabel} onBack={onBack} />
) : null}
<SearchInput
placeholder={view.kind === "provider" ? "Search models..." : "Search models or providers..."}
value={searchQuery}
onChangeText={onSearchChange}
autoFocus={Platform.OS === "web"}
/>
<FavoritesSection
favoriteRows={favoriteRows}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
onSelect={onSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
/>
{groupedRegularRows.length > 0 ? (
<GroupedProviderRows
providerDefinitions={providerDefinitions}
groupedRows={groupedRegularRows}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
onSelect={onSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
onDrillDown={onDrillDown}
/>
) : null}
{favoriteRows.length === 0 && groupedRegularRows.length === 0 ? (
<View style={styles.emptyState}>
<Search size={16} color="#777" />
<Text style={styles.emptyStateText}>No models match your search</Text>
</View>
) : null}
</View>
);
}
function ProviderBackButton({
providerId,
providerLabel,
onBack,
}: {
providerId: string;
providerLabel: string;
onBack?: () => void;
}) {
const { theme } = useUnistyles();
const ProviderIcon = getProviderIcon(providerId);
if (!onBack) {
return null;
}
return (
<Pressable
onPress={onBack}
style={({ pressed, hovered }) => [
styles.backButton,
hovered && styles.backButtonHovered,
pressed && styles.backButtonPressed,
]}
>
<ArrowLeft size={14} color={theme.colors.foregroundMuted} />
<ProviderIcon size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.backButtonText}>{providerLabel}</Text>
</Pressable>
);
}
export function CombinedModelSelector({
providerDefinitions,
allProviderModels,
@@ -35,48 +428,61 @@ export function CombinedModelSelector({
selectedModel,
onSelect,
isLoading,
canSelectProvider = () => true,
favoriteKeys = new Set<string>(),
onToggleFavorite,
renderTrigger,
disabled = false,
}: CombinedModelSelectorProps) {
const { theme } = useUnistyles();
const anchorRef = useRef<View>(null);
const [isOpen, setIsOpen] = useState(false);
const [view, setView] = useState<"groups" | DrillDownView>("groups");
const [view, setView] = useState<SelectorView>({ 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"
>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.triggerText}>{selectedModelLabel}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
{renderTrigger ? (
renderTrigger({
selectedModelLabel: triggerLabel,
onPress: () => handleOpenChange(!isOpen),
disabled,
isOpen,
})
) : (
<>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.triggerText}>{triggerLabel}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</>
)}
</Pressable>
<Combobox
options={[]}
@@ -109,180 +527,34 @@ export function CombinedModelSelector({
desktopPlacement="top-start"
title="Select model"
>
{view === "groups" ? (
<GroupsView
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
onSelect={handleSelect}
onDrillDown={(provider) => {
setView({ provider });
setSearchQuery("");
}}
/>
) : (
<DrillDownModelView
provider={view.provider}
providerDefinitions={providerDefinitions}
models={allProviderModels.get(view.provider) ?? []}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onSelect={handleSelect}
onBack={() => {
setView("groups");
setSearchQuery("");
}}
/>
)}
<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
}
/>
</Combobox>
</>
);
}
function GroupsView({
providerDefinitions,
allProviderModels,
selectedProvider,
selectedModel,
onSelect,
onDrillDown,
}: {
providerDefinitions: AgentProviderDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]>;
selectedProvider: string;
selectedModel: string;
onSelect: (provider: string, modelId: string) => void;
onDrillDown: (provider: string) => void;
}) {
const { theme } = useUnistyles();
return (
<View>
{providerDefinitions.map((def, index) => {
const models = allProviderModels.get(def.id) ?? [];
const isInline = models.length <= INLINE_MODEL_THRESHOLD;
const ProvIcon = getProviderIcon(def.id);
return (
<View key={def.id}>
{index > 0 ? <View style={styles.separator} /> : null}
{isInline ? (
<>
<View style={styles.sectionHeading}>
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.sectionHeadingText}>{def.label}</Text>
</View>
{models.map((model) => (
<ComboboxItem
key={model.id}
label={model.label}
selected={model.id === selectedModel && def.id === selectedProvider}
onPress={() => onSelect(def.id, model.id)}
/>
))}
</>
) : (
<Pressable
onPress={() => onDrillDown(def.id)}
style={({ pressed, hovered }) => [
styles.drillDownRow,
hovered && styles.drillDownRowHovered,
pressed && styles.drillDownRowPressed,
]}
>
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownText}>{def.label}</Text>
<View style={styles.drillDownTrailing}>
<Text style={styles.drillDownCount}>{models.length}</Text>
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
</View>
</Pressable>
)}
</View>
);
})}
</View>
);
}
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 (
<View>
<Pressable
onPress={onBack}
style={({ pressed, hovered }) => [
styles.backButton,
hovered && styles.backButtonHovered,
pressed && styles.backButtonPressed,
]}
>
<ArrowLeft size={14} color={theme.colors.foregroundMuted} />
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.backButtonText}>{providerLabel}</Text>
</Pressable>
<SearchInput
placeholder="Search models..."
value={searchQuery}
onChangeText={onSearchChange}
autoFocus={Platform.OS === "web"}
/>
{filteredModels.map((model) => (
<ComboboxItem
key={model.id}
label={model.label}
description={model.description}
selected={model.id === selectedModel && provider === selectedProvider}
onPress={() => onSelect(provider, model.id)}
/>
))}
{filteredModels.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyStateText}>No models match your search</Text>
</View>
) : null}
</View>
);
}
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,
},
}));

View File

@@ -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<string, AgentModelDefinition[]>,
): 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),
);
}

View File

@@ -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 (
<Pressable
testID={testID}
disabled={disabled}
onPress={onPress}
style={({ pressed, hovered = false }) => [
styles.comboboxItem,
hovered && styles.comboboxItemHovered,
pressed && styles.comboboxItemPressed,
active && styles.comboboxItemActive,
disabled && styles.comboboxItemDisabled,
]}
>
{leadingContent}
@@ -197,9 +203,14 @@ export function ComboboxItem({
</Text>
) : null}
</View>
{selected ? (
<View style={styles.comboboxItemTrailingSlot}>
<Check size={16} color={theme.colors.foregroundMuted} />
{selected || trailingSlot ? (
<View style={styles.comboboxItemTrailingContainer}>
{trailingSlot}
{selected ? (
<View style={styles.comboboxItemTrailingSlot}>
<Check size={16} color={theme.colors.foregroundMuted} />
</View>
) : null}
</View>
) : null}
</Pressable>
@@ -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: {

View File

@@ -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);
});
});

View File

@@ -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<typeof providerPreferencesSchema>;
@@ -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({