From 8c04e0028f46e0b67439a2cffe826dd98820ae05 Mon Sep 17 00:00:00 2001 From: Shirone Date: Fri, 2 Jan 2026 15:22:06 +0100 Subject: [PATCH] feat: integrate thinking level support across agent and UI components - Enhanced the agent service and request handling to include an optional thinking level parameter, improving the configurability of model interactions. - Updated the UI components to manage and display the selected model along with its thinking level, ensuring a cohesive user experience. - Refactored the model selector and input controls to accommodate the new model selection structure, enhancing usability and clarity. - Adjusted the Electron API and HTTP client to support the new thinking level parameter in requests, ensuring consistent data handling across the application. This update significantly improves the agent's ability to adapt its reasoning capabilities based on user-defined thinking levels, enhancing overall performance and user satisfaction. --- apps/server/src/routes/agent/routes/send.ts | 19 +- apps/server/src/services/agent-service.ts | 13 +- apps/ui/src/components/views/agent-view.tsx | 13 +- .../input-area/agent-input-area.tsx | 14 +- .../agent-view/input-area/input-controls.tsx | 15 +- .../shared/agent-model-selector.tsx | 136 ++---------- .../phase-models/phase-model-selector.tsx | 210 +++++++++++------- apps/ui/src/hooks/use-electron-agent.ts | 12 +- apps/ui/src/lib/electron.ts | 3 +- apps/ui/src/lib/http-api-client.ts | 4 +- apps/ui/src/types/electron.d.ts | 3 +- 11 files changed, 211 insertions(+), 231 deletions(-) diff --git a/apps/server/src/routes/agent/routes/send.ts b/apps/server/src/routes/agent/routes/send.ts index 35c1e88a..0c5bbc1c 100644 --- a/apps/server/src/routes/agent/routes/send.ts +++ b/apps/server/src/routes/agent/routes/send.ts @@ -3,6 +3,7 @@ */ import type { Request, Response } from 'express'; +import type { ThinkingLevel } from '@automaker/types'; import { AgentService } from '../../../services/agent-service.js'; import { createLogger } from '@automaker/utils'; import { getErrorMessage, logError } from '../common.js'; @@ -11,13 +12,15 @@ const logger = createLogger('Agent'); export function createSendHandler(agentService: AgentService) { return async (req: Request, res: Response): Promise => { try { - const { sessionId, message, workingDirectory, imagePaths, model } = req.body as { - sessionId: string; - message: string; - workingDirectory?: string; - imagePaths?: string[]; - model?: string; - }; + const { sessionId, message, workingDirectory, imagePaths, model, thinkingLevel } = + req.body as { + sessionId: string; + message: string; + workingDirectory?: string; + imagePaths?: string[]; + model?: string; + thinkingLevel?: ThinkingLevel; + }; console.log('[Send Handler] Received request:', { sessionId, @@ -25,6 +28,7 @@ export function createSendHandler(agentService: AgentService) { workingDirectory, imageCount: imagePaths?.length || 0, model, + thinkingLevel, }); if (!sessionId || !message) { @@ -46,6 +50,7 @@ export function createSendHandler(agentService: AgentService) { workingDirectory, imagePaths, model, + thinkingLevel, }) .catch((error) => { console.error('[Send Handler] ERROR: Background error in sendMessage():', error); diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index c507d81b..9692306e 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -6,7 +6,7 @@ import path from 'path'; import * as secureFs from '../lib/secure-fs.js'; import type { EventEmitter } from '../lib/events.js'; -import type { ExecuteOptions } from '@automaker/types'; +import type { ExecuteOptions, ThinkingLevel } from '@automaker/types'; import { readImageAsBase64, buildPromptWithImages, @@ -54,6 +54,7 @@ interface Session { abortController: AbortController | null; workingDirectory: string; model?: string; + thinkingLevel?: ThinkingLevel; // Thinking level for Claude models sdkSessionId?: string; // Claude SDK session ID for conversation continuity promptQueue: QueuedPrompt[]; // Queue of prompts to auto-run after current task } @@ -142,12 +143,14 @@ export class AgentService { workingDirectory, imagePaths, model, + thinkingLevel, }: { sessionId: string; message: string; workingDirectory?: string; imagePaths?: string[]; model?: string; + thinkingLevel?: ThinkingLevel; }) { const session = this.sessions.get(sessionId); if (!session) { @@ -160,11 +163,14 @@ export class AgentService { throw new Error('Agent is already processing a message'); } - // Update session model if provided + // Update session model and thinking level if provided if (model) { session.model = model; await this.updateSession(sessionId, { model }); } + if (thinkingLevel !== undefined) { + session.thinkingLevel = thinkingLevel; + } // Read images and convert to base64 const images: Message['images'] = []; @@ -255,6 +261,8 @@ export class AgentService { : baseSystemPrompt; // Build SDK options using centralized configuration + // Use thinking level from request, or fall back to session's stored thinking level + const effectiveThinkingLevel = thinkingLevel ?? session.thinkingLevel; const sdkOptions = createChatOptions({ cwd: effectiveWorkDir, model: model, @@ -263,6 +271,7 @@ export class AgentService { abortController: session.abortController!, autoLoadClaudeMd, enableSandboxMode, + thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, diff --git a/apps/ui/src/components/views/agent-view.tsx b/apps/ui/src/components/views/agent-view.tsx index 3adea4f5..b70e32d9 100644 --- a/apps/ui/src/components/views/agent-view.tsx +++ b/apps/ui/src/components/views/agent-view.tsx @@ -1,6 +1,6 @@ import { useState, useCallback, useRef, useEffect } from 'react'; -import { useAppStore, type ModelAlias } from '@/store/app-store'; -import type { CursorModelId } from '@automaker/types'; +import { useAppStore } from '@/store/app-store'; +import type { PhaseModelEntry } from '@automaker/types'; import { useElectronAgent } from '@/hooks/use-electron-agent'; import { SessionManager } from '@/components/session-manager'; @@ -21,7 +21,7 @@ export function AgentView() { const [input, setInput] = useState(''); const [currentTool, setCurrentTool] = useState(null); const [showSessionManager, setShowSessionManager] = useState(true); - const [selectedModel, setSelectedModel] = useState('sonnet'); + const [modelSelection, setModelSelection] = useState({ model: 'sonnet' }); // Input ref for auto-focus const inputRef = useRef(null); @@ -50,7 +50,8 @@ export function AgentView() { } = useElectronAgent({ sessionId: currentSessionId || '', workingDirectory: currentProject?.path, - model: selectedModel, + model: modelSelection.model, + thinkingLevel: modelSelection.thinkingLevel, onToolUse: (toolName) => { setCurrentTool(toolName); setTimeout(() => setCurrentTool(null), 2000); @@ -185,8 +186,8 @@ export function AgentView() { onInputChange={setInput} onSend={handleSend} onStop={stopExecution} - selectedModel={selectedModel} - onModelSelect={setSelectedModel} + modelSelection={modelSelection} + onModelSelect={setModelSelection} isProcessing={isProcessing} isConnected={isConnected} selectedImages={fileAttachments.selectedImages} diff --git a/apps/ui/src/components/views/agent-view/input-area/agent-input-area.tsx b/apps/ui/src/components/views/agent-view/input-area/agent-input-area.tsx index e9790d43..c04cf87f 100644 --- a/apps/ui/src/components/views/agent-view/input-area/agent-input-area.tsx +++ b/apps/ui/src/components/views/agent-view/input-area/agent-input-area.tsx @@ -1,6 +1,6 @@ import { ImageDropZone } from '@/components/ui/image-drop-zone'; -import type { ImageAttachment, TextFileAttachment, ModelAlias } from '@/store/app-store'; -import type { CursorModelId } from '@automaker/types'; +import type { ImageAttachment, TextFileAttachment } from '@/store/app-store'; +import type { PhaseModelEntry } from '@automaker/types'; import { FilePreview } from './file-preview'; import { QueueDisplay } from './queue-display'; import { InputControls } from './input-controls'; @@ -16,8 +16,10 @@ interface AgentInputAreaProps { onInputChange: (value: string) => void; onSend: () => void; onStop: () => void; - selectedModel: ModelAlias | CursorModelId; - onModelSelect: (model: ModelAlias | CursorModelId) => void; + /** Current model selection (model + optional thinking level) */ + modelSelection: PhaseModelEntry; + /** Callback when model is selected */ + onModelSelect: (entry: PhaseModelEntry) => void; isProcessing: boolean; isConnected: boolean; // File attachments @@ -48,7 +50,7 @@ export function AgentInputArea({ onInputChange, onSend, onStop, - selectedModel, + modelSelection, onModelSelect, isProcessing, isConnected, @@ -113,7 +115,7 @@ export function AgentInputArea({ onStop={onStop} onToggleImageDropZone={onToggleImageDropZone} onPaste={onPaste} - selectedModel={selectedModel} + modelSelection={modelSelection} onModelSelect={onModelSelect} isProcessing={isProcessing} isConnected={isConnected} diff --git a/apps/ui/src/components/views/agent-view/input-area/input-controls.tsx b/apps/ui/src/components/views/agent-view/input-area/input-controls.tsx index 72028e6d..efa1aafa 100644 --- a/apps/ui/src/components/views/agent-view/input-area/input-controls.tsx +++ b/apps/ui/src/components/views/agent-view/input-area/input-controls.tsx @@ -4,8 +4,7 @@ import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { cn } from '@/lib/utils'; import { AgentModelSelector } from '../shared/agent-model-selector'; -import type { ModelAlias } from '@/store/app-store'; -import type { CursorModelId } from '@automaker/types'; +import type { PhaseModelEntry } from '@automaker/types'; interface InputControlsProps { input: string; @@ -14,8 +13,10 @@ interface InputControlsProps { onStop: () => void; onToggleImageDropZone: () => void; onPaste: (e: React.ClipboardEvent) => Promise; - selectedModel: ModelAlias | CursorModelId; - onModelSelect: (model: ModelAlias | CursorModelId) => void; + /** Current model selection (model + optional thinking level) */ + modelSelection: PhaseModelEntry; + /** Callback when model is selected */ + onModelSelect: (entry: PhaseModelEntry) => void; isProcessing: boolean; isConnected: boolean; hasFiles: boolean; @@ -37,7 +38,7 @@ export function InputControls({ onStop, onToggleImageDropZone, onPaste, - selectedModel, + modelSelection, onModelSelect, isProcessing, isConnected, @@ -125,8 +126,8 @@ export function InputControls({ {/* Model Selector */} diff --git a/apps/ui/src/components/views/agent-view/shared/agent-model-selector.tsx b/apps/ui/src/components/views/agent-view/shared/agent-model-selector.tsx index 393f474c..12fbf36a 100644 --- a/apps/ui/src/components/views/agent-view/shared/agent-model-selector.tsx +++ b/apps/ui/src/components/views/agent-view/shared/agent-model-selector.tsx @@ -1,127 +1,25 @@ -import { ChevronDown, AlertCircle } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { cn } from '@/lib/utils'; -import { useAppStore, type ModelAlias } from '@/store/app-store'; -import { useSetupStore } from '@/store/setup-store'; -import { CLAUDE_MODELS, CURSOR_MODELS } from '@/components/views/board-view/shared/model-constants'; -import type { CursorModelId } from '@automaker/types'; -import { getModelProvider, stripProviderPrefix } from '@automaker/types'; +/** + * Re-export PhaseModelSelector in compact mode for use in agent chat view. + * This ensures we have a single source of truth for model selection logic. + */ + +import { PhaseModelSelector } from '@/components/views/settings-view/phase-models/phase-model-selector'; +import type { PhaseModelEntry } from '@automaker/types'; + +// Re-export types for convenience +export type { PhaseModelEntry }; interface AgentModelSelectorProps { - selectedModel: ModelAlias | CursorModelId; - onModelSelect: (model: ModelAlias | CursorModelId) => void; + /** Current model selection (model + optional thinking level) */ + value: PhaseModelEntry; + /** Callback when model is selected */ + onChange: (entry: PhaseModelEntry) => void; + /** Disabled state */ disabled?: boolean; } -export function AgentModelSelector({ - selectedModel, - onModelSelect, - disabled, -}: AgentModelSelectorProps) { - const { enabledCursorModels } = useAppStore(); - const { cursorCliStatus } = useSetupStore(); - - // Check if Cursor CLI is available - const isCursorAvailable = cursorCliStatus?.installed && cursorCliStatus?.auth?.authenticated; - - // Filter cursor models by enabled settings - const filteredCursorModels = CURSOR_MODELS.filter((model) => { - const modelId = stripProviderPrefix(model.id) as CursorModelId; - return enabledCursorModels.includes(modelId); - }); - - // Determine current provider and display label - const currentProvider = getModelProvider(selectedModel); - const currentModel = - currentProvider === 'cursor' - ? CURSOR_MODELS.find((m) => m.id === selectedModel) - : CLAUDE_MODELS.find((m) => m.id === selectedModel); - - // Get display label (strip "Claude " prefix for brevity) - const displayLabel = currentModel?.label.replace('Claude ', '') || 'Sonnet'; - +export function AgentModelSelector({ value, onChange, disabled }: AgentModelSelectorProps) { return ( - - - - - - {/* Claude Models Section */} - Claude - {CLAUDE_MODELS.map((model) => ( - onModelSelect(model.id as ModelAlias)} - className={cn('cursor-pointer', selectedModel === model.id && 'bg-accent')} - data-testid={`model-option-${model.id}`} - > -
- {model.label} - {model.description} -
-
- ))} - - {/* Cursor Models Section */} - {filteredCursorModels.length > 0 && ( - <> - - - Cursor CLI - {!isCursorAvailable && ( - - - Setup required - - )} - - {filteredCursorModels.map((model) => ( - onModelSelect(model.id as CursorModelId)} - className={cn( - 'cursor-pointer', - selectedModel === model.id && 'bg-accent', - !isCursorAvailable && 'opacity-50' - )} - disabled={!isCursorAvailable} - data-testid={`model-option-${model.id}`} - > -
-
- {model.label} - {model.hasThinking && ( - - Thinking - - )} -
- {model.description} -
-
- ))} - - )} -
-
+ ); } diff --git a/apps/ui/src/components/views/settings-view/phase-models/phase-model-selector.tsx b/apps/ui/src/components/views/settings-view/phase-models/phase-model-selector.tsx index 0315fe88..657f0044 100644 --- a/apps/ui/src/components/views/settings-view/phase-models/phase-model-selector.tsx +++ b/apps/ui/src/components/views/settings-view/phase-models/phase-model-selector.tsx @@ -36,10 +36,22 @@ import { import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; interface PhaseModelSelectorProps { - label: string; - description: string; + /** Label shown in full mode */ + label?: string; + /** Description shown in full mode */ + description?: string; + /** Current model selection */ value: PhaseModelEntry; + /** Callback when model is selected */ onChange: (entry: PhaseModelEntry) => void; + /** Compact mode - just shows the button trigger without label/description wrapper */ + compact?: boolean; + /** Custom trigger class name */ + triggerClassName?: string; + /** Popover alignment */ + align?: 'start' | 'end'; + /** Disabled state */ + disabled?: boolean; } export function PhaseModelSelector({ @@ -47,6 +59,10 @@ export function PhaseModelSelector({ description, value, onChange, + compact = false, + triggerClassName, + align = 'end', + disabled = false, }: PhaseModelSelectorProps) { const [open, setOpen] = React.useState(false); const [expandedGroup, setExpandedGroup] = React.useState(null); @@ -505,6 +521,119 @@ export function PhaseModelSelector({ ); }; + // Compact trigger button (for agent view etc.) + const compactTrigger = ( + + ); + + // Full trigger button (for settings view) + const fullTrigger = ( + + ); + + // The popover content (shared between both modes) + const popoverContent = ( + + + + + No model found. + + {favorites.length > 0 && ( + <> + + {(() => { + const renderedGroups = new Set(); + return favorites.map((model) => { + // Check if this favorite is part of a grouped model + if (model.provider === 'cursor') { + const cursorId = stripProviderPrefix(model.id) as CursorModelId; + const group = getModelGroup(cursorId); + if (group) { + // Skip if we already rendered this group + if (renderedGroups.has(group.baseId)) { + return null; + } + renderedGroups.add(group.baseId); + // Find the group in groupedModels (which has filtered variants) + const filteredGroup = groupedModels.find((g) => g.baseId === group.baseId); + if (filteredGroup) { + return renderGroupedModelItem(filteredGroup); + } + } + // Standalone Cursor model + return renderCursorModelItem(model); + } + // Claude model + return renderClaudeModelItem(model); + }); + })()} + + + + )} + + {claude.length > 0 && ( + + {claude.map((model) => renderClaudeModelItem(model))} + + )} + + {(groupedModels.length > 0 || standaloneCursorModels.length > 0) && ( + + {/* Grouped models with secondary popover */} + {groupedModels.map((group) => renderGroupedModelItem(group))} + {/* Standalone models */} + {standaloneCursorModels.map((model) => renderCursorModelItem(model))} + + )} + + + + ); + + // Compact mode - just the popover with compact trigger + if (compact) { + return ( + + {compactTrigger} + {popoverContent} + + ); + } + + // Full mode - with label and description wrapper return (
- - - - - - - - No model found. - - {favorites.length > 0 && ( - <> - - {(() => { - const renderedGroups = new Set(); - return favorites.map((model) => { - // Check if this favorite is part of a grouped model - if (model.provider === 'cursor') { - const cursorId = stripProviderPrefix(model.id) as CursorModelId; - const group = getModelGroup(cursorId); - if (group) { - // Skip if we already rendered this group - if (renderedGroups.has(group.baseId)) { - return null; - } - renderedGroups.add(group.baseId); - // Find the group in groupedModels (which has filtered variants) - const filteredGroup = groupedModels.find( - (g) => g.baseId === group.baseId - ); - if (filteredGroup) { - return renderGroupedModelItem(filteredGroup); - } - } - // Standalone Cursor model - return renderCursorModelItem(model); - } - // Claude model - return renderClaudeModelItem(model); - }); - })()} - - - - )} - - {claude.length > 0 && ( - - {claude.map((model) => renderClaudeModelItem(model))} - - )} - - {(groupedModels.length > 0 || standaloneCursorModels.length > 0) && ( - - {/* Grouped models with secondary popover */} - {groupedModels.map((group) => renderGroupedModelItem(group))} - {/* Standalone models */} - {standaloneCursorModels.map((model) => renderCursorModelItem(model))} - - )} - - - + {fullTrigger} + {popoverContent}
); diff --git a/apps/ui/src/hooks/use-electron-agent.ts b/apps/ui/src/hooks/use-electron-agent.ts index 603bcc8e..5b0d9b43 100644 --- a/apps/ui/src/hooks/use-electron-agent.ts +++ b/apps/ui/src/hooks/use-electron-agent.ts @@ -9,6 +9,7 @@ interface UseElectronAgentOptions { sessionId: string; workingDirectory?: string; model?: string; + thinkingLevel?: string; onToolUse?: (toolName: string, toolInput: unknown) => void; } @@ -64,6 +65,7 @@ export function useElectronAgent({ sessionId, workingDirectory, model, + thinkingLevel, onToolUse, }: UseElectronAgentOptions): UseElectronAgentResult { const [messages, setMessages] = useState([]); @@ -133,7 +135,8 @@ export function useElectronAgent({ messageContent, workingDirectory, imagePaths, - model + model, + thinkingLevel ); if (!result.success) { @@ -149,7 +152,7 @@ export function useElectronAgent({ throw err; } }, - [sessionId, workingDirectory, model, isProcessing] + [sessionId, workingDirectory, model, thinkingLevel, isProcessing] ); // Message queue for queuing messages when agent is busy @@ -410,7 +413,8 @@ export function useElectronAgent({ messageContent, workingDirectory, imagePaths, - model + model, + thinkingLevel ); if (!result.success) { @@ -425,7 +429,7 @@ export function useElectronAgent({ setIsProcessing(false); } }, - [sessionId, workingDirectory, model, isProcessing] + [sessionId, workingDirectory, model, thinkingLevel, isProcessing] ); // Stop current execution diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 432a029a..79208527 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -605,7 +605,8 @@ export interface ElectronAPI { message: string, workingDirectory?: string, imagePaths?: string[], - model?: string + model?: string, + thinkingLevel?: string ) => Promise<{ success: boolean; error?: string }>; getHistory: (sessionId: string) => Promise<{ success: boolean; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index bc4f457f..595496d3 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1422,7 +1422,8 @@ export class HttpApiClient implements ElectronAPI { message: string, workingDirectory?: string, imagePaths?: string[], - model?: string + model?: string, + thinkingLevel?: string ): Promise<{ success: boolean; error?: string }> => this.post('/api/agent/send', { sessionId, @@ -1430,6 +1431,7 @@ export class HttpApiClient implements ElectronAPI { workingDirectory, imagePaths, model, + thinkingLevel, }), getHistory: ( diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index 15c61f8c..068feb61 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -85,7 +85,8 @@ export interface AgentAPI { message: string, workingDirectory?: string, imagePaths?: string[], - model?: string + model?: string, + thinkingLevel?: string ) => Promise<{ success: boolean; error?: string;