Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
@@ -6,9 +6,14 @@ import { useStoreWithEqualityFn } from "zustand/traditional";
|
|||||||
import { Brain, ChevronDown, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react-native";
|
import { Brain, ChevronDown, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react-native";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
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 {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -25,6 +30,7 @@ import type {
|
|||||||
} from "@server/server/agent/agent-sdk-types";
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
import {
|
import {
|
||||||
|
AGENT_PROVIDER_DEFINITIONS,
|
||||||
getModeVisuals,
|
getModeVisuals,
|
||||||
type AgentModeColorTier,
|
type AgentModeColorTier,
|
||||||
type AgentModeIcon,
|
type AgentModeIcon,
|
||||||
@@ -42,6 +48,10 @@ type StatusOption = {
|
|||||||
|
|
||||||
type StatusSelector = "provider" | "mode" | "model" | "thinking";
|
type StatusSelector = "provider" | "mode" | "model" | "thinking";
|
||||||
|
|
||||||
|
const PROVIDER_DEFINITION_MAP = new Map(
|
||||||
|
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
|
||||||
|
);
|
||||||
|
|
||||||
type ControlledAgentStatusBarProps = {
|
type ControlledAgentStatusBarProps = {
|
||||||
provider: string;
|
provider: string;
|
||||||
providerOptions?: StatusOption[];
|
providerOptions?: StatusOption[];
|
||||||
@@ -58,6 +68,11 @@ type ControlledAgentStatusBarProps = {
|
|||||||
onSelectThinkingOption?: (thinkingOptionId: string) => void;
|
onSelectThinkingOption?: (thinkingOptionId: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isModelLoading?: 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 {
|
export interface DraftAgentStatusBarProps {
|
||||||
@@ -142,6 +157,11 @@ function ControlledStatusBar({
|
|||||||
onSelectThinkingOption,
|
onSelectThinkingOption,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
isModelLoading = false,
|
isModelLoading = false,
|
||||||
|
providerDefinitions,
|
||||||
|
allProviderModels,
|
||||||
|
canSelectModelProvider,
|
||||||
|
favoriteKeys = new Set<string>(),
|
||||||
|
onToggleFavoriteModel,
|
||||||
}: ControlledAgentStatusBarProps) {
|
}: ControlledAgentStatusBarProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isWeb = Platform.OS === "web";
|
const isWeb = Platform.OS === "web";
|
||||||
@@ -205,6 +225,26 @@ function ControlledStatusBar({
|
|||||||
() => (modelOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
() => (modelOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
||||||
[modelOptions],
|
[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[]>(
|
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
|
||||||
() => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
() => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
||||||
[thinkingOptions],
|
[thinkingOptions],
|
||||||
@@ -289,49 +329,36 @@ function ControlledStatusBar({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{canSelectModel ? (
|
{canSelectModel ? (
|
||||||
<>
|
<Tooltip
|
||||||
<Tooltip
|
key={`model-${displayModel}`}
|
||||||
key={`model-${openSelector === "model" ? "open" : "closed"}`}
|
delayDuration={0}
|
||||||
delayDuration={0}
|
enabledOnDesktop
|
||||||
enabledOnDesktop
|
enabledOnMobile={false}
|
||||||
enabledOnMobile={false}
|
>
|
||||||
>
|
<TooltipTrigger asChild triggerRefProp="ref">
|
||||||
<TooltipTrigger asChild triggerRefProp="ref">
|
<View>
|
||||||
<Pressable
|
<CombinedModelSelector
|
||||||
ref={modelAnchorRef}
|
providerDefinitions={effectiveProviderDefinitions}
|
||||||
collapsable={false}
|
allProviderModels={effectiveAllProviderModels}
|
||||||
|
selectedProvider={provider}
|
||||||
|
selectedModel={selectedModelId ?? ""}
|
||||||
|
canSelectProvider={canSelectProviderInModelMenu}
|
||||||
|
onSelect={(selectedProviderId, modelId) => {
|
||||||
|
if (selectedProviderId === provider) {
|
||||||
|
onSelectModel?.(modelId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
favoriteKeys={favoriteKeys}
|
||||||
|
onToggleFavorite={onToggleFavoriteModel}
|
||||||
|
isLoading={isModelLoading}
|
||||||
disabled={modelDisabled}
|
disabled={modelDisabled}
|
||||||
onPress={() => handleSelectorPress("model")}
|
/>
|
||||||
style={({ pressed, hovered }) => [
|
</View>
|
||||||
styles.modeBadge,
|
</TooltipTrigger>
|
||||||
hovered && styles.modeBadgeHovered,
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
(pressed || openSelector === "model") && styles.modeBadgePressed,
|
<Text style={styles.tooltipText}>{getStatusSelectorHint("model")}</Text>
|
||||||
modelDisabled && styles.disabledBadge,
|
</TooltipContent>
|
||||||
]}
|
</Tooltip>
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{thinkingOptions && thinkingOptions.length > 0 ? (
|
{thinkingOptions && thinkingOptions.length > 0 ? (
|
||||||
@@ -491,36 +518,35 @@ function ControlledStatusBar({
|
|||||||
|
|
||||||
{canSelectModel ? (
|
{canSelectModel ? (
|
||||||
<View style={styles.sheetSection}>
|
<View style={styles.sheetSection}>
|
||||||
<DropdownMenu
|
<CombinedModelSelector
|
||||||
open={openSelector === "model"}
|
providerDefinitions={effectiveProviderDefinitions}
|
||||||
onOpenChange={handleOpenChange("model")}
|
allProviderModels={effectiveAllProviderModels}
|
||||||
>
|
selectedProvider={provider}
|
||||||
<DropdownMenuTrigger
|
selectedModel={selectedModelId ?? ""}
|
||||||
disabled={modelDisabled}
|
canSelectProvider={canSelectProviderInModelMenu}
|
||||||
style={({ pressed }) => [
|
onSelect={(selectedProviderId, modelId) => {
|
||||||
styles.sheetSelect,
|
if (selectedProviderId === provider) {
|
||||||
pressed && styles.sheetSelectPressed,
|
onSelectModel?.(modelId);
|
||||||
modelDisabled && styles.disabledSheetSelect,
|
}
|
||||||
]}
|
}}
|
||||||
accessibilityRole="button"
|
favoriteKeys={favoriteKeys}
|
||||||
accessibilityLabel="Select agent model"
|
onToggleFavorite={onToggleFavoriteModel}
|
||||||
testID="agent-preferences-model"
|
isLoading={isModelLoading}
|
||||||
>
|
disabled={modelDisabled}
|
||||||
<Text style={styles.sheetSelectText}>{displayModel}</Text>
|
renderTrigger={({ selectedModelLabel }) => (
|
||||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
<View
|
||||||
</DropdownMenuTrigger>
|
style={[
|
||||||
<DropdownMenuContent side="top" align="start">
|
styles.sheetSelect,
|
||||||
{(modelOptions ?? []).map((model) => (
|
modelDisabled && styles.disabledSheetSelect,
|
||||||
<DropdownMenuItem
|
]}
|
||||||
key={model.id}
|
pointerEvents="none"
|
||||||
selected={model.id === selectedModelId}
|
testID="agent-preferences-model"
|
||||||
onSelect={() => onSelectModel?.(model.id)}
|
>
|
||||||
>
|
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
|
||||||
{model.label}
|
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
</DropdownMenuItem>
|
</View>
|
||||||
))}
|
)}
|
||||||
</DropdownMenuContent>
|
/>
|
||||||
</DropdownMenu>
|
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : 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 models = modelsQuery.data ?? null;
|
||||||
|
|
||||||
const displayMode =
|
const displayMode =
|
||||||
@@ -674,6 +754,10 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
|||||||
const modelOptions = useMemo<StatusOption[]>(() => {
|
const modelOptions = useMemo<StatusOption[]>(() => {
|
||||||
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
|
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
|
||||||
}, [models]);
|
}, [models]);
|
||||||
|
const favoriteKeys = useMemo(
|
||||||
|
() => new Set((preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite))),
|
||||||
|
[preferences.favoriteModels],
|
||||||
|
);
|
||||||
|
|
||||||
const thinkingOptions = useMemo<StatusOption[]>(() => {
|
const thinkingOptions = useMemo<StatusOption[]>(() => {
|
||||||
return (modelSelection.thinkingOptions ?? []).map((option) => ({
|
return (modelSelection.thinkingOptions ?? []).map((option) => ({
|
||||||
@@ -693,6 +777,9 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
|||||||
modeOptions.length > 0 ? modeOptions : [{ id: agent.currentModeId ?? "", label: displayMode }]
|
modeOptions.length > 0 ? modeOptions : [{ id: agent.currentModeId ?? "", label: displayMode }]
|
||||||
}
|
}
|
||||||
selectedModeId={agent.currentModeId ?? undefined}
|
selectedModeId={agent.currentModeId ?? undefined}
|
||||||
|
providerDefinitions={availableProviderDefinitions}
|
||||||
|
allProviderModels={liveAllProviderModels}
|
||||||
|
canSelectModelProvider={(providerId) => providerId === agent.provider}
|
||||||
onSelectMode={(modeId) => {
|
onSelectMode={(modeId) => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return;
|
return;
|
||||||
@@ -722,6 +809,12 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
|||||||
console.warn("[AgentStatusBar] setAgentModel failed", error);
|
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}
|
thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined}
|
||||||
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
|
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
|
||||||
onSelectThinkingOption={(thinkingOptionId) => {
|
onSelectThinkingOption={(thinkingOptionId) => {
|
||||||
@@ -775,6 +868,7 @@ export function DraftAgentStatusBar({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
}: DraftAgentStatusBarProps) {
|
}: DraftAgentStatusBarProps) {
|
||||||
const isWeb = Platform.OS === "web";
|
const isWeb = Platform.OS === "web";
|
||||||
|
const { preferences, updatePreferences } = useFormPreferences();
|
||||||
|
|
||||||
const mappedModeOptions = useMemo<StatusOption[]>(() => {
|
const mappedModeOptions = useMemo<StatusOption[]>(() => {
|
||||||
if (modeOptions.length === 0) {
|
if (modeOptions.length === 0) {
|
||||||
@@ -789,6 +883,10 @@ export function DraftAgentStatusBar({
|
|||||||
const mappedThinkingOptions = useMemo<StatusOption[]>(() => {
|
const mappedThinkingOptions = useMemo<StatusOption[]>(() => {
|
||||||
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }));
|
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }));
|
||||||
}, [thinkingOptions]);
|
}, [thinkingOptions]);
|
||||||
|
const favoriteKeys = useMemo(
|
||||||
|
() => new Set((preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite))),
|
||||||
|
[preferences.favoriteModels],
|
||||||
|
);
|
||||||
|
|
||||||
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
|
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
|
||||||
const effectiveSelectedThinkingOption =
|
const effectiveSelectedThinkingOption =
|
||||||
@@ -803,6 +901,12 @@ export function DraftAgentStatusBar({
|
|||||||
selectedProvider={selectedProvider}
|
selectedProvider={selectedProvider}
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
onSelect={onSelectProviderAndModel}
|
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}
|
isLoading={isAllModelsLoading}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
@@ -843,6 +947,12 @@ export function DraftAgentStatusBar({
|
|||||||
selectedModelId={selectedModel}
|
selectedModelId={selectedModel}
|
||||||
onSelectModel={onSelectModel}
|
onSelectModel={onSelectModel}
|
||||||
isModelLoading={isModelLoading}
|
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}
|
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||||
onSelectThinkingOption={onSelectThinkingOption}
|
onSelectThinkingOption={onSelectThinkingOption}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,22 +1,31 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
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 { 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 { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
import { Combobox, ComboboxItem, SearchInput } from "@/components/ui/combobox";
|
import { Combobox, ComboboxItem, SearchInput } from "@/components/ui/combobox";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
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 };
|
type SelectorView =
|
||||||
|
| { kind: "all" }
|
||||||
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
|
| { kind: "provider"; providerId: string; providerLabel: string };
|
||||||
if (!models || models.length === 0) {
|
|
||||||
return "Select model";
|
|
||||||
}
|
|
||||||
return (models.find((model) => model.isDefault) ?? models[0])?.label ?? "Select model";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CombinedModelSelectorProps {
|
interface CombinedModelSelectorProps {
|
||||||
providerDefinitions: AgentProviderDefinition[];
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
@@ -25,9 +34,393 @@ interface CombinedModelSelectorProps {
|
|||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
onSelect: (provider: AgentProvider, modelId: string) => void;
|
onSelect: (provider: AgentProvider, modelId: string) => void;
|
||||||
isLoading: boolean;
|
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;
|
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({
|
export function CombinedModelSelector({
|
||||||
providerDefinitions,
|
providerDefinitions,
|
||||||
allProviderModels,
|
allProviderModels,
|
||||||
@@ -35,48 +428,61 @@ export function CombinedModelSelector({
|
|||||||
selectedModel,
|
selectedModel,
|
||||||
onSelect,
|
onSelect,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
canSelectProvider = () => true,
|
||||||
|
favoriteKeys = new Set<string>(),
|
||||||
|
onToggleFavorite,
|
||||||
|
renderTrigger,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: CombinedModelSelectorProps) {
|
}: CombinedModelSelectorProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const anchorRef = useRef<View>(null);
|
const anchorRef = useRef<View>(null);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [view, setView] = useState<"groups" | DrillDownView>("groups");
|
const [view, setView] = useState<SelectorView>({ kind: "all" });
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
const handleOpenChange = useCallback(
|
const handleOpenChange = useCallback(
|
||||||
(open: boolean) => {
|
(open: boolean) => {
|
||||||
setIsOpen(open);
|
setIsOpen(open);
|
||||||
if (open) {
|
setView({ kind: "all" });
|
||||||
const models = allProviderModels.get(selectedProvider);
|
if (!open) {
|
||||||
if (models && models.length > INLINE_MODEL_THRESHOLD) {
|
|
||||||
setView({ provider: selectedProvider });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setView("groups");
|
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[allProviderModels, selectedProvider],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(provider: string, modelId: string) => {
|
(provider: string, modelId: string) => {
|
||||||
onSelect(provider as AgentProvider, modelId);
|
onSelect(provider as AgentProvider, modelId);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setView("groups");
|
setView({ kind: "all" });
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
},
|
},
|
||||||
[onSelect],
|
[onSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
const ProviderIcon = getProviderIcon(selectedProvider);
|
const ProviderIcon = getProviderIcon(selectedProvider);
|
||||||
|
const selectedProviderLabel = useMemo(
|
||||||
|
() => resolveProviderLabel(providerDefinitions, selectedProvider),
|
||||||
|
[providerDefinitions, selectedProvider],
|
||||||
|
);
|
||||||
|
|
||||||
const selectedModelLabel = useMemo(() => {
|
const selectedModelLabel = useMemo(() => {
|
||||||
const models = allProviderModels.get(selectedProvider);
|
const models = allProviderModels.get(selectedProvider);
|
||||||
if (!models) return isLoading ? "Loading..." : "Select model";
|
if (!models) {
|
||||||
const model = models.find((m) => m.id === selectedModel);
|
return isLoading ? "Loading..." : "Select model";
|
||||||
|
}
|
||||||
|
const model = models.find((entry) => entry.id === selectedModel);
|
||||||
return model?.label ?? resolveDefaultModelLabel(models);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -90,14 +496,26 @@ export function CombinedModelSelector({
|
|||||||
hovered && styles.triggerHovered,
|
hovered && styles.triggerHovered,
|
||||||
(pressed || isOpen) && styles.triggerPressed,
|
(pressed || isOpen) && styles.triggerPressed,
|
||||||
disabled && styles.triggerDisabled,
|
disabled && styles.triggerDisabled,
|
||||||
|
renderTrigger ? styles.customTriggerWrapper : null,
|
||||||
]}
|
]}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={`Select model (${selectedModelLabel})`}
|
accessibilityLabel={`Select model (${selectedModelLabel})`}
|
||||||
testID="combined-model-selector"
|
testID="combined-model-selector"
|
||||||
>
|
>
|
||||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
{renderTrigger ? (
|
||||||
<Text style={styles.triggerText}>{selectedModelLabel}</Text>
|
renderTrigger({
|
||||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
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>
|
</Pressable>
|
||||||
<Combobox
|
<Combobox
|
||||||
options={[]}
|
options={[]}
|
||||||
@@ -109,180 +527,34 @@ export function CombinedModelSelector({
|
|||||||
desktopPlacement="top-start"
|
desktopPlacement="top-start"
|
||||||
title="Select model"
|
title="Select model"
|
||||||
>
|
>
|
||||||
{view === "groups" ? (
|
<SelectorContent
|
||||||
<GroupsView
|
view={view}
|
||||||
providerDefinitions={providerDefinitions}
|
providerDefinitions={providerDefinitions}
|
||||||
allProviderModels={allProviderModels}
|
allProviderModels={allProviderModels}
|
||||||
selectedProvider={selectedProvider}
|
selectedProvider={selectedProvider}
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
onSelect={handleSelect}
|
searchQuery={searchQuery}
|
||||||
onDrillDown={(provider) => {
|
onSearchChange={setSearchQuery}
|
||||||
setView({ provider });
|
favoriteKeys={favoriteKeys}
|
||||||
setSearchQuery("");
|
onSelect={handleSelect}
|
||||||
}}
|
canSelectProvider={canSelectProvider}
|
||||||
/>
|
onToggleFavorite={onToggleFavorite}
|
||||||
) : (
|
onDrillDown={(providerId, providerLabel) => {
|
||||||
<DrillDownModelView
|
setView({ kind: "provider", providerId, providerLabel });
|
||||||
provider={view.provider}
|
}}
|
||||||
providerDefinitions={providerDefinitions}
|
onBack={
|
||||||
models={allProviderModels.get(view.provider) ?? []}
|
view.kind === "provider"
|
||||||
selectedProvider={selectedProvider}
|
? () => {
|
||||||
selectedModel={selectedModel}
|
setView({ kind: "all" });
|
||||||
searchQuery={searchQuery}
|
}
|
||||||
onSearchChange={setSearchQuery}
|
: undefined
|
||||||
onSelect={handleSelect}
|
}
|
||||||
onBack={() => {
|
/>
|
||||||
setView("groups");
|
|
||||||
setSearchQuery("");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Combobox>
|
</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) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
trigger: {
|
trigger: {
|
||||||
height: 28,
|
height: 28,
|
||||||
@@ -307,6 +579,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
},
|
},
|
||||||
|
customTriggerWrapper: {
|
||||||
|
paddingHorizontal: 0,
|
||||||
|
paddingVertical: 0,
|
||||||
|
height: "auto",
|
||||||
|
},
|
||||||
separator: {
|
separator: {
|
||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: theme.colors.border,
|
backgroundColor: theme.colors.border,
|
||||||
@@ -374,9 +651,23 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
emptyState: {
|
emptyState: {
|
||||||
paddingVertical: theme.spacing[4],
|
paddingVertical: theme.spacing[4],
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
emptyStateText: {
|
emptyStateText: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
color: theme.colors.foregroundMuted,
|
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,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -145,8 +145,10 @@ export interface ComboboxItemProps {
|
|||||||
description?: string;
|
description?: string;
|
||||||
kind?: "directory" | "file";
|
kind?: "directory" | "file";
|
||||||
leadingSlot?: ReactNode;
|
leadingSlot?: ReactNode;
|
||||||
|
trailingSlot?: ReactNode;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
}
|
}
|
||||||
@@ -156,8 +158,10 @@ export function ComboboxItem({
|
|||||||
description,
|
description,
|
||||||
kind,
|
kind,
|
||||||
leadingSlot,
|
leadingSlot,
|
||||||
|
trailingSlot,
|
||||||
selected,
|
selected,
|
||||||
active,
|
active,
|
||||||
|
disabled,
|
||||||
onPress,
|
onPress,
|
||||||
testID,
|
testID,
|
||||||
}: ComboboxItemProps): ReactElement {
|
}: ComboboxItemProps): ReactElement {
|
||||||
@@ -178,12 +182,14 @@ export function ComboboxItem({
|
|||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
testID={testID}
|
testID={testID}
|
||||||
|
disabled={disabled}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
style={({ pressed, hovered = false }) => [
|
style={({ pressed, hovered = false }) => [
|
||||||
styles.comboboxItem,
|
styles.comboboxItem,
|
||||||
hovered && styles.comboboxItemHovered,
|
hovered && styles.comboboxItemHovered,
|
||||||
pressed && styles.comboboxItemPressed,
|
pressed && styles.comboboxItemPressed,
|
||||||
active && styles.comboboxItemActive,
|
active && styles.comboboxItemActive,
|
||||||
|
disabled && styles.comboboxItemDisabled,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{leadingContent}
|
{leadingContent}
|
||||||
@@ -197,9 +203,14 @@ export function ComboboxItem({
|
|||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
{selected ? (
|
{selected || trailingSlot ? (
|
||||||
<View style={styles.comboboxItemTrailingSlot}>
|
<View style={styles.comboboxItemTrailingContainer}>
|
||||||
<Check size={16} color={theme.colors.foregroundMuted} />
|
{trailingSlot}
|
||||||
|
{selected ? (
|
||||||
|
<View style={styles.comboboxItemTrailingSlot}>
|
||||||
|
<Check size={16} color={theme.colors.foregroundMuted} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -777,10 +788,18 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
comboboxItemActive: {
|
comboboxItemActive: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
|
comboboxItemDisabled: {
|
||||||
|
opacity: 0.55,
|
||||||
|
},
|
||||||
comboboxItemTrailingSlot: {
|
comboboxItemTrailingSlot: {
|
||||||
width: 16,
|
width: 16,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
comboboxItemTrailingContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
marginLeft: "auto",
|
marginLeft: "auto",
|
||||||
},
|
},
|
||||||
comboboxItemContent: {
|
comboboxItemContent: {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { mergeProviderPreferences } from "./use-form-preferences";
|
import {
|
||||||
|
buildFavoriteModelKey,
|
||||||
|
isFavoriteModel,
|
||||||
|
mergeProviderPreferences,
|
||||||
|
toggleFavoriteModel,
|
||||||
|
} from "./use-form-preferences";
|
||||||
|
|
||||||
describe("mergeProviderPreferences", () => {
|
describe("mergeProviderPreferences", () => {
|
||||||
it("stores the selected model for a provider", () => {
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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_STORAGE_KEY = "@paseo:create-agent-preferences";
|
||||||
const FORM_PREFERENCES_QUERY_KEY = ["form-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({
|
const providerPreferencesSchema = z.object({
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
mode: z.string().optional(),
|
mode: z.string().optional(),
|
||||||
@@ -16,6 +30,12 @@ const providerPreferencesSchema = z.object({
|
|||||||
const formPreferencesSchema = z.object({
|
const formPreferencesSchema = z.object({
|
||||||
provider: z.string().optional(),
|
provider: z.string().optional(),
|
||||||
providerPreferences: z.record(providerPreferencesSchema).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>;
|
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 {
|
export function useFormPreferences(): UseFormPreferencesReturn {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data, isPending } = useQuery({
|
const { data, isPending } = useQuery({
|
||||||
|
|||||||
Reference in New Issue
Block a user