feat: add support for model variants across chat components and session management

This commit is contained in:
Bohdan Triapitsyn
2026-01-08 14:48:06 +02:00
parent 8ff7b61b8a
commit 8e777a94c2
11 changed files with 472 additions and 35 deletions
@@ -274,9 +274,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
providers,
currentProviderId,
currentModelId,
currentVariant,
currentAgentName,
setProvider,
setModel,
setCurrentVariant,
getCurrentModelVariants,
setAgent,
getCurrentProvider,
getModelMetadata,
@@ -294,6 +297,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
getSessionAgentSelection,
saveAgentModelForSession,
getAgentModelForSession,
saveAgentModelVariantForSession,
getAgentModelVariantForSession,
analyzeAndSaveExternalSessionChoices,
getSessionAgentEditMode,
setSessionAgentEditMode,
@@ -308,7 +313,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
const isVSCodeRuntime = useIsVSCodeRuntime();
// Only use mobile panels on actual mobile devices, VSCode uses desktop dropdowns
const isCompact = isMobile;
const [activeMobilePanel, setActiveMobilePanel] = React.useState<'model' | 'agent' | null>(null);
const [activeMobilePanel, setActiveMobilePanel] = React.useState<'model' | 'agent' | 'variant' | null>(null);
const [mobileTooltipOpen, setMobileTooltipOpen] = React.useState<'model' | 'agent' | null>(null);
const [mobileModelQuery, setMobileModelQuery] = React.useState('');
const closeMobilePanel = React.useCallback(() => setActiveMobilePanel(null), []);
@@ -498,6 +503,15 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
const inputModalityIcons = getModalityIcons(currentMetadata, 'input');
const outputModalityIcons = getModalityIcons(currentMetadata, 'output');
const availableVariants = React.useMemo(() => {
const variantKey = `${currentProviderId}/${currentModelId}`;
if (!variantKey) {
return [];
}
return getCurrentModelVariants();
}, [getCurrentModelVariants, currentProviderId, currentModelId]);
const hasVariants = availableVariants.length > 0;
const costRows = [
{ label: 'Input', value: formatCost(currentMetadata?.cost?.input) },
{ label: 'Output', value: formatCost(currentMetadata?.cost?.output) },
@@ -850,6 +864,62 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
handleAgentSwitch();
}, [currentAgentName, currentSessionId, getAgentModelForSession, tryApplyModelSelection, agents, contextHydrated]);
React.useEffect(() => {
if (!contextHydrated || !currentSessionId || !currentAgentName) {
setCurrentVariant(undefined);
return;
}
if (!currentProviderId || !currentModelId) {
setCurrentVariant(undefined);
return;
}
const savedVariant = getAgentModelVariantForSession(
currentSessionId,
currentAgentName,
currentProviderId,
currentModelId,
);
if (savedVariant && !availableVariants.includes(savedVariant)) {
setCurrentVariant(undefined);
return;
}
setCurrentVariant(savedVariant);
}, [
availableVariants,
contextHydrated,
currentSessionId,
currentAgentName,
currentProviderId,
currentModelId,
getAgentModelVariantForSession,
setCurrentVariant,
]);
const handleVariantSelect = React.useCallback((variant: string | undefined) => {
setCurrentVariant(variant);
if (currentSessionId && currentAgentName && currentProviderId && currentModelId) {
saveAgentModelVariantForSession(
currentSessionId,
currentAgentName,
currentProviderId,
currentModelId,
variant,
);
}
}, [
currentAgentName,
currentModelId,
currentProviderId,
currentSessionId,
saveAgentModelVariantForSession,
setCurrentVariant,
]);
const handleAgentChange = (agentName: string) => {
try {
setAgent(agentName);
@@ -1464,11 +1534,70 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
);
};
const renderMobileVariantPanel = () => {
if (!isCompact || !hasVariants) return null;
const isDefault = !currentVariant;
const handleSelect = (variant: string | undefined) => {
handleVariantSelect(variant);
closeMobilePanel();
requestAnimationFrame(() => {
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
textarea?.focus();
});
};
return (
<MobileOverlayPanel
open={activeMobilePanel === 'variant'}
onClose={closeMobilePanel}
title="Thinking"
>
<div className="flex flex-col gap-1.5">
<button
type="button"
className={cn(
'flex w-full items-center justify-between gap-2 rounded-xl border px-2 py-1.5 text-left',
'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary',
isDefault ? 'border-primary/30 bg-primary/10' : 'border-border/40'
)}
onClick={() => handleSelect(undefined)}
>
<span className="typography-meta font-medium text-foreground">Default</span>
{isDefault && <RiCheckLine className="h-4 w-4 text-primary flex-shrink-0" />}
</button>
{availableVariants.map((variant) => {
const selected = currentVariant === variant;
const label = variant.charAt(0).toUpperCase() + variant.slice(1);
return (
<button
key={variant}
type="button"
className={cn(
'flex w-full items-center justify-between gap-2 rounded-xl border px-2 py-1.5 text-left',
'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary',
selected ? 'border-primary/30 bg-primary/10' : 'border-border/40'
)}
onClick={() => handleSelect(variant)}
>
<span className="typography-meta font-medium text-foreground">{label}</span>
{selected && <RiCheckLine className="h-4 w-4 text-primary flex-shrink-0" />}
</button>
);
})}
</div>
</MobileOverlayPanel>
);
};
const renderMobileAgentPanel = () => {
if (!isCompact) return null;
const primaryAgents = agents.filter(agent => isPrimaryMode(agent.mode));
return (
<MobileOverlayPanel
open={activeMobilePanel === 'agent'}
@@ -2152,6 +2281,94 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
);
};
const renderVariantSelector = () => {
if (!hasVariants) {
return null;
}
const displayVariant = currentVariant ?? 'Default';
const isDefault = !currentVariant;
const colorClass = isDefault ? 'text-muted-foreground' : 'text-[color:var(--status-info)]';
if (isCompact) {
return (
<button
type="button"
onClick={() => setActiveMobilePanel('variant')}
className={cn(
'model-controls__variant-trigger flex items-center gap-1.5 transition-opacity min-w-0 focus:outline-none',
buttonHeight,
'cursor-pointer hover:opacity-70',
)}
>
<RiBrainAi3Line className={cn(controlIconSize, 'flex-shrink-0', colorClass)} />
<span className={cn('model-controls__variant-label', controlTextSize, 'font-medium truncate min-w-0', colorClass)}>
{displayVariant}
</span>
</button>
);
}
return (
<Tooltip delayDuration={800}>
<DropdownMenu>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<div
className={cn(
'model-controls__variant-trigger flex items-center gap-1.5 transition-opacity cursor-pointer hover:opacity-70 min-w-0',
buttonHeight,
)}
>
<RiBrainAi3Line className={cn(controlIconSize, 'flex-shrink-0', colorClass)} />
<span
className={cn(
'model-controls__variant-label',
controlTextSize,
'font-medium min-w-0 truncate',
isDesktopRuntime ? 'max-w-[180px]' : undefined,
colorClass,
)}
>
{displayVariant}
</span>
</div>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end" alignOffset={-40} className="w-[min(180px,calc(100vw-2rem))]">
<DropdownMenuLabel className="typography-ui-header font-semibold text-foreground">Thinking</DropdownMenuLabel>
<DropdownMenuItem className="typography-meta" onSelect={() => handleVariantSelect(undefined)}>
<div className="flex items-center justify-between gap-2 w-full min-w-0">
<span className="typography-meta font-medium text-foreground truncate min-w-0">Default</span>
{isDefault && <RiCheckLine className="h-4 w-4 text-primary flex-shrink-0" />}
</div>
</DropdownMenuItem>
{availableVariants.length > 0 && <DropdownMenuSeparator />}
{availableVariants.map((variant) => {
const selected = currentVariant === variant;
const label = variant.charAt(0).toUpperCase() + variant.slice(1);
return (
<DropdownMenuItem
key={variant}
className="typography-meta"
onSelect={() => handleVariantSelect(variant)}
>
<div className="flex items-center justify-between gap-2 w-full min-w-0">
<span className="typography-meta font-medium text-foreground truncate min-w-0">{label}</span>
{selected && <RiCheckLine className="h-4 w-4 text-primary flex-shrink-0" />}
</div>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent side="top">
<p className="typography-meta">Thinking: {displayVariant}</p>
</TooltipContent>
</Tooltip>
);
};
const renderAgentSelector = () => {
if (!isCompact) {
return (
@@ -2344,7 +2561,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
const inlineClassName = cn(
'@container/model-controls flex items-center min-w-0',
inlineGapClass,
// Only force full-width + truncation behaviors on true mobile layouts.
// VS Code also uses "compact" mode, but should keep its right-aligned inline sizing.
isMobile && 'w-full',
@@ -2357,17 +2573,18 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
<div
className={cn(
'flex items-center min-w-0 flex-1 justify-end',
inlineGapClass,
isMobile && 'overflow-hidden'
)}
>
{renderVariantSelector()}
{renderModelSelector()}
</div>
<div className={cn('flex items-center min-w-0', inlineGapClass, isMobile && 'flex-shrink-0')}>
{renderAgentSelector()}
</div>
</div>
{renderMobileModelPanel()}
{renderMobileVariantPanel()}
{renderMobileAgentPanel()}
{renderMobileModelTooltip()}
{renderMobileAgentTooltip()}