feat: add variant support across model selection and multi-run components

This commit is contained in:
Bohdan Triapitsyn
2026-01-08 17:01:24 +02:00
parent ab731fdc79
commit 4b0ae1d95f
8 changed files with 106 additions and 32 deletions
+3
View File
@@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [Unreleased]
- Added support for model variants (thinking effort)
## [1.4.4] - 2026-01-08 ## [1.4.4] - 2026-01-08
- Agent Manager / Multi Run: select agent per worktree session (thanks to @wienans). - Agent Manager / Multi Run: select agent per worktree session (thanks to @wienans).
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { RiBrainAi3Line, RiUser3Line } from '@remixicon/react'; import { RiAiAgentLine, RiBrainAi3Line, RiUser3Line } from '@remixicon/react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { getAgentColor } from '@/lib/agentColors'; import { getAgentColor } from '@/lib/agentColors';
import { FadeInOnReveal } from './FadeInOnReveal'; import { FadeInOnReveal } from './FadeInOnReveal';
@@ -60,19 +60,22 @@ const MessageHeader: React.FC<MessageHeaderProps> = ({ isUser, providerID, agent
{!isUser && agentName && ( {!isUser && agentName && (
<div <div
className={cn( className={cn(
'flex items-center gap-1 px-1.5 py-0 rounded', 'flex items-center gap-1 px-1.5 py-0 rounded cursor-default',
'agent-badge typography-meta', 'agent-badge typography-meta',
'hover:bg-[rgb(from_var(--agent-color-bg)_r_g_b_/_0.1)] hover:border-[rgb(from_var(--agent-color)_r_g_b_/_0.2)]',
getAgentColor(agentName).class getAgentColor(agentName).class
)} )}
> >
<RiAiAgentLine className="h-3 w-3 flex-shrink-0" />
<span className="font-medium">{agentName}</span> <span className="font-medium">{agentName}</span>
</div> </div>
)} )}
{!isUser && variant && ( {!isUser && variant && (
<div <div
className={cn( className={cn(
'flex items-center gap-1 px-1.5 py-0 rounded', 'flex items-center gap-1 px-1.5 py-0 rounded cursor-default',
'agent-badge typography-meta', 'agent-badge typography-meta',
'hover:bg-[rgb(from_var(--agent-color-bg)_r_g_b_/_0.1)] hover:border-[rgb(from_var(--agent-color)_r_g_b_/_0.2)]',
variant === 'Default' ? undefined : 'agent-info' variant === 'Default' ? undefined : 'agent-info'
)} )}
style={ style={
@@ -84,6 +87,7 @@ const MessageHeader: React.FC<MessageHeaderProps> = ({ isUser, providerID, agent
: undefined : undefined
} }
> >
<RiBrainAi3Line className="h-3 w-3 flex-shrink-0" />
<span className="font-medium">{variant.length > 0 ? variant[0].toLowerCase() + variant.slice(1) : variant}</span> <span className="font-medium">{variant.length > 0 ? variant[0].toLowerCase() + variant.slice(1) : variant}</span>
</div> </div>
)} )}
@@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import { RiAddLine, RiCloseLine, RiSearchLine, RiStarFill, RiTimeLine } from '@remixicon/react'; import { RiAddLine, RiBrainAi3Line, RiCloseLine, RiSearchLine, RiStarFill, RiTimeLine } from '@remixicon/react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -18,6 +19,7 @@ export interface ModelSelectionWithId {
providerID: string; providerID: string;
modelID: string; modelID: string;
displayName?: string; displayName?: string;
variant?: string;
instanceId: string; instanceId: string;
} }
@@ -26,6 +28,7 @@ export interface ModelSelection {
providerID: string; providerID: string;
modelID: string; modelID: string;
displayName?: string; displayName?: string;
variant?: string;
} }
// eslint-disable-next-line react-refresh/only-export-components -- Utility is tightly coupled with ModelMultiSelect // eslint-disable-next-line react-refresh/only-export-components -- Utility is tightly coupled with ModelMultiSelect
@@ -85,11 +88,12 @@ export interface ModelMultiSelectProps {
selectedModels: ModelSelectionWithId[]; selectedModels: ModelSelectionWithId[];
onAdd: (model: ModelSelectionWithId) => void; onAdd: (model: ModelSelectionWithId) => void;
onRemove: (index: number) => void; onRemove: (index: number) => void;
onUpdate?: (index: number, model: ModelSelectionWithId) => void;
/** Minimum models required (shows validation hint) */ /** Minimum models required (shows validation hint) */
minModels?: number; minModels?: number;
/** Label for the add button */ /** Label for the add button */
addButtonLabel?: string; addButtonLabel?: string;
/** Whether to show the selected chips inline */ /** Whether to show the selected chips */
showChips?: boolean; showChips?: boolean;
/** Maximum models allowed */ /** Maximum models allowed */
maxModels?: number; maxModels?: number;
@@ -102,6 +106,7 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
selectedModels, selectedModels,
onAdd, onAdd,
onRemove, onRemove,
onUpdate,
minModels, minModels,
addButtonLabel = 'Add model', addButtonLabel = 'Add model',
showChips = true, showChips = true,
@@ -462,20 +467,67 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
</div> </div>
{/* Selected models */} {/* Selected models */}
{showChips && selectedModels.map((model, index) => { {showChips && selectedModels.length > 0 && (
const key = `${model.providerID}:${model.modelID}`; <div className="flex flex-col gap-2 w-full">
const totalSameModel = modelCounts.get(key) || 1; {selectedModels.map((model, index) => {
const instanceIndex = getInstanceIndex(model); const key = `${model.providerID}:${model.modelID}`;
return ( const totalSameModel = modelCounts.get(key) || 1;
<ModelChip const instanceIndex = getInstanceIndex(model);
key={model.instanceId}
model={model} const provider = providers.find((p) => p.id === model.providerID);
instanceIndex={instanceIndex} const providerModel = provider?.models.find((m: Record<string, unknown>) => (m as { id?: string }).id === model.modelID) as
totalSameModel={totalSameModel} | { variants?: Record<string, unknown> }
onRemove={() => onRemove(index)} | undefined;
/> const variantKeys = providerModel?.variants ? Object.keys(providerModel.variants) : [];
); const hasVariants = variantKeys.length > 0;
})}
const DEFAULT_VARIANT_VALUE = '__default__';
const variantValue = model.variant ?? DEFAULT_VARIANT_VALUE;
return (
<div key={model.instanceId} className="flex items-center gap-2 min-w-0">
<ModelChip
model={model}
instanceIndex={instanceIndex}
totalSameModel={totalSameModel}
onRemove={() => onRemove(index)}
/>
{hasVariants && (
<Select
value={variantValue}
onValueChange={(value) => {
if (!onUpdate) return;
const nextVariant = value === DEFAULT_VARIANT_VALUE ? undefined : value;
onUpdate(index, { ...model, variant: nextVariant });
}}
>
<SelectTrigger size="chip" className="px-2 gap-1.5 rounded-md bg-accent/50 border-border/30 hover:bg-accent/60 typography-meta font-medium text-foreground">
<RiBrainAi3Line
className={cn(
'h-3.5 w-3.5 flex-shrink-0',
variantValue === DEFAULT_VARIANT_VALUE ? 'text-muted-foreground' : 'text-[color:var(--status-info)]'
)}
/>
<SelectValue placeholder="Thinking" />
</SelectTrigger>
<SelectContent fitContent>
<SelectItem value={DEFAULT_VARIANT_VALUE} className="pr-2 [&>span:first-child]:hidden">
Default
</SelectItem>
{variantKeys.map((variant) => (
<SelectItem key={variant} value={variant} className="pr-2 [&>span:first-child]:hidden">
{variant}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
);
})}
</div>
)}
</div> </div>
{/* Validation hint */} {/* Validation hint */}
@@ -174,6 +174,10 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
clearError(); clearError();
}; };
const handleUpdateModel = React.useCallback((index: number, model: ModelSelectionWithId) => {
setSelectedModels((prev) => prev.map((item, i) => (i === index ? model : item)));
}, []);
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files; const files = e.target.files;
if (!files) return; if (!files) return;
@@ -533,6 +537,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
selectedModels={selectedModels} selectedModels={selectedModels}
onAdd={handleAddModel} onAdd={handleAddModel}
onRemove={handleRemoveModel} onRemove={handleRemoveModel}
onUpdate={handleUpdateModel}
minModels={2} minModels={2}
maxModels={MAX_MODELS} maxModels={MAX_MODELS}
/> />
+2 -2
View File
@@ -31,14 +31,14 @@ function SelectTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default" | "lg" size?: "sm" | "default" | "lg" | "chip"
}) { }) {
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-lg border bg-transparent px-2 py-2 typography-ui-label whitespace-nowrap shadow-none outline-none focus-visible:outline-none hover:bg-muted data-[state=open]:bg-muted focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-6 data-[size=sm]:h-6 data-[size=lg]:h-8 data-[size=lg]:py-1.5 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-lg border bg-transparent px-2 py-2 typography-ui-label whitespace-nowrap shadow-none outline-none focus-visible:outline-none hover:bg-muted data-[state=open]:bg-muted focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-6 data-[size=sm]:h-6 data-[size=lg]:h-8 data-[size=lg]:py-1.5 data-[size=chip]:h-7 data-[size=chip]:py-1 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
{...props} {...props}
@@ -146,6 +146,10 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
setSelectedModels((prev) => prev.filter((_, i) => i !== index)); setSelectedModels((prev) => prev.filter((_, i) => i !== index));
}, []); }, []);
const handleUpdateModel = React.useCallback((index: number, model: ModelSelectionWithId) => {
setSelectedModels((prev) => prev.map((item, i) => (i === index ? model : item)));
}, []);
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files; const files = e.target.files;
if (!files) return; if (!files) return;
@@ -214,10 +218,11 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
setIsSubmitting(true); setIsSubmitting(true);
try { try {
const models = selectedModels.map(({ providerID, modelID, displayName }) => ({ const models = selectedModels.map(({ providerID, modelID, displayName, variant }) => ({
providerID, providerID,
modelID, modelID,
displayName, displayName,
variant,
})); }));
const files: MultiRunFileAttachment[] | undefined = attachedFiles.length > 0 const files: MultiRunFileAttachment[] | undefined = attachedFiles.length > 0
@@ -394,6 +399,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
selectedModels={selectedModels} selectedModels={selectedModels}
onAdd={handleAddModel} onAdd={handleAddModel}
onRemove={handleRemoveModel} onRemove={handleRemoveModel}
onUpdate={handleUpdateModel}
minModels={1} minModels={1}
addButtonLabel="Add model" addButtonLabel="Add model"
maxModels={5} maxModels={5}
+13 -10
View File
@@ -150,6 +150,7 @@ export const useMultiRunStore = create<MultiRunStore>()(
worktreePath: string; worktreePath: string;
providerID: string; providerID: string;
modelID: string; modelID: string;
variant?: string;
}> = []; }> = [];
const commandsToRun = setupCommands?.filter((cmd) => cmd.trim().length > 0) ?? []; const commandsToRun = setupCommands?.filter((cmd) => cmd.trim().length > 0) ?? [];
@@ -214,6 +215,7 @@ export const useMultiRunStore = create<MultiRunStore>()(
worktreePath: worktreeMetadata.path, worktreePath: worktreeMetadata.path,
providerID: model.providerID, providerID: model.providerID,
modelID: model.modelID, modelID: model.modelID,
variant: model.variant,
}); });
} catch (error) { } catch (error) {
@@ -276,16 +278,17 @@ export const useMultiRunStore = create<MultiRunStore>()(
await Promise.allSettled( await Promise.allSettled(
createdRuns.map(async (run) => { createdRuns.map(async (run) => {
try { try {
await opencodeClient.withDirectory(run.worktreePath, () => await opencodeClient.withDirectory(run.worktreePath, () =>
opencodeClient.sendMessage({ opencodeClient.sendMessage({
id: run.sessionId, id: run.sessionId,
providerID: run.providerID, providerID: run.providerID,
modelID: run.modelID, modelID: run.modelID,
text: prompt, variant: run.variant,
agent, text: prompt,
files: filesForMessage, agent,
}) files: filesForMessage,
); })
);
} catch (error) { } catch (error) {
console.warn('[MultiRun] Failed to start run:', error); console.warn('[MultiRun] Failed to start run:', error);
} }
+1
View File
@@ -9,6 +9,7 @@ export interface MultiRunModelSelection {
providerID: string; providerID: string;
modelID: string; modelID: string;
displayName?: string; displayName?: string;
variant?: string;
} }
export interface MultiRunFileAttachment { export interface MultiRunFileAttachment {