"use client"; import { memo, useCallback, useMemo } from "react"; import { useAppStore } from "@/components/state-provider"; import { Combobox, type ComboboxOption } from "@/components/combobox"; import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import type { Agent, AgentProfile, AvailableAgent } from "@/lib/types/http"; import { setSessionModel } from "@/lib/api/domains/session-api"; import type { SessionModelEntry } from "@/lib/state/slices/session-runtime/types"; type ModelSelectorProps = { sessionId: string | null; }; type ModelOption = { id: string; name: string; provider?: string; context_window?: number; is_default?: boolean; description?: string; usageMultiplier?: string; }; function resolveSnapshotModel(snapshot: unknown): string | null { if (!snapshot || typeof snapshot !== "object") return null; const model = (snapshot as Record).model; return typeof model === "string" && model ? model : null; } function resolveStaticModels( agents: Agent[], profileId: string | null | undefined, availableAgents: AvailableAgent[], ): ModelOption[] { if (!profileId) return []; for (const agent of agents) { const profile = agent.profiles.find((p: AgentProfile) => p.id === profileId); if (!profile) continue; const available = availableAgents.find((a: AvailableAgent) => a.name === agent.name); const models = available?.model_config?.available_models ?? []; // Static models don't include description — use model ID as subtitle when it differs from name return models.map((m) => ({ ...m, description: m.id !== m.name ? m.id : undefined, })); } return []; } function sessionModelsToOptions(models: SessionModelEntry[]): ModelOption[] { return models.map((m) => ({ id: m.modelId, name: m.name, provider: "", context_window: 0, is_default: false, description: m.description, usageMultiplier: m.usageMultiplier, })); } function buildModelOptions( availableModels: ModelOption[], currentModel: string | null, ): ModelOption[] { const options = [...availableModels]; if (currentModel && !options.some((m) => m.id === currentModel)) { options.unshift({ id: currentModel, name: currentModel, provider: "unknown", context_window: 0, is_default: false, }); } return options; } function resolveProfileModel(profileId: string | null | undefined, agents: Agent[]): string | null { if (!profileId) return null; for (const agent of agents) { const profile = agent.profiles.find((p: AgentProfile) => p.id === profileId); if (profile?.model) return profile.model; } return null; } function resolveCurrentModel( activeModel: string | null, acpCurrentModel: string | null, snapshotModel: string | null, profileModel: string | null, ): string | null { return activeModel || acpCurrentModel || snapshotModel || profileModel; } /** Resolves available models and current model from store state. */ function useModelSelectorState(sessionId: string | null) { useSettingsData(true); const settingsAgents = useAppStore((state) => state.settingsAgents.items); const taskSessions = useAppStore((state) => state.taskSessions.items); const activeModels = useAppStore((state) => state.activeModel.bySessionId); const setActiveModel = useAppStore((state) => state.setActiveModel); const { items: availableAgents } = useAvailableAgents(); const sessionModelsData = useAppStore((state) => sessionId ? state.sessionModels.bySessionId[sessionId] : undefined, ); const session = sessionId ? (taskSessions[sessionId] ?? null) : null; const snapshotModel = resolveSnapshotModel(session?.agent_profile_snapshot); const profileModel = useMemo( () => resolveProfileModel(session?.agent_profile_id, settingsAgents as Agent[]), [session?.agent_profile_id, settingsAgents], ); const usingAcpModels = !!sessionModelsData?.models?.length; const availableModels = usingAcpModels ? sessionModelsToOptions(sessionModelsData.models) : resolveStaticModels(settingsAgents as Agent[], session?.agent_profile_id, availableAgents); const activeModel = sessionId ? activeModels[sessionId] || null : null; const acpCurrentModel = sessionModelsData?.currentModelId || null; const currentModel = resolveCurrentModel( activeModel, acpCurrentModel, snapshotModel, profileModel, ); const modelOptions = buildModelOptions(availableModels, currentModel); const handleModelChange = useCallback( (sid: string, modelId: string) => { setActiveModel(sid, modelId); setSessionModel(sid, modelId).catch((err) => { console.error("[ModelSelector] set-model API failed:", err); }); }, [setActiveModel], ); return { currentModel, modelOptions, handleModelChange }; } function modelToComboboxOption(model: ModelOption): ComboboxOption { return { value: model.id, label: model.name, description: model.description, renderLabel: () => ( <>
{model.name}
{model.description && (
{model.description}
)}
{model.usageMultiplier && ( {model.usageMultiplier} )} ), }; } export const ModelSelector = memo(function ModelSelector({ sessionId }: ModelSelectorProps) { const { currentModel, modelOptions, handleModelChange } = useModelSelectorState(sessionId); const comboboxOptions = useMemo(() => modelOptions.map(modelToComboboxOption), [modelOptions]); const onValueChange = useCallback( (value: string) => { // Don't allow deselecting — always keep a model selected if (!value || !sessionId) return; handleModelChange(sessionId, value); }, [sessionId, handleModelChange], ); if (!sessionId || !currentModel) return null; return ( 5} dropdownLabel="Available Models" triggerClassName="h-7 gap-1 px-2 text-xs w-auto hover:bg-muted/40 whitespace-nowrap" className="min-w-[280px]" plainTrigger popoverSide="top" popoverAlign="end" /> ); });