diff --git a/apps/server/src/providers/cursor-provider.ts b/apps/server/src/providers/cursor-provider.ts index 47a52b33..37535dce 100644 --- a/apps/server/src/providers/cursor-provider.ts +++ b/apps/server/src/providers/cursor-provider.ts @@ -28,6 +28,7 @@ import type { ModelDefinition, ContentBlock, } from './types.js'; +import { stripProviderPrefix } from '@automaker/types'; import { type CursorStreamEvent, type CursorSystemEvent, @@ -115,10 +116,7 @@ export class CursorProvider extends CliProvider { buildCliArgs(options: ExecuteOptions): string[] { // Extract model (strip 'cursor-' prefix if present) - let model = options.model || 'auto'; - if (model.startsWith('cursor-')) { - model = model.substring(7); - } + const model = stripProviderPrefix(options.model || 'auto'); // Build prompt content let promptText: string; diff --git a/apps/server/src/providers/provider-factory.ts b/apps/server/src/providers/provider-factory.ts index 7da470e8..25eb7bd0 100644 --- a/apps/server/src/providers/provider-factory.ts +++ b/apps/server/src/providers/provider-factory.ts @@ -7,7 +7,7 @@ import { BaseProvider } from './base-provider.js'; import type { InstallationStatus, ModelDefinition } from './types.js'; -import { CURSOR_MODEL_MAP, type ModelProvider } from '@automaker/types'; +import { isCursorModel, type ModelProvider } from '@automaker/types'; /** * Provider registration entry @@ -181,14 +181,6 @@ registerProvider('claude', { // Register Cursor provider registerProvider('cursor', { factory: () => new CursorProvider(), - canHandleModel: (model: string) => { - // Check for explicit cursor prefix - if (model.startsWith('cursor-')) { - return true; - } - // Check if it's a known Cursor model ID - const modelId = model.replace('cursor-', ''); - return modelId in CURSOR_MODEL_MAP; - }, + canHandleModel: (model: string) => isCursorModel(model), priority: 10, // Higher priority - check Cursor models first }); diff --git a/apps/server/src/routes/enhance-prompt/routes/enhance.ts b/apps/server/src/routes/enhance-prompt/routes/enhance.ts index e0edd515..3cbea851 100644 --- a/apps/server/src/routes/enhance-prompt/routes/enhance.ts +++ b/apps/server/src/routes/enhance-prompt/routes/enhance.ts @@ -1,7 +1,7 @@ /** * POST /enhance-prompt endpoint - Enhance user input text * - * Uses Claude AI to enhance text based on the specified enhancement mode. + * Uses Claude AI or Cursor to enhance text based on the specified enhancement mode. * Supports modes: improve, technical, simplify, acceptance */ @@ -9,7 +9,8 @@ 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 } from '@automaker/types'; +import { CLAUDE_MODEL_MAP, isCursorModel } from '@automaker/types'; +import { ProviderFactory } from '../../../providers/provider-factory.js'; import { getSystemPrompt, buildUserPrompt, @@ -80,6 +81,40 @@ async function extractTextFromStream( return responseText; } +/** + * Execute enhancement using Cursor provider + * + * @param prompt - The enhancement prompt + * @param model - The Cursor model to use + * @returns The enhanced text + */ +async function executeWithCursor(prompt: string, model: string): Promise { + const provider = ProviderFactory.getProviderForModel(model); + + let responseText = ''; + + for await (const msg of provider.executeQuery({ + prompt, + model, + cwd: process.cwd(), // Enhancement doesn't need a specific working directory + })) { + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + responseText += block.text; + } + } + } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { + // Use result if it's a final accumulated message + if (msg.result.length > responseText.length) { + responseText = msg.result; + } + } + } + + return responseText; +} + /** * Create the enhance request handler * @@ -140,24 +175,36 @@ export function createEnhanceHandler(): (req: Request, res: Response) => Promise logger.debug(`Using model: ${resolvedModel}`); - // Call Claude SDK with minimal configuration for text transformation - // Key: no tools, just text completion - const stream = query({ - prompt: userPrompt, - options: { - model: resolvedModel, - systemPrompt, - maxTurns: 1, - allowedTools: [], - permissionMode: 'acceptEdits', - }, - }); + let enhancedText: string; - // Extract the enhanced text from the response - const enhancedText = await extractTextFromStream(stream); + // Route to appropriate provider based on model + if (isCursorModel(resolvedModel)) { + // Use Cursor provider for Cursor models + logger.info(`Using Cursor provider for model: ${resolvedModel}`); + + // Cursor doesn't have a separate system prompt concept, so combine them + const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`; + enhancedText = await executeWithCursor(combinedPrompt, resolvedModel); + } else { + // Use Claude SDK for Claude models + logger.info(`Using Claude provider for model: ${resolvedModel}`); + + const stream = query({ + prompt: userPrompt, + options: { + model: resolvedModel, + systemPrompt, + maxTurns: 1, + allowedTools: [], + permissionMode: 'acceptEdits', + }, + }); + + enhancedText = await extractTextFromStream(stream); + } if (!enhancedText || enhancedText.trim().length === 0) { - logger.warn('Received empty response from Claude'); + logger.warn('Received empty response from AI'); const response: EnhanceErrorResponse = { success: false, error: 'Failed to generate enhanced text - empty response', diff --git a/apps/server/src/routes/github/routes/validate-issue.ts b/apps/server/src/routes/github/routes/validate-issue.ts index c987453a..19abdb80 100644 --- a/apps/server/src/routes/github/routes/validate-issue.ts +++ b/apps/server/src/routes/github/routes/validate-issue.ts @@ -1,16 +1,24 @@ /** - * POST /validate-issue endpoint - Validate a GitHub issue using Claude SDK (async) + * POST /validate-issue endpoint - Validate a GitHub issue using Claude SDK or Cursor (async) * * Scans the codebase to determine if an issue is valid, invalid, or needs clarification. * Runs asynchronously and emits events for progress and completion. + * Supports both Claude models and Cursor models. */ import type { Request, Response } from 'express'; import { query } from '@anthropic-ai/claude-agent-sdk'; import type { EventEmitter } from '../../../lib/events.js'; -import type { IssueValidationResult, IssueValidationEvent, AgentModel } from '@automaker/types'; +import type { + IssueValidationResult, + IssueValidationEvent, + ModelAlias, + CursorModelId, +} from '@automaker/types'; +import { isCursorModel } from '@automaker/types'; import { createSuggestionsOptions } from '../../../lib/sdk-options.js'; import { writeValidation } from '../../../lib/validation-storage.js'; +import { ProviderFactory } from '../../../providers/provider-factory.js'; import { issueValidationSchema, ISSUE_VALIDATION_SYSTEM_PROMPT, @@ -26,8 +34,8 @@ import { import type { SettingsService } from '../../../services/settings-service.js'; import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js'; -/** Valid model values for validation */ -const VALID_MODELS: readonly AgentModel[] = ['opus', 'sonnet', 'haiku'] as const; +/** Valid Claude model values for validation */ +const VALID_CLAUDE_MODELS: readonly ModelAlias[] = ['opus', 'sonnet', 'haiku'] as const; /** * Request body for issue validation @@ -38,8 +46,8 @@ interface ValidateIssueRequestBody { issueTitle: string; issueBody: string; issueLabels?: string[]; - /** Model to use for validation (opus, sonnet, haiku) */ - model?: AgentModel; + /** Model to use for validation (opus, sonnet, haiku, or cursor model IDs) */ + model?: ModelAlias | CursorModelId; } /** @@ -47,6 +55,7 @@ interface ValidateIssueRequestBody { * * Emits events for start, progress, complete, and error. * Stores result on completion. + * Supports both Claude models (with structured output) and Cursor models (with JSON parsing). */ async function runValidation( projectPath: string, @@ -54,7 +63,7 @@ async function runValidation( issueTitle: string, issueBody: string, issueLabels: string[] | undefined, - model: AgentModel, + model: ModelAlias | CursorModelId, events: EventEmitter, abortController: AbortController, settingsService?: SettingsService @@ -79,65 +88,133 @@ async function runValidation( // Build the prompt const prompt = buildValidationPrompt(issueNumber, issueTitle, issueBody, issueLabels); - // Load autoLoadClaudeMd setting - const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( - projectPath, - settingsService, - '[ValidateIssue]' - ); - - // Create SDK options with structured output and abort controller - const options = createSuggestionsOptions({ - cwd: projectPath, - model, - systemPrompt: ISSUE_VALIDATION_SYSTEM_PROMPT, - abortController, - autoLoadClaudeMd, - outputFormat: { - type: 'json_schema', - schema: issueValidationSchema as Record, - }, - }); - - // Execute the query - const stream = query({ prompt, options }); let validationResult: IssueValidationResult | null = null; let responseText = ''; - for await (const msg of stream) { - // Collect assistant text for debugging and emit progress - if (msg.type === 'assistant' && msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text') { - responseText += block.text; + // Route to appropriate provider based on model + if (isCursorModel(model)) { + // Use Cursor provider for Cursor models + logger.info(`Using Cursor provider for validation with model: ${model}`); - // Emit progress event - const progressEvent: IssueValidationEvent = { - type: 'issue_validation_progress', - issueNumber, - content: block.text, - projectPath, - }; - events.emit('issue-validation:event', progressEvent); + const provider = ProviderFactory.getProviderForModel(model); + + // For Cursor, include the system prompt and schema in the user prompt + const cursorPrompt = `${ISSUE_VALIDATION_SYSTEM_PROMPT} + +You MUST respond with a valid JSON object matching this schema: +${JSON.stringify(issueValidationSchema, null, 2)} + +${prompt}`; + + for await (const msg of provider.executeQuery({ + prompt: cursorPrompt, + model, + cwd: projectPath, + })) { + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + responseText += block.text; + + // Emit progress event + const progressEvent: IssueValidationEvent = { + type: 'issue_validation_progress', + issueNumber, + content: block.text, + projectPath, + }; + events.emit('issue-validation:event', progressEvent); + } + } + } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { + // Use result if it's a final accumulated message + if (msg.result.length > responseText.length) { + responseText = msg.result; } } } - // Extract structured output on success - if (msg.type === 'result' && msg.subtype === 'success') { - const resultMsg = msg as { structured_output?: IssueValidationResult }; - if (resultMsg.structured_output) { - validationResult = resultMsg.structured_output; - logger.debug('Received structured output:', validationResult); + // Parse JSON from the response text + if (responseText) { + try { + // Try to extract JSON from response (it might be wrapped in markdown code blocks) + let jsonStr = responseText; + + // Remove markdown code blocks if present + const jsonMatch = responseText.match(/```(?:json)?\s*([\s\S]*?)```/); + if (jsonMatch) { + jsonStr = jsonMatch[1].trim(); + } + + validationResult = JSON.parse(jsonStr) as IssueValidationResult; + logger.debug('Parsed validation result from Cursor response:', validationResult); + } catch (parseError) { + logger.error('Failed to parse JSON from Cursor response:', parseError); + logger.debug('Raw response:', responseText); } } + } else { + // Use Claude SDK for Claude models + logger.info(`Using Claude provider for validation with model: ${model}`); - // Handle errors - if (msg.type === 'result') { - const resultMsg = msg as { subtype?: string }; - if (resultMsg.subtype === 'error_max_structured_output_retries') { - logger.error('Failed to produce valid structured output after retries'); - throw new Error('Could not produce valid validation output'); + // Load autoLoadClaudeMd setting + const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( + projectPath, + settingsService, + '[ValidateIssue]' + ); + + // Create SDK options with structured output and abort controller + const options = createSuggestionsOptions({ + cwd: projectPath, + model: model as ModelAlias, + systemPrompt: ISSUE_VALIDATION_SYSTEM_PROMPT, + abortController, + autoLoadClaudeMd, + outputFormat: { + type: 'json_schema', + schema: issueValidationSchema as Record, + }, + }); + + // Execute the query + const stream = query({ prompt, options }); + + for await (const msg of stream) { + // Collect assistant text for debugging and emit progress + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text') { + responseText += block.text; + + // Emit progress event + const progressEvent: IssueValidationEvent = { + type: 'issue_validation_progress', + issueNumber, + content: block.text, + projectPath, + }; + events.emit('issue-validation:event', progressEvent); + } + } + } + + // Extract structured output on success + if (msg.type === 'result' && msg.subtype === 'success') { + const resultMsg = msg as { structured_output?: IssueValidationResult }; + if (resultMsg.structured_output) { + validationResult = resultMsg.structured_output; + logger.debug('Received structured output:', validationResult); + } + } + + // Handle errors + if (msg.type === 'result') { + const resultMsg = msg as { subtype?: string }; + if (resultMsg.subtype === 'error_max_structured_output_retries') { + logger.error('Failed to produce valid structured output after retries'); + throw new Error('Could not produce valid validation output'); + } } } } @@ -145,11 +222,11 @@ async function runValidation( // Clear timeout clearTimeout(timeoutId); - // Require structured output + // Require validation result if (!validationResult) { - logger.error('No structured output received from Claude SDK'); + logger.error('No validation result received from AI provider'); logger.debug('Raw response text:', responseText); - throw new Error('Validation failed: no structured output received'); + throw new Error('Validation failed: no valid result received'); } logger.info(`Issue #${issueNumber} validation complete: ${validationResult.verdict}`); @@ -239,11 +316,14 @@ export function createValidateIssueHandler( return; } - // Validate model parameter at runtime - if (!VALID_MODELS.includes(model)) { + // Validate model parameter at runtime - accept Claude models or Cursor models + const isValidClaudeModel = VALID_CLAUDE_MODELS.includes(model as ModelAlias); + const isValidCursorModel = isCursorModel(model); + + if (!isValidClaudeModel && !isValidCursorModel) { res.status(400).json({ success: false, - error: `Invalid model. Must be one of: ${VALID_MODELS.join(', ')}`, + error: `Invalid model. Must be one of: ${VALID_CLAUDE_MODELS.join(', ')}, or a Cursor model ID`, }); return; } diff --git a/apps/server/src/types/settings.ts b/apps/server/src/types/settings.ts index 4b4fa3ac..260e90d1 100644 --- a/apps/server/src/types/settings.ts +++ b/apps/server/src/types/settings.ts @@ -8,7 +8,7 @@ export type { ThemeMode, KanbanCardDetailLevel, - AgentModel, + ModelAlias, PlanningMode, ThinkingLevel, ModelProvider, diff --git a/apps/ui/src/app.tsx b/apps/ui/src/app.tsx index 50380095..479e5ca2 100644 --- a/apps/ui/src/app.tsx +++ b/apps/ui/src/app.tsx @@ -3,6 +3,7 @@ import { RouterProvider } from '@tanstack/react-router'; import { router } from './utils/router'; import { SplashScreen } from './components/splash-screen'; import { useSettingsMigration } from './hooks/use-settings-migration'; +import { useCursorStatusInit } from './hooks/use-cursor-status-init'; import './styles/global.css'; import './styles/theme-imports'; @@ -21,6 +22,9 @@ export default function App() { console.log('[App] Settings migrated to file storage'); } + // Initialize Cursor CLI status at startup + useCursorStatusInit(); + const handleSplashComplete = useCallback(() => { sessionStorage.setItem('automaker-splash-shown', 'true'); setShowSplash(false); diff --git a/apps/ui/src/components/shared/model-override-trigger.tsx b/apps/ui/src/components/shared/model-override-trigger.tsx index d6b74c22..bad7748a 100644 --- a/apps/ui/src/components/shared/model-override-trigger.tsx +++ b/apps/ui/src/components/shared/model-override-trigger.tsx @@ -4,14 +4,15 @@ 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 type { ModelAlias, CursorModelId, PhaseModelKey } from '@automaker/types'; +import { PROVIDER_PREFIXES, stripProviderPrefix } 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; + currentModel: ModelAlias | CursorModelId; /** Callback when user selects override */ - onModelChange: (model: AgentModel | CursorModelId | null) => void; + onModelChange: (model: ModelAlias | CursorModelId | null) => void; /** Optional: which phase this is for (shows global default) */ phase?: PhaseModelKey; /** Size variants for different contexts */ @@ -24,13 +25,13 @@ export interface ModelOverrideTriggerProps { className?: string; } -function getModelLabel(modelId: AgentModel | CursorModelId): 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 === `cursor-${modelId}`); + const cursorModel = CURSOR_MODELS.find((m) => m.id === `${PROVIDER_PREFIXES.cursor}${modelId}`); if (cursorModel) return cursorModel.label; // Check Cursor models (with cursor- prefix) @@ -57,11 +58,11 @@ export function ModelOverrideTrigger({ // Filter Cursor models to only show enabled ones const availableCursorModels = CURSOR_MODELS.filter((model) => { - const cursorId = model.id.replace('cursor-', '') as CursorModelId; + const cursorId = stripProviderPrefix(model.id) as CursorModelId; return enabledCursorModels.includes(cursorId); }); - const handleSelect = (model: AgentModel | CursorModelId) => { + const handleSelect = (model: ModelAlias | CursorModelId) => { onModelChange(model); setOpen(false); }; @@ -162,7 +163,7 @@ export function ModelOverrideTrigger({ return ( + +
@@ -540,7 +550,7 @@ export function AddFeatureDialog({ profiles={aiProfiles} selectedModel={newFeature.model} selectedThinkingLevel={newFeature.thinkingLevel} - selectedCursorModel={isCursorModel ? newFeature.model : undefined} + selectedCursorModel={isCurrentModelCursor ? newFeature.model : undefined} onSelect={handleProfileSelect} showManageLink onManageLinkClick={() => { diff --git a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx index bcf68150..ca68f4c7 100644 --- a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx @@ -32,7 +32,7 @@ import { getElectronAPI } from '@/lib/electron'; import { modelSupportsThinking } from '@/lib/utils'; import { Feature, - AgentModel, + ModelAlias, ThinkingLevel, AIProfile, useAppStore, @@ -47,6 +47,7 @@ import { BranchSelector, PlanningModeSelector, } from '../shared'; +import { ModelOverrideTrigger, useModelOverride } from '@/components/shared'; import { DropdownMenu, DropdownMenuContent, @@ -54,6 +55,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { DependencyTreeDialog } from './dependency-tree-dialog'; +import { isCursorModel, PROVIDER_PREFIXES } from '@automaker/types'; interface EditFeatureDialogProps { feature: Feature | null; @@ -65,7 +67,7 @@ interface EditFeatureDialogProps { category: string; description: string; skipTests: boolean; - model: AgentModel; + model: ModelAlias; thinkingLevel: ThinkingLevel; imagePaths: DescriptionImagePath[]; textFilePaths: DescriptionTextFilePath[]; @@ -117,8 +119,11 @@ export function EditFeatureDialog({ feature?.requirePlanApproval ?? false ); - // Get enhancement model and worktrees setting from store - const { enhancementModel, useWorktrees } = useAppStore(); + // Get worktrees setting from store + const { useWorktrees } = useAppStore(); + + // Enhancement model override + const enhancementOverride = useModelOverride({ phase: 'enhancementModel' }); useEffect(() => { setEditingFeature(feature); @@ -148,7 +153,7 @@ export function EditFeatureDialog({ return; } - const selectedModel = (editingFeature.model ?? 'opus') as AgentModel; + const selectedModel = (editingFeature.model ?? 'opus') as ModelAlias; const normalizedThinking: ThinkingLevel = modelSupportsThinking(selectedModel) ? (editingFeature.thinkingLevel ?? 'none') : 'none'; @@ -187,22 +192,40 @@ export function EditFeatureDialog({ } }; - const handleModelSelect = (model: AgentModel) => { + const handleModelSelect = (model: string) => { if (!editingFeature) return; + // For Cursor models, thinking is handled by the model itself + // For Claude models, check if it supports extended thinking + const isCursor = isCursorModel(model); setEditingFeature({ ...editingFeature, - model, - thinkingLevel: modelSupportsThinking(model) ? editingFeature.thinkingLevel : 'none', + model: model as ModelAlias, + thinkingLevel: isCursor + ? 'none' + : modelSupportsThinking(model) + ? editingFeature.thinkingLevel + : 'none', }); }; - const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => { + const handleProfileSelect = (profile: AIProfile) => { if (!editingFeature) return; - setEditingFeature({ - ...editingFeature, - model, - thinkingLevel, - }); + if (profile.provider === 'cursor') { + // Cursor profile - set cursor model + const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`; + setEditingFeature({ + ...editingFeature, + model: cursorModel as ModelAlias, + thinkingLevel: 'none', // Cursor handles thinking internally + }); + } else { + // Claude profile + setEditingFeature({ + ...editingFeature, + model: profile.model || 'sonnet', + thinkingLevel: profile.thinkingLevel || 'none', + }); + } }; const handleEnhanceDescription = async () => { @@ -214,7 +237,7 @@ export function EditFeatureDialog({ const result = await api.enhancePrompt?.enhance( editingFeature.description, enhancementMode, - enhancementModel + enhancementOverride.effectiveModel ); if (result?.success && result.enhancedText) { @@ -232,7 +255,10 @@ export function EditFeatureDialog({ } }; - const editModelAllowsThinking = modelSupportsThinking(editingFeature?.model); + // Cursor models handle thinking internally, so only show thinking selector for Claude models + const isCurrentModelCursor = isCursorModel(editingFeature?.model as string); + const editModelAllowsThinking = + !isCurrentModelCursor && modelSupportsThinking(editingFeature?.model); if (!editingFeature) { return null; @@ -361,6 +387,15 @@ export function EditFeatureDialog({ Enhance with AI + +
@@ -437,6 +472,9 @@ export function EditFeatureDialog({ profiles={aiProfiles} selectedModel={editingFeature.model ?? 'opus'} selectedThinkingLevel={editingFeature.thinkingLevel ?? 'none'} + selectedCursorModel={ + isCurrentModelCursor ? (editingFeature.model as string) : undefined + } onSelect={handleProfileSelect} testIdPrefix="edit-profile-quick-select" /> @@ -450,7 +488,7 @@ export function EditFeatureDialog({ {(!showProfilesOnly || showEditAdvancedOptions) && ( <> diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts index 0b48ad6b..fdf2d90c 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts @@ -2,7 +2,7 @@ import { useCallback } from 'react'; import { Feature, FeatureImage, - AgentModel, + ModelAlias, ThinkingLevel, PlanningMode, useAppStore, @@ -92,7 +92,7 @@ export function useBoardActions({ images: FeatureImage[]; imagePaths: DescriptionImagePath[]; skipTests: boolean; - model: AgentModel; + model: ModelAlias; thinkingLevel: ThinkingLevel; branchName: string; priority: number; @@ -210,7 +210,7 @@ export function useBoardActions({ category: string; description: string; skipTests: boolean; - model: AgentModel; + model: ModelAlias; thinkingLevel: ThinkingLevel; imagePaths: DescriptionImagePath[]; branchName: string; diff --git a/apps/ui/src/components/views/board-view/shared/model-constants.ts b/apps/ui/src/components/views/board-view/shared/model-constants.ts index 53615143..4d40eaa8 100644 --- a/apps/ui/src/components/views/board-view/shared/model-constants.ts +++ b/apps/ui/src/components/views/board-view/shared/model-constants.ts @@ -1,10 +1,10 @@ -import type { AgentModel, ThinkingLevel } from '@/store/app-store'; +import type { ModelAlias, ThinkingLevel } from '@/store/app-store'; import type { ModelProvider } from '@automaker/types'; import { CURSOR_MODEL_MAP } from '@automaker/types'; import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react'; export type ModelOption = { - id: string; // Claude models use AgentModel, Cursor models use "cursor-{id}" + id: string; // Claude models use ModelAlias, Cursor models use "cursor-{id}" label: string; description: string; badge?: string; diff --git a/apps/ui/src/components/views/board-view/shared/model-selector.tsx b/apps/ui/src/components/views/board-view/shared/model-selector.tsx index 70c6e802..0d0e2b72 100644 --- a/apps/ui/src/components/views/board-view/shared/model-selector.tsx +++ b/apps/ui/src/components/views/board-view/shared/model-selector.tsx @@ -2,28 +2,19 @@ import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; import { Brain, Bot, Terminal, AlertTriangle } from 'lucide-react'; import { cn } from '@/lib/utils'; -import type { AgentModel } from '@/store/app-store'; +import type { ModelAlias } from '@/store/app-store'; import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; +import { getModelProvider, PROVIDER_PREFIXES, stripProviderPrefix } from '@automaker/types'; import type { ModelProvider } from '@automaker/types'; import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants'; interface ModelSelectorProps { - selectedModel: string; // Can be AgentModel or "cursor-{id}" + selectedModel: string; // Can be ModelAlias or "cursor-{id}" onModelSelect: (model: string) => void; testIdPrefix?: string; } -/** - * Get the provider from a model string - */ -function getProviderFromModelString(model: string): ModelProvider { - if (model.startsWith('cursor-')) { - return 'cursor'; - } - return 'claude'; -} - export function ModelSelector({ selectedModel, onModelSelect, @@ -32,7 +23,7 @@ export function ModelSelector({ const { enabledCursorModels, cursorDefaultModel } = useAppStore(); const { cursorCliStatus } = useSetupStore(); - const selectedProvider = getProviderFromModelString(selectedModel); + const selectedProvider = getModelProvider(selectedModel); // Check if Cursor CLI is available const isCursorAvailable = cursorCliStatus?.installed && cursorCliStatus?.auth?.authenticated; @@ -40,14 +31,14 @@ export function ModelSelector({ // Filter Cursor models based on enabled models from global settings const filteredCursorModels = CURSOR_MODELS.filter((model) => { // Extract the cursor model ID from the prefixed ID (e.g., "cursor-auto" -> "auto") - const cursorModelId = model.id.replace('cursor-', ''); + const cursorModelId = stripProviderPrefix(model.id); return enabledCursorModels.includes(cursorModelId as any); }); const handleProviderChange = (provider: ModelProvider) => { if (provider === 'cursor' && selectedProvider !== 'cursor') { // Switch to Cursor's default model (from global settings) - onModelSelect(`cursor-${cursorDefaultModel}`); + onModelSelect(`${PROVIDER_PREFIXES.cursor}${cursorDefaultModel}`); } else if (provider === 'claude' && selectedProvider !== 'claude') { // Switch to Claude's default model onModelSelect('sonnet'); diff --git a/apps/ui/src/components/views/board-view/shared/profile-quick-select.tsx b/apps/ui/src/components/views/board-view/shared/profile-quick-select.tsx index e86a7071..674d1455 100644 --- a/apps/ui/src/components/views/board-view/shared/profile-quick-select.tsx +++ b/apps/ui/src/components/views/board-view/shared/profile-quick-select.tsx @@ -1,8 +1,8 @@ import { Label } from '@/components/ui/label'; import { Brain, UserCircle, Terminal } from 'lucide-react'; import { cn } from '@/lib/utils'; -import type { AgentModel, ThinkingLevel, AIProfile } from '@automaker/types'; -import { CURSOR_MODEL_MAP, profileHasThinking } from '@automaker/types'; +import type { ModelAlias, ThinkingLevel, AIProfile, CursorModelId } from '@automaker/types'; +import { CURSOR_MODEL_MAP, profileHasThinking, PROVIDER_PREFIXES } from '@automaker/types'; import { PROFILE_ICONS } from './model-constants'; /** @@ -32,7 +32,7 @@ function getProfileThinkingDisplay(profile: AIProfile): string | null { interface ProfileQuickSelectProps { profiles: AIProfile[]; - selectedModel: AgentModel; + selectedModel: ModelAlias | CursorModelId; selectedThinkingLevel: ThinkingLevel; selectedCursorModel?: string; // For detecting cursor profile selection onSelect: (profile: AIProfile) => void; // Changed to pass full profile @@ -62,7 +62,7 @@ export function ProfileQuickSelect({ const isProfileSelected = (profile: AIProfile): boolean => { if (profile.provider === 'cursor') { // For cursor profiles, check if cursor model matches - const profileCursorModel = `cursor-${profile.cursorModel || 'auto'}`; + const profileCursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`; return selectedCursorModel === profileCursorModel; } // For Claude profiles diff --git a/apps/ui/src/components/views/github-issues-view.tsx b/apps/ui/src/components/views/github-issues-view.tsx index 10876e38..a66bf03b 100644 --- a/apps/ui/src/components/views/github-issues-view.tsx +++ b/apps/ui/src/components/views/github-issues-view.tsx @@ -11,6 +11,7 @@ import { useGithubIssues, useIssueValidation } from './github-issues-view/hooks' import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components'; import { ValidationDialog } from './github-issues-view/dialogs'; import { formatDate, getFeaturePriority } from './github-issues-view/utils'; +import { useModelOverride } from '@/components/shared'; export function GitHubIssuesView() { const [selectedIssue, setSelectedIssue] = useState(null); @@ -21,6 +22,9 @@ export function GitHubIssuesView() { const { currentProject, defaultAIProfileId, aiProfiles, getCurrentWorktree, worktreesByProject } = useAppStore(); + // Model override for validation + const validationModelOverride = useModelOverride({ phase: 'validationModel' }); + const { openIssues, closedIssues, loading, refreshing, error, refresh } = useGithubIssues(); const { validatingIssues, cachedValidations, handleValidateIssue, handleViewCachedValidation } = @@ -85,6 +89,9 @@ export function GitHubIssuesView() { .filter(Boolean) .join('\n'); + // Use profile default model + const featureModel = defaultProfile?.model ?? 'opus'; + const feature = { id: `issue-${issue.number}-${crypto.randomUUID()}`, title: issue.title, @@ -93,7 +100,7 @@ export function GitHubIssuesView() { status: 'backlog' as const, passes: false, priority: getFeaturePriority(validation.estimatedComplexity), - model: defaultProfile?.model ?? 'opus', + model: featureModel, thinkingLevel: defaultProfile?.thinkingLevel ?? 'none', branchName: currentBranch, createdAt: new Date().toISOString(), @@ -205,6 +212,7 @@ export function GitHubIssuesView() { onClose={() => setSelectedIssue(null)} onShowRevalidateConfirm={() => setShowRevalidateConfirm(true)} formatDate={formatDate} + modelOverride={validationModelOverride} /> )} @@ -228,7 +236,10 @@ export function GitHubIssuesView() { confirmText="Re-validate" onConfirm={() => { if (selectedIssue) { - handleValidateIssue(selectedIssue, { forceRevalidate: true }); + handleValidateIssue(selectedIssue, { + forceRevalidate: true, + model: validationModelOverride.effectiveModel, + }); } }} /> diff --git a/apps/ui/src/components/views/github-issues-view/components/issue-detail-panel.tsx b/apps/ui/src/components/views/github-issues-view/components/issue-detail-panel.tsx index 7969da38..0d242d6f 100644 --- a/apps/ui/src/components/views/github-issues-view/components/issue-detail-panel.tsx +++ b/apps/ui/src/components/views/github-issues-view/components/issue-detail-panel.tsx @@ -16,6 +16,7 @@ import { Markdown } from '@/components/ui/markdown'; import { cn } from '@/lib/utils'; import type { IssueDetailPanelProps } from '../types'; import { isValidationStale } from '../utils'; +import { ModelOverrideTrigger } from '@/components/shared'; export function IssueDetailPanel({ issue, @@ -27,6 +28,7 @@ export function IssueDetailPanel({ onClose, onShowRevalidateConfirm, formatDate, + modelOverride, }: IssueDetailPanelProps) { const isValidating = validatingIssues.has(issue.number); const cached = cachedValidations.get(issue.number); @@ -83,10 +85,23 @@ export function IssueDetailPanel({ View (stale) + + <> + + + ); })()}