diff --git a/auto-claude-ui/package.json b/auto-claude-ui/package.json index 437e249d..dc802097 100644 --- a/auto-claude-ui/package.json +++ b/auto-claude-ui/package.json @@ -1,6 +1,6 @@ { "name": "auto-claude-ui", - "version": "2.5.6", + "version": "2.6.0", "description": "Desktop UI for Auto Claude autonomous coding framework", "main": "./out/main/index.js", "author": "Auto Claude Team", diff --git a/auto-claude-ui/src/main/ipc-handlers/github/__tests__/oauth-handlers.spec.ts b/auto-claude-ui/src/main/ipc-handlers/github/__tests__/oauth-handlers.spec.ts new file mode 100644 index 00000000..6ff4db97 --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/github/__tests__/oauth-handlers.spec.ts @@ -0,0 +1,548 @@ +/** + * Unit tests for GitHub OAuth handlers + * Tests device code parsing, shell.openExternal handling, and error recovery + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; + +// Mock child_process before importing +const mockSpawn = vi.fn(); +const mockExecSync = vi.fn(); +const mockExecFileSync = vi.fn(); + +vi.mock('child_process', () => ({ + spawn: (...args: unknown[]) => mockSpawn(...args), + execSync: (...args: unknown[]) => mockExecSync(...args), + execFileSync: (...args: unknown[]) => mockExecFileSync(...args) +})); + +// Mock shell.openExternal +const mockOpenExternal = vi.fn(); + +vi.mock('electron', () => { + const mockIpcMain = new (class extends EventEmitter { + private handlers: Map = new Map(); + + handle(channel: string, handler: Function): void { + this.handlers.set(channel, handler); + } + + removeHandler(channel: string): void { + this.handlers.delete(channel); + } + + async invokeHandler(channel: string, event: unknown, ...args: unknown[]): Promise { + const handler = this.handlers.get(channel); + if (handler) { + return handler(event, ...args); + } + throw new Error(`No handler for channel: ${channel}`); + } + + getHandler(channel: string): Function | undefined { + return this.handlers.get(channel); + } + })(); + + return { + ipcMain: mockIpcMain, + shell: { + openExternal: (...args: unknown[]) => mockOpenExternal(...args) + } + }; +}); + +// Mock @electron-toolkit/utils +vi.mock('@electron-toolkit/utils', () => ({ + is: { + dev: true, + windows: process.platform === 'win32', + macos: process.platform === 'darwin', + linux: process.platform === 'linux' + } +})); + +// Create mock process for spawn +function createMockProcess(): EventEmitter & { + stdout: EventEmitter | null; + stderr: EventEmitter | null; + stdin: { write: ReturnType; end: ReturnType } | null; +} { + const proc = new EventEmitter() as EventEmitter & { + stdout: EventEmitter | null; + stderr: EventEmitter | null; + stdin: { write: ReturnType; end: ReturnType } | null; + }; + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.stdin = { write: vi.fn(), end: vi.fn() }; + return proc; +} + +describe('GitHub OAuth Handlers', () => { + let ipcMain: EventEmitter & { + handlers: Map; + invokeHandler: (channel: string, event: unknown, ...args: unknown[]) => Promise; + getHandler: (channel: string) => Function | undefined; + }; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + + // Get mocked ipcMain + const electron = await import('electron'); + ipcMain = electron.ipcMain as unknown as typeof ipcMain; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Device Code Parsing', () => { + it('should parse device code from standard gh CLI output format', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockResolvedValue(undefined); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + // Start the handler + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + // Simulate gh CLI output with device code + mockProcess.stderr?.emit('data', '! First copy your one-time code: ABCD-1234\n'); + mockProcess.stderr?.emit('data', '- Press Enter to open github.com in your browser...\n'); + + // Complete the process + mockProcess.emit('close', 0); + + const result = await resultPromise; + + expect(result).toHaveProperty('success', true); + expect(result).toHaveProperty('data'); + const data = (result as { data: { deviceCode: string } }).data; + expect(data.deviceCode).toBe('ABCD-1234'); + }); + + it('should parse device code from alternate output format (lowercase "code")', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockResolvedValue(undefined); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + // Alternate format: "code: XXXX-XXXX" without "one-time" + mockProcess.stderr?.emit('data', 'Enter the code: EFGH-5678\n'); + mockProcess.emit('close', 0); + + const result = await resultPromise; + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { deviceCode: string } }).data; + expect(data.deviceCode).toBe('EFGH-5678'); + }); + + it('should parse device code from stdout (not just stderr)', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockResolvedValue(undefined); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + // Device code in stdout instead of stderr + mockProcess.stdout?.emit('data', '! First copy your one-time code: IJKL-9012\n'); + mockProcess.emit('close', 0); + + const result = await resultPromise; + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { deviceCode: string } }).data; + expect(data.deviceCode).toBe('IJKL-9012'); + }); + + it('should handle output without device code gracefully', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + // Output without device code + mockProcess.stderr?.emit('data', 'Some other message\n'); + mockProcess.emit('close', 0); + + const result = await resultPromise; + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { deviceCode?: string } }).data; + expect(data.deviceCode).toBeUndefined(); + }); + + it('should extract URL from output containing https://github.com/login/device', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockResolvedValue(undefined); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + mockProcess.stderr?.emit('data', '! First copy your one-time code: MNOP-3456\n'); + mockProcess.stderr?.emit('data', 'Then visit https://github.com/login/device to authenticate\n'); + mockProcess.emit('close', 0); + + const result = await resultPromise; + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { authUrl: string } }).data; + expect(data.authUrl).toBe('https://github.com/login/device'); + }); + }); + + describe('shell.openExternal Handling', () => { + it('should call shell.openExternal with extracted URL when device code found', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockResolvedValue(undefined); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + mockProcess.stderr?.emit('data', '! First copy your one-time code: QRST-7890\n'); + + // Wait for next tick to allow async browser opening + await new Promise(resolve => setTimeout(resolve, 10)); + + mockProcess.emit('close', 0); + await resultPromise; + + expect(mockOpenExternal).toHaveBeenCalledWith('https://github.com/login/device'); + }); + + it('should set browserOpened to true when shell.openExternal succeeds', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockResolvedValue(undefined); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + mockProcess.stderr?.emit('data', '! First copy your one-time code: UVWX-1234\n'); + + // Wait for async browser opening + await new Promise(resolve => setTimeout(resolve, 10)); + + mockProcess.emit('close', 0); + const result = await resultPromise; + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { browserOpened: boolean } }).data; + expect(data.browserOpened).toBe(true); + }); + + it('should set browserOpened to false when shell.openExternal fails', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockRejectedValue(new Error('Failed to open browser')); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + mockProcess.stderr?.emit('data', '! First copy your one-time code: YZAB-5678\n'); + + // Wait for async browser opening to fail + await new Promise(resolve => setTimeout(resolve, 10)); + + mockProcess.emit('close', 0); + const result = await resultPromise; + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { browserOpened: boolean } }).data; + expect(data.browserOpened).toBe(false); + }); + + it('should provide fallbackUrl when browser fails to open', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockRejectedValue(new Error('Failed to open browser')); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + mockProcess.stderr?.emit('data', '! First copy your one-time code: CDEF-9012\n'); + + // Wait for async browser opening to fail + await new Promise(resolve => setTimeout(resolve, 10)); + + mockProcess.emit('close', 0); + const result = await resultPromise; + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { fallbackUrl?: string } }).data; + expect(data.fallbackUrl).toBe('https://github.com/login/device'); + }); + + it('should not provide fallbackUrl when browser opens successfully', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockResolvedValue(undefined); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + mockProcess.stderr?.emit('data', '! First copy your one-time code: GHIJ-3456\n'); + + // Wait for async browser opening + await new Promise(resolve => setTimeout(resolve, 10)); + + mockProcess.emit('close', 0); + const result = await resultPromise; + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { fallbackUrl?: string } }).data; + expect(data.fallbackUrl).toBeUndefined(); + }); + }); + + describe('Error Handling', () => { + it('should handle gh CLI process error', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + // Emit error event + mockProcess.emit('error', new Error('spawn gh ENOENT')); + + const result = await resultPromise; + + expect(result).toHaveProperty('success', false); + expect(result).toHaveProperty('error', 'spawn gh ENOENT'); + const data = (result as { data: { fallbackUrl: string } }).data; + expect(data.fallbackUrl).toBe('https://github.com/login/device'); + }); + + it('should handle non-zero exit code', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + mockProcess.stderr?.emit('data', 'error: some authentication error\n'); + mockProcess.emit('close', 1); + + const result = await resultPromise; + + expect(result).toHaveProperty('success', false); + const data = (result as { data: { fallbackUrl: string } }).data; + expect(data.fallbackUrl).toBe('https://github.com/login/device'); + }); + + it('should include device code in error result if it was extracted before failure', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + mockOpenExternal.mockResolvedValue(undefined); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + // Device code output followed by failure + mockProcess.stderr?.emit('data', '! First copy your one-time code: KLMN-7890\n'); + + // Wait for async browser opening + await new Promise(resolve => setTimeout(resolve, 10)); + + mockProcess.stderr?.emit('data', 'error: authentication failed\n'); + mockProcess.emit('close', 1); + + const result = await resultPromise; + + expect(result).toHaveProperty('success', false); + const data = (result as { data: { deviceCode: string; fallbackUrl: string } }).data; + expect(data.deviceCode).toBe('KLMN-7890'); + expect(data.fallbackUrl).toBe('https://github.com/login/device'); + }); + + it('should provide user-friendly error message on process spawn failure', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + const resultPromise = ipcMain.invokeHandler('github:startAuth', {}); + + mockProcess.emit('error', new Error('spawn gh ENOENT')); + + const result = await resultPromise; + + expect(result).toHaveProperty('success', false); + const data = (result as { data: { message: string } }).data; + expect(data.message).toContain('Failed to start GitHub CLI'); + }); + }); + + describe('gh CLI Check Handler', () => { + it('should return installed: true when gh CLI is found', async () => { + mockExecSync.mockImplementation((cmd: string) => { + if (cmd.includes('which gh') || cmd.includes('where gh')) { + return '/usr/local/bin/gh\n'; + } + if (cmd === 'gh --version') { + return 'gh version 2.65.0 (2024-01-15)\n'; + } + return ''; + }); + + const { registerCheckGhCli } = await import('../oauth-handlers'); + registerCheckGhCli(); + + const result = await ipcMain.invokeHandler('github:checkCli', {}); + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { installed: boolean; version: string } }).data; + expect(data.installed).toBe(true); + expect(data.version).toContain('gh version'); + }); + + it('should return installed: false when gh CLI is not found', async () => { + mockExecSync.mockImplementation(() => { + throw new Error('Command not found'); + }); + + const { registerCheckGhCli } = await import('../oauth-handlers'); + registerCheckGhCli(); + + const result = await ipcMain.invokeHandler('github:checkCli', {}); + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { installed: boolean } }).data; + expect(data.installed).toBe(false); + }); + }); + + describe('gh Auth Check Handler', () => { + it('should return authenticated: true with username when logged in', async () => { + mockExecSync.mockImplementation((cmd: string) => { + if (cmd === 'gh auth status') { + return 'Logged in to github.com as testuser\n'; + } + if (cmd === 'gh api user --jq .login') { + return 'testuser\n'; + } + return ''; + }); + + const { registerCheckGhAuth } = await import('../oauth-handlers'); + registerCheckGhAuth(); + + const result = await ipcMain.invokeHandler('github:checkAuth', {}); + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { authenticated: boolean; username: string } }).data; + expect(data.authenticated).toBe(true); + expect(data.username).toBe('testuser'); + }); + + it('should return authenticated: false when not logged in', async () => { + mockExecSync.mockImplementation(() => { + throw new Error('You are not logged into any GitHub hosts'); + }); + + const { registerCheckGhAuth } = await import('../oauth-handlers'); + registerCheckGhAuth(); + + const result = await ipcMain.invokeHandler('github:checkAuth', {}); + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { authenticated: boolean } }).data; + expect(data.authenticated).toBe(false); + }); + }); + + describe('Spawn Arguments', () => { + it('should spawn gh with correct auth login arguments', async () => { + const mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + + const { registerStartGhAuth } = await import('../oauth-handlers'); + registerStartGhAuth(); + + ipcMain.invokeHandler('github:startAuth', {}); + + expect(mockSpawn).toHaveBeenCalledWith( + 'gh', + ['auth', 'login', '--web', '--scopes', 'repo'], + expect.objectContaining({ + stdio: ['pipe', 'pipe', 'pipe'] + }) + ); + }); + }); + + describe('Repository Validation', () => { + it('should reject invalid repository format', async () => { + const { registerGetGitHubBranches } = await import('../oauth-handlers'); + registerGetGitHubBranches(); + + // Test with injection attempt + const result = await ipcMain.invokeHandler( + 'github:getBranches', + {}, + 'owner/repo; rm -rf /', + 'token' + ); + + expect(result).toHaveProperty('success', false); + expect(result).toHaveProperty('error', 'Invalid repository format. Expected: owner/repo'); + }); + + it('should accept valid repository format', async () => { + mockExecFileSync.mockReturnValue('main\nfeature-branch\n'); + + const { registerGetGitHubBranches } = await import('../oauth-handlers'); + registerGetGitHubBranches(); + + const result = await ipcMain.invokeHandler( + 'github:getBranches', + {}, + 'valid-owner/valid-repo', + 'token' + ); + + expect(result).toHaveProperty('success', true); + const data = (result as { data: string[] }).data; + expect(data).toContain('main'); + expect(data).toContain('feature-branch'); + }); + }); +}); diff --git a/auto-claude-ui/src/main/ipc-handlers/github/oauth-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/github/oauth-handlers.ts index 95f19f44..864eb367 100644 --- a/auto-claude-ui/src/main/ipc-handlers/github/oauth-handlers.ts +++ b/auto-claude-ui/src/main/ipc-handlers/github/oauth-handlers.ts @@ -3,7 +3,7 @@ * Provides a simpler OAuth flow than manual PAT creation */ -import { ipcMain } from 'electron'; +import { ipcMain, shell } from 'electron'; import { execSync, execFileSync, spawn } from 'child_process'; import { IPC_CHANNELS } from '../../../shared/constants'; import type { IPCResult } from '../../../shared/types'; @@ -33,6 +33,64 @@ function isValidGitHubRepo(repo: string): boolean { return GITHUB_REPO_PATTERN.test(repo); } +// Regex patterns for parsing device code from gh CLI output +// Expected format: "! First copy your one-time code: XXXX-XXXX" +const DEVICE_CODE_PATTERN = /(?:one-time code|code):\s*([A-Z0-9]{4}-[A-Z0-9]{4})/i; + +// GitHub device flow URL pattern +const DEVICE_URL_PATTERN = /https:\/\/github\.com\/login\/device/i; + +// Default GitHub device flow URL +const GITHUB_DEVICE_URL = 'https://github.com/login/device'; + +/** + * Parse device code from gh CLI stdout output + * Returns the device code (format: XXXX-XXXX) if found, null otherwise + */ +function parseDeviceCode(output: string): string | null { + const match = output.match(DEVICE_CODE_PATTERN); + if (match && match[1]) { + debugLog('Parsed device code:', match[1]); + return match[1]; + } + return null; +} + +/** + * Parse device URL from gh CLI output + * Returns the URL if found, or the default GitHub device URL + */ +function parseDeviceUrl(output: string): string { + const match = output.match(DEVICE_URL_PATTERN); + if (match) { + debugLog('Found device URL in output:', match[0]); + return match[0]; + } + // Default to standard GitHub device flow URL + return GITHUB_DEVICE_URL; +} + +/** + * Result of parsing device flow output from gh CLI + */ +interface DeviceFlowInfo { + deviceCode: string | null; + authUrl: string; +} + +/** + * Parse both device code and URL from combined gh CLI output + * Searches through both stdout and stderr as gh may output to either + */ +function parseDeviceFlowOutput(stdout: string, stderr: string): DeviceFlowInfo { + const combinedOutput = `${stdout}\n${stderr}`; + + return { + deviceCode: parseDeviceCode(combinedOutput), + authUrl: parseDeviceUrl(combinedOutput) + }; +} + /** * Check if gh CLI is installed */ @@ -114,14 +172,31 @@ export function registerCheckGhAuth(): void { ); } +/** + * Result type for GitHub auth start, including device flow information + */ +interface GitHubAuthStartResult { + success: boolean; + message?: string; + deviceCode?: string; + authUrl?: string; + browserOpened?: boolean; + /** + * Fallback URL provided when browser launch fails. + * The frontend should display this URL so users can manually navigate to complete auth. + */ + fallbackUrl?: string; +} + /** * Start GitHub OAuth flow using gh CLI - * This will open the browser for device flow authentication + * This will extract the device code from gh CLI output and open the browser + * using Electron's shell.openExternal (bypasses macOS child process restrictions) */ export function registerStartGhAuth(): void { ipcMain.handle( IPC_CHANNELS.GITHUB_START_AUTH, - async (): Promise> => { + async (): Promise> => { debugLog('startGitHubAuth handler called'); return new Promise((resolve) => { try { @@ -135,17 +210,60 @@ export function registerStartGhAuth(): void { let output = ''; let errorOutput = ''; + let deviceCodeExtracted = false; + let extractedDeviceCode: string | null = null; + let extractedAuthUrl: string = GITHUB_DEVICE_URL; + let browserOpenedSuccessfully = false; + let extractionInProgress = false; + + // Function to attempt device code extraction and browser opening + // Uses mutex pattern to prevent race conditions from concurrent data handlers + const tryExtractAndOpenBrowser = async () => { + if (deviceCodeExtracted || extractionInProgress) return; + extractionInProgress = true; + + const deviceFlowInfo = parseDeviceFlowOutput(output, errorOutput); + + if (deviceFlowInfo.deviceCode) { + deviceCodeExtracted = true; + extractedDeviceCode = deviceFlowInfo.deviceCode; + extractedAuthUrl = deviceFlowInfo.authUrl; + + debugLog('Device code extracted:', extractedDeviceCode); + debugLog('Auth URL:', extractedAuthUrl); + + // Open browser using Electron's shell.openExternal + // This bypasses macOS child process restrictions that block gh CLI's browser launch + try { + await shell.openExternal(extractedAuthUrl); + browserOpenedSuccessfully = true; + debugLog('Browser opened successfully via shell.openExternal'); + } catch (browserError) { + debugLog('Failed to open browser:', browserError instanceof Error ? browserError.message : browserError); + browserOpenedSuccessfully = false; + // Don't fail here - we'll return the device code so user can manually navigate + } + } else { + // No device code found yet, allow next data chunk to try again + extractionInProgress = false; + } + }; ghProcess.stdout?.on('data', (data) => { const chunk = data.toString(); output += chunk; debugLog('gh stdout:', chunk); + // Try to extract device code as data comes in + // Use void to explicitly ignore promise + void tryExtractAndOpenBrowser(); }); ghProcess.stderr?.on('data', (data) => { const chunk = data.toString(); errorOutput += chunk; debugLog('gh stderr:', chunk); + // gh often outputs to stderr, so check there too + void tryExtractAndOpenBrowser(); }); ghProcess.on('close', (code) => { @@ -154,17 +272,39 @@ export function registerStartGhAuth(): void { debugLog('Full stderr:', errorOutput); if (code === 0) { + // Success case - include fallbackUrl if browser failed to open + // so the user can manually navigate if needed resolve({ success: true, data: { success: true, - message: 'Successfully authenticated with GitHub' + message: browserOpenedSuccessfully + ? 'Successfully authenticated with GitHub' + : 'Authentication successful. Browser could not be opened automatically.', + deviceCode: extractedDeviceCode || undefined, + authUrl: extractedAuthUrl, + browserOpened: browserOpenedSuccessfully, + // Provide fallback URL when browser failed to open + fallbackUrl: !browserOpenedSuccessfully ? extractedAuthUrl : undefined } }); } else { + // Even if auth failed, return device code info if we extracted it + // This allows user to retry manually with the fallback URL + const fallbackUrlForManualAuth = extractedDeviceCode ? extractedAuthUrl : GITHUB_DEVICE_URL; + resolve({ success: false, - error: errorOutput || `Authentication failed with exit code ${code}` + error: errorOutput || `Authentication failed with exit code ${code}`, + data: { + success: false, + deviceCode: extractedDeviceCode || undefined, + authUrl: extractedAuthUrl, + browserOpened: browserOpenedSuccessfully, + // Always provide fallback URL on failure for manual recovery + fallbackUrl: fallbackUrlForManualAuth, + message: 'Authentication failed. Please visit the URL manually to complete authentication.' + } }); } }); @@ -173,14 +313,28 @@ export function registerStartGhAuth(): void { debugLog('gh process error:', error.message); resolve({ success: false, - error: error.message + error: error.message, + data: { + success: false, + browserOpened: false, + // Provide fallback URL so user can attempt manual auth + fallbackUrl: GITHUB_DEVICE_URL, + message: 'Failed to start GitHub CLI. Please visit the URL manually to authenticate.' + } }); }); } catch (error) { debugLog('Exception in startGitHubAuth:', error instanceof Error ? error.message : error); resolve({ success: false, - error: error instanceof Error ? error.message : 'Unknown error' + error: error instanceof Error ? error.message : 'Unknown error', + data: { + success: false, + browserOpened: false, + // Provide fallback URL for manual authentication recovery + fallbackUrl: GITHUB_DEVICE_URL, + message: 'An unexpected error occurred. Please visit the URL manually to authenticate.' + } }); } }); diff --git a/auto-claude-ui/src/main/ipc-handlers/settings-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/settings-handlers.ts index 9297a838..25b1a763 100644 --- a/auto-claude-ui/src/main/ipc-handlers/settings-handlers.ts +++ b/auto-claude-ui/src/main/ipc-handlers/settings-handlers.ts @@ -94,7 +94,8 @@ export function registerSettingsHandlers( ipcMain.handle( IPC_CHANNELS.SETTINGS_GET, async (): Promise> => { - let settings = { ...DEFAULT_APP_SETTINGS }; + let settings: AppSettings = { ...DEFAULT_APP_SETTINGS }; + let needsSave = false; if (existsSync(settingsPath)) { try { @@ -105,6 +106,18 @@ export function registerSettingsHandlers( } } + // Migration: Set agent profile to 'auto' for users who haven't made a selection (one-time) + // This ensures new users get the optimized 'auto' profile as the default + // while preserving existing user preferences + if (!settings._migratedAgentProfileToAuto) { + // Only set 'auto' if user hasn't made a selection yet + if (!settings.selectedAgentProfile) { + settings.selectedAgentProfile = 'auto'; + } + settings._migratedAgentProfileToAuto = true; + needsSave = true; + } + // If no manual autoBuildPath is set, try to auto-detect if (!settings.autoBuildPath) { const detectedPath = detectAutoBuildSourcePath(); @@ -113,6 +126,16 @@ export function registerSettingsHandlers( } } + // Persist migration changes + if (needsSave) { + try { + writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + } catch (error) { + console.error('[SETTINGS_GET] Failed to persist migration:', error); + // Continue anyway - settings will be migrated in-memory for this session + } + } + return { success: true, data: settings as AppSettings }; } ); diff --git a/auto-claude-ui/src/main/ipc-handlers/task/worktree-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/task/worktree-handlers.ts index acb3a821..a34a7d49 100644 --- a/auto-claude-ui/src/main/ipc-handlers/task/worktree-handlers.ts +++ b/auto-claude-ui/src/main/ipc-handlers/task/worktree-handlers.ts @@ -500,6 +500,21 @@ export function registerWorktreeHandlers( debug('Merge result. isStageOnly:', isStageOnly, 'newStatus:', newStatus, 'staged:', staged); + // Read suggested commit message if staging succeeded + let suggestedCommitMessage: string | undefined; + if (staged) { + const commitMsgPath = path.join(specDir, 'suggested_commit_message.txt'); + try { + if (existsSync(commitMsgPath)) { + const { readFileSync } = require('fs'); + suggestedCommitMessage = readFileSync(commitMsgPath, 'utf-8').trim(); + debug('Read suggested commit message:', suggestedCommitMessage?.substring(0, 100)); + } + } catch (e) { + debug('Failed to read suggested commit message:', e); + } + } + // Persist the status change to implementation_plan.json const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); try { @@ -531,7 +546,8 @@ export function registerWorktreeHandlers( success: true, message, staged, - projectPath: staged ? project.path : undefined + projectPath: staged ? project.path : undefined, + suggestedCommitMessage } }); } else { diff --git a/auto-claude-ui/src/main/ipc-handlers/terminal-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/terminal-handlers.ts index 6091635c..0dd83ba7 100644 --- a/auto-claude-ui/src/main/ipc-handlers/terminal-handlers.ts +++ b/auto-claude-ui/src/main/ipc-handlers/terminal-handlers.ts @@ -8,7 +8,7 @@ import { TerminalManager } from '../terminal-manager'; import { projectStore } from '../project-store'; import { terminalNameGenerator } from '../terminal-name-generator'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; -import { escapeShellArg } from '../../shared/utils/shell-escape'; +import { escapeShellArg, escapeShellArgWindows } from '../../shared/utils/shell-escape'; /** @@ -327,13 +327,20 @@ export function registerTerminalHandlers( await new Promise(resolve => setTimeout(resolve, 500)); // Build the login command with the profile's config dir - // Use export to ensure the variable persists, then run setup-token + // Use platform-specific syntax and escaping for environment variables let loginCommand: string; if (!profile.isDefault && profile.configDir) { - // Use export and run in subshell to ensure CLAUDE_CONFIG_DIR is properly set - // SECURITY: Use escapeShellArg to prevent command injection via configDir - const escapedConfigDir = escapeShellArg(profile.configDir); - loginCommand = `export CLAUDE_CONFIG_DIR=${escapedConfigDir} && echo "Config dir: $CLAUDE_CONFIG_DIR" && claude setup-token`; + if (process.platform === 'win32') { + // SECURITY: Use Windows-specific escaping for cmd.exe + const escapedConfigDir = escapeShellArgWindows(profile.configDir); + // Windows cmd.exe syntax: set "VAR=value" with %VAR% for expansion + loginCommand = `set "CLAUDE_CONFIG_DIR=${escapedConfigDir}" && echo Config dir: %CLAUDE_CONFIG_DIR% && claude setup-token`; + } else { + // SECURITY: Use POSIX escaping for bash/zsh + const escapedConfigDir = escapeShellArg(profile.configDir); + // Unix/Mac bash/zsh syntax: export VAR=value with $VAR for expansion + loginCommand = `export CLAUDE_CONFIG_DIR=${escapedConfigDir} && echo "Config dir: $CLAUDE_CONFIG_DIR" && claude setup-token`; + } } else { loginCommand = 'claude setup-token'; } diff --git a/auto-claude-ui/src/renderer/App.tsx b/auto-claude-ui/src/renderer/App.tsx index 591766b7..2961a920 100644 --- a/auto-claude-ui/src/renderer/App.tsx +++ b/auto-claude-ui/src/renderer/App.tsx @@ -29,7 +29,6 @@ import { Insights } from './components/Insights'; import { GitHubIssues } from './components/GitHubIssues'; import { Changelog } from './components/Changelog'; import { Worktrees } from './components/Worktrees'; -import { AgentProfiles } from './components/AgentProfiles'; import { WelcomeScreen } from './components/WelcomeScreen'; import { RateLimitModal } from './components/RateLimitModal'; import { SDKRateLimitModal } from './components/SDKRateLimitModal'; @@ -462,9 +461,6 @@ export function App() { {activeView === 'worktrees' && selectedProjectId && ( )} - {activeView === 'agent-profiles' && ( - - )} {activeView === 'agent-tools' && (
diff --git a/auto-claude-ui/src/renderer/components/AgentProfileSelector.tsx b/auto-claude-ui/src/renderer/components/AgentProfileSelector.tsx index 85f0f4c8..88a16942 100644 --- a/auto-claude-ui/src/renderer/components/AgentProfileSelector.tsx +++ b/auto-claude-ui/src/renderer/components/AgentProfileSelector.tsx @@ -8,7 +8,7 @@ * Used in TaskCreationWizard and TaskEditDialog. */ import { useState } from 'react'; -import { Brain, Scale, Zap, Sliders, Sparkles, ChevronDown, ChevronUp } from 'lucide-react'; +import { Brain, Scale, Zap, Sliders, Sparkles, ChevronDown, ChevronUp, Pencil } from 'lucide-react'; import { Label } from './ui/label'; import { Select, @@ -220,28 +220,37 @@ export function AgentProfileSelector({ {/* Auto Profile - Phase Configuration */} {isAuto && ( -
- {/* Phase Summary */} -
- +
+ {showPhaseDetails ? ( + + ) : ( + + )} + - {/* Compact summary when collapsed */} - {!showPhaseDetails && ( + {/* Compact summary when collapsed */} + {!showPhaseDetails && ( +
{(Object.keys(PHASE_LABELS) as Array).map((phase) => { const modelLabel = AVAILABLE_MODELS.find(m => m.value === currentPhaseModels[phase])?.label?.replace('Claude ', '') || currentPhaseModels[phase]; @@ -253,55 +262,61 @@ export function AgentProfileSelector({ ); })}
- )} -
+
+ )} {/* Detailed Phase Configuration */} {showPhaseDetails && ( -
+
{(Object.keys(PHASE_LABELS) as Array).map((phase) => (
-
- - +
+ + +
+
+ + +
))} diff --git a/auto-claude-ui/src/renderer/components/AgentProfiles.tsx b/auto-claude-ui/src/renderer/components/AgentProfiles.tsx index e47a5dd0..83bd7a90 100644 --- a/auto-claude-ui/src/renderer/components/AgentProfiles.tsx +++ b/auto-claude-ui/src/renderer/components/AgentProfiles.tsx @@ -19,7 +19,7 @@ const iconMap: Record = { */ export function AgentProfiles() { const settings = useSettingsStore((state) => state.settings); - const selectedProfileId = settings.selectedAgentProfile || 'balanced'; + const selectedProfileId = settings.selectedAgentProfile || 'auto'; const handleSelectProfile = async (profileId: string) => { await saveSettings({ selectedAgentProfile: profileId }); diff --git a/auto-claude-ui/src/renderer/components/Sidebar.tsx b/auto-claude-ui/src/renderer/components/Sidebar.tsx index fcae0347..9f626407 100644 --- a/auto-claude-ui/src/renderer/components/Sidebar.tsx +++ b/auto-claude-ui/src/renderer/components/Sidebar.tsx @@ -16,8 +16,7 @@ import { FileText, Sparkles, GitBranch, - HelpCircle, - UserCog + HelpCircle } from 'lucide-react'; import { Button } from './ui/button'; import { ScrollArea } from './ui/scroll-area'; @@ -57,7 +56,7 @@ import { GitSetupModal } from './GitSetupModal'; import { RateLimitIndicator } from './RateLimitIndicator'; import type { Project, AutoBuildVersionInfo, GitStatus } from '../../shared/types'; -export type SidebarView = 'kanban' | 'terminals' | 'roadmap' | 'context' | 'ideation' | 'github-issues' | 'changelog' | 'insights' | 'worktrees' | 'agent-tools' | 'agent-profiles'; +export type SidebarView = 'kanban' | 'terminals' | 'roadmap' | 'context' | 'ideation' | 'github-issues' | 'changelog' | 'insights' | 'worktrees' | 'agent-tools'; interface SidebarProps { onSettingsClick: () => void; @@ -85,8 +84,7 @@ const projectNavItems: NavItem[] = [ const toolsNavItems: NavItem[] = [ { id: 'github-issues', label: 'GitHub Issues', icon: Github, shortcut: 'G' }, - { id: 'worktrees', label: 'Worktrees', icon: GitBranch, shortcut: 'W' }, - { id: 'agent-profiles', label: 'Agent Profiles', icon: UserCog, shortcut: 'P' } + { id: 'worktrees', label: 'Worktrees', icon: GitBranch, shortcut: 'W' } ]; export function Sidebar({ diff --git a/auto-claude-ui/src/renderer/components/TaskCreationWizard.tsx b/auto-claude-ui/src/renderer/components/TaskCreationWizard.tsx index 6aaa57d9..58b4c449 100644 --- a/auto-claude-ui/src/renderer/components/TaskCreationWizard.tsx +++ b/auto-claude-ui/src/renderer/components/TaskCreationWizard.tsx @@ -102,11 +102,12 @@ export function TaskCreationWizard({ const [model, setModel] = useState(selectedProfile.model); const [thinkingLevel, setThinkingLevel] = useState(selectedProfile.thinkingLevel); // Auto profile - per-phase configuration + // Use custom settings from app settings if available, otherwise fall back to defaults const [phaseModels, setPhaseModels] = useState( - selectedProfile.phaseModels || DEFAULT_PHASE_MODELS + settings.customPhaseModels || selectedProfile.phaseModels || DEFAULT_PHASE_MODELS ); const [phaseThinking, setPhaseThinking] = useState( - selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING + settings.customPhaseThinking || selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING ); // Image attachments @@ -143,8 +144,8 @@ export function TaskCreationWizard({ setProfileId(draft.profileId || settings.selectedAgentProfile || 'auto'); setModel(draft.model || selectedProfile.model); setThinkingLevel(draft.thinkingLevel || selectedProfile.thinkingLevel); - setPhaseModels(draft.phaseModels || selectedProfile.phaseModels || DEFAULT_PHASE_MODELS); - setPhaseThinking(draft.phaseThinking || selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING); + setPhaseModels(draft.phaseModels || settings.customPhaseModels || selectedProfile.phaseModels || DEFAULT_PHASE_MODELS); + setPhaseThinking(draft.phaseThinking || settings.customPhaseThinking || selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING); setImages(draft.images); setReferencedFiles(draft.referencedFiles ?? []); setRequireReviewBeforeCoding(draft.requireReviewBeforeCoding ?? false); @@ -159,15 +160,15 @@ export function TaskCreationWizard({ } // Note: Referenced Files section is always visible, no need to expand } else { - // No draft - initialize from selected profile + // No draft - initialize from selected profile and custom settings setProfileId(settings.selectedAgentProfile || 'auto'); setModel(selectedProfile.model); setThinkingLevel(selectedProfile.thinkingLevel); - setPhaseModels(selectedProfile.phaseModels || DEFAULT_PHASE_MODELS); - setPhaseThinking(selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING); + setPhaseModels(settings.customPhaseModels || selectedProfile.phaseModels || DEFAULT_PHASE_MODELS); + setPhaseThinking(settings.customPhaseThinking || selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING); } } - }, [open, projectId, settings.selectedAgentProfile, selectedProfile.model, selectedProfile.thinkingLevel]); + }, [open, projectId, settings.selectedAgentProfile, settings.customPhaseModels, settings.customPhaseThinking, selectedProfile.model, selectedProfile.thinkingLevel]); // Fetch branches and project default branch when dialog opens useEffect(() => { @@ -542,12 +543,12 @@ export function TaskCreationWizard({ setPriority(''); setComplexity(''); setImpact(''); - // Reset to selected profile defaults + // Reset to selected profile defaults and custom settings setProfileId(settings.selectedAgentProfile || 'auto'); setModel(selectedProfile.model); setThinkingLevel(selectedProfile.thinkingLevel); - setPhaseModels(selectedProfile.phaseModels || DEFAULT_PHASE_MODELS); - setPhaseThinking(selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING); + setPhaseModels(settings.customPhaseModels || selectedProfile.phaseModels || DEFAULT_PHASE_MODELS); + setPhaseThinking(settings.customPhaseThinking || selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING); setImages([]); setReferencedFiles([]); setRequireReviewBeforeCoding(false); diff --git a/auto-claude-ui/src/renderer/components/onboarding/GraphitiStep.tsx b/auto-claude-ui/src/renderer/components/onboarding/GraphitiStep.tsx index f6783ba4..7f765ced 100644 --- a/auto-claude-ui/src/renderer/components/onboarding/GraphitiStep.tsx +++ b/auto-claude-ui/src/renderer/components/onboarding/GraphitiStep.tsx @@ -26,7 +26,7 @@ import { SelectValue } from '../ui/select'; import { useSettingsStore } from '../../stores/settings-store'; -import type { GraphitiLLMProvider, GraphitiEmbeddingProvider } from '../../../shared/types'; +import type { GraphitiLLMProvider, GraphitiEmbeddingProvider, AppSettings } from '../../../shared/types'; interface GraphitiStepProps { onNext: () => void; @@ -314,8 +314,8 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) { const result = await window.electronAPI.saveSettings(settingsToSave); if (result?.success) { - // Update local settings store - const storeUpdate: Record = {}; + // Update local settings store with API key settings + const storeUpdate: Partial> = {}; if (config.openaiApiKey.trim()) storeUpdate.globalOpenAIApiKey = config.openaiApiKey.trim(); if (config.anthropicApiKey.trim()) storeUpdate.globalAnthropicApiKey = config.anthropicApiKey.trim(); if (config.googleApiKey.trim()) storeUpdate.globalGoogleApiKey = config.googleApiKey.trim(); diff --git a/auto-claude-ui/src/renderer/components/project-settings/GitHubOAuthFlow.tsx b/auto-claude-ui/src/renderer/components/project-settings/GitHubOAuthFlow.tsx index c743cd6c..16764b24 100644 --- a/auto-claude-ui/src/renderer/components/project-settings/GitHubOAuthFlow.tsx +++ b/auto-claude-ui/src/renderer/components/project-settings/GitHubOAuthFlow.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { Github, Loader2, @@ -6,7 +6,10 @@ import { AlertCircle, Info, ExternalLink, - Terminal + Terminal, + Copy, + Check, + Clock } from 'lucide-react'; import { Button } from '../ui/button'; import { Card, CardContent } from '../ui/card'; @@ -29,6 +32,10 @@ function debugLog(message: string, data?: unknown) { } } +// Authentication timeout in milliseconds (5 minutes) +// GitHub device codes typically expire after 15 minutes, but 5 minutes is a reasonable UX timeout +const AUTH_TIMEOUT_MS = 5 * 60 * 1000; + /** * GitHub OAuth flow component using gh CLI * Guides users through authenticating with GitHub using the gh CLI @@ -40,10 +47,54 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { const [cliVersion, setCliVersion] = useState(); const [username, setUsername] = useState(); + // Device flow state for displaying code and auth URL + const [deviceCode, setDeviceCode] = useState(null); + const [authUrl, setAuthUrl] = useState(null); + const [browserOpened, setBrowserOpened] = useState(false); + const [codeCopied, setCodeCopied] = useState(false); + const [urlCopied, setUrlCopied] = useState(false); + const [isTimeout, setIsTimeout] = useState(false); + + // Ref to track authentication timeout + const authTimeoutRef = useRef | null>(null); + // Refs to track copy feedback timeouts + const codeCopyTimeoutRef = useRef | null>(null); + const urlCopyTimeoutRef = useRef | null>(null); + // Check gh CLI installation and authentication status on mount // Use a ref to prevent double-execution in React Strict Mode const hasCheckedRef = useRef(false); + // Clear the authentication timeout + const clearAuthTimeout = useCallback(() => { + if (authTimeoutRef.current) { + debugLog('Clearing auth timeout'); + clearTimeout(authTimeoutRef.current); + authTimeoutRef.current = null; + } + }, []); + + // Cleanup copy feedback timeouts on unmount + useEffect(() => { + return () => { + if (codeCopyTimeoutRef.current) { + clearTimeout(codeCopyTimeoutRef.current); + } + if (urlCopyTimeoutRef.current) { + clearTimeout(urlCopyTimeoutRef.current); + } + }; + }, []); + + // Handle authentication timeout + const handleAuthTimeout = useCallback(() => { + debugLog('Authentication timeout triggered after 5 minutes'); + setIsTimeout(true); + setError('Authentication timed out. The authentication window was open for too long. Please try again.'); + setStatus('error'); + authTimeoutRef.current = null; + }, []); + useEffect(() => { if (hasCheckedRef.current) { debugLog('Skipping duplicate check (Strict Mode)'); @@ -52,8 +103,13 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { hasCheckedRef.current = true; debugLog('Component mounted, checking GitHub status...'); checkGitHubStatus(); + + // Cleanup timeout on unmount + return () => { + clearAuthTimeout(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps -- Only run once on mount, checkGitHubStatus is intentionally excluded - }, []); + }, [clearAuthTimeout]); const checkGitHubStatus = async () => { debugLog('checkGitHubStatus() called'); @@ -138,21 +194,59 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { setStatus('authenticating'); setError(null); + // Reset device flow state + setDeviceCode(null); + setAuthUrl(null); + setBrowserOpened(false); + setCodeCopied(false); + setUrlCopied(false); + setIsTimeout(false); + + // Clear any existing timeout and start a new one + clearAuthTimeout(); + debugLog(`Starting auth timeout (${AUTH_TIMEOUT_MS / 1000 / 60} minutes)`); + authTimeoutRef.current = setTimeout(handleAuthTimeout, AUTH_TIMEOUT_MS); + try { debugLog('Calling startGitHubAuth...'); const result = await window.electronAPI.startGitHubAuth(); debugLog('startGitHubAuth result:', result); + // Clear timeout since we got a response + clearAuthTimeout(); + + // Capture device flow info if available + if (result.data?.deviceCode) { + debugLog('Device code received:', result.data.deviceCode); + setDeviceCode(result.data.deviceCode); + } + if (result.data?.authUrl) { + debugLog('Auth URL received:', result.data.authUrl); + setAuthUrl(result.data.authUrl); + } + if (result.data?.browserOpened !== undefined) { + debugLog('Browser opened status:', result.data.browserOpened); + setBrowserOpened(result.data.browserOpened); + } + if (result.success && result.data?.success) { debugLog('Auth successful, fetching token...'); // Fetch the token and notify parent await fetchAndNotifyToken(); } else { debugLog('Auth failed:', result.error); - setError(result.error || 'Authentication failed'); + // Include fallback URL info in error message if available + const errorMessage = result.error || 'Authentication failed'; + setError(errorMessage); + // Keep authUrl from response for fallback display + if (result.data?.fallbackUrl) { + setAuthUrl(result.data.fallbackUrl); + } setStatus('error'); } } catch (err) { + // Clear timeout on error + clearAuthTimeout(); debugLog('Error in handleStartAuth:', err); setError(err instanceof Error ? err.message : 'Authentication failed'); setStatus('error'); @@ -169,6 +263,30 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { checkGitHubStatus(); }; + const handleCopyDeviceCode = async () => { + if (!deviceCode) return; + debugLog('Copying device code to clipboard'); + try { + await navigator.clipboard.writeText(deviceCode); + setCodeCopied(true); + // Clear any existing timeout before setting a new one + if (codeCopyTimeoutRef.current) { + clearTimeout(codeCopyTimeoutRef.current); + } + // Reset the copied state after 2 seconds + codeCopyTimeoutRef.current = setTimeout(() => setCodeCopied(false), 2000); + } catch (err) { + debugLog('Failed to copy device code:', err); + } + }; + + const handleOpenAuthUrl = () => { + if (authUrl) { + debugLog('Opening auth URL manually:', authUrl); + window.open(authUrl, '_blank'); + } + }; + debugLog('Rendering with status:', status); return ( @@ -263,21 +381,81 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { {/* Authenticating */} {status === 'authenticating' && ( - - -
- -
-

- Authenticating... -

-

- Please complete the authentication in your browser. This window will update automatically. -

+
+ + +
+ +
+

+ Authenticating... +

+

+ {browserOpened + ? 'Please complete the authentication in your browser. This window will update automatically.' + : 'Waiting for authentication flow to start...'} +

+
-
- - + + + + {/* Device Code Display */} + {deviceCode && ( + + +
+
+

+ Your one-time code +

+
+ + {deviceCode} + + +
+
+ +
+

+ {browserOpened + ? 'Enter this code in your browser to complete authentication.' + : 'Copy this code, then open the link below to authenticate.'} +

+ {!browserOpened && authUrl && ( + + )} +
+
+
+
+ )} +
)} {/* Success */} @@ -302,22 +480,106 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) { {/* Error */} {status === 'error' && error && (
- +
- + {isTimeout ? ( + + ) : ( + + )}
-

- Authentication Failed +

+ {isTimeout ? 'Authentication Timed Out' : 'Authentication Failed'}

-

{error}

+

{error}

+ {/* Fallback URL display when browser failed to open */} + {authUrl && ( + + +
+
+ +
+

+ Complete Authentication Manually +

+

+ The browser couldn't be opened automatically. Please visit the URL below to complete authentication: +

+
+
+ +
+
+ + {authUrl} + + +
+ + +
+ + {/* Device code reminder if available */} + {deviceCode && ( +
+

+ When prompted, enter this code:{' '} + + {deviceCode} + +

+
+ )} +
+
+
+ )} +
- {onCancel && ( diff --git a/auto-claude-ui/src/renderer/components/settings/AgentProfileSettings.tsx b/auto-claude-ui/src/renderer/components/settings/AgentProfileSettings.tsx new file mode 100644 index 00000000..08a51f32 --- /dev/null +++ b/auto-claude-ui/src/renderer/components/settings/AgentProfileSettings.tsx @@ -0,0 +1,304 @@ +import { useState } from 'react'; +import { Brain, Scale, Zap, Check, Sparkles, ChevronDown, ChevronUp, RotateCcw } from 'lucide-react'; +import { cn } from '../../lib/utils'; +import { + DEFAULT_AGENT_PROFILES, + AVAILABLE_MODELS, + THINKING_LEVELS, + DEFAULT_PHASE_MODELS, + DEFAULT_PHASE_THINKING +} from '../../../shared/constants'; +import { useSettingsStore, saveSettings } from '../../stores/settings-store'; +import { SettingsSection } from './SettingsSection'; +import { Label } from '../ui/label'; +import { Button } from '../ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '../ui/select'; +import type { AgentProfile, PhaseModelConfig, PhaseThinkingConfig, ModelTypeShort, ThinkingLevel } from '../../../shared/types/settings'; + +/** + * Icon mapping for agent profile icons + */ +const iconMap: Record = { + Brain, + Scale, + Zap, + Sparkles +}; + +const PHASE_LABELS: Record = { + spec: { label: 'Spec Creation', description: 'Discovery, requirements, context gathering' }, + planning: { label: 'Planning', description: 'Implementation planning and architecture' }, + coding: { label: 'Coding', description: 'Actual code implementation' }, + qa: { label: 'QA Review', description: 'Quality assurance and validation' } +}; + +/** + * Agent Profile Settings component + * Displays preset agent profiles for quick model/thinking level configuration + * Used in the Settings page under Agent Settings + */ +export function AgentProfileSettings() { + const settings = useSettingsStore((state) => state.settings); + const selectedProfileId = settings.selectedAgentProfile || 'auto'; + const [showPhaseConfig, setShowPhaseConfig] = useState(selectedProfileId === 'auto'); + + // Get current phase config from settings or defaults + const currentPhaseModels: PhaseModelConfig = settings.customPhaseModels || DEFAULT_PHASE_MODELS; + const currentPhaseThinking: PhaseThinkingConfig = settings.customPhaseThinking || DEFAULT_PHASE_THINKING; + + const handleSelectProfile = async (profileId: string) => { + const success = await saveSettings({ selectedAgentProfile: profileId }); + if (!success) { + // Log error for debugging - in future could show user toast notification + console.error('Failed to save agent profile selection'); + return; + } + // Auto-expand phase config when Auto profile is selected + if (profileId === 'auto') { + setShowPhaseConfig(true); + } + }; + + const handlePhaseModelChange = async (phase: keyof PhaseModelConfig, value: ModelTypeShort) => { + const newPhaseModels = { ...currentPhaseModels, [phase]: value }; + await saveSettings({ customPhaseModels: newPhaseModels }); + }; + + const handlePhaseThinkingChange = async (phase: keyof PhaseThinkingConfig, value: ThinkingLevel) => { + const newPhaseThinking = { ...currentPhaseThinking, [phase]: value }; + await saveSettings({ customPhaseThinking: newPhaseThinking }); + }; + + const handleResetToDefaults = async () => { + await saveSettings({ + customPhaseModels: DEFAULT_PHASE_MODELS, + customPhaseThinking: DEFAULT_PHASE_THINKING + }); + }; + + /** + * Get human-readable model label + */ + const getModelLabel = (modelValue: string): string => { + const model = AVAILABLE_MODELS.find((m) => m.value === modelValue); + return model?.label || modelValue; + }; + + /** + * Get human-readable thinking level label + */ + const getThinkingLabel = (thinkingValue: string): string => { + const level = THINKING_LEVELS.find((l) => l.value === thinkingValue); + return level?.label || thinkingValue; + }; + + /** + * Check if current config differs from defaults + */ + const hasCustomConfig = (): boolean => { + const phases: Array = ['spec', 'planning', 'coding', 'qa']; + return phases.some( + phase => + currentPhaseModels[phase] !== DEFAULT_PHASE_MODELS[phase] || + currentPhaseThinking[phase] !== DEFAULT_PHASE_THINKING[phase] + ); + }; + + /** + * Render a single profile card + */ + const renderProfileCard = (profile: AgentProfile) => { + const isSelected = selectedProfileId === profile.id; + const Icon = iconMap[profile.icon || 'Brain'] || Brain; + + return ( + + ); + }; + + return ( + +
+ {/* Description */} +
+

+ Agent profiles provide preset configurations for Claude model and thinking level. + When you create a new task, these settings will be used as defaults. You can always + override them in the task creation wizard. +

+
+ + {/* Profile cards - 2 column grid on larger screens */} +
+ {DEFAULT_AGENT_PROFILES.map(renderProfileCard)} +
+ + {/* Phase Configuration (only for Auto profile) */} + {selectedProfileId === 'auto' && ( +
+ {/* Header - Collapsible */} + + + {/* Phase Configuration Content */} + {showPhaseConfig && ( +
+ {/* Reset button */} + {hasCustomConfig() && ( +
+ +
+ )} + + {/* Phase Configuration Grid */} +
+ {(Object.keys(PHASE_LABELS) as Array).map((phase) => ( +
+
+ + + {PHASE_LABELS[phase].description} + +
+
+ {/* Model Select */} +
+ + +
+ {/* Thinking Level Select */} +
+ + +
+
+
+ ))} +
+ + {/* Info note */} +

+ These settings will be used as defaults when creating new tasks with the Auto profile. + You can override them per-task in the task creation wizard. +

+
+ )} +
+ )} +
+
+ ); +} diff --git a/auto-claude-ui/src/renderer/components/settings/GeneralSettings.tsx b/auto-claude-ui/src/renderer/components/settings/GeneralSettings.tsx index 1f49c2f3..be4b1a3f 100644 --- a/auto-claude-ui/src/renderer/components/settings/GeneralSettings.tsx +++ b/auto-claude-ui/src/renderer/components/settings/GeneralSettings.tsx @@ -3,6 +3,7 @@ import { Input } from '../ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; import { Switch } from '../ui/switch'; import { SettingsSection } from './SettingsSection'; +import { AgentProfileSettings } from './AgentProfileSettings'; import { AVAILABLE_MODELS } from '../../../shared/constants'; import type { AppSettings } from '../../../shared/types'; @@ -18,64 +19,51 @@ interface GeneralSettingsProps { export function GeneralSettings({ settings, onSettingsChange, section }: GeneralSettingsProps) { if (section === 'agent') { return ( - -
-
- -

The AI model used for agent tasks

- -
-
- -

The coding framework used for autonomous tasks

- -
-
-
-
- -

- Automatically name terminals based on commands (uses Haiku) -

+
+ {/* Agent Profile Selection */} + + + {/* Other Agent Settings */} + +
+
+ +

The coding framework used for autonomous tasks

+ +
+
+
+
+ +

+ Automatically name terminals based on commands (uses Haiku) +

+
+ onSettingsChange({ ...settings, autoNameTerminals: checked })} + />
- onSettingsChange({ ...settings, autoNameTerminals: checked })} - />
-
- + +
); } diff --git a/auto-claude-ui/src/renderer/components/task-detail/TaskDetailModal.tsx b/auto-claude-ui/src/renderer/components/task-detail/TaskDetailModal.tsx index 6163e9f6..8e6510c5 100644 --- a/auto-claude-ui/src/renderer/components/task-detail/TaskDetailModal.tsx +++ b/auto-claude-ui/src/renderer/components/task-detail/TaskDetailModal.tsx @@ -120,6 +120,7 @@ function TaskDetailModalContent({ open, task, onOpenChange }: { open: boolean; t state.setWorkspaceError(null); state.setStagedSuccess(result.data.message || 'Changes staged in main project'); state.setStagedProjectPath(result.data.projectPath); + state.setSuggestedCommitMessage(result.data.suggestedCommitMessage); } else { onOpenChange(false); } @@ -393,6 +394,7 @@ function TaskDetailModalContent({ open, task, onOpenChange }: { open: boolean; t stageOnly={state.stageOnly} stagedSuccess={state.stagedSuccess} stagedProjectPath={state.stagedProjectPath} + suggestedCommitMessage={state.suggestedCommitMessage} mergePreview={state.mergePreview} isLoadingPreview={state.isLoadingPreview} showConflictDialog={state.showConflictDialog} @@ -405,6 +407,7 @@ function TaskDetailModalContent({ open, task, onOpenChange }: { open: boolean; t onStageOnlyChange={state.setStageOnly} onShowConflictDialog={state.setShowConflictDialog} onLoadMergePreview={state.loadMergePreview} + onClose={handleClose} /> )} diff --git a/auto-claude-ui/src/renderer/components/task-detail/TaskDetailPanel.tsx b/auto-claude-ui/src/renderer/components/task-detail/TaskDetailPanel.tsx index 7090f5d7..cb9e6237 100644 --- a/auto-claude-ui/src/renderer/components/task-detail/TaskDetailPanel.tsx +++ b/auto-claude-ui/src/renderer/components/task-detail/TaskDetailPanel.tsx @@ -84,6 +84,7 @@ export function TaskDetailPanel({ task, onClose }: TaskDetailPanelProps) { state.setWorkspaceError(null); state.setStagedSuccess(result.data.message || 'Changes staged in main project'); state.setStagedProjectPath(result.data.projectPath); + state.setSuggestedCommitMessage(result.data.suggestedCommitMessage); } else { console.warn('[TaskDetailPanel] Full merge success, closing panel'); onClose(); @@ -196,6 +197,7 @@ export function TaskDetailPanel({ task, onClose }: TaskDetailPanelProps) { stageOnly={state.stageOnly} stagedSuccess={state.stagedSuccess} stagedProjectPath={state.stagedProjectPath} + suggestedCommitMessage={state.suggestedCommitMessage} mergePreview={state.mergePreview} isLoadingPreview={state.isLoadingPreview} showConflictDialog={state.showConflictDialog} diff --git a/auto-claude-ui/src/renderer/components/task-detail/TaskReview.tsx b/auto-claude-ui/src/renderer/components/task-detail/TaskReview.tsx index bf633524..ebfd96d5 100644 --- a/auto-claude-ui/src/renderer/components/task-detail/TaskReview.tsx +++ b/auto-claude-ui/src/renderer/components/task-detail/TaskReview.tsx @@ -26,6 +26,7 @@ interface TaskReviewProps { stageOnly: boolean; stagedSuccess: string | null; stagedProjectPath: string | undefined; + suggestedCommitMessage: string | undefined; mergePreview: { files: string[]; conflicts: MergeConflict[]; summary: MergeStats; gitConflicts?: GitConflictInfo; uncommittedChanges?: { hasChanges: boolean; files: string[]; count: number } | null } | null; isLoadingPreview: boolean; showConflictDialog: boolean; @@ -38,6 +39,7 @@ interface TaskReviewProps { onStageOnlyChange: (value: boolean) => void; onShowConflictDialog: (show: boolean) => void; onLoadMergePreview: () => void; + onClose?: () => void; } /** @@ -64,6 +66,7 @@ export function TaskReview({ stageOnly, stagedSuccess, stagedProjectPath, + suggestedCommitMessage, mergePreview, isLoadingPreview, showConflictDialog, @@ -75,7 +78,8 @@ export function TaskReview({ onShowDiffDialog, onStageOnlyChange, onShowConflictDialog, - onLoadMergePreview + onLoadMergePreview, + onClose }: TaskReviewProps) { return (
@@ -88,6 +92,7 @@ export function TaskReview({ stagedSuccess={stagedSuccess} stagedProjectPath={stagedProjectPath} task={task} + suggestedCommitMessage={suggestedCommitMessage} /> )} @@ -116,9 +121,10 @@ export function TaskReview({ task={task} projectPath={stagedProjectPath} hasWorktree={worktreeStatus?.exists || false} + onClose={onClose} /> ) : ( - + )} {/* QA Feedback Section */} diff --git a/auto-claude-ui/src/renderer/components/task-detail/hooks/useTaskDetail.ts b/auto-claude-ui/src/renderer/components/task-detail/hooks/useTaskDetail.ts index 71ad2a2c..9d5aab0c 100644 --- a/auto-claude-ui/src/renderer/components/task-detail/hooks/useTaskDetail.ts +++ b/auto-claude-ui/src/renderer/components/task-detail/hooks/useTaskDetail.ts @@ -30,6 +30,7 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) { const [stageOnly, setStageOnly] = useState(task.status === 'human_review'); const [stagedSuccess, setStagedSuccess] = useState(null); const [stagedProjectPath, setStagedProjectPath] = useState(undefined); + const [suggestedCommitMessage, setSuggestedCommitMessage] = useState(undefined); const [phaseLogs, setPhaseLogs] = useState(null); const [isLoadingLogs, setIsLoadingLogs] = useState(false); const [expandedPhases, setExpandedPhases] = useState>(new Set()); @@ -279,6 +280,7 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) { stageOnly, stagedSuccess, stagedProjectPath, + suggestedCommitMessage, phaseLogs, isLoadingLogs, expandedPhases, @@ -318,6 +320,7 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) { setStageOnly, setStagedSuccess, setStagedProjectPath, + setSuggestedCommitMessage, setPhaseLogs, setIsLoadingLogs, setExpandedPhases, diff --git a/auto-claude-ui/src/renderer/components/task-detail/task-review/StagedSuccessMessage.tsx b/auto-claude-ui/src/renderer/components/task-detail/task-review/StagedSuccessMessage.tsx index 802f4e78..95d3e5f3 100644 --- a/auto-claude-ui/src/renderer/components/task-detail/task-review/StagedSuccessMessage.tsx +++ b/auto-claude-ui/src/renderer/components/task-detail/task-review/StagedSuccessMessage.tsx @@ -1,11 +1,14 @@ -import { GitMerge, ExternalLink } from 'lucide-react'; +import { useState } from 'react'; +import { GitMerge, ExternalLink, Copy, Check, Sparkles } from 'lucide-react'; import { Button } from '../../ui/button'; +import { Textarea } from '../../ui/textarea'; import type { Task } from '../../../../shared/types'; interface StagedSuccessMessageProps { stagedSuccess: string; stagedProjectPath: string | undefined; task: Task; + suggestedCommitMessage?: string; } /** @@ -14,8 +17,23 @@ interface StagedSuccessMessageProps { export function StagedSuccessMessage({ stagedSuccess, stagedProjectPath, - task + task, + suggestedCommitMessage }: StagedSuccessMessageProps) { + const [commitMessage, setCommitMessage] = useState(suggestedCommitMessage || ''); + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + if (!commitMessage) return; + try { + await navigator.clipboard.writeText(commitMessage); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + return (

@@ -25,6 +43,47 @@ export function StagedSuccessMessage({

{stagedSuccess}

+ + {/* Commit Message Section */} + {suggestedCommitMessage && ( +
+
+

+ + AI-generated commit message +

+ +
+