import { type ComponentProps, type ReactNode, memo, useState } from 'react' import { AnimatePresence, motion } from 'motion/react' import { IconBolt, IconBoltFilled } from '@tabler/icons-react' import { useSaveSessionConfig, useSessionConfig } from '../api' import { useWorkspaceAgent } from '@/client/features/workspace/api' import { groupModels, hasEffortChoice, resolveDisplayedEffort, resolveEffortIndex, resolveFastMode, sortModelsByProviderOrder } from './model-order' import { Button } from '@/client/components/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from '@/client/components/ui/dropdown-menu' import { Popover, PopoverContent, PopoverHeader, PopoverTrigger } from '@/client/components/ui/popover' import { Slider } from '@/client/components/ui/slider' import { Tooltip, TooltipContent, TooltipTrigger } from '@/client/components/ui/tooltip' import { useWorkspaceLayoutCtx } from '@/client/features/workspace/WorkspaceLayoutContext' import { cn } from '@/client/lib/cn' import type { Model } from '@/lib/types' // Models describe themselves with a " · "-joined blurb; we show only the // headline (e.g. "Opus 4.8 with 1M context · Most capable…" → "Opus 4.8 with 1M context"). function headline(description?: string): string { return description?.split(/\s*·\s*/)[0] ?? '' } // Row and trigger label. Claude's description headline names the model better // than its `displayName` ("Sonnet 4.6 with 1M context" vs "Sonnet"); backends // without one fall back to the name. function modelLabel(model: Model): string { return headline(model.description) || model.displayName } function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1) } // Display label for a reasoning-effort level. Values stay as the SDK's ids // ('low'…'max'); only the label differs — 'xhigh' reads as "Extra". function effortLabel(level: string): string { return level === 'xhigh' ? 'Extra' : capitalize(level) } function singleSliderValue(value: number | readonly number[]): number { return typeof value === 'number' ? value : (value[0] ?? 0) } const effortHeaderVariants = { from: { opacity: 0, filter: 'blur(4px)' }, to: { opacity: 1, filter: 'blur(0px)' } } const effortHeaderTransition = { type: 'spring', duration: 0.3, delay: 0.1, bounce: 0 } as const type PickerTriggerProps = Omit, 'children' | 'variant'> & { label: string leadingIcon?: ReactNode } function PickerTrigger({ label, leadingIcon, className, ...props }: PickerTriggerProps) { return ( ) } type ModelDropdownProps = { current: string model: Model models: readonly Model[] onValueChange: (value: string) => void } function ModelDropdown({ current, model, models, onValueChange }: ModelDropdownProps) { const label = modelLabel(model) const groups = groupModels(models, 'Models') return ( } /> {groups.map(group => ( {group.label} {group.models.map(item => ( {modelLabel(item)} ))} ))} ) } type EffortPickerProps = { currentEffort: string effortLevels: readonly string[] fastMode: boolean showFastMode: boolean onFastModeChange: (value: boolean) => void onValueChange: (value: string) => void } function EffortPicker({ currentEffort, effortLevels, fastMode, showFastMode, onFastModeChange, onValueChange }: EffortPickerProps) { const currentIndex = resolveEffortIndex(effortLevels, currentEffort) const [open, setOpen] = useState(false) const [draftIndex, setDraftIndex] = useState(currentIndex) const [dragging, setDragging] = useState(false) const displayedIndex = open ? draftIndex : currentIndex const displayedEffort = effortLevels[displayedIndex] ?? currentEffort const displayedLabel = effortLabel(displayedEffort) const handleOpenChange = (nextOpen: boolean) => { if (nextOpen) setDraftIndex(currentIndex) else setDragging(false) setOpen(nextOpen) } const commitEffort = (index: number) => { const nextEffort = effortLevels[index] if (nextEffort && nextEffort !== currentEffort) onValueChange(nextEffort) } return ( : undefined} aria-label={`Effort: ${displayedLabel}`} /> } /> {dragging ? ( ) : (
Effort {displayedLabel}
{showFastMode && ( onFastModeChange(!fastMode)} className="-my-1 -mr-1" > {fastMode ? ( ) : ( )} } /> Fast mode More usage )}
)}
) } type ModelPickerProps = { sessionId: string | null } // Model selector for composer surfaces. The workspace's available models come // from `/api/workspaces/:id/agent`; effort options follow the selected model. // A session id targets that chat's stored config. Null targets the workspace // defaults that seed new chats such as the view builder. export const ModelPicker = memo(function ModelPicker({ sessionId }: ModelPickerProps) { const { workspaceId, layout, setLayout } = useWorkspaceLayoutCtx() const { data } = useWorkspaceAgent(workspaceId) // The SDK prepends a synthetic "default" entry ("Use the default model // (currently …)"). Drop it and name the concrete model it resolves to. const allModels = data?.models ?? [] const defaultEntry = allModels.find(model => model.value === 'default') const models = data ? sortModelsByProviderOrder( allModels.filter(model => model.value !== 'default'), data.provider ) : [] const sessionConfig = useSessionConfig(workspaceId, sessionId).data const saveSessionConfig = useSaveSessionConfig(workspaceId) const selectedModel = (sessionId ? sessionConfig?.model : undefined) ?? layout.selectedModel const selectedEffort = (sessionId ? sessionConfig?.effort : undefined) ?? layout.selectedEffort const selectedFastMode = (sessionId ? sessionConfig?.fastMode : undefined) ?? layout.selectedFastMode const setSelectedModel = (value: string) => { if (sessionId) saveSessionConfig.mutate({ sessionId, patch: { model: value } }) else setLayout({ selectedModel: value }) } const setSelectedEffort = (value: string) => { if (sessionId) saveSessionConfig.mutate({ sessionId, patch: { effort: value } }) else setLayout({ selectedEffort: value }) } const setSelectedFastMode = (value: boolean) => { if (sessionId) saveSessionConfig.mutate({ sessionId, patch: { fastMode: value } }) else setLayout({ selectedFastMode: value }) } if (models.length === 0) return null // Show a persisted pick when it still exists. Otherwise name the concrete // model behind the SDK default, or fall back to the first available model. const persistedModel = models.some(model => model.value === selectedModel) ? selectedModel : undefined const defaultModel = models.find( model => defaultEntry?.resolvedModel && model.resolvedModel === defaultEntry.resolvedModel ) ?? models[0] const currentModelValue = persistedModel ?? defaultModel.value const model = models.find(item => item.value === currentModelValue) ?? models[0] const effortLevels = model.supportsEffort ? (model.supportedEffortLevels ?? []) : [] const currentEffort = resolveDisplayedEffort(effortLevels, selectedEffort, model.defaultEffort) const showEffort = hasEffortChoice(effortLevels) && currentEffort !== undefined const fastMode = resolveFastMode(model, selectedFastMode) return (
{showEffort && ( )}
) })