feat: add variant support across model selection and multi-run components
This commit is contained in:
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user