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;