mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
feat(ui): Add ModelOverrideTrigger component for quick model overrides
- Add ModelOverrideTrigger with three variants: icon, button, inline - Add useModelOverride hook for managing override state per phase - Create shared components directory for reusable UI components - Popover shows Claude + enabled Cursor models - Visual indicator dot when model is overridden from global 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
7
apps/ui/src/components/shared/index.ts
Normal file
7
apps/ui/src/components/shared/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Model Override Components
|
||||
export { ModelOverrideTrigger, type ModelOverrideTriggerProps } from './model-override-trigger';
|
||||
export {
|
||||
useModelOverride,
|
||||
type UseModelOverrideOptions,
|
||||
type UseModelOverrideResult,
|
||||
} from './use-model-override';
|
||||
238
apps/ui/src/components/shared/model-override-trigger.tsx
Normal file
238
apps/ui/src/components/shared/model-override-trigger.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { useState } from 'react';
|
||||
import { Settings2, X, RotateCcw } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type { AgentModel, CursorModelId, PhaseModelKey } from '@automaker/types';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS } from '@/components/views/board-view/shared/model-constants';
|
||||
|
||||
export interface ModelOverrideTriggerProps {
|
||||
/** Current effective model (from global settings or explicit override) */
|
||||
currentModel: AgentModel | CursorModelId;
|
||||
/** Callback when user selects override */
|
||||
onModelChange: (model: AgentModel | CursorModelId | null) => void;
|
||||
/** Optional: which phase this is for (shows global default) */
|
||||
phase?: PhaseModelKey;
|
||||
/** Size variants for different contexts */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** Show as icon-only or with label */
|
||||
variant?: 'icon' | 'button' | 'inline';
|
||||
/** Whether the model is currently overridden from global */
|
||||
isOverridden?: boolean;
|
||||
/** Optional class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function getModelLabel(modelId: AgentModel | CursorModelId): string {
|
||||
// Check Claude models
|
||||
const claudeModel = CLAUDE_MODELS.find((m) => m.id === modelId);
|
||||
if (claudeModel) return claudeModel.label;
|
||||
|
||||
// Check Cursor models (without cursor- prefix)
|
||||
const cursorModel = CURSOR_MODELS.find((m) => m.id === `cursor-${modelId}`);
|
||||
if (cursorModel) return cursorModel.label;
|
||||
|
||||
// Check Cursor models (with cursor- prefix)
|
||||
const cursorModelDirect = CURSOR_MODELS.find((m) => m.id === modelId);
|
||||
if (cursorModelDirect) return cursorModelDirect.label;
|
||||
|
||||
return modelId;
|
||||
}
|
||||
|
||||
export function ModelOverrideTrigger({
|
||||
currentModel,
|
||||
onModelChange,
|
||||
phase,
|
||||
size = 'sm',
|
||||
variant = 'icon',
|
||||
isOverridden = false,
|
||||
className,
|
||||
}: ModelOverrideTriggerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { phaseModels, enabledCursorModels } = useAppStore();
|
||||
|
||||
// Get the global default for this phase
|
||||
const globalDefault = phase ? phaseModels[phase] : null;
|
||||
|
||||
// Filter Cursor models to only show enabled ones
|
||||
const availableCursorModels = CURSOR_MODELS.filter((model) => {
|
||||
const cursorId = model.id.replace('cursor-', '') as CursorModelId;
|
||||
return enabledCursorModels.includes(cursorId);
|
||||
});
|
||||
|
||||
const handleSelect = (model: AgentModel | CursorModelId) => {
|
||||
onModelChange(model);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onModelChange(null);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
// Size classes
|
||||
const sizeClasses = {
|
||||
sm: 'h-6 w-6',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-10 w-10',
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: 'w-3.5 h-3.5',
|
||||
md: 'w-4 h-4',
|
||||
lg: 'w-5 h-5',
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{variant === 'icon' ? (
|
||||
<button
|
||||
className={cn(
|
||||
'relative rounded-md flex items-center justify-center',
|
||||
'transition-colors duration-150',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
title={`Model: ${getModelLabel(currentModel)}${isOverridden ? ' (overridden)' : ''}`}
|
||||
>
|
||||
<Settings2 className={iconSizes[size]} />
|
||||
{isOverridden && (
|
||||
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-brand-500 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
) : variant === 'button' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size={size === 'md' ? 'default' : size}
|
||||
className={cn('gap-2', className)}
|
||||
>
|
||||
<Settings2 className={iconSizes[size]} />
|
||||
<span className="text-xs">{getModelLabel(currentModel)}</span>
|
||||
{isOverridden && <div className="w-1.5 h-1.5 bg-brand-500 rounded-full" />}
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 text-xs',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'transition-colors duration-150',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span>Using {getModelLabel(currentModel)}</span>
|
||||
<Settings2 className="w-3 h-3" />
|
||||
{isOverridden && <div className="w-1.5 h-1.5 bg-brand-500 rounded-full" />}
|
||||
</button>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-80 p-0" align="end">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b border-border/50">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">Model Override</h4>
|
||||
{globalDefault && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Default: {getModelLabel(globalDefault)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-3 space-y-4">
|
||||
{/* Claude Models */}
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Claude
|
||||
</h5>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{CLAUDE_MODELS.map((model) => {
|
||||
const isActive = currentModel === model.id;
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => handleSelect(model.id as AgentModel)}
|
||||
className={cn(
|
||||
'px-3 py-2 rounded-lg text-xs font-medium text-center',
|
||||
'transition-all duration-150',
|
||||
isActive
|
||||
? ['bg-brand-500/20 text-brand-500', 'border border-brand-500/40']
|
||||
: [
|
||||
'bg-accent/50 text-muted-foreground',
|
||||
'border border-transparent',
|
||||
'hover:bg-accent hover:text-foreground',
|
||||
]
|
||||
)}
|
||||
>
|
||||
{model.label.replace('Claude ', '')}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cursor Models */}
|
||||
{availableCursorModels.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Cursor
|
||||
</h5>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{availableCursorModels.slice(0, 6).map((model) => {
|
||||
const cursorId = model.id.replace('cursor-', '') as CursorModelId;
|
||||
const isActive = currentModel === cursorId;
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => handleSelect(cursorId)}
|
||||
className={cn(
|
||||
'px-3 py-2 rounded-lg text-xs font-medium text-center truncate',
|
||||
'transition-all duration-150',
|
||||
isActive
|
||||
? ['bg-purple-500/20 text-purple-400', 'border border-purple-500/40']
|
||||
: [
|
||||
'bg-accent/50 text-muted-foreground',
|
||||
'border border-transparent',
|
||||
'hover:bg-accent hover:text-foreground',
|
||||
]
|
||||
)}
|
||||
title={model.description}
|
||||
>
|
||||
{model.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{isOverridden && (
|
||||
<div className="p-3 border-t border-border/50">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClear}
|
||||
className="w-full gap-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Use Global Default
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
82
apps/ui/src/components/shared/use-model-override.ts
Normal file
82
apps/ui/src/components/shared/use-model-override.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type { AgentModel, CursorModelId, PhaseModelKey } from '@automaker/types';
|
||||
|
||||
export interface UseModelOverrideOptions {
|
||||
/** Which phase this override is for */
|
||||
phase: PhaseModelKey;
|
||||
/** Initial override value (optional) */
|
||||
initialOverride?: AgentModel | CursorModelId | null;
|
||||
}
|
||||
|
||||
export interface UseModelOverrideResult {
|
||||
/** The effective model (override or global default) */
|
||||
effectiveModel: AgentModel | CursorModelId;
|
||||
/** Whether the model is currently overridden */
|
||||
isOverridden: boolean;
|
||||
/** Set a model override */
|
||||
setOverride: (model: AgentModel | CursorModelId | null) => void;
|
||||
/** Clear the override and use global default */
|
||||
clearOverride: () => void;
|
||||
/** The global default for this phase */
|
||||
globalDefault: AgentModel | CursorModelId;
|
||||
/** The current override value (null if not overridden) */
|
||||
override: AgentModel | CursorModelId | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing model overrides per phase
|
||||
*
|
||||
* Provides a simple way to allow users to override the global phase model
|
||||
* for a specific run or context.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function EnhanceDialog() {
|
||||
* const { effectiveModel, isOverridden, setOverride, clearOverride } = useModelOverride({
|
||||
* phase: 'enhancementModel',
|
||||
* });
|
||||
*
|
||||
* return (
|
||||
* <ModelOverrideTrigger
|
||||
* currentModel={effectiveModel}
|
||||
* onModelChange={setOverride}
|
||||
* phase="enhancementModel"
|
||||
* isOverridden={isOverridden}
|
||||
* />
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useModelOverride({
|
||||
phase,
|
||||
initialOverride = null,
|
||||
}: UseModelOverrideOptions): UseModelOverrideResult {
|
||||
const { phaseModels } = useAppStore();
|
||||
const [override, setOverrideState] = useState<AgentModel | CursorModelId | null>(initialOverride);
|
||||
|
||||
const globalDefault = phaseModels[phase];
|
||||
|
||||
const effectiveModel = useMemo(() => {
|
||||
return override ?? globalDefault;
|
||||
}, [override, globalDefault]);
|
||||
|
||||
const isOverridden = override !== null;
|
||||
|
||||
const setOverride = useCallback((model: AgentModel | CursorModelId | null) => {
|
||||
setOverrideState(model);
|
||||
}, []);
|
||||
|
||||
const clearOverride = useCallback(() => {
|
||||
setOverrideState(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
effectiveModel,
|
||||
isOverridden,
|
||||
setOverride,
|
||||
clearOverride,
|
||||
globalDefault,
|
||||
override,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user