From 3d655c3298b2680d27991ac37a3f575ee1eb17b1 Mon Sep 17 00:00:00 2001 From: Kacper Date: Tue, 30 Dec 2025 02:13:46 +0100 Subject: [PATCH] feat(ui): Add ModelOverrideTrigger component for quick model overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/ui/src/components/shared/index.ts | 7 + .../shared/model-override-trigger.tsx | 238 ++++++++++++++++++ .../components/shared/use-model-override.ts | 82 ++++++ 3 files changed, 327 insertions(+) create mode 100644 apps/ui/src/components/shared/index.ts create mode 100644 apps/ui/src/components/shared/model-override-trigger.tsx create mode 100644 apps/ui/src/components/shared/use-model-override.ts diff --git a/apps/ui/src/components/shared/index.ts b/apps/ui/src/components/shared/index.ts new file mode 100644 index 00000000..2497d409 --- /dev/null +++ b/apps/ui/src/components/shared/index.ts @@ -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'; diff --git a/apps/ui/src/components/shared/model-override-trigger.tsx b/apps/ui/src/components/shared/model-override-trigger.tsx new file mode 100644 index 00000000..d6b74c22 --- /dev/null +++ b/apps/ui/src/components/shared/model-override-trigger.tsx @@ -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 ( + + + {variant === 'icon' ? ( + + ) : variant === 'button' ? ( + + ) : ( + + )} + + + + {/* Header */} +
+
+

Model Override

+ {globalDefault && ( +

+ Default: {getModelLabel(globalDefault)} +

+ )} +
+ +
+ + {/* Content */} +
+ {/* Claude Models */} +
+
+ Claude +
+
+ {CLAUDE_MODELS.map((model) => { + const isActive = currentModel === model.id; + return ( + + ); + })} +
+
+ + {/* Cursor Models */} + {availableCursorModels.length > 0 && ( +
+
+ Cursor +
+
+ {availableCursorModels.slice(0, 6).map((model) => { + const cursorId = model.id.replace('cursor-', '') as CursorModelId; + const isActive = currentModel === cursorId; + return ( + + ); + })} +
+
+ )} +
+ + {/* Footer */} + {isOverridden && ( +
+ +
+ )} +
+
+ ); +} diff --git a/apps/ui/src/components/shared/use-model-override.ts b/apps/ui/src/components/shared/use-model-override.ts new file mode 100644 index 00000000..188ff20a --- /dev/null +++ b/apps/ui/src/components/shared/use-model-override.ts @@ -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 ( + * + * ); + * } + * ``` + */ +export function useModelOverride({ + phase, + initialOverride = null, +}: UseModelOverrideOptions): UseModelOverrideResult { + const { phaseModels } = useAppStore(); + const [override, setOverrideState] = useState(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, + }; +}