diff --git a/console/web/src/components/chat/Composer.stories.tsx b/console/web/src/components/chat/Composer.stories.tsx index cf3f784a7..386755a45 100644 --- a/console/web/src/components/chat/Composer.stories.tsx +++ b/console/web/src/components/chat/Composer.stories.tsx @@ -3,7 +3,13 @@ import { $createParagraphNode, $createTextNode, $getRoot } from 'lexical' import { useState } from 'react' import { fn } from 'storybook/test' import { STATIC_FUNCTIONS } from '@/lib/functions' -import type { Attachment, Mode, ModelId, ModelOption } from '@/types/chat' +import type { + Attachment, + Mode, + ModelId, + ModelOption, + ThinkingLevel, +} from '@/types/chat' const STORY_MODEL_OPTIONS: ModelOption[] = [ { @@ -11,6 +17,15 @@ const STORY_MODEL_OPTIONS: ModelOption[] = [ label: 'gpt-5', contextWindow: 400_000, supportsThinking: true, + reasoningEfforts: [ + { effort: 'none', description: 'fastest responses without reasoning' }, + { effort: 'minimal', description: 'minimal reasoning overhead' }, + { effort: 'low', description: 'quick reasoning for routine tasks' }, + { effort: 'medium', description: 'balanced reasoning and latency' }, + { effort: 'high', description: 'deeper reasoning for complex tasks' }, + { effort: 'xhigh', description: 'extended reasoning for hard tasks' }, + { effort: 'ultra', description: 'maximum model-native reasoning' }, + ], }, { id: 'anthropic::claude-opus-4-7', @@ -24,6 +39,12 @@ const STORY_MODEL_OPTIONS: ModelOption[] = [ contextWindow: 400_000, supportsThinking: true, }, + { + id: 'openai::gpt-4.1', + label: 'gpt-4.1', + contextWindow: 1_000_000, + supportsThinking: false, + }, ] import { Composer } from './Composer' @@ -73,6 +94,7 @@ function ComposerHarness({ }) { const [mode, setMode] = useState(initialMode) const [model, setModel] = useState(initialModel) + const [thinkingLevel, setThinkingLevel] = useState('default') return ( ({ label, options: opts })) } -function formatThinkingLevel(level: ThinkingLevel): string { - return level === 'off' ? 'thinking off' : `thinking ${level}` +function providerForModel(modelId: ModelId | undefined): string | null { + return modelId?.split(CATALOG_MODEL_KEY_SEP)[0] || null } -function triggerLabel( +function effortOptionsFor( model: ModelOption | undefined, - thinkingLevel: ThinkingLevel, -): string | undefined { - if (!model) return undefined - if (model.supportsThinking && thinkingLevel !== 'off') { - return `${model.label} · ${formatThinkingLevel(thinkingLevel)}` +): ReasoningEffortOption[] { + if (!model) return [] + if (model.reasoningEfforts && model.reasoningEfforts.length > 0) { + return [ + DEFAULT_EFFORT, + ...model.reasoningEfforts.filter((option) => option.effort !== 'default'), + ] } - return model.label + if (!model.supportsThinking) return [] + return THINKING_LEVELS.map((effort) => ({ effort })) +} + +function effortSupported( + options: ReasoningEffortOption[], + effort: ThinkingLevel, +): boolean { + return options.some((option) => option.effort === effort) } export function ModelPicker({ @@ -78,11 +95,19 @@ export function ModelPicker({ }: ModelPickerProps) { const ctx = useConversationsCtxOptional() const [open, setOpen] = useState(false) - const [expandedModelId, setExpandedModelId] = useState(null) + const [expandedProvider, setExpandedProvider] = useState(null) + const [effortFocusRequest, setEffortFocusRequest] = useState(0) + const effortByModel = useRef(new Map()) + const modelItemRefs = useRef(new Map()) + const effortItemRefs = useRef(new Map()) + const pendingEffortFocus = useRef<{ + model: ModelId + effort: ThinkingLevel + } | null>(null) const presentIds = ctx?.presentProviders.map((p) => p.id) ?? [] const presentSet = new Set(presentIds) - // Providers the router declares but whose worker is not loaded — their + // Providers the router declares but whose worker is not loaded: their // catalog models would only fail with `provider_unavailable` at dispatch. const unavailableSet = new Set( (ctx?.presentProviders ?? []) @@ -91,19 +116,20 @@ export function ModelPicker({ ) const optionsById = useMemo( - () => new Map(options.map((o) => [o.id, o])), + () => new Map(options.map((option) => [option.id, option])), [options], ) const pickerOptions = options const safeValue = - value != null && pickerOptions.some((o) => o.id === value) + value != null && pickerOptions.some((option) => option.id === value) ? value : undefined const selected = safeValue ? optionsById.get(safeValue) : undefined + const selectedEfforts = useMemo(() => effortOptionsFor(selected), [selected]) const modelGroups = groupByProvider(pickerOptions) - const grouped = new Set(modelGroups.map((g) => g.label)) + const grouped = new Set(modelGroups.map((group) => group.label)) const emptyGroups: ModelGroup[] = presentIds .filter((id) => !grouped.has(id)) .map((id) => ({ label: id, options: [] })) @@ -113,72 +139,151 @@ export function ModelPicker({ const pickerDisabled = disabled || loading || groups.length === 0 - function handleThinkingPick(modelId: ModelId, level: ThinkingLevel) { - onThinkingLevelChange(level) - if (safeValue !== modelId) onChange(modelId) + useEffect(() => { + if (pickerDisabled && open) setOpen(false) + }, [pickerDisabled, open]) + + useEffect(() => { + if (!safeValue) return + if (effortSupported(selectedEfforts, thinkingLevel)) { + effortByModel.current.set(safeValue, thinkingLevel) + return + } + if (thinkingLevel !== 'default') onThinkingLevelChange('default') + effortByModel.current.set(safeValue, 'default') + }, [safeValue, selectedEfforts, thinkingLevel, onThinkingLevelChange]) + + useEffect(() => { + if ( + !open || + !safeValue || + expandedProvider !== providerForModel(safeValue) + ) { + return + } + const frame = window.requestAnimationFrame(() => { + modelItemRefs.current.get(safeValue)?.focus() + }) + return () => window.cancelAnimationFrame(frame) + }, [expandedProvider, open, safeValue]) + + useEffect(() => { + if (effortFocusRequest === 0) return + const pending = pendingEffortFocus.current + if (!open || !pending || pending.model !== safeValue) return + const target = + effortItemRefs.current.get(pending.effort) ?? + effortItemRefs.current.get('default') + if (!target) return + pendingEffortFocus.current = null + target.focus() + }, [effortFocusRequest, open, safeValue]) + + function handleModelChange(next: string) { + const nextModel = optionsById.get(next) + const nextEfforts = effortOptionsFor(nextModel) + const remembered = effortByModel.current.get(next) ?? 'default' + const nextEffort = effortSupported(nextEfforts, remembered) + ? remembered + : 'default' + onChange(next) + if (nextEffort !== thinkingLevel) onThinkingLevelChange(nextEffort) + return { nextEfforts, nextEffort } + } + + function handleEffortChange(next: ThinkingLevel) { + if (safeValue) effortByModel.current.set(safeValue, next) + onThinkingLevelChange(next) + } + + function handleOpenChange(nextOpen: boolean) { + if (nextOpen) { + setExpandedProvider( + providerForModel(safeValue) ?? groups[0]?.label ?? null, + ) + } + setOpen(nextOpen) } return ( - - { - setOpen(next) - if (!next) setExpandedModelId(null) - }} - onValueChange={(next) => { - onChange(next as ModelId) - setExpandedModelId(null) - }} - disabled={pickerDisabled} - > - - - - {triggerLabel(selected, thinkingLevel)} - - - + + + + + - - + - - {groups.map((g) => { - const unavailable = unavailableSet.has(g.label) - const unconfigured = !unavailable && g.options.length === 0 + + {groups.map((group) => { + const unavailable = unavailableSet.has(group.label) + const unconfigured = !unavailable && group.options.length === 0 + const expanded = expandedProvider === group.label return ( - -
- - - {g.label} - + +
+ { + event.preventDefault() + setExpandedProvider(expanded ? null : group.label) + }} + className="flex min-w-0 flex-1 cursor-pointer select-none items-center gap-1.5 px-2 py-2 text-ink-faint outline-none data-[highlighted]:bg-panel data-[highlighted]:text-ink" + > + {expanded ? ( + + ) : ( + + )} + + {group.label} + {unavailable ? ( not loaded @@ -188,122 +293,118 @@ export function ModelPicker({ not configured ) : null} - - {presentSet.has(g.label) ? ( + + {presentSet.has(group.label) ? ( ) : null}
- {g.options.map((opt) => { - const expanded = expandedModelId === opt.id - // Gates the edit button AND the level panel: picking a - // level calls onChange(opt.id), which must not select a - // model whose row is disabled as unavailable. - const showThinking = - opt.supportsThinking === true && !unavailable - return ( -
-
- - - - - - {opt.label} - - - {showThinking ? ( - - ) : null} -
- {expanded && showThinking ? ( -
- {THINKING_LEVELS.map((level) => { - const active = - safeValue === opt.id && - thinkingLevel === level - return ( - - ) - })} -
- ) : null} -
- ) - })} - + {expanded + ? group.options.map((option) => ( + { + if (node) { + modelItemRefs.current.set(option.id, node) + } else { + modelItemRefs.current.delete(option.id) + } + }} + value={option.id} + disabled={unavailable} + onSelect={(event) => { + event.preventDefault() + const { nextEfforts, nextEffort } = + handleModelChange(option.id) + if (nextEfforts.length > 1) { + pendingEffortFocus.current = { + model: option.id, + effort: nextEffort, + } + setEffortFocusRequest((request) => request + 1) + } + }} + className={cn( + 'relative flex min-w-0 cursor-pointer select-none items-center py-1.5 pl-7 pr-3 outline-none', + 'data-[highlighted]:bg-panel data-[highlighted]:text-ink', + 'data-[state=checked]:text-ink', + 'data-[disabled]:cursor-default data-[disabled]:opacity-40', + )} + > + + + + {option.label} + + )) + : null} +
) })} - - - - + + +
+ + effort + + {selectedEfforts.length > 1 ? ( + + {selectedEfforts.map((option) => ( + { + if (node) { + effortItemRefs.current.set(option.effort, node) + } else { + effortItemRefs.current.delete(option.effort) + } + }} + aria-label={option.effort} + title={option.description} + onSelect={() => { + handleEffortChange(option.effort) + setOpen(false) + }} + className={cn( + 'cursor-pointer select-none border px-1.5 py-1 text-[11px] outline-none transition-colors data-[highlighted]:bg-paper-2 data-[highlighted]:text-ink', + 'border-transparent text-ink-faint data-[state=checked]:border-ink data-[state=checked]:bg-ink data-[state=checked]:text-bg', + )} + > + {option.effort} + + ))} + + ) : ( + + {selected + ? 'this model uses its default effort' + : 'select a model to see effort options'} + + )} +
+ + + {ctx ? (