From 2b942a6cb16e47177dde48b12a2a619414e3dca8 Mon Sep 17 00:00:00 2001 From: Shirone Date: Fri, 2 Jan 2026 17:52:03 +0100 Subject: [PATCH] feat: integrate thinking level support across various components - Enhanced multiple server and UI components to include an optional thinking level parameter, improving the configurability of model interactions. - Updated request handlers and services to manage and pass the thinking level, ensuring consistent data handling across the application. - Refactored UI components to display and manage the selected model along with its thinking level, enhancing user experience and clarity. - Adjusted the Electron API and HTTP client to support the new thinking level parameter in requests, ensuring seamless integration. This update significantly improves the application's ability to adapt reasoning capabilities based on user-defined thinking levels, enhancing overall performance and user satisfaction. --- .../src/routes/agent/routes/queue-add.ts | 11 +- .../routes/enhance-prompt/routes/enhance.ts | 8 +- .../routes/github/routes/validate-issue.ts | 26 +- apps/server/src/services/agent-service.ts | 10 +- .../shared/model-override-trigger.tsx | 253 +++++------------- .../components/shared/use-model-override.ts | 47 +++- .../board-view/dialogs/add-feature-dialog.tsx | 5 +- .../dialogs/backlog-plan-dialog.tsx | 22 +- .../dialogs/edit-feature-dialog.tsx | 5 +- .../components/views/github-issues-view.tsx | 3 + .../components/issue-detail-panel.tsx | 7 +- .../hooks/use-issue-validation.ts | 24 +- .../views/github-issues-view/types.ts | 5 +- apps/ui/src/hooks/use-electron-agent.ts | 11 +- apps/ui/src/lib/electron.ts | 3 +- apps/ui/src/lib/http-api-client.ts | 20 +- libs/platform/src/subprocess.ts | 27 +- 17 files changed, 233 insertions(+), 254 deletions(-) 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)