import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, dropdownMenuRow, dropdownMenuSectionLabel, DropdownMenuSeparator, DropdownMenuSubContent } from '@/components/ui/dropdown-menu' import { Switch } from '@/components/ui/switch' import { useI18n } from '@/i18n' import { isThinkingEnabled, REASONING_EFFORTS, resolveReasoningEffort } from '@/lib/reasoning-effort' // Hermes' real reasoning levels live in lib/reasoning-effort; `none` is owned // by the Thinking toggle, not the radio. /** How "fast" is achieved for a given model — two different mechanisms: * - `param`: the Anthropic/OpenAI `speed=fast` request parameter. * - `variant`: a separate `…-fast` sibling model selected via the model field. */ export type FastControl = { kind: 'none' } | { kind: 'param'; on: boolean } | { kind: 'variant'; baseId: string; fastId: string; on: boolean } /** Resolve the fast mechanism for a model: prefer the speed=fast parameter * when the backend supports it, else fall back to a `…-fast` sibling model. */ export function resolveFastControl( model: string, providerModels: readonly string[], paramSupported: boolean, currentFastMode: boolean ): FastControl { if (paramSupported) { return { kind: 'param', on: currentFastMode } } if (/-fast$/i.test(model)) { const baseId = model.replace(/-fast$/i, '') // Only a toggle if there's a base to switch back to; otherwise it's a // standalone fast model with no "off" state. return providerModels.includes(baseId) ? { kind: 'variant', baseId, fastId: model, on: true } : { kind: 'none' } } const fastId = `${model}-fast` if (providerModels.includes(fastId)) { return { kind: 'variant', baseId: model, fastId, on: false } } // Fast isn't natively offered here, but if the session still has the speed // param on (carried over from a previous model), expose the toggle so it can // be turned off rather than stranded. if (currentFastMode) { return { kind: 'param', on: true } } return { kind: 'none' } } interface ModelEditSubmenuProps { /** Whether this model can turn thinking off. False on reasoning-mandatory * routes, whose upstream rejects a disable — the toggle is hidden rather * than offered as a control that silently does nothing. */ canDisableReasoning?: boolean /** The profile's configured default effort — what an unset row inherits. * Passed in (not read from a store) so this submenu stays pure. */ defaultEffort: string /** This row's effective reasoning effort (live for the active model, else its * preset) — the submenu shows and edits from this, never the raw session. */ effort: string /** How fast mode is offered for this model (param toggle vs. variant swap). */ fastControl: FastControl /** Whether this row's model is the active one. */ isActive: boolean /** This row's model id. */ model: string /** Read-only details; the owner handles opening without changing selection. */ onOpenDetails?: () => void /** Switch to a specific model id (used to swap base ⇄ -fast variant). */ onSelectModel: (model: string) => Promise | void /** Report an option change. This submenu is PURE: it never writes to a * session, a preset store, or the gateway itself — the owning surface's * controller decides what an edit means. That's what lets the same submenu * drive a live chat session and a detached per-task override. */ onSetOptions: (patch: { effort?: string; fast?: boolean }) => void /** This row's provider slug. */ provider: string /** Whether this model supports reasoning effort. */ reasoning: boolean } export function ModelEditSubmenu(props: ModelEditSubmenuProps) { // The panel mounts one of these per model row; only the hovered row's // submenu is ever open. Keep this wrapper hook-free and render the body as // a CHILD of SubContent so Radix's Presence gate leaves it unrendered until // the sub actually opens — eagerly running the body's hooks/JSX for every // row made opening the menu itself lag on large catalogs. return ( ) } function ModelEditSubmenuBody({ canDisableReasoning, defaultEffort, effort, fastControl, isActive, onOpenDetails, onSelectModel, onSetOptions, reasoning }: ModelEditSubmenuProps) { const { t } = useI18n() const copy = t.shell.modelOptions const effortValue = resolveReasoningEffort(effort, defaultEffort) const thinkingOn = isThinkingEnabled(effort, defaultEffort) const showThinkingToggle = reasoning && canDisableReasoning !== false const setFast = (enabled: boolean) => { if (fastControl.kind === 'variant') { // Fast is a separate model id. Report the choice so the controller can // record it against the base model, and only swap models now if this is // the active row — inactive edits stay preference-only. onSetOptions({ fast: enabled }) if (isActive) { void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId) } return } if (fastControl.kind === 'param') { onSetOptions({ fast: enabled }) } } const hasFast = fastControl.kind !== 'none' const fastOn = fastControl.kind === 'none' ? false : fastControl.on return !hasFast && !reasoning && !onOpenDetails ? (
{copy.noOptions}
) : ( <> {onOpenDetails ? ( <> {copy.comboDetails} {hasFast || reasoning ? : null} ) : null} {hasFast || reasoning ? {copy.options} : null} {showThinkingToggle ? ( event.preventDefault()}> {copy.thinking} onSetOptions({ effort: checked ? effortValue || defaultEffort : 'none' })} size="xs" /> ) : null} {hasFast ? ( event.preventDefault()}> {copy.fast} ) : null} {reasoning ? ( <> {copy.effort} onSetOptions({ effort: value })} value={effortValue}> {REASONING_EFFORTS.map(value => ( event.preventDefault()} value={value} > {copy[value]} ))} ) : null} ) }