From eb94e4de72a838954d22ae60b97f30cf680c5f35 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sat, 10 Jan 2026 00:11:42 +0100 Subject: [PATCH 01/79] feat: enhance CodexUsageService to fetch usage data from app-server JSON-RPC API - Implemented a new method to retrieve usage data from the Codex app-server, providing real-time data and improving reliability. - Updated the fetchUsageData method to prioritize app-server data over fallback methods. - Added detailed logging for better traceability and debugging. - Removed unused methods related to OpenAI API usage and Codex CLI requests, streamlining the service. These changes enhance the functionality and robustness of the CodexUsageService, ensuring accurate usage statistics retrieval. --- .../src/services/codex-usage-service.ts | 535 +++++++++++------- 1 file changed, 334 insertions(+), 201 deletions(-) diff --git a/apps/server/src/services/codex-usage-service.ts b/apps/server/src/services/codex-usage-service.ts index bf8aff99..fc3490b6 100644 --- a/apps/server/src/services/codex-usage-service.ts +++ b/apps/server/src/services/codex-usage-service.ts @@ -1,6 +1,7 @@ +import { spawn, type ChildProcess } from 'child_process'; +import readline from 'readline'; import { findCodexCliPath, - spawnProcess, getCodexAuthPath, systemPathExists, systemPathReadFile, @@ -36,17 +37,51 @@ export interface CodexUsageData { lastUpdated: string; } +/** + * JSON-RPC response types from Codex app-server + */ +interface AppServerAccountResponse { + account: { + type: 'apiKey' | 'chatgpt'; + email?: string; + planType?: string; + } | null; + requiresOpenaiAuth: boolean; +} + +interface AppServerRateLimitsResponse { + rateLimits: { + primary: { + usedPercent: number; + windowDurationMins: number; + resetsAt: number; + } | null; + secondary: { + usedPercent: number; + windowDurationMins: number; + resetsAt: number; + } | null; + credits?: unknown; + planType?: string; // This is the most accurate/current plan type + }; +} + /** * Codex Usage Service * - * Attempts to fetch usage data from Codex CLI and OpenAI API. - * Codex CLI doesn't provide a direct usage command, but we can: - * 1. Parse usage info from error responses (rate limit errors contain plan info) - * 2. Check for OpenAI API usage if API key is available + * Fetches usage data from Codex CLI using the app-server JSON-RPC API. + * Falls back to auth file parsing if app-server is unavailable. */ export class CodexUsageService { private cachedCliPath: string | null = null; - + private accountPlanTypeArray: CodexPlanType[] = [ + 'free', + 'plus', + 'pro', + 'team', + 'enterprise', + 'edu', + ]; /** * Check if Codex CLI is available on the system */ @@ -58,60 +93,283 @@ export class CodexUsageService { /** * Attempt to fetch usage data * - * Tries multiple approaches: - * 1. Always try to get plan type from auth file first (authoritative source) - * 2. Check for OpenAI API key in environment for API usage - * 3. Make a test request to capture rate limit headers from CLI - * 4. Combine results from auth file and CLI + * Priority order: + * 1. Codex app-server JSON-RPC API (most reliable, provides real-time data) + * 2. Auth file JWT parsing (fallback for plan type) */ async fetchUsageData(): Promise { + logger.info('[fetchUsageData] Starting...'); const cliPath = this.cachedCliPath || (await findCodexCliPath()); if (!cliPath) { + logger.error('[fetchUsageData] Codex CLI not found'); throw new Error('Codex CLI not found. Please install it with: npm install -g @openai/codex'); } - // Always try to get plan type from auth file first - this is the authoritative source - const authPlanType = await this.getPlanTypeFromAuthFile(); + logger.info(`[fetchUsageData] Using CLI path: ${cliPath}`); - // Check if user has an API key that we can use - const hasApiKey = !!process.env.OPENAI_API_KEY; - - if (hasApiKey) { - // Try to get usage from OpenAI API - const openaiUsage = await this.fetchOpenAIUsage(); - if (openaiUsage) { - // Merge with auth file plan type if available - if (authPlanType && openaiUsage.rateLimits) { - openaiUsage.rateLimits.planType = authPlanType; - } - return openaiUsage; - } + // Try to get usage from Codex app-server (most reliable method) + const appServerUsage = await this.fetchFromAppServer(cliPath); + if (appServerUsage) { + logger.info( + '[fetchUsageData] Got data from app-server:', + JSON.stringify(appServerUsage, null, 2) + ); + return appServerUsage; } - // Try to get usage from Codex CLI by making a simple request - const codexUsage = await this.fetchCodexUsage(cliPath, authPlanType); - if (codexUsage) { - return codexUsage; - } + logger.info('[fetchUsageData] App-server failed, trying auth file fallback...'); - // Fallback: try to parse full usage from auth file + // Fallback: try to parse usage from auth file const authUsage = await this.fetchFromAuthFile(); if (authUsage) { + logger.info('[fetchUsageData] Got data from auth file:', JSON.stringify(authUsage, null, 2)); return authUsage; } - // If all else fails, return a message with helpful information - throw new Error( - 'Codex usage statistics require additional configuration. ' + - 'To enable usage tracking:\n\n' + - '1. Set your OpenAI API key in the environment:\n' + - ' export OPENAI_API_KEY=sk-...\n\n' + - '2. Or check your usage at:\n' + - ' https://platform.openai.com/usage\n\n' + - 'Note: If using Codex CLI with ChatGPT OAuth authentication, ' + - 'usage data must be queried through your OpenAI account.' - ); + logger.info('[fetchUsageData] All methods failed, returning unknown'); + + // If all else fails, return unknown + return { + rateLimits: { + planType: 'unknown', + credits: { + hasCredits: true, + }, + }, + lastUpdated: new Date().toISOString(), + }; + } + + /** + * Fetch usage data from Codex app-server using JSON-RPC API + * This is the most reliable method as it gets real-time data from OpenAI + */ + private async fetchFromAppServer(cliPath: string): Promise { + let childProcess: ChildProcess | null = null; + + try { + // On Windows, .cmd files must be run through shell + const needsShell = process.platform === 'win32' && cliPath.toLowerCase().endsWith('.cmd'); + + childProcess = spawn(cliPath, ['app-server'], { + cwd: process.cwd(), + env: { + ...process.env, + TERM: 'dumb', + }, + stdio: ['pipe', 'pipe', 'pipe'], + shell: needsShell, + }); + + if (!childProcess.stdin || !childProcess.stdout) { + throw new Error('Failed to create stdio pipes'); + } + + // Setup readline for reading JSONL responses + const rl = readline.createInterface({ + input: childProcess.stdout, + crlfDelay: Infinity, + }); + + // Message ID counter for JSON-RPC + let messageId = 0; + const pendingRequests = new Map< + number, + { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; + } + >(); + + // Process incoming messages + rl.on('line', (line) => { + if (!line.trim()) return; + + try { + const message = JSON.parse(line); + + // Handle response to our request + if ('id' in message && message.id !== undefined) { + const pending = pendingRequests.get(message.id); + if (pending) { + clearTimeout(pending.timeout); + pendingRequests.delete(message.id); + if (message.error) { + pending.reject(new Error(message.error.message || 'Unknown error')); + } else { + pending.resolve(message.result); + } + } + } + // Ignore notifications (no id field) + } catch { + // Ignore parse errors for non-JSON lines + } + }); + + // Helper to send JSON-RPC request and wait for response + const sendRequest = (method: string, params?: unknown): Promise => { + return new Promise((resolve, reject) => { + const id = ++messageId; + const request = params ? { method, id, params } : { method, id }; + + // Set timeout for request + const timeout = setTimeout(() => { + pendingRequests.delete(id); + reject(new Error(`Request timeout: ${method}`)); + }, 10000); + + pendingRequests.set(id, { + resolve: resolve as (value: unknown) => void, + reject, + timeout, + }); + + childProcess!.stdin!.write(JSON.stringify(request) + '\n'); + }); + }; + + // Helper to send notification (no response expected) + const sendNotification = (method: string, params?: unknown): void => { + const notification = params ? { method, params } : { method }; + childProcess!.stdin!.write(JSON.stringify(notification) + '\n'); + }; + + // 1. Initialize the app-server + logger.info('[fetchFromAppServer] Sending initialize request...'); + const initResult = await sendRequest('initialize', { + clientInfo: { + name: 'automaker', + title: 'AutoMaker', + version: '1.0.0', + }, + }); + logger.info('[fetchFromAppServer] Initialize result:', JSON.stringify(initResult, null, 2)); + + // 2. Send initialized notification + sendNotification('initialized'); + logger.info('[fetchFromAppServer] Sent initialized notification'); + + // 3. Get account info (includes plan type) + logger.info('[fetchFromAppServer] Requesting account/read...'); + const accountResult = await sendRequest('account/read', { + refreshToken: false, + }); + logger.info('[fetchFromAppServer] Account result:', JSON.stringify(accountResult, null, 2)); + + // 4. Get rate limits + let rateLimitsResult: AppServerRateLimitsResponse | null = null; + try { + logger.info('[fetchFromAppServer] Requesting account/rateLimits/read...'); + rateLimitsResult = + await sendRequest('account/rateLimits/read'); + logger.info( + '[fetchFromAppServer] Rate limits result:', + JSON.stringify(rateLimitsResult, null, 2) + ); + } catch (rateLimitError) { + // Rate limits may not be available for API key auth + logger.info('[fetchFromAppServer] Rate limits not available:', rateLimitError); + } + + // Clean up + rl.close(); + childProcess.kill('SIGTERM'); + + // Build response + // Prefer planType from rateLimits (more accurate/current) over account (can be stale) + let planType: CodexPlanType = 'unknown'; + + // First try rate limits planType (most accurate) + const rateLimitsPlanType = rateLimitsResult?.rateLimits?.planType; + if (rateLimitsPlanType) { + const normalizedType = rateLimitsPlanType.toLowerCase() as CodexPlanType; + logger.info( + `[fetchFromAppServer] Rate limits planType: "${rateLimitsPlanType}", normalized: "${normalizedType}"` + ); + if (this.accountPlanTypeArray.includes(normalizedType)) { + planType = normalizedType; + } + } + + // Fall back to account planType if rate limits didn't have it + if (planType === 'unknown' && accountResult.account?.planType) { + const normalizedType = accountResult.account.planType.toLowerCase() as CodexPlanType; + logger.info( + `[fetchFromAppServer] Fallback to account planType: "${accountResult.account.planType}", normalized: "${normalizedType}"` + ); + if (this.accountPlanTypeArray.includes(normalizedType)) { + planType = normalizedType; + } + } + + if (planType === 'unknown') { + logger.info('[fetchFromAppServer] No planType found in either response'); + } else { + logger.info(`[fetchFromAppServer] Final planType: ${planType}`); + } + + const result: CodexUsageData = { + rateLimits: { + planType, + credits: { + hasCredits: true, + unlimited: planType !== 'free' && planType !== 'unknown', + }, + }, + lastUpdated: new Date().toISOString(), + }; + + // Add rate limit info if available + if (rateLimitsResult?.rateLimits?.primary) { + const primary = rateLimitsResult.rateLimits.primary; + logger.info( + '[fetchFromAppServer] Adding primary rate limit:', + JSON.stringify(primary, null, 2) + ); + result.rateLimits!.primary = { + limit: 100, // Not provided by API, using placeholder + used: primary.usedPercent, + remaining: 100 - primary.usedPercent, + usedPercent: primary.usedPercent, + windowDurationMins: primary.windowDurationMins, + resetsAt: primary.resetsAt, + }; + } else { + logger.info('[fetchFromAppServer] No primary rate limit in result'); + } + + // Add secondary rate limit if available + if (rateLimitsResult?.rateLimits?.secondary) { + const secondary = rateLimitsResult.rateLimits.secondary; + logger.info( + '[fetchFromAppServer] Adding secondary rate limit:', + JSON.stringify(secondary, null, 2) + ); + result.rateLimits!.secondary = { + limit: 100, + used: secondary.usedPercent, + remaining: 100 - secondary.usedPercent, + usedPercent: secondary.usedPercent, + windowDurationMins: secondary.windowDurationMins, + resetsAt: secondary.resetsAt, + }; + } + + logger.info('[fetchFromAppServer] Final result:', JSON.stringify(result, null, 2)); + return result; + } catch (error) { + // App-server method failed, will fall back to other methods + logger.error('Failed to fetch from app-server:', error); + return null; + } finally { + // Ensure process is killed + if (childProcess && !childProcess.killed) { + childProcess.kill('SIGTERM'); + } + } } /** @@ -121,9 +379,11 @@ export class CodexUsageService { private async getPlanTypeFromAuthFile(): Promise { try { const authFilePath = getCodexAuthPath(); - const exists = await systemPathExists(authFilePath); + logger.info(`[getPlanTypeFromAuthFile] Auth file path: ${authFilePath}`); + const exists = systemPathExists(authFilePath); if (!exists) { + logger.info('[getPlanTypeFromAuthFile] Auth file does not exist'); return 'unknown'; } @@ -131,16 +391,24 @@ export class CodexUsageService { const authData = JSON.parse(authContent); if (!authData.tokens?.id_token) { + logger.info('[getPlanTypeFromAuthFile] No id_token in auth file'); return 'unknown'; } const claims = this.parseJwt(authData.tokens.id_token); if (!claims) { + logger.info('[getPlanTypeFromAuthFile] Failed to parse JWT'); return 'unknown'; } + logger.info('[getPlanTypeFromAuthFile] JWT claims keys:', Object.keys(claims)); + // Extract plan type from nested OpenAI auth object with type validation const openaiAuthClaim = claims['https://api.openai.com/auth']; + logger.info( + '[getPlanTypeFromAuthFile] OpenAI auth claim:', + JSON.stringify(openaiAuthClaim, null, 2) + ); let accountType: string | undefined; let isSubscriptionExpired = false; @@ -188,154 +456,23 @@ export class CodexUsageService { } if (accountType) { - const normalizedType = accountType.toLowerCase(); - if (['free', 'plus', 'pro', 'team', 'enterprise', 'edu'].includes(normalizedType)) { - return normalizedType as CodexPlanType; - } - } - } catch (error) { - logger.error('Failed to get plan type from auth file:', error); - } - - return 'unknown'; - } - - /** - * Try to fetch usage from OpenAI API using the API key - */ - private async fetchOpenAIUsage(): Promise { - const apiKey = process.env.OPENAI_API_KEY; - if (!apiKey) { - return null; - } - - try { - const endTime = Math.floor(Date.now() / 1000); - const startTime = endTime - 7 * 24 * 60 * 60; // Last 7 days - - const response = await fetch( - `https://api.openai.com/v1/organization/usage/completions?start_time=${startTime}&end_time=${endTime}&limit=1`, - { - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - } - ); - - if (response.ok) { - const data = await response.json(); - return this.parseOpenAIUsage(data); - } - } catch (error) { - logger.error('Failed to fetch from OpenAI API:', error); - } - - return null; - } - - /** - * Parse OpenAI usage API response - */ - private parseOpenAIUsage(data: any): CodexUsageData { - let totalInputTokens = 0; - let totalOutputTokens = 0; - - if (data.data && Array.isArray(data.data)) { - for (const bucket of data.data) { - if (bucket.results && Array.isArray(bucket.results)) { - for (const result of bucket.results) { - totalInputTokens += result.input_tokens || 0; - totalOutputTokens += result.output_tokens || 0; - } - } - } - } - - return { - rateLimits: { - planType: 'unknown', - credits: { - hasCredits: true, - }, - }, - lastUpdated: new Date().toISOString(), - }; - } - - /** - * Try to fetch usage by making a test request to Codex CLI - * and parsing rate limit information from the response - */ - private async fetchCodexUsage( - cliPath: string, - authPlanType: CodexPlanType - ): Promise { - try { - // Make a simple request to trigger rate limit info if at limit - const result = await spawnProcess({ - command: cliPath, - args: ['exec', '--', 'echo', 'test'], - cwd: process.cwd(), - env: { - ...process.env, - TERM: 'dumb', - }, - timeout: 10000, - }); - - // Parse the output for rate limit information - const combinedOutput = (result.stdout + result.stderr).toLowerCase(); - - // Check if we got a rate limit error - const rateLimitMatch = combinedOutput.match( - /usage_limit_reached.*?"plan_type":"([^"]+)".*?"resets_at":(\d+).*?"resets_in_seconds":(\d+)/ - ); - - if (rateLimitMatch) { - // Rate limit error contains the plan type - use that as it's the most authoritative - const planType = rateLimitMatch[1] as CodexPlanType; - const resetsAt = parseInt(rateLimitMatch[2], 10); - const resetsInSeconds = parseInt(rateLimitMatch[3], 10); - + const normalizedType = accountType.toLowerCase() as CodexPlanType; logger.info( - `Rate limit hit - plan: ${planType}, resets in ${Math.ceil(resetsInSeconds / 60)} mins` + `[getPlanTypeFromAuthFile] Account type: "${accountType}", normalized: "${normalizedType}"` ); - - return { - rateLimits: { - planType, - primary: { - limit: 0, - used: 0, - remaining: 0, - usedPercent: 100, - windowDurationMins: Math.ceil(resetsInSeconds / 60), - resetsAt, - }, - }, - lastUpdated: new Date().toISOString(), - }; + if (this.accountPlanTypeArray.includes(normalizedType)) { + logger.info(`[getPlanTypeFromAuthFile] Returning plan type: ${normalizedType}`); + return normalizedType; + } + } else { + logger.info('[getPlanTypeFromAuthFile] No account type found in claims'); } - - // No rate limit error - use the plan type from auth file - const isFreePlan = authPlanType === 'free'; - - return { - rateLimits: { - planType: authPlanType, - credits: { - hasCredits: true, - unlimited: !isFreePlan && authPlanType !== 'unknown', - }, - }, - lastUpdated: new Date().toISOString(), - }; } catch (error) { - logger.error('Failed to fetch from Codex CLI:', error); + logger.error('[getPlanTypeFromAuthFile] Failed to get plan type from auth file:', error); } - return null; + logger.info('[getPlanTypeFromAuthFile] Returning unknown'); + return 'unknown'; } /** @@ -343,16 +480,19 @@ export class CodexUsageService { * Reuses getPlanTypeFromAuthFile to avoid code duplication */ private async fetchFromAuthFile(): Promise { + logger.info('[fetchFromAuthFile] Starting...'); try { const planType = await this.getPlanTypeFromAuthFile(); + logger.info(`[fetchFromAuthFile] Got plan type: ${planType}`); if (planType === 'unknown') { + logger.info('[fetchFromAuthFile] Plan type unknown, returning null'); return null; } const isFreePlan = planType === 'free'; - return { + const result: CodexUsageData = { rateLimits: { planType, credits: { @@ -362,8 +502,11 @@ export class CodexUsageService { }, lastUpdated: new Date().toISOString(), }; + + logger.info('[fetchFromAuthFile] Returning result:', JSON.stringify(result, null, 2)); + return result; } catch (error) { - logger.error('Failed to parse auth file:', error); + logger.error('[fetchFromAuthFile] Failed to parse auth file:', error); } return null; @@ -372,7 +515,7 @@ export class CodexUsageService { /** * Parse JWT token to extract claims */ - private parseJwt(token: string): any { + private parseJwt(token: string): Record | null { try { const parts = token.split('.'); @@ -383,18 +526,8 @@ export class CodexUsageService { const base64Url = parts[1]; const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); - // Use Buffer for Node.js environment instead of atob - let jsonPayload: string; - if (typeof Buffer !== 'undefined') { - jsonPayload = Buffer.from(base64, 'base64').toString('utf-8'); - } else { - jsonPayload = decodeURIComponent( - atob(base64) - .split('') - .map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) - .join('') - ); - } + // Use Buffer for Node.js environment + const jsonPayload = Buffer.from(base64, 'base64').toString('utf-8'); return JSON.parse(jsonPayload); } catch { From fc20dd5ad43e492e33fe192c755772d0f38ce992 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Fri, 9 Jan 2026 19:21:30 -0500 Subject: [PATCH 02/79] refactor: remove AI profile functionality and related components - Deleted the AI profile management feature, including all associated views, hooks, and types. - Updated settings and navigation components to remove references to AI profiles. - Adjusted local storage and settings synchronization logic to reflect the removal of AI profiles. - Cleaned up tests and utility functions that were dependent on the AI profile feature. These changes streamline the application by eliminating unused functionality, improving maintainability and reducing complexity. --- apps/server/src/services/settings-service.ts | 5 - apps/server/src/types/settings.ts | 1 - apps/ui/scripts/setup-e2e-fixtures.mjs | 46 -- apps/ui/src/components/layout/sidebar.tsx | 3 +- .../components/layout/sidebar/constants.ts | 1 - .../layout/sidebar/hooks/use-navigation.ts | 15 - apps/ui/src/components/ui/keyboard-map.tsx | 4 - apps/ui/src/components/views/board-view.tsx | 8 - .../board-view/dialogs/add-feature-dialog.tsx | 95 +-- .../dialogs/edit-feature-dialog.tsx | 85 +-- .../board-view/dialogs/mass-edit-dialog.tsx | 57 +- .../views/board-view/shared/index.ts | 3 - .../shared/profile-quick-select.tsx | 155 ----- .../board-view/shared/profile-select.tsx | 187 ------ .../board-view/shared/profile-typeahead.tsx | 237 -------- .../components/views/github-issues-view.tsx | 18 +- .../ui/src/components/views/profiles-view.tsx | 275 --------- .../views/profiles-view/components/index.ts | 3 - .../profiles-view/components/profile-form.tsx | 560 ------------------ .../components/profiles-header.tsx | 55 -- .../components/sortable-profile-card.tsx | 142 ----- .../views/profiles-view/constants.ts | 44 -- .../components/views/profiles-view/utils.ts | 48 -- .../ui/src/components/views/settings-view.tsx | 10 - .../feature-defaults-section.tsx | 82 --- apps/ui/src/components/views/wiki-view.tsx | 20 - apps/ui/src/hooks/use-settings-migration.ts | 44 +- apps/ui/src/hooks/use-settings-sync.ts | 6 - apps/ui/src/lib/http-api-client.ts | 3 - apps/ui/src/routes/profiles.tsx | 6 - apps/ui/src/store/app-store.ts | 120 +--- apps/ui/tests/profiles/profiles-crud.spec.ts | 59 -- apps/ui/tests/utils/core/constants.ts | 14 - apps/ui/tests/utils/git/worktree.ts | 3 - apps/ui/tests/utils/index.ts | 1 - apps/ui/tests/utils/project/setup.ts | 124 ---- apps/ui/tests/utils/views/profiles.ts | 522 ---------------- docs/settings-api-migration.md | 5 +- libs/types/src/index.ts | 3 - libs/types/src/settings.ts | 109 +--- 40 files changed, 38 insertions(+), 3140 deletions(-) delete mode 100644 apps/ui/src/components/views/board-view/shared/profile-quick-select.tsx delete mode 100644 apps/ui/src/components/views/board-view/shared/profile-select.tsx delete mode 100644 apps/ui/src/components/views/board-view/shared/profile-typeahead.tsx delete mode 100644 apps/ui/src/components/views/profiles-view.tsx delete mode 100644 apps/ui/src/components/views/profiles-view/components/index.ts delete mode 100644 apps/ui/src/components/views/profiles-view/components/profile-form.tsx delete mode 100644 apps/ui/src/components/views/profiles-view/components/profiles-header.tsx delete mode 100644 apps/ui/src/components/views/profiles-view/components/sortable-profile-card.tsx delete mode 100644 apps/ui/src/components/views/profiles-view/constants.ts delete mode 100644 apps/ui/src/components/views/profiles-view/utils.ts delete mode 100644 apps/ui/src/routes/profiles.tsx delete mode 100644 apps/ui/tests/profiles/profiles-crud.spec.ts delete mode 100644 apps/ui/tests/utils/views/profiles.ts diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 7acd2ed1..9270b3fb 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -22,7 +22,6 @@ import type { Credentials, ProjectSettings, KeyboardShortcuts, - AIProfile, ProjectRef, TrashedProjectRef, BoardBackgroundSettings, @@ -299,7 +298,6 @@ export class SettingsService { ignoreEmptyArrayOverwrite('trashedProjects'); ignoreEmptyArrayOverwrite('projectHistory'); ignoreEmptyArrayOverwrite('recentFolders'); - ignoreEmptyArrayOverwrite('aiProfiles'); ignoreEmptyArrayOverwrite('mcpServers'); ignoreEmptyArrayOverwrite('enabledCursorModels'); @@ -617,18 +615,15 @@ export class SettingsService { : false, useWorktrees: appState.useWorktrees !== undefined ? (appState.useWorktrees as boolean) : true, - showProfilesOnly: (appState.showProfilesOnly as boolean) || false, defaultPlanningMode: (appState.defaultPlanningMode as GlobalSettings['defaultPlanningMode']) || 'skip', defaultRequirePlanApproval: (appState.defaultRequirePlanApproval as boolean) || false, - defaultAIProfileId: (appState.defaultAIProfileId as string | null) || null, muteDoneSound: (appState.muteDoneSound as boolean) || false, enhancementModel: (appState.enhancementModel as GlobalSettings['enhancementModel']) || 'sonnet', keyboardShortcuts: (appState.keyboardShortcuts as KeyboardShortcuts) || DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts, - aiProfiles: (appState.aiProfiles as AIProfile[]) || [], projects: (appState.projects as ProjectRef[]) || [], trashedProjects: (appState.trashedProjects as TrashedProjectRef[]) || [], projectHistory: (appState.projectHistory as string[]) || [], diff --git a/apps/server/src/types/settings.ts b/apps/server/src/types/settings.ts index a92e706e..e785f8ea 100644 --- a/apps/server/src/types/settings.ts +++ b/apps/server/src/types/settings.ts @@ -13,7 +13,6 @@ export type { ThinkingLevel, ModelProvider, KeyboardShortcuts, - AIProfile, ProjectRef, TrashedProjectRef, ChatSessionRef, diff --git a/apps/ui/scripts/setup-e2e-fixtures.mjs b/apps/ui/scripts/setup-e2e-fixtures.mjs index e6009fd4..26488791 100644 --- a/apps/ui/scripts/setup-e2e-fixtures.mjs +++ b/apps/ui/scripts/setup-e2e-fixtures.mjs @@ -47,10 +47,8 @@ const E2E_SETTINGS = { enableDependencyBlocking: true, skipVerificationInAutoMode: false, useWorktrees: true, - showProfilesOnly: false, defaultPlanningMode: 'skip', defaultRequirePlanApproval: false, - defaultAIProfileId: null, muteDoneSound: false, phaseModels: { enhancementModel: { model: 'sonnet' }, @@ -73,7 +71,6 @@ const E2E_SETTINGS = { spec: 'D', context: 'C', settings: 'S', - profiles: 'M', terminal: 'T', toggleSidebar: '`', addFeature: 'N', @@ -84,7 +81,6 @@ const E2E_SETTINGS = { projectPicker: 'P', cyclePrevProject: 'Q', cycleNextProject: 'E', - addProfile: 'N', splitTerminalRight: 'Alt+D', splitTerminalDown: 'Alt+S', closeTerminal: 'Alt+W', @@ -94,48 +90,6 @@ const E2E_SETTINGS = { githubPrs: 'R', newTerminalTab: 'Alt+T', }, - aiProfiles: [ - { - id: 'profile-heavy-task', - name: 'Heavy Task', - description: - 'Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.', - model: 'opus', - thinkingLevel: 'ultrathink', - provider: 'claude', - isBuiltIn: true, - icon: 'Brain', - }, - { - id: 'profile-balanced', - name: 'Balanced', - description: 'Claude Sonnet with medium thinking for typical development tasks.', - model: 'sonnet', - thinkingLevel: 'medium', - provider: 'claude', - isBuiltIn: true, - icon: 'Scale', - }, - { - id: 'profile-quick-edit', - name: 'Quick Edit', - description: 'Claude Haiku for fast, simple edits and minor fixes.', - model: 'haiku', - thinkingLevel: 'none', - provider: 'claude', - isBuiltIn: true, - icon: 'Zap', - }, - { - id: 'profile-cursor-refactoring', - name: 'Cursor Refactoring', - description: 'Cursor Composer 1 for refactoring tasks.', - provider: 'cursor', - cursorModel: 'composer-1', - isBuiltIn: true, - icon: 'Sparkles', - }, - ], // Default test project using the fixture path - tests can override via route mocking if needed projects: [ { diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index a1d03e87..98535bda 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -59,7 +59,7 @@ export function Sidebar() { } = useAppStore(); // Environment variable flags for hiding sidebar items - const { hideTerminal, hideWiki, hideRunningAgents, hideContext, hideSpecEditor, hideAiProfiles } = + const { hideTerminal, hideWiki, hideRunningAgents, hideContext, hideSpecEditor } = SIDEBAR_FEATURE_FLAGS; // Get customizable keyboard shortcuts @@ -232,7 +232,6 @@ export function Sidebar() { hideSpecEditor, hideContext, hideTerminal, - hideAiProfiles, currentProject, projects, projectHistory, diff --git a/apps/ui/src/components/layout/sidebar/constants.ts b/apps/ui/src/components/layout/sidebar/constants.ts index 4beca953..0ca50172 100644 --- a/apps/ui/src/components/layout/sidebar/constants.ts +++ b/apps/ui/src/components/layout/sidebar/constants.ts @@ -20,5 +20,4 @@ export const SIDEBAR_FEATURE_FLAGS = { hideRunningAgents: import.meta.env.VITE_HIDE_RUNNING_AGENTS === 'true', hideContext: import.meta.env.VITE_HIDE_CONTEXT === 'true', hideSpecEditor: import.meta.env.VITE_HIDE_SPEC_EDITOR === 'true', - hideAiProfiles: import.meta.env.VITE_HIDE_AI_PROFILES === 'true', } as const; diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts index 350bd2f8..9ea163d5 100644 --- a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts +++ b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts @@ -5,11 +5,9 @@ import { LayoutGrid, Bot, BookOpen, - UserCircle, Terminal, CircleDot, GitPullRequest, - Zap, Lightbulb, } from 'lucide-react'; import type { NavSection, NavItem } from '../types'; @@ -26,7 +24,6 @@ interface UseNavigationProps { cycleNextProject: string; spec: string; context: string; - profiles: string; board: string; agent: string; terminal: string; @@ -38,7 +35,6 @@ interface UseNavigationProps { hideSpecEditor: boolean; hideContext: boolean; hideTerminal: boolean; - hideAiProfiles: boolean; currentProject: Project | null; projects: Project[]; projectHistory: string[]; @@ -57,7 +53,6 @@ export function useNavigation({ hideSpecEditor, hideContext, hideTerminal, - hideAiProfiles, currentProject, projects, projectHistory, @@ -114,12 +109,6 @@ export function useNavigation({ icon: BookOpen, shortcut: shortcuts.context, }, - { - id: 'profiles', - label: 'AI Profiles', - icon: UserCircle, - shortcut: shortcuts.profiles, - }, ]; // Filter out hidden items @@ -130,9 +119,6 @@ export function useNavigation({ if (item.id === 'context' && hideContext) { return false; } - if (item.id === 'profiles' && hideAiProfiles) { - return false; - } return true; }); @@ -201,7 +187,6 @@ export function useNavigation({ hideSpecEditor, hideContext, hideTerminal, - hideAiProfiles, hasGitHubRemote, unviewedValidationsCount, ]); diff --git a/apps/ui/src/components/ui/keyboard-map.tsx b/apps/ui/src/components/ui/keyboard-map.tsx index 2e00c1e2..0b2fe541 100644 --- a/apps/ui/src/components/ui/keyboard-map.tsx +++ b/apps/ui/src/components/ui/keyboard-map.tsx @@ -88,7 +88,6 @@ const SHORTCUT_LABELS: Record = { spec: 'Spec Editor', context: 'Context', settings: 'Settings', - profiles: 'AI Profiles', terminal: 'Terminal', ideation: 'Ideation', githubIssues: 'GitHub Issues', @@ -102,7 +101,6 @@ const SHORTCUT_LABELS: Record = { projectPicker: 'Project Picker', cyclePrevProject: 'Prev Project', cycleNextProject: 'Next Project', - addProfile: 'Add Profile', splitTerminalRight: 'Split Right', splitTerminalDown: 'Split Down', closeTerminal: 'Close Terminal', @@ -116,7 +114,6 @@ const SHORTCUT_CATEGORIES: Record setShowMassEditDialog(false)} selectedFeatures={selectedFeatures} onApply={handleBulkUpdate} - showProfilesOnly={showProfilesOnly} - aiProfiles={aiProfiles} /> {/* Board Background Modal */} @@ -1348,8 +1344,6 @@ export function BoardView() { defaultBranch={selectedWorktreeBranch} currentBranch={currentWorktreeBranch || undefined} isMaximized={isMaximized} - showProfilesOnly={showProfilesOnly} - aiProfiles={aiProfiles} parentFeature={spawnParentFeature} allFeatures={hookFeatures} /> @@ -1364,8 +1358,6 @@ export function BoardView() { branchCardCounts={branchCardCounts} currentBranch={currentWorktreeBranch || undefined} isMaximized={isMaximized} - showProfilesOnly={showProfilesOnly} - aiProfiles={aiProfiles} allFeatures={hookFeatures} /> 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 bae7ce50..bab34522 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 @@ -32,24 +32,17 @@ import { ModelAlias, ThinkingLevel, FeatureImage, - AIProfile, PlanningMode, Feature, } from '@/store/app-store'; import type { ReasoningEffort, PhaseModelEntry } from '@automaker/types'; -import { - supportsReasoningEffort, - PROVIDER_PREFIXES, - isCursorModel, - isClaudeModel, -} from '@automaker/types'; +import { supportsReasoningEffort, isClaudeModel } from '@automaker/types'; import { TestingTabContent, PrioritySelector, WorkModeSelector, PlanningModeSelect, AncestorContextSection, - ProfileTypeahead, } from '../shared'; import type { WorkMode } from '../shared'; import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector'; @@ -60,7 +53,6 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { useNavigate } from '@tanstack/react-router'; import { getAncestors, formatAncestorContextForPrompt, @@ -100,8 +92,6 @@ interface AddFeatureDialogProps { defaultBranch?: string; currentBranch?: string; isMaximized: boolean; - showProfilesOnly: boolean; - aiProfiles: AIProfile[]; parentFeature?: Feature | null; allFeatures?: Feature[]; } @@ -118,13 +108,10 @@ export function AddFeatureDialog({ defaultBranch = 'main', currentBranch, isMaximized, - showProfilesOnly, - aiProfiles, parentFeature = null, allFeatures = [], }: AddFeatureDialogProps) { const isSpawnMode = !!parentFeature; - const navigate = useNavigate(); const [workMode, setWorkMode] = useState('current'); // Form state @@ -139,7 +126,6 @@ export function AddFeatureDialog({ const [priority, setPriority] = useState(2); // Model selection state - const [selectedProfileId, setSelectedProfileId] = useState(); const [modelEntry, setModelEntry] = useState({ model: 'opus' }); // Check if current model supports planning mode (Claude/Anthropic only) @@ -163,7 +149,7 @@ export function AddFeatureDialog({ const [selectedAncestorIds, setSelectedAncestorIds] = useState>(new Set()); // Get defaults from store - const { defaultPlanningMode, defaultRequirePlanApproval, defaultAIProfileId } = useAppStore(); + const { defaultPlanningMode, defaultRequirePlanApproval } = useAppStore(); // Enhancement model override const enhancementOverride = useModelOverride({ phase: 'enhancementModel' }); @@ -177,24 +163,12 @@ export function AddFeatureDialog({ wasOpenRef.current = open; if (justOpened) { - const defaultProfile = defaultAIProfileId - ? aiProfiles.find((p) => p.id === defaultAIProfileId) - : null; - setSkipTests(defaultSkipTests); setBranchName(defaultBranch || ''); setWorkMode('current'); setPlanningMode(defaultPlanningMode); setRequirePlanApproval(defaultRequirePlanApproval); - - // Set model from default profile or fallback - if (defaultProfile) { - setSelectedProfileId(defaultProfile.id); - applyProfileToModel(defaultProfile); - } else { - setSelectedProfileId(undefined); - setModelEntry({ model: 'opus' }); - } + setModelEntry({ model: 'opus' }); // Initialize ancestors for spawn mode if (parentFeature) { @@ -212,41 +186,12 @@ export function AddFeatureDialog({ defaultBranch, defaultPlanningMode, defaultRequirePlanApproval, - defaultAIProfileId, - aiProfiles, parentFeature, allFeatures, ]); - const applyProfileToModel = (profile: AIProfile) => { - if (profile.provider === 'cursor') { - const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`; - setModelEntry({ model: cursorModel as ModelAlias }); - } else if (profile.provider === 'codex') { - setModelEntry({ - model: profile.codexModel || 'codex-gpt-5.2-codex', - reasoningEffort: 'none', - }); - } else if (profile.provider === 'opencode') { - setModelEntry({ model: profile.opencodeModel || 'opencode/big-pickle' }); - } else { - // Claude - setModelEntry({ - model: profile.model || 'sonnet', - thinkingLevel: profile.thinkingLevel || 'none', - }); - } - }; - - const handleProfileSelect = (profile: AIProfile) => { - setSelectedProfileId(profile.id); - applyProfileToModel(profile); - }; - const handleModelChange = (entry: PhaseModelEntry) => { setModelEntry(entry); - // Clear profile selection when manually changing model - setSelectedProfileId(undefined); }; const buildFeatureData = (): FeatureData | null => { @@ -327,7 +272,6 @@ export function AddFeatureDialog({ setSkipTests(defaultSkipTests); setBranchName(''); setPriority(2); - setSelectedProfileId(undefined); setModelEntry({ model: 'opus' }); setWorkMode('current'); setPlanningMode(defaultPlanningMode); @@ -538,31 +482,14 @@ export function AddFeatureDialog({ AI & Execution -
-
- - { - onOpenChange(false); - navigate({ to: '/profiles' }); - }} - testIdPrefix="add-feature-profile" - /> -
-
- - -
+
+ +
; // Map of branch name to unarchived card count currentBranch?: string; isMaximized: boolean; - showProfilesOnly: boolean; - aiProfiles: AIProfile[]; allFeatures: Feature[]; } @@ -113,11 +97,8 @@ export function EditFeatureDialog({ branchCardCounts, currentBranch, isMaximized, - showProfilesOnly, - aiProfiles, allFeatures, }: EditFeatureDialogProps) { - const navigate = useNavigate(); const [editingFeature, setEditingFeature] = useState(feature); // Derive initial workMode from feature's branchName const [workMode, setWorkMode] = useState(() => { @@ -140,7 +121,6 @@ export function EditFeatureDialog({ ); // Model selection state - const [selectedProfileId, setSelectedProfileId] = useState(); const [modelEntry, setModelEntry] = useState(() => ({ model: (feature?.model as ModelAlias) || 'opus', thinkingLevel: feature?.thinkingLevel || 'none', @@ -180,7 +160,6 @@ export function EditFeatureDialog({ thinkingLevel: feature.thinkingLevel || 'none', reasoningEffort: feature.reasoningEffort || 'none', }); - setSelectedProfileId(undefined); } else { setEditFeaturePreviewMap(new Map()); setDescriptionChangeSource(null); @@ -188,35 +167,8 @@ export function EditFeatureDialog({ } }, [feature]); - const applyProfileToModel = (profile: AIProfile) => { - if (profile.provider === 'cursor') { - const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`; - setModelEntry({ model: cursorModel as ModelAlias }); - } else if (profile.provider === 'codex') { - setModelEntry({ - model: profile.codexModel || 'codex-gpt-5.2-codex', - reasoningEffort: 'none', - }); - } else if (profile.provider === 'opencode') { - setModelEntry({ model: profile.opencodeModel || 'opencode/big-pickle' }); - } else { - // Claude - setModelEntry({ - model: profile.model || 'sonnet', - thinkingLevel: profile.thinkingLevel || 'none', - }); - } - }; - - const handleProfileSelect = (profile: AIProfile) => { - setSelectedProfileId(profile.id); - applyProfileToModel(profile); - }; - const handleModelChange = (entry: PhaseModelEntry) => { setModelEntry(entry); - // Clear profile selection when manually changing model - setSelectedProfileId(undefined); }; const handleUpdate = () => { @@ -554,31 +506,14 @@ export function EditFeatureDialog({ AI & Execution
-
-
- - { - onClose(); - navigate({ to: '/profiles' }); - }} - testIdPrefix="edit-feature-profile" - /> -
-
- - -
+
+ +
void; selectedFeatures: Feature[]; onApply: (updates: Partial) => Promise; - showProfilesOnly: boolean; - aiProfiles: AIProfile[]; } interface ApplyState { @@ -98,14 +96,7 @@ function FieldWrapper({ label, isMixed, willApply, onApplyChange, children }: Fi ); } -export function MassEditDialog({ - open, - onClose, - selectedFeatures, - onApply, - showProfilesOnly, - aiProfiles, -}: MassEditDialogProps) { +export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: MassEditDialogProps) { const [isApplying, setIsApplying] = useState(false); // Track which fields to apply @@ -149,26 +140,6 @@ export function MassEditDialog({ } }, [open, selectedFeatures]); - const handleModelSelect = (newModel: string) => { - const isCursor = isCursorModel(newModel); - setModel(newModel as ModelAlias); - if (isCursor || !modelSupportsThinking(newModel)) { - setThinkingLevel('none'); - } - }; - - const handleProfileSelect = (profile: AIProfile) => { - if (profile.provider === 'cursor') { - const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`; - setModel(cursorModel as ModelAlias); - setThinkingLevel('none'); - } else { - setModel((profile.model || 'sonnet') as ModelAlias); - setThinkingLevel(profile.thinkingLevel || 'none'); - } - setApplyState((prev) => ({ ...prev, model: true, thinkingLevel: true })); - }; - const handleApply = async () => { const updates: Partial = {}; @@ -208,29 +179,11 @@ export function MassEditDialog({
- {/* Quick Select Profile Section */} - {aiProfiles.length > 0 && ( -
- -

- Selecting a profile will automatically enable model settings -

- -
- )} - {/* Model Selector */}

- Or select a specific model configuration + Select a specific model configuration

void; // Changed to pass full profile - testIdPrefix?: string; - showManageLink?: boolean; - onManageLinkClick?: () => void; -} - -export function ProfileQuickSelect({ - profiles, - selectedModel, - selectedThinkingLevel, - selectedCursorModel, - onSelect, - testIdPrefix = 'profile-quick-select', - showManageLink = false, - onManageLinkClick, -}: ProfileQuickSelectProps) { - // Show both Claude and Cursor profiles - const allProfiles = profiles; - - if (allProfiles.length === 0) { - return null; - } - - // Check if a profile is selected - const isProfileSelected = (profile: AIProfile): boolean => { - if (profile.provider === 'cursor') { - // For cursor profiles, check if cursor model matches - const profileCursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`; - return selectedCursorModel === profileCursorModel; - } - // For Claude profiles - return selectedModel === profile.model && selectedThinkingLevel === profile.thinkingLevel; - }; - - return ( -
-
- - - Presets - -
-
- {allProfiles.slice(0, 6).map((profile) => { - const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain; - const isSelected = isProfileSelected(profile); - const isCursorProfile = profile.provider === 'cursor'; - - return ( - - ); - })} -
-

- Or customize below. - {showManageLink && onManageLinkClick && ( - <> - {' '} - Manage profiles in{' '} - - - )} -

-
- ); -} diff --git a/apps/ui/src/components/views/board-view/shared/profile-select.tsx b/apps/ui/src/components/views/board-view/shared/profile-select.tsx deleted file mode 100644 index c3c68a1c..00000000 --- a/apps/ui/src/components/views/board-view/shared/profile-select.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Brain, Terminal } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import type { ModelAlias, ThinkingLevel, AIProfile, CursorModelId } from '@automaker/types'; -import { - CURSOR_MODEL_MAP, - profileHasThinking, - PROVIDER_PREFIXES, - getCodexModelLabel, -} from '@automaker/types'; -import { PROFILE_ICONS } from './model-constants'; - -/** - * Get display string for a profile's model configuration - */ -function getProfileModelDisplay(profile: AIProfile): string { - if (profile.provider === 'cursor') { - const cursorModel = profile.cursorModel || 'auto'; - const modelConfig = CURSOR_MODEL_MAP[cursorModel]; - return modelConfig?.label || cursorModel; - } - if (profile.provider === 'codex') { - return getCodexModelLabel(profile.codexModel || 'codex-gpt-5.2-codex'); - } - // Claude - return profile.model || 'sonnet'; -} - -/** - * Get display string for a profile's thinking configuration - */ -function getProfileThinkingDisplay(profile: AIProfile): string | null { - if (profile.provider === 'cursor') { - // For Cursor, thinking is embedded in the model - return profileHasThinking(profile) ? 'thinking' : null; - } - if (profile.provider === 'codex') { - // For Codex, thinking is embedded in the model - return profileHasThinking(profile) ? 'thinking' : null; - } - // Claude - return profile.thinkingLevel && profile.thinkingLevel !== 'none' ? profile.thinkingLevel : null; -} - -interface ProfileSelectProps { - profiles: AIProfile[]; - selectedModel: ModelAlias | CursorModelId; - selectedThinkingLevel: ThinkingLevel; - selectedCursorModel?: string; // For detecting cursor profile selection - onSelect: (profile: AIProfile) => void; - testIdPrefix?: string; - className?: string; - disabled?: boolean; -} - -/** - * ProfileSelect - Compact dropdown selector for AI profiles - * - * A lightweight alternative to ProfileQuickSelect for contexts where - * space is limited (e.g., mass edit, bulk operations). - * - * Shows icon + profile name in dropdown, with model details below. - * - * @example - * ```tsx - * - * ``` - */ -export function ProfileSelect({ - profiles, - selectedModel, - selectedThinkingLevel, - selectedCursorModel, - onSelect, - testIdPrefix = 'profile-select', - className, - disabled = false, -}: ProfileSelectProps) { - if (profiles.length === 0) { - return null; - } - - // Check if a profile is selected - const isProfileSelected = (profile: AIProfile): boolean => { - if (profile.provider === 'cursor') { - // For cursor profiles, check if cursor model matches - const profileCursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`; - return selectedCursorModel === profileCursorModel; - } - // For Claude profiles - return selectedModel === profile.model && selectedThinkingLevel === profile.thinkingLevel; - }; - - const selectedProfile = profiles.find(isProfileSelected); - - return ( -
- - {selectedProfile && ( -

- {getProfileModelDisplay(selectedProfile)} - {getProfileThinkingDisplay(selectedProfile) && - ` + ${getProfileThinkingDisplay(selectedProfile)}`} -

- )} -
- ); -} diff --git a/apps/ui/src/components/views/board-view/shared/profile-typeahead.tsx b/apps/ui/src/components/views/board-view/shared/profile-typeahead.tsx deleted file mode 100644 index 4080676c..00000000 --- a/apps/ui/src/components/views/board-view/shared/profile-typeahead.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import * as React from 'react'; -import { Check, ChevronsUpDown, UserCircle, Settings2 } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import { Button } from '@/components/ui/button'; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, -} from '@/components/ui/command'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { Badge } from '@/components/ui/badge'; -import type { AIProfile } from '@automaker/types'; -import { CURSOR_MODEL_MAP, profileHasThinking, getCodexModelLabel } from '@automaker/types'; -import { PROVIDER_ICON_COMPONENTS } from '@/components/ui/provider-icon'; - -/** - * Get display string for a profile's model configuration - */ -function getProfileModelDisplay(profile: AIProfile): string { - if (profile.provider === 'cursor') { - const cursorModel = profile.cursorModel || 'auto'; - const modelConfig = CURSOR_MODEL_MAP[cursorModel]; - return modelConfig?.label || cursorModel; - } - if (profile.provider === 'codex') { - return getCodexModelLabel(profile.codexModel || 'codex-gpt-5.2-codex'); - } - if (profile.provider === 'opencode') { - // Extract a short label from the opencode model - const modelId = profile.opencodeModel || ''; - if (modelId.includes('/')) { - const parts = modelId.split('/'); - return parts[parts.length - 1].split('.')[0] || modelId; - } - return modelId; - } - // Claude - return profile.model || 'sonnet'; -} - -/** - * Get display string for a profile's thinking configuration - */ -function getProfileThinkingDisplay(profile: AIProfile): string | null { - if (profile.provider === 'cursor' || profile.provider === 'codex') { - return profileHasThinking(profile) ? 'thinking' : null; - } - // Claude - return profile.thinkingLevel && profile.thinkingLevel !== 'none' ? profile.thinkingLevel : null; -} - -interface ProfileTypeaheadProps { - profiles: AIProfile[]; - selectedProfileId?: string; - onSelect: (profile: AIProfile) => void; - placeholder?: string; - className?: string; - disabled?: boolean; - showManageLink?: boolean; - onManageLinkClick?: () => void; - testIdPrefix?: string; -} - -export function ProfileTypeahead({ - profiles, - selectedProfileId, - onSelect, - placeholder = 'Select profile...', - className, - disabled = false, - showManageLink = false, - onManageLinkClick, - testIdPrefix = 'profile-typeahead', -}: ProfileTypeaheadProps) { - const [open, setOpen] = React.useState(false); - const [inputValue, setInputValue] = React.useState(''); - const [triggerWidth, setTriggerWidth] = React.useState(0); - const triggerRef = React.useRef(null); - - const selectedProfile = React.useMemo( - () => profiles.find((p) => p.id === selectedProfileId), - [profiles, selectedProfileId] - ); - - // Update trigger width when component mounts or value changes - React.useEffect(() => { - if (triggerRef.current) { - const updateWidth = () => { - setTriggerWidth(triggerRef.current?.offsetWidth || 0); - }; - updateWidth(); - const resizeObserver = new ResizeObserver(updateWidth); - resizeObserver.observe(triggerRef.current); - return () => { - resizeObserver.disconnect(); - }; - } - }, [selectedProfileId]); - - // Filter profiles based on input - const filteredProfiles = React.useMemo(() => { - if (!inputValue) return profiles; - const lower = inputValue.toLowerCase(); - return profiles.filter( - (p) => - p.name.toLowerCase().includes(lower) || - p.description?.toLowerCase().includes(lower) || - p.provider.toLowerCase().includes(lower) - ); - }, [profiles, inputValue]); - - const handleSelect = (profile: AIProfile) => { - onSelect(profile); - setInputValue(''); - setOpen(false); - }; - - return ( - - - - - - - - - No profile found. - - {filteredProfiles.map((profile) => { - const ProviderIcon = PROVIDER_ICON_COMPONENTS[profile.provider]; - const isSelected = profile.id === selectedProfileId; - const modelDisplay = getProfileModelDisplay(profile); - const thinkingDisplay = getProfileThinkingDisplay(profile); - - return ( - handleSelect(profile)} - className="flex items-center gap-2 py-2" - data-testid={`${testIdPrefix}-option-${profile.id}`} - > -
- {ProviderIcon ? ( - - ) : ( - - )} -
- {profile.name} - - {modelDisplay} - {thinkingDisplay && ( - + {thinkingDisplay} - )} - -
-
-
- {profile.isBuiltIn && ( - - Built-in - - )} - -
-
- ); - })} -
- {showManageLink && onManageLinkClick && ( - <> - - - { - setOpen(false); - onManageLinkClick(); - }} - className="text-muted-foreground" - data-testid={`${testIdPrefix}-manage-link`} - > - - Manage AI Profiles - - - - )} -
-
-
-
- ); -} diff --git a/apps/ui/src/components/views/github-issues-view.tsx b/apps/ui/src/components/views/github-issues-view.tsx index e8e5536b..e1e09cad 100644 --- a/apps/ui/src/components/views/github-issues-view.tsx +++ b/apps/ui/src/components/views/github-issues-view.tsx @@ -26,8 +26,7 @@ export function GitHubIssuesView() { const [pendingRevalidateOptions, setPendingRevalidateOptions] = useState(null); - const { currentProject, defaultAIProfileId, aiProfiles, getCurrentWorktree, worktreesByProject } = - useAppStore(); + const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore(); // Model override for validation const validationModelOverride = useModelOverride({ phase: 'validationModel' }); @@ -45,12 +44,6 @@ export function GitHubIssuesView() { onShowValidationDialogChange: setShowValidationDialog, }); - // Get default AI profile for task creation - const defaultProfile = useMemo(() => { - if (!defaultAIProfileId) return null; - return aiProfiles.find((p) => p.id === defaultAIProfileId) ?? null; - }, [defaultAIProfileId, aiProfiles]); - // Get current branch from selected worktree const currentBranch = useMemo(() => { if (!currentProject?.path) return ''; @@ -99,9 +92,6 @@ 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, @@ -110,8 +100,8 @@ export function GitHubIssuesView() { status: 'backlog' as const, passes: false, priority: getFeaturePriority(validation.estimatedComplexity), - model: featureModel, - thinkingLevel: defaultProfile?.thinkingLevel ?? 'none', + model: 'opus', + thinkingLevel: 'none' as const, branchName: currentBranch, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -129,7 +119,7 @@ export function GitHubIssuesView() { toast.error(err instanceof Error ? err.message : 'Failed to create task'); } }, - [currentProject?.path, defaultProfile, currentBranch] + [currentProject?.path, currentBranch] ); if (loading) { diff --git a/apps/ui/src/components/views/profiles-view.tsx b/apps/ui/src/components/views/profiles-view.tsx deleted file mode 100644 index e11ec8b6..00000000 --- a/apps/ui/src/components/views/profiles-view.tsx +++ /dev/null @@ -1,275 +0,0 @@ -import { useState, useMemo, useCallback } from 'react'; -import { useAppStore, AIProfile } from '@/store/app-store'; -import { - useKeyboardShortcuts, - useKeyboardShortcutsConfig, - KeyboardShortcut, -} from '@/hooks/use-keyboard-shortcuts'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Sparkles } from 'lucide-react'; -import { toast } from 'sonner'; -import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog'; -import { - DndContext, - DragEndEvent, - PointerSensor, - useSensor, - useSensors, - closestCenter, -} from '@dnd-kit/core'; -import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { SortableProfileCard, ProfileForm, ProfilesHeader } from './profiles-view/components'; - -export function ProfilesView() { - const { - aiProfiles, - addAIProfile, - updateAIProfile, - removeAIProfile, - reorderAIProfiles, - resetAIProfiles, - } = useAppStore(); - const shortcuts = useKeyboardShortcutsConfig(); - - const [showAddDialog, setShowAddDialog] = useState(false); - const [editingProfile, setEditingProfile] = useState(null); - const [profileToDelete, setProfileToDelete] = useState(null); - - // Sensors for drag-and-drop - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 5, - }, - }) - ); - - // Separate built-in and custom profiles - const builtInProfiles = useMemo(() => aiProfiles.filter((p) => p.isBuiltIn), [aiProfiles]); - const customProfiles = useMemo(() => aiProfiles.filter((p) => !p.isBuiltIn), [aiProfiles]); - - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - - if (over && active.id !== over.id) { - const oldIndex = aiProfiles.findIndex((p) => p.id === active.id); - const newIndex = aiProfiles.findIndex((p) => p.id === over.id); - - if (oldIndex !== -1 && newIndex !== -1) { - reorderAIProfiles(oldIndex, newIndex); - } - } - }, - [aiProfiles, reorderAIProfiles] - ); - - const handleAddProfile = (profile: Omit) => { - addAIProfile(profile); - setShowAddDialog(false); - toast.success('Profile created', { - description: `Created "${profile.name}" profile`, - }); - }; - - const handleUpdateProfile = (profile: Omit) => { - if (editingProfile) { - updateAIProfile(editingProfile.id, profile); - setEditingProfile(null); - toast.success('Profile updated', { - description: `Updated "${profile.name}" profile`, - }); - } - }; - - const confirmDeleteProfile = () => { - if (!profileToDelete) return; - - removeAIProfile(profileToDelete.id); - toast.success('Profile deleted', { - description: `Deleted "${profileToDelete.name}" profile`, - }); - setProfileToDelete(null); - }; - - const handleResetProfiles = () => { - resetAIProfiles(); - toast.success('Profiles refreshed', { - description: 'Default profiles have been updated to the latest version', - }); - }; - - // Build keyboard shortcuts for profiles view - const profilesShortcuts: KeyboardShortcut[] = useMemo(() => { - const shortcutsList: KeyboardShortcut[] = []; - - // Add profile shortcut - when in profiles view - shortcutsList.push({ - key: shortcuts.addProfile, - action: () => setShowAddDialog(true), - description: 'Create new profile', - }); - - return shortcutsList; - }, [shortcuts]); - - // Register keyboard shortcuts for profiles view - useKeyboardShortcuts(profilesShortcuts); - - return ( -
- {/* Header Section */} - setShowAddDialog(true)} - addProfileHotkey={shortcuts.addProfile} - /> - - {/* Content */} -
-
- {/* Custom Profiles Section */} -
-
-

Custom Profiles

- - {customProfiles.length} - -
- {customProfiles.length === 0 ? ( -
setShowAddDialog(true)} - > - -

- No custom profiles yet. Create one to get started! -

-
- ) : ( - - p.id)} - strategy={verticalListSortingStrategy} - > -
- {customProfiles.map((profile) => ( - setEditingProfile(profile)} - onDelete={() => setProfileToDelete(profile)} - /> - ))} -
-
-
- )} -
- - {/* Built-in Profiles Section */} -
-
-

Built-in Profiles

- - {builtInProfiles.length} - -
-

- Pre-configured profiles for common use cases. These cannot be edited or deleted. -

- - p.id)} - strategy={verticalListSortingStrategy} - > -
- {builtInProfiles.map((profile) => ( - {}} - onDelete={() => {}} - /> - ))} -
-
-
-
-
-
- - {/* Add Profile Dialog */} - - - - Create New Profile - Define a reusable model configuration preset. - - setShowAddDialog(false)} - isEditing={false} - hotkeyActive={showAddDialog} - /> - - - - {/* Edit Profile Dialog */} - setEditingProfile(null)}> - - - Edit Profile - Modify your profile settings. - - {editingProfile && ( - setEditingProfile(null)} - isEditing={true} - hotkeyActive={!!editingProfile} - /> - )} - - - - {/* Delete Confirmation Dialog */} - !open && setProfileToDelete(null)} - onConfirm={confirmDeleteProfile} - title="Delete Profile" - description={ - profileToDelete - ? `Are you sure you want to delete "${profileToDelete.name}"? This action cannot be undone.` - : '' - } - confirmText="Delete Profile" - testId="delete-profile-confirm-dialog" - confirmTestId="confirm-delete-profile-button" - /> -
- ); -} diff --git a/apps/ui/src/components/views/profiles-view/components/index.ts b/apps/ui/src/components/views/profiles-view/components/index.ts deleted file mode 100644 index 729626a2..00000000 --- a/apps/ui/src/components/views/profiles-view/components/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { SortableProfileCard } from './sortable-profile-card'; -export { ProfileForm } from './profile-form'; -export { ProfilesHeader } from './profiles-header'; diff --git a/apps/ui/src/components/views/profiles-view/components/profile-form.tsx b/apps/ui/src/components/views/profiles-view/components/profile-form.tsx deleted file mode 100644 index 1e7090d8..00000000 --- a/apps/ui/src/components/views/profiles-view/components/profile-form.tsx +++ /dev/null @@ -1,560 +0,0 @@ -import { useState, useEffect } from 'react'; -import { Button } from '@/components/ui/button'; -import { HotkeyButton } from '@/components/ui/hotkey-button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Textarea } from '@/components/ui/textarea'; -import { Badge } from '@/components/ui/badge'; -import { cn, modelSupportsThinking } from '@/lib/utils'; -import { DialogFooter } from '@/components/ui/dialog'; -import { Brain } from 'lucide-react'; -import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon'; -import { toast } from 'sonner'; -import type { - AIProfile, - ModelAlias, - ThinkingLevel, - ModelProvider, - CursorModelId, - CodexModelId, - OpencodeModelId, -} from '@automaker/types'; -import { - CURSOR_MODEL_MAP, - cursorModelHasThinking, - CODEX_MODEL_MAP, - OPENCODE_MODELS, - DEFAULT_OPENCODE_MODEL, -} from '@automaker/types'; -import { useAppStore } from '@/store/app-store'; -import { CLAUDE_MODELS, THINKING_LEVELS, ICON_OPTIONS } from '../constants'; - -interface ProfileFormProps { - profile: Partial; - onSave: (profile: Omit) => void; - onCancel: () => void; - isEditing: boolean; - hotkeyActive: boolean; -} - -export function ProfileForm({ - profile, - onSave, - onCancel, - isEditing, - hotkeyActive, -}: ProfileFormProps) { - const { enabledCursorModels } = useAppStore(); - - const [formData, setFormData] = useState({ - name: profile.name || '', - description: profile.description || '', - provider: (profile.provider || 'claude') as ModelProvider, - // Claude-specific - model: profile.model || ('sonnet' as ModelAlias), - thinkingLevel: profile.thinkingLevel || ('none' as ThinkingLevel), - // Cursor-specific - cursorModel: profile.cursorModel || ('auto' as CursorModelId), - // Codex-specific - use a valid CodexModelId from CODEX_MODEL_MAP - codexModel: profile.codexModel || (CODEX_MODEL_MAP.gpt52Codex as CodexModelId), - // OpenCode-specific - opencodeModel: profile.opencodeModel || (DEFAULT_OPENCODE_MODEL as OpencodeModelId), - icon: profile.icon || 'Brain', - }); - - // Sync formData with profile prop when it changes - useEffect(() => { - setFormData({ - name: profile.name || '', - description: profile.description || '', - provider: (profile.provider || 'claude') as ModelProvider, - // Claude-specific - model: profile.model || ('sonnet' as ModelAlias), - thinkingLevel: profile.thinkingLevel || ('none' as ThinkingLevel), - // Cursor-specific - cursorModel: profile.cursorModel || ('auto' as CursorModelId), - // Codex-specific - use a valid CodexModelId from CODEX_MODEL_MAP - codexModel: profile.codexModel || (CODEX_MODEL_MAP.gpt52Codex as CodexModelId), - // OpenCode-specific - opencodeModel: profile.opencodeModel || (DEFAULT_OPENCODE_MODEL as OpencodeModelId), - icon: profile.icon || 'Brain', - }); - }, [profile]); - - const supportsThinking = formData.provider === 'claude' && modelSupportsThinking(formData.model); - - const handleProviderChange = (provider: ModelProvider) => { - setFormData({ - ...formData, - provider, - // Only reset Claude fields when switching TO Claude; preserve otherwise - model: provider === 'claude' ? 'sonnet' : formData.model, - thinkingLevel: provider === 'claude' ? 'none' : formData.thinkingLevel, - // Reset cursor/codex/opencode models when switching to that provider - cursorModel: provider === 'cursor' ? 'auto' : formData.cursorModel, - codexModel: - provider === 'codex' ? (CODEX_MODEL_MAP.gpt52Codex as CodexModelId) : formData.codexModel, - opencodeModel: - provider === 'opencode' - ? (DEFAULT_OPENCODE_MODEL as OpencodeModelId) - : formData.opencodeModel, - }); - }; - - const handleModelChange = (model: ModelAlias) => { - setFormData({ - ...formData, - model, - }); - }; - - const handleCursorModelChange = (cursorModel: CursorModelId) => { - setFormData({ - ...formData, - cursorModel, - }); - }; - - const handleCodexModelChange = (codexModel: CodexModelId) => { - setFormData({ - ...formData, - codexModel, - }); - }; - - const handleOpencodeModelChange = (opencodeModel: OpencodeModelId) => { - setFormData({ - ...formData, - opencodeModel, - }); - }; - - const handleSubmit = () => { - if (!formData.name.trim()) { - toast.error('Please enter a profile name'); - return; - } - - // Ensure model is always set for Claude profiles - const validModels: ModelAlias[] = ['haiku', 'sonnet', 'opus']; - const finalModel = - formData.provider === 'claude' - ? validModels.includes(formData.model) - ? formData.model - : 'sonnet' - : undefined; - - const baseProfile = { - name: formData.name.trim(), - description: formData.description.trim(), - provider: formData.provider, - isBuiltIn: false, - icon: formData.icon, - }; - - if (formData.provider === 'cursor') { - onSave({ - ...baseProfile, - cursorModel: formData.cursorModel, - }); - } else if (formData.provider === 'codex') { - onSave({ - ...baseProfile, - codexModel: formData.codexModel, - }); - } else if (formData.provider === 'opencode') { - onSave({ - ...baseProfile, - opencodeModel: formData.opencodeModel, - }); - } else { - onSave({ - ...baseProfile, - model: finalModel as ModelAlias, - thinkingLevel: supportsThinking ? formData.thinkingLevel : 'none', - }); - } - }; - - return ( - <> -
- {/* Name */} -
- - setFormData({ ...formData, name: e.target.value })} - placeholder="e.g., Heavy Task, Quick Fix" - data-testid="profile-name-input" - /> -
- - {/* Description */} -
- -