diff --git a/apps/server/src/routes/agent/routes/queue-add.ts b/apps/server/src/routes/agent/routes/queue-add.ts index 697f51c3..e5b8a875 100644 --- a/apps/server/src/routes/agent/routes/queue-add.ts +++ b/apps/server/src/routes/agent/routes/queue-add.ts @@ -3,17 +3,19 @@ */ import type { Request, Response } from 'express'; +import type { ThinkingLevel } from '@automaker/types'; import { AgentService } from '../../../services/agent-service.js'; import { getErrorMessage, logError } from '../common.js'; export function createQueueAddHandler(agentService: AgentService) { return async (req: Request, res: Response): Promise => { try { - const { sessionId, message, imagePaths, model } = req.body as { + const { sessionId, message, imagePaths, model, thinkingLevel } = req.body as { sessionId: string; message: string; imagePaths?: string[]; model?: string; + thinkingLevel?: ThinkingLevel; }; if (!sessionId || !message) { @@ -24,7 +26,12 @@ export function createQueueAddHandler(agentService: AgentService) { return; } - const result = await agentService.addToQueue(sessionId, { message, imagePaths, model }); + const result = await agentService.addToQueue(sessionId, { + message, + imagePaths, + model, + thinkingLevel, + }); res.json(result); } catch (error) { logError(error, 'Add to queue failed'); diff --git a/apps/server/src/routes/enhance-prompt/routes/enhance.ts b/apps/server/src/routes/enhance-prompt/routes/enhance.ts index 313af10c..d3b7532f 100644 --- a/apps/server/src/routes/enhance-prompt/routes/enhance.ts +++ b/apps/server/src/routes/enhance-prompt/routes/enhance.ts @@ -9,7 +9,7 @@ import type { Request, Response } from 'express'; import { query } from '@anthropic-ai/claude-agent-sdk'; import { createLogger } from '@automaker/utils'; import { resolveModelString } from '@automaker/model-resolver'; -import { CLAUDE_MODEL_MAP, isCursorModel } from '@automaker/types'; +import { CLAUDE_MODEL_MAP, isCursorModel, ThinkingLevel } from '@automaker/types'; import { ProviderFactory } from '../../../providers/provider-factory.js'; import type { SettingsService } from '../../../services/settings-service.js'; import { getPromptCustomization } from '../../../lib/settings-helpers.js'; @@ -31,6 +31,8 @@ interface EnhanceRequestBody { enhancementMode: string; /** Optional model override */ model?: string; + /** Optional thinking level for Claude models (ignored for Cursor models) */ + thinkingLevel?: ThinkingLevel; } /** @@ -128,7 +130,8 @@ export function createEnhanceHandler( ): (req: Request, res: Response) => Promise { return async (req: Request, res: Response): Promise => { try { - const { originalText, enhancementMode, model } = req.body as EnhanceRequestBody; + const { originalText, enhancementMode, model, thinkingLevel } = + req.body as EnhanceRequestBody; // Validate required fields if (!originalText || typeof originalText !== 'string') { @@ -213,6 +216,7 @@ export function createEnhanceHandler( maxTurns: 1, allowedTools: [], permissionMode: 'acceptEdits', + thinkingLevel: thinkingLevel, // Pass thinking level for Claude models }, }); diff --git a/apps/server/src/routes/github/routes/validate-issue.ts b/apps/server/src/routes/github/routes/validate-issue.ts index 18dd6917..9c65d330 100644 --- a/apps/server/src/routes/github/routes/validate-issue.ts +++ b/apps/server/src/routes/github/routes/validate-issue.ts @@ -16,6 +16,7 @@ import type { CursorModelId, GitHubComment, LinkedPRInfo, + ThinkingLevel, } from '@automaker/types'; import { isCursorModel, DEFAULT_PHASE_MODELS } from '@automaker/types'; import { resolvePhaseModel } from '@automaker/model-resolver'; @@ -54,6 +55,8 @@ interface ValidateIssueRequestBody { issueLabels?: string[]; /** Model to use for validation (opus, sonnet, haiku, or cursor model IDs) */ model?: ModelAlias | CursorModelId; + /** Thinking level for Claude models (ignored for Cursor models) */ + thinkingLevel?: ThinkingLevel; /** Comments to include in validation analysis */ comments?: GitHubComment[]; /** Linked pull requests for this issue */ @@ -78,7 +81,8 @@ async function runValidation( abortController: AbortController, settingsService?: SettingsService, comments?: ValidationComment[], - linkedPRs?: ValidationLinkedPR[] + linkedPRs?: ValidationLinkedPR[], + thinkingLevel?: ThinkingLevel ): Promise { // Emit start event const startEvent: IssueValidationEvent = { @@ -175,11 +179,15 @@ ${prompt}`; '[ValidateIssue]' ); - // Get thinkingLevel from phase model settings (the model comes from request, but thinkingLevel from settings) - const settings = await settingsService?.getGlobalSettings(); - const phaseModelEntry = - settings?.phaseModels?.validationModel || DEFAULT_PHASE_MODELS.validationModel; - const { thinkingLevel } = resolvePhaseModel(phaseModelEntry); + // Use thinkingLevel from request if provided, otherwise fall back to settings + let effectiveThinkingLevel: ThinkingLevel | undefined = thinkingLevel; + if (!effectiveThinkingLevel) { + const settings = await settingsService?.getGlobalSettings(); + const phaseModelEntry = + settings?.phaseModels?.validationModel || DEFAULT_PHASE_MODELS.validationModel; + const resolved = resolvePhaseModel(phaseModelEntry); + effectiveThinkingLevel = resolved.thinkingLevel; + } // Create SDK options with structured output and abort controller const options = createSuggestionsOptions({ @@ -188,7 +196,7 @@ ${prompt}`; systemPrompt: ISSUE_VALIDATION_SYSTEM_PROMPT, abortController, autoLoadClaudeMd, - thinkingLevel, + thinkingLevel: effectiveThinkingLevel, outputFormat: { type: 'json_schema', schema: issueValidationSchema as Record, @@ -308,6 +316,7 @@ export function createValidateIssueHandler( issueBody, issueLabels, model = 'opus', + thinkingLevel, comments: rawComments, linkedPRs: rawLinkedPRs, } = req.body as ValidateIssueRequestBody; @@ -392,7 +401,8 @@ export function createValidateIssueHandler( abortController, settingsService, validationComments, - validationLinkedPRs + validationLinkedPRs, + thinkingLevel ) .catch(() => { // Error is already handled inside runValidation (event emitted) diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index 9692306e..f72634ac 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -45,6 +45,7 @@ interface QueuedPrompt { message: string; imagePaths?: string[]; model?: string; + thinkingLevel?: ThinkingLevel; addedAt: string; } @@ -637,7 +638,12 @@ export class AgentService { */ async addToQueue( sessionId: string, - prompt: { message: string; imagePaths?: string[]; model?: string } + prompt: { + message: string; + imagePaths?: string[]; + model?: string; + thinkingLevel?: ThinkingLevel; + } ): Promise<{ success: boolean; queuedPrompt?: QueuedPrompt; error?: string }> { const session = this.sessions.get(sessionId); if (!session) { @@ -649,6 +655,7 @@ export class AgentService { message: prompt.message, imagePaths: prompt.imagePaths, model: prompt.model, + thinkingLevel: prompt.thinkingLevel, addedAt: new Date().toISOString(), }; @@ -778,6 +785,7 @@ export class AgentService { message: nextPrompt.message, imagePaths: nextPrompt.imagePaths, model: nextPrompt.model, + thinkingLevel: nextPrompt.thinkingLevel, }); } catch (error) { this.logger.error('Failed to process queued prompt:', error); diff --git a/apps/ui/src/components/shared/model-override-trigger.tsx b/apps/ui/src/components/shared/model-override-trigger.tsx index afb1e077..5b065fd9 100644 --- a/apps/ui/src/components/shared/model-override-trigger.tsx +++ b/apps/ui/src/components/shared/model-override-trigger.tsx @@ -1,30 +1,27 @@ -import { useState } from 'react'; -import { Settings2, X, RotateCcw } from 'lucide-react'; +import * as React from 'react'; +import { Settings2 } 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 { ModelAlias, CursorModelId, PhaseModelKey, PhaseModelEntry } from '@automaker/types'; -import { PROVIDER_PREFIXES, stripProviderPrefix } from '@automaker/types'; - -import { CLAUDE_MODELS, CURSOR_MODELS } from '@/components/views/board-view/shared/model-constants'; +import { PhaseModelSelector } from '@/components/views/settings-view/phase-models/phase-model-selector'; /** - * Extract model string from PhaseModelEntry or string + * Normalize PhaseModelEntry or string to PhaseModelEntry */ -function extractModel(entry: PhaseModelEntry | string | null): ModelAlias | CursorModelId | null { - if (!entry) return null; +function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry { if (typeof entry === 'string') { - return entry as ModelAlias | CursorModelId; + return { model: entry as ModelAlias | CursorModelId }; } - return entry.model; + return entry; } export interface ModelOverrideTriggerProps { - /** Current effective model (from global settings or explicit override) */ - currentModel: ModelAlias | CursorModelId; + /** Current effective model entry (from global settings or explicit override) */ + currentModelEntry: PhaseModelEntry; /** Callback when user selects override */ - onModelChange: (model: ModelAlias | CursorModelId | null) => void; + onModelChange: (entry: PhaseModelEntry | null) => void; /** Optional: which phase this is for (shows global default) */ phase?: PhaseModelKey; /** Size variants for different contexts */ @@ -37,24 +34,8 @@ export interface ModelOverrideTriggerProps { className?: string; } -function getModelLabel(modelId: ModelAlias | 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 === `${PROVIDER_PREFIXES.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, + currentModelEntry, onModelChange, phase, size = 'sm', @@ -62,29 +43,31 @@ export function ModelOverrideTrigger({ isOverridden = false, className, }: ModelOverrideTriggerProps) { - const [open, setOpen] = useState(false); - const { phaseModels, enabledCursorModels } = useAppStore(); + const { phaseModels } = useAppStore(); - // Get the global default for this phase (extract model string from PhaseModelEntry) - const globalDefault = phase ? extractModel(phaseModels[phase]) : null; + const handleChange = (entry: PhaseModelEntry) => { + // If the new entry matches the global default, clear the override + // Otherwise, set it as override + if (phase) { + const globalDefault = phaseModels[phase]; + const normalizedGlobal = normalizeEntry(globalDefault); - // Filter Cursor models to only show enabled ones - const availableCursorModels = CURSOR_MODELS.filter((model) => { - const cursorId = stripProviderPrefix(model.id) as CursorModelId; - return enabledCursorModels.includes(cursorId); - }); + // Compare models (and thinking levels if both have them) + const modelsMatch = entry.model === normalizedGlobal.model; + const thinkingMatch = + (entry.thinkingLevel || 'none') === (normalizedGlobal.thinkingLevel || 'none'); - const handleSelect = (model: ModelAlias | CursorModelId) => { - onModelChange(model); - setOpen(false); + if (modelsMatch && thinkingMatch) { + onModelChange(null); // Clear override + } else { + onModelChange(entry); // Set override + } + } else { + onModelChange(entry); + } }; - const handleClear = () => { - onModelChange(null); - setOpen(false); - }; - - // Size classes + // Size classes for icon variant const sizeClasses = { sm: 'h-6 w-6', md: 'h-8 w-8', @@ -97,155 +80,47 @@ export function ModelOverrideTrigger({ lg: 'w-5 h-5', }; - return ( - - - {variant === 'icon' ? ( - - ) : variant === 'button' ? ( - - ) : ( - - )} - - - - {/* Header */} -
-
-

Model Override

- {globalDefault && ( -

- Default: {getModelLabel(globalDefault)} -

- )} -
- + disabled={false} + align="end" + />
- - {/* 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 = stripProviderPrefix(model.id) as CursorModelId; - const isActive = currentModel === cursorId; - return ( - - ); - })} -
-
- )} -
- - {/* Footer */} {isOverridden && ( -
- -
+
)} - - +
+ ); + } + + // For button and inline variants, use PhaseModelSelector in compact mode + return ( +
+ + {isOverridden && ( +
+ )} +
); } diff --git a/apps/ui/src/components/shared/use-model-override.ts b/apps/ui/src/components/shared/use-model-override.ts index 054838c9..ee108468 100644 --- a/apps/ui/src/components/shared/use-model-override.ts +++ b/apps/ui/src/components/shared/use-model-override.ts @@ -6,22 +6,34 @@ export interface UseModelOverrideOptions { /** Which phase this override is for */ phase: PhaseModelKey; /** Initial override value (optional) */ - initialOverride?: ModelAlias | CursorModelId | null; + initialOverride?: PhaseModelEntry | null; } export interface UseModelOverrideResult { - /** The effective model (override or global default) */ + /** The effective model entry (override or global default) */ + effectiveModelEntry: PhaseModelEntry; + /** The effective model string (for backward compatibility with APIs that only accept strings) */ effectiveModel: ModelAlias | CursorModelId; /** Whether the model is currently overridden */ isOverridden: boolean; /** Set a model override */ - setOverride: (model: ModelAlias | CursorModelId | null) => void; + setOverride: (entry: PhaseModelEntry | null) => void; /** Clear the override and use global default */ clearOverride: () => void; /** The global default for this phase */ - globalDefault: ModelAlias | CursorModelId; + globalDefault: PhaseModelEntry; /** The current override value (null if not overridden) */ - override: ModelAlias | CursorModelId | null; + override: PhaseModelEntry | null; +} + +/** + * Normalize PhaseModelEntry or string to PhaseModelEntry + */ +function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry { + if (typeof entry === 'string') { + return { model: entry as ModelAlias | CursorModelId }; + } + return entry; } /** @@ -38,18 +50,18 @@ function extractModel(entry: PhaseModelEntry | string): ModelAlias | CursorModel * 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. + * for a specific run or context. Now supports PhaseModelEntry with thinking levels. * * @example * ```tsx * function EnhanceDialog() { - * const { effectiveModel, isOverridden, setOverride, clearOverride } = useModelOverride({ + * const { effectiveModelEntry, isOverridden, setOverride, clearOverride } = useModelOverride({ * phase: 'enhancementModel', * }); * * return ( * (initialOverride); + const [override, setOverrideState] = useState( + initialOverride ? normalizeEntry(initialOverride) : null + ); - // Extract model string from PhaseModelEntry (handles both old string format and new object format) - const globalDefault = extractModel(phaseModels[phase]); + // Normalize global default to PhaseModelEntry + const globalDefault = normalizeEntry(phaseModels[phase]); - const effectiveModel = useMemo(() => { + const effectiveModelEntry = useMemo(() => { return override ?? globalDefault; }, [override, globalDefault]); + const effectiveModel = useMemo(() => { + return effectiveModelEntry.model; + }, [effectiveModelEntry]); + const isOverridden = override !== null; - const setOverride = useCallback((model: ModelAlias | CursorModelId | null) => { - setOverrideState(model); + const setOverride = useCallback((entry: PhaseModelEntry | null) => { + setOverrideState(entry ? normalizeEntry(entry) : null); }, []); const clearOverride = useCallback(() => { @@ -83,6 +101,7 @@ export function useModelOverride({ }, []); return { + effectiveModelEntry, effectiveModel, isOverridden, setOverride, diff --git a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx index f06fbefd..db4b8e62 100644 --- a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx @@ -323,7 +323,8 @@ export function AddFeatureDialog({ const result = await api.enhancePrompt?.enhance( newFeature.description, enhancementMode, - enhancementOverride.effectiveModel + enhancementOverride.effectiveModel, // API accepts string, extract from PhaseModelEntry + enhancementOverride.effectiveModelEntry.thinkingLevel // Pass thinking level ); if (result?.success && result.enhancedText) { @@ -512,7 +513,7 @@ export function AddFeatureDialog({ >(new Set()); const [selectedChanges, setSelectedChanges] = useState>(new Set()); - const [modelOverride, setModelOverride] = useState(null); + const [modelOverride, setModelOverride] = useState(null); const { phaseModels } = useAppStore(); @@ -105,7 +115,8 @@ export function BacklogPlanDialog({ setIsGeneratingPlan(true); // Use model override if set, otherwise use global default (extract model string from PhaseModelEntry) - const effectiveModel = modelOverride || extractModel(phaseModels.backlogPlanningModel); + const effectiveModelEntry = modelOverride || normalizeEntry(phaseModels.backlogPlanningModel); + const effectiveModel = effectiveModelEntry.model; const result = await api.backlogPlan.generate(projectPath, prompt, effectiveModel); if (!result.success) { setIsGeneratingPlan(false); @@ -381,8 +392,9 @@ export function BacklogPlanDialog({ } }; - // Get effective model (override or global default) - extract model string from PhaseModelEntry - const effectiveModel = modelOverride || extractModel(phaseModels.backlogPlanningModel); + // Get effective model entry (override or global default) + const effectiveModelEntry = modelOverride || normalizeEntry(phaseModels.backlogPlanningModel); + const effectiveModel = effectiveModelEntry.model; return ( !isOpen && onClose()}> @@ -407,7 +419,7 @@ export function BacklogPlanDialog({
Model: { return { forceRevalidate, + modelEntry: modelOverride.effectiveModelEntry, // Pass the full PhaseModelEntry to preserve thinking level comments: includeCommentsInAnalysis && comments.length > 0 ? comments : undefined, linkedPRs: issue.linkedPRs?.map((pr) => ({ number: pr.number, @@ -119,12 +120,13 @@ export function IssueDetailPanel({ View (stale)