Merge pull request #69 from AndyMik90/v2.6.0

Version 2.6.0
This commit is contained in:
Andy
2025-12-20 14:36:09 +01:00
committed by GitHub
37 changed files with 2561 additions and 221 deletions
+1 -1
View File
@@ -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",
@@ -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<string, Function> = 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<unknown> {
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<typeof vi.fn>; end: ReturnType<typeof vi.fn> } | null;
} {
const proc = new EventEmitter() as EventEmitter & {
stdout: EventEmitter | null;
stderr: EventEmitter | null;
stdin: { write: ReturnType<typeof vi.fn>; end: ReturnType<typeof vi.fn> } | 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<string, Function>;
invokeHandler: (channel: string, event: unknown, ...args: unknown[]) => Promise<unknown>;
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');
});
});
});
@@ -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<IPCResult<{ success: boolean; message?: string }>> => {
async (): Promise<IPCResult<GitHubAuthStartResult>> => {
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.'
}
});
}
});
@@ -94,7 +94,8 @@ export function registerSettingsHandlers(
ipcMain.handle(
IPC_CHANNELS.SETTINGS_GET,
async (): Promise<IPCResult<AppSettings>> => {
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 };
}
);
@@ -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 {
@@ -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';
}
-4
View File
@@ -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 && (
<Worktrees projectId={selectedProjectId} />
)}
{activeView === 'agent-profiles' && (
<AgentProfiles />
)}
{activeView === 'agent-tools' && (
<div className="flex h-full items-center justify-center">
<div className="text-center">
@@ -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 && (
<div className="space-y-3 rounded-lg border border-border bg-muted/30 p-4">
{/* Phase Summary */}
<div className="space-y-2">
<button
type="button"
onClick={() => setShowPhaseDetails(!showPhaseDetails)}
className={cn(
'flex w-full items-center justify-between text-sm',
'text-muted-foreground hover:text-foreground transition-colors'
<div className="rounded-lg border border-border bg-muted/30 overflow-hidden">
{/* Clickable Header */}
<button
type="button"
onClick={() => setShowPhaseDetails(!showPhaseDetails)}
className={cn(
'flex w-full items-center justify-between p-4 text-left',
'hover:bg-muted/50 transition-colors',
!disabled && 'cursor-pointer'
)}
disabled={disabled}
>
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-foreground">Phase Configuration</span>
{!showPhaseDetails && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Pencil className="h-3 w-3" />
<span>Click to customize</span>
</span>
)}
disabled={disabled}
>
<span className="font-medium text-foreground">Phase Configuration</span>
{showPhaseDetails ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
</div>
{showPhaseDetails ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</button>
{/* Compact summary when collapsed */}
{!showPhaseDetails && (
{/* Compact summary when collapsed */}
{!showPhaseDetails && (
<div className="px-4 pb-4 -mt-1">
<div className="grid grid-cols-2 gap-2 text-xs">
{(Object.keys(PHASE_LABELS) as Array<keyof PhaseModelConfig>).map((phase) => {
const modelLabel = AVAILABLE_MODELS.find(m => m.value === currentPhaseModels[phase])?.label?.replace('Claude ', '') || currentPhaseModels[phase];
@@ -253,55 +262,61 @@ export function AgentProfileSelector({
);
})}
</div>
)}
</div>
</div>
)}
{/* Detailed Phase Configuration */}
{showPhaseDetails && (
<div className="space-y-4 pt-2">
<div className="px-4 pb-4 space-y-4 border-t border-border pt-4">
{(Object.keys(PHASE_LABELS) as Array<keyof PhaseModelConfig>).map((phase) => (
<div key={phase} className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium text-muted-foreground">
<Label className="text-xs font-medium text-foreground">
{PHASE_LABELS[phase].label}
</Label>
<span className="text-xs text-muted-foreground">
<span className="text-[10px] text-muted-foreground">
{PHASE_LABELS[phase].description}
</span>
</div>
<div className="grid grid-cols-2 gap-2">
<Select
value={currentPhaseModels[phase]}
onValueChange={(value) => handlePhaseModelChange(phase, value as ModelType)}
disabled={disabled}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AVAILABLE_MODELS.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={currentPhaseThinking[phase]}
onValueChange={(value) => handlePhaseThinkingChange(phase, value as ThinkingLevel)}
disabled={disabled}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{THINKING_LEVELS.map((level) => (
<SelectItem key={level.value} value={level.value}>
{level.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">Model</Label>
<Select
value={currentPhaseModels[phase]}
onValueChange={(value) => handlePhaseModelChange(phase, value as ModelType)}
disabled={disabled}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AVAILABLE_MODELS.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">Thinking</Label>
<Select
value={currentPhaseThinking[phase]}
onValueChange={(value) => handlePhaseThinkingChange(phase, value as ThinkingLevel)}
disabled={disabled}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{THINKING_LEVELS.map((level) => (
<SelectItem key={level.value} value={level.value}>
{level.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
))}
@@ -19,7 +19,7 @@ const iconMap: Record<string, React.ElementType> = {
*/
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 });
@@ -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({
@@ -102,11 +102,12 @@ export function TaskCreationWizard({
const [model, setModel] = useState<ModelType | ''>(selectedProfile.model);
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel | ''>(selectedProfile.thinkingLevel);
// Auto profile - per-phase configuration
// Use custom settings from app settings if available, otherwise fall back to defaults
const [phaseModels, setPhaseModels] = useState<PhaseModelConfig | undefined>(
selectedProfile.phaseModels || DEFAULT_PHASE_MODELS
settings.customPhaseModels || selectedProfile.phaseModels || DEFAULT_PHASE_MODELS
);
const [phaseThinking, setPhaseThinking] = useState<PhaseThinkingConfig | undefined>(
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);
@@ -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<string, string> = {};
// Update local settings store with API key settings
const storeUpdate: Partial<Pick<AppSettings, 'globalOpenAIApiKey' | 'globalAnthropicApiKey' | 'globalGoogleApiKey' | 'globalGroqApiKey' | 'ollamaBaseUrl'>> = {};
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();
@@ -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<string | undefined>();
const [username, setUsername] = useState<string | undefined>();
// Device flow state for displaying code and auth URL
const [deviceCode, setDeviceCode] = useState<string | null>(null);
const [authUrl, setAuthUrl] = useState<string | null>(null);
const [browserOpened, setBrowserOpened] = useState<boolean>(false);
const [codeCopied, setCodeCopied] = useState<boolean>(false);
const [urlCopied, setUrlCopied] = useState<boolean>(false);
const [isTimeout, setIsTimeout] = useState<boolean>(false);
// Ref to track authentication timeout
const authTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Refs to track copy feedback timeouts
const codeCopyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const urlCopyTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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' && (
<Card className="border border-info/30 bg-info/10">
<CardContent className="p-6">
<div className="flex items-center gap-4">
<Loader2 className="h-6 w-6 animate-spin text-info shrink-0" />
<div className="flex-1">
<h3 className="text-lg font-medium text-foreground">
Authenticating...
</h3>
<p className="text-sm text-muted-foreground mt-1">
Please complete the authentication in your browser. This window will update automatically.
</p>
<div className="space-y-4">
<Card className="border border-info/30 bg-info/10">
<CardContent className="p-6">
<div className="flex items-center gap-4">
<Loader2 className="h-6 w-6 animate-spin text-info shrink-0" />
<div className="flex-1">
<h3 className="text-lg font-medium text-foreground">
Authenticating...
</h3>
<p className="text-sm text-muted-foreground mt-1">
{browserOpened
? 'Please complete the authentication in your browser. This window will update automatically.'
: 'Waiting for authentication flow to start...'}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
{/* Device Code Display */}
{deviceCode && (
<Card className="border border-primary/30 bg-primary/5">
<CardContent className="p-6">
<div className="text-center space-y-4">
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">
Your one-time code
</p>
<div className="flex items-center justify-center gap-3">
<code className="text-3xl font-mono font-bold tracking-widest text-primary px-4 py-2 bg-primary/10 rounded-lg">
{deviceCode}
</code>
<Button
variant="outline"
size="sm"
onClick={handleCopyDeviceCode}
className="shrink-0"
>
{codeCopied ? (
<>
<Check className="h-4 w-4 mr-1 text-success" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
Copy
</>
)}
</Button>
</div>
</div>
<div className="text-sm text-muted-foreground space-y-2">
<p>
{browserOpened
? 'Enter this code in your browser to complete authentication.'
: 'Copy this code, then open the link below to authenticate.'}
</p>
{!browserOpened && authUrl && (
<Button
variant="link"
onClick={handleOpenAuthUrl}
className="text-info hover:text-info/80 p-0 h-auto gap-1"
>
<ExternalLink className="h-4 w-4" />
Open {authUrl}
</Button>
)}
</div>
</div>
</CardContent>
</Card>
)}
</div>
)}
{/* Success */}
@@ -302,22 +480,106 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) {
{/* Error */}
{status === 'error' && error && (
<div className="space-y-4">
<Card className="border border-destructive/30 bg-destructive/10">
<Card className={`border ${isTimeout ? 'border-warning/30 bg-warning/10' : 'border-destructive/30 bg-destructive/10'}`}>
<CardContent className="p-5">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
{isTimeout ? (
<Clock className="h-5 w-5 text-warning shrink-0 mt-0.5" />
) : (
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
)}
<div className="flex-1">
<h3 className="text-lg font-medium text-destructive">
Authentication Failed
<h3 className={`text-lg font-medium ${isTimeout ? 'text-warning' : 'text-destructive'}`}>
{isTimeout ? 'Authentication Timed Out' : 'Authentication Failed'}
</h3>
<p className="text-sm text-destructive/80 mt-1">{error}</p>
<p className={`text-sm mt-1 ${isTimeout ? 'text-warning/80' : 'text-destructive/80'}`}>{error}</p>
</div>
</div>
</CardContent>
</Card>
{/* Fallback URL display when browser failed to open */}
{authUrl && (
<Card className="border border-warning/30 bg-warning/10">
<CardContent className="p-5">
<div className="space-y-4">
<div className="flex items-start gap-3">
<Info className="h-5 w-5 text-warning shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-base font-medium text-foreground">
Complete Authentication Manually
</h3>
<p className="text-sm text-muted-foreground mt-1">
The browser couldn't be opened automatically. Please visit the URL below to complete authentication:
</p>
</div>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 p-3 bg-muted rounded-lg">
<code className="text-sm font-mono text-foreground flex-1 break-all">
{authUrl}
</code>
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
await navigator.clipboard.writeText(authUrl);
setUrlCopied(true);
// Clear any existing timeout before setting a new one
if (urlCopyTimeoutRef.current) {
clearTimeout(urlCopyTimeoutRef.current);
}
urlCopyTimeoutRef.current = setTimeout(() => setUrlCopied(false), 2000);
} catch (err) {
debugLog('Failed to copy URL:', err);
}
}}
className="shrink-0"
>
{urlCopied ? (
<>
<Check className="h-4 w-4 mr-1 text-success" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
Copy
</>
)}
</Button>
</div>
<Button
variant="secondary"
onClick={handleOpenAuthUrl}
className="gap-2"
>
<ExternalLink className="h-4 w-4" />
Open URL in Browser
</Button>
</div>
{/* Device code reminder if available */}
{deviceCode && (
<div className="pt-2 border-t border-warning/20">
<p className="text-sm text-muted-foreground">
When prompted, enter this code:{' '}
<code className="font-mono font-bold text-primary px-2 py-0.5 bg-primary/10 rounded">
{deviceCode}
</code>
</p>
</div>
)}
</div>
</CardContent>
</Card>
)}
<div className="flex justify-center gap-3">
<Button onClick={handleRetry} variant="outline">
<Button onClick={handleStartAuth} variant="outline">
Retry
</Button>
{onCancel && (
@@ -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<string, React.ElementType> = {
Brain,
Scale,
Zap,
Sparkles
};
const PHASE_LABELS: Record<keyof PhaseModelConfig, { label: string; description: string }> = {
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<keyof PhaseModelConfig> = ['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 (
<button
key={profile.id}
onClick={() => handleSelectProfile(profile.id)}
className={cn(
'relative w-full rounded-lg border p-4 text-left transition-all duration-200',
'hover:border-primary/50 hover:shadow-sm',
isSelected
? 'border-primary bg-primary/5'
: 'border-border bg-card'
)}
>
{/* Selected indicator */}
{isSelected && (
<div className="absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded-full bg-primary">
<Check className="h-3 w-3 text-primary-foreground" />
</div>
)}
{/* Profile content */}
<div className="flex items-start gap-3">
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-lg shrink-0',
isSelected ? 'bg-primary/10' : 'bg-muted'
)}
>
<Icon
className={cn(
'h-5 w-5',
isSelected ? 'text-primary' : 'text-muted-foreground'
)}
/>
</div>
<div className="flex-1 min-w-0 pr-6">
<h3 className="font-medium text-sm text-foreground">{profile.name}</h3>
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-2">
{profile.description}
</p>
{/* Model and thinking level badges */}
<div className="mt-2 flex flex-wrap gap-1.5">
<span className="inline-flex items-center rounded bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{getModelLabel(profile.model)}
</span>
<span className="inline-flex items-center rounded bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{getThinkingLabel(profile.thinkingLevel)} Thinking
</span>
</div>
</div>
</div>
</button>
);
};
return (
<SettingsSection
title="Default Agent Profile"
description="Select a preset configuration for model and thinking level"
>
<div className="space-y-4">
{/* Description */}
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
{/* Profile cards - 2 column grid on larger screens */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{DEFAULT_AGENT_PROFILES.map(renderProfileCard)}
</div>
{/* Phase Configuration (only for Auto profile) */}
{selectedProfileId === 'auto' && (
<div className="mt-6 rounded-lg border border-border bg-card">
{/* Header - Collapsible */}
<button
type="button"
onClick={() => setShowPhaseConfig(!showPhaseConfig)}
className="flex w-full items-center justify-between p-4 text-left hover:bg-muted/50 transition-colors rounded-t-lg"
>
<div>
<h4 className="font-medium text-sm text-foreground">Phase Configuration</h4>
<p className="text-xs text-muted-foreground mt-0.5">
Customize model and thinking level for each phase
</p>
</div>
{showPhaseConfig ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</button>
{/* Phase Configuration Content */}
{showPhaseConfig && (
<div className="border-t border-border p-4 space-y-4">
{/* Reset button */}
{hasCustomConfig() && (
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={handleResetToDefaults}
className="text-xs h-7"
>
<RotateCcw className="h-3 w-3 mr-1.5" />
Reset to defaults
</Button>
</div>
)}
{/* Phase Configuration Grid */}
<div className="space-y-4">
{(Object.keys(PHASE_LABELS) as Array<keyof PhaseModelConfig>).map((phase) => (
<div key={phase} className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-foreground">
{PHASE_LABELS[phase].label}
</Label>
<span className="text-xs text-muted-foreground">
{PHASE_LABELS[phase].description}
</span>
</div>
<div className="grid grid-cols-2 gap-3">
{/* Model Select */}
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Model</Label>
<Select
value={currentPhaseModels[phase]}
onValueChange={(value) => handlePhaseModelChange(phase, value as ModelTypeShort)}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AVAILABLE_MODELS.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Thinking Level Select */}
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Thinking Level</Label>
<Select
value={currentPhaseThinking[phase]}
onValueChange={(value) => handlePhaseThinkingChange(phase, value as ThinkingLevel)}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{THINKING_LEVELS.map((level) => (
<SelectItem key={level.value} value={level.value}>
{level.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
))}
</div>
{/* Info note */}
<p className="text-[10px] text-muted-foreground mt-4 pt-3 border-t border-border">
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.
</p>
</div>
)}
</div>
)}
</div>
</SettingsSection>
);
}
@@ -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 (
<SettingsSection
title="Default Agent Settings"
description="Configure defaults for new projects"
>
<div className="space-y-6">
<div className="space-y-3">
<Label htmlFor="defaultModel" className="text-sm font-medium text-foreground">Default Model</Label>
<p className="text-sm text-muted-foreground">The AI model used for agent tasks</p>
<Select
value={settings.defaultModel}
onValueChange={(value) => onSettingsChange({ ...settings, defaultModel: value })}
>
<SelectTrigger id="defaultModel" className="w-full max-w-md">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AVAILABLE_MODELS.map((model) => (
<SelectItem key={model.value} value={model.value}>
{model.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<Label htmlFor="agentFramework" className="text-sm font-medium text-foreground">Agent Framework</Label>
<p className="text-sm text-muted-foreground">The coding framework used for autonomous tasks</p>
<Select
value={settings.agentFramework}
onValueChange={(value) => onSettingsChange({ ...settings, agentFramework: value })}
>
<SelectTrigger id="agentFramework" className="w-full max-w-md">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto-claude">Auto Claude</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between max-w-md">
<div className="space-y-1">
<Label htmlFor="autoNameTerminals" className="text-sm font-medium text-foreground">
AI Terminal Naming
</Label>
<p className="text-sm text-muted-foreground">
Automatically name terminals based on commands (uses Haiku)
</p>
<div className="space-y-8">
{/* Agent Profile Selection */}
<AgentProfileSettings />
{/* Other Agent Settings */}
<SettingsSection
title="Other Agent Settings"
description="Additional agent configuration options"
>
<div className="space-y-6">
<div className="space-y-3">
<Label htmlFor="agentFramework" className="text-sm font-medium text-foreground">Agent Framework</Label>
<p className="text-sm text-muted-foreground">The coding framework used for autonomous tasks</p>
<Select
value={settings.agentFramework}
onValueChange={(value) => onSettingsChange({ ...settings, agentFramework: value })}
>
<SelectTrigger id="agentFramework" className="w-full max-w-md">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto-claude">Auto Claude</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between max-w-md">
<div className="space-y-1">
<Label htmlFor="autoNameTerminals" className="text-sm font-medium text-foreground">
AI Terminal Naming
</Label>
<p className="text-sm text-muted-foreground">
Automatically name terminals based on commands (uses Haiku)
</p>
</div>
<Switch
id="autoNameTerminals"
checked={settings.autoNameTerminals}
onCheckedChange={(checked) => onSettingsChange({ ...settings, autoNameTerminals: checked })}
/>
</div>
<Switch
id="autoNameTerminals"
checked={settings.autoNameTerminals}
onCheckedChange={(checked) => onSettingsChange({ ...settings, autoNameTerminals: checked })}
/>
</div>
</div>
</div>
</SettingsSection>
</SettingsSection>
</div>
);
}
@@ -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}
/>
</>
)}
@@ -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}
@@ -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 (
<div className="space-y-4">
@@ -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}
/>
) : (
<NoWorkspaceMessage task={task} />
<NoWorkspaceMessage task={task} onClose={onClose} />
)}
{/* QA Feedback Section */}
@@ -30,6 +30,7 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
const [stageOnly, setStageOnly] = useState(task.status === 'human_review');
const [stagedSuccess, setStagedSuccess] = useState<string | null>(null);
const [stagedProjectPath, setStagedProjectPath] = useState<string | undefined>(undefined);
const [suggestedCommitMessage, setSuggestedCommitMessage] = useState<string | undefined>(undefined);
const [phaseLogs, setPhaseLogs] = useState<TaskLogs | null>(null);
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
const [expandedPhases, setExpandedPhases] = useState<Set<TaskLogPhase>>(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,
@@ -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 (
<div className="rounded-xl border border-success/30 bg-success/10 p-4">
<h3 className="font-medium text-sm text-foreground mb-2 flex items-center gap-2">
@@ -25,6 +43,47 @@ export function StagedSuccessMessage({
<p className="text-sm text-muted-foreground mb-3">
{stagedSuccess}
</p>
{/* Commit Message Section */}
{suggestedCommitMessage && (
<div className="bg-background/50 rounded-lg p-3 mb-3">
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<Sparkles className="h-3 w-3 text-purple-400" />
AI-generated commit message
</p>
<Button
variant="ghost"
size="sm"
onClick={handleCopy}
className="h-6 px-2 text-xs"
disabled={!commitMessage}
>
{copied ? (
<>
<Check className="h-3 w-3 mr-1 text-success" />
Copied!
</>
) : (
<>
<Copy className="h-3 w-3 mr-1" />
Copy
</>
)}
</Button>
</div>
<Textarea
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
className="font-mono text-xs min-h-[100px] bg-background/80 resize-y"
placeholder="Commit message..."
/>
<p className="text-[10px] text-muted-foreground mt-1.5">
Edit as needed, then copy and use with <code className="bg-background px-1 rounded">git commit -m "..."</code>
</p>
</div>
)}
<div className="bg-background/50 rounded-lg p-3 mb-3">
<p className="text-xs text-muted-foreground mb-2">Next steps:</p>
<ol className="text-xs text-muted-foreground space-y-1 list-decimal list-inside">
@@ -24,12 +24,13 @@ export function LoadingMessage({ message = 'Loading workspace info...' }: Loadin
interface NoWorkspaceMessageProps {
task?: Task;
onClose?: () => void;
}
/**
* Displays message when no workspace is found for the task
*/
export function NoWorkspaceMessage({ task }: NoWorkspaceMessageProps) {
export function NoWorkspaceMessage({ task, onClose }: NoWorkspaceMessageProps) {
const [isMarkingDone, setIsMarkingDone] = useState(false);
const handleMarkDone = async () => {
@@ -38,6 +39,8 @@ export function NoWorkspaceMessage({ task }: NoWorkspaceMessageProps) {
setIsMarkingDone(true);
try {
await persistTaskStatus(task.id, 'done');
// Auto-close modal after marking as done
onClose?.();
} catch (err) {
console.error('Error marking task as done:', err);
} finally {
@@ -85,12 +88,13 @@ interface StagedInProjectMessageProps {
task: Task;
projectPath?: string;
hasWorktree?: boolean;
onClose?: () => void;
}
/**
* Displays message when changes have already been staged in the main project
*/
export function StagedInProjectMessage({ task, projectPath, hasWorktree = false }: StagedInProjectMessageProps) {
export function StagedInProjectMessage({ task, projectPath, hasWorktree = false, onClose }: StagedInProjectMessageProps) {
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -110,7 +114,8 @@ export function StagedInProjectMessage({ task, projectPath, hasWorktree = false
// Mark task as done
await persistTaskStatus(task.id, 'done');
// Success - the UI will update automatically via the store
// Auto-close modal after marking as done
onClose?.();
} catch (err) {
console.error('Error deleting worktree:', err);
setError(err instanceof Error ? err.message : 'Failed to delete worktree');
+8 -1
View File
@@ -319,7 +319,14 @@ export interface ElectronAPI {
// GitHub OAuth operations (gh CLI)
checkGitHubCli: () => Promise<IPCResult<{ installed: boolean; version?: string }>>;
checkGitHubAuth: () => Promise<IPCResult<{ authenticated: boolean; username?: string }>>;
startGitHubAuth: () => Promise<IPCResult<{ success: boolean; message?: string }>>;
startGitHubAuth: () => Promise<IPCResult<{
success: boolean;
message?: string;
deviceCode?: string;
authUrl?: string;
browserOpened?: boolean;
fallbackUrl?: string;
}>>;
getGitHubToken: () => Promise<IPCResult<{ token: string }>>;
getGitHubUser: () => Promise<IPCResult<{ username: string; name?: string }>>;
listGitHubUserRepos: () => Promise<IPCResult<{ repos: Array<{ fullName: string; description: string | null; isPrivate: boolean }> }>>;
@@ -82,10 +82,15 @@ export interface AppSettings {
onboardingCompleted?: boolean;
// Selected agent profile for preset model/thinking configurations
selectedAgentProfile?: string;
// Custom phase configuration for Auto profile (overrides defaults)
customPhaseModels?: PhaseModelConfig;
customPhaseThinking?: PhaseThinkingConfig;
// Changelog preferences
changelogFormat?: ChangelogFormat;
changelogAudience?: ChangelogAudience;
changelogEmojiLevel?: ChangelogEmojiLevel;
// Migration flags (internal use)
_migratedAgentProfileToAuto?: boolean;
}
// Auto-Claude Source Environment Configuration (for auto-claude repo .env)
+2
View File
@@ -356,6 +356,8 @@ export interface WorktreeMergeResult {
staged?: boolean;
alreadyStaged?: boolean;
projectPath?: string;
// AI-generated commit message suggestion (for stage-only mode)
suggestedCommitMessage?: string;
// New conflict info from smart merge
conflicts?: MergeConflict[];
stats?: MergeStats;
@@ -51,6 +51,33 @@ export function buildCdCommand(path: string | undefined): string {
return `cd ${escapeShellPath(path)} && `;
}
/**
* Escape a string for safe use as a Windows cmd.exe argument.
*
* Windows cmd.exe uses different escaping rules than POSIX shells.
* This function escapes special characters that could break out of strings
* or execute additional commands.
*
* @param arg - The argument to escape
* @returns The escaped argument safe for use in cmd.exe
*/
export function escapeShellArgWindows(arg: string): string {
// Escape characters that have special meaning in cmd.exe:
// ^ is the escape character in cmd.exe
// " & | < > ^ need to be escaped
// % is used for variable expansion
const escaped = arg
.replace(/\^/g, '^^') // Escape carets first (escape char itself)
.replace(/"/g, '^"') // Escape double quotes
.replace(/&/g, '^&') // Escape ampersand (command separator)
.replace(/\|/g, '^|') // Escape pipe
.replace(/</g, '^<') // Escape less than
.replace(/>/g, '^>') // Escape greater than
.replace(/%/g, '%%'); // Escape percent (variable expansion)
return escaped;
}
/**
* Validate that a path doesn't contain obviously malicious patterns.
* This is a defense-in-depth measure - escaping should handle all cases,
+2 -2
View File
@@ -5,13 +5,13 @@ export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
include: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts', 'src/**/*.spec.tsx'],
exclude: ['node_modules', 'dist', 'out'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts', 'src/**/*.tsx'],
exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.d.ts']
exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts', 'src/**/*.spec.tsx', 'src/**/*.d.ts']
},
// Mock Electron modules for unit tests
alias: {
+77 -1
View File
@@ -189,7 +189,83 @@ def handle_merge_command(
Returns:
True if merge succeeded, False otherwise
"""
return merge_existing_build(project_dir, spec_name, no_commit=no_commit)
success = merge_existing_build(project_dir, spec_name, no_commit=no_commit)
# Generate commit message suggestion if staging succeeded (no_commit mode)
if success and no_commit:
_generate_and_save_commit_message(project_dir, spec_name)
return success
def _generate_and_save_commit_message(project_dir: Path, spec_name: str) -> None:
"""
Generate a commit message suggestion and save it for the UI.
Args:
project_dir: Project root directory
spec_name: Name of the spec
"""
try:
from commit_message import generate_commit_message_sync
# Get diff summary for context
diff_summary = ""
files_changed = []
try:
result = subprocess.run(
["git", "diff", "--staged", "--stat"],
cwd=project_dir,
capture_output=True,
text=True,
)
if result.returncode == 0:
diff_summary = result.stdout.strip()
# Get list of changed files
result = subprocess.run(
["git", "diff", "--staged", "--name-only"],
cwd=project_dir,
capture_output=True,
text=True,
)
if result.returncode == 0:
files_changed = [
f.strip() for f in result.stdout.strip().split("\n") if f.strip()
]
except Exception as e:
debug_warning(MODULE, f"Could not get diff summary: {e}")
# Generate commit message
debug(MODULE, "Generating commit message suggestion...")
commit_message = generate_commit_message_sync(
project_dir=project_dir,
spec_name=spec_name,
diff_summary=diff_summary,
files_changed=files_changed,
)
if commit_message:
# Save to spec directory for UI to read
spec_dir = project_dir / ".auto-claude" / "specs" / spec_name
if not spec_dir.exists():
spec_dir = project_dir / "auto-claude" / "specs" / spec_name
if spec_dir.exists():
commit_msg_file = spec_dir / "suggested_commit_message.txt"
commit_msg_file.write_text(commit_message, encoding="utf-8")
debug_success(
MODULE, f"Saved commit message suggestion to {commit_msg_file}"
)
else:
debug_warning(MODULE, f"Spec directory not found: {spec_dir}")
else:
debug_warning(MODULE, "No commit message generated")
except ImportError:
debug_warning(MODULE, "commit_message module not available")
except Exception as e:
debug_warning(MODULE, f"Failed to generate commit message: {e}")
def handle_review_command(project_dir: Path, spec_name: str) -> None:
+373
View File
@@ -0,0 +1,373 @@
"""
Commit Message Generator
========================
Generates high-quality commit messages using Claude Haiku.
Features:
- Conventional commits format (feat/fix/refactor/etc)
- GitHub issue references (Fixes #123)
- Context-aware descriptions from spec metadata
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
import sys
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
logger = logging.getLogger(__name__)
# Map task categories to conventional commit types
CATEGORY_TO_COMMIT_TYPE = {
"feature": "feat",
"bug_fix": "fix",
"bug": "fix",
"refactoring": "refactor",
"refactor": "refactor",
"documentation": "docs",
"docs": "docs",
"testing": "test",
"test": "test",
"performance": "perf",
"perf": "perf",
"security": "security",
"chore": "chore",
"style": "style",
"ci": "ci",
"build": "build",
}
SYSTEM_PROMPT = """You are a Git expert who writes clear, concise commit messages following conventional commits format.
Rules:
1. First line: type(scope): description (max 72 chars total)
2. Leave blank line after first line
3. Body: 1-3 sentences explaining WHAT changed and WHY
4. If GitHub issue number provided, end with "Fixes #N" on its own line
5. Be specific about the changes, not generic
6. Use imperative mood ("Add feature" not "Added feature")
Types: feat, fix, refactor, docs, test, perf, chore, style, ci, build
Example output:
feat(auth): add OAuth2 login flow
Implement OAuth2 authentication with Google and GitHub providers.
Add token refresh logic and secure storage.
Fixes #42"""
def _get_spec_context(spec_dir: Path) -> dict:
"""
Extract context from spec files for commit message generation.
Returns dict with:
- title: Feature/task title
- category: Task category (feature, bug_fix, etc)
- description: Brief description
- github_issue: GitHub issue number if linked
"""
context = {
"title": "",
"category": "chore",
"description": "",
"github_issue": None,
}
# Try to read spec.md for title
spec_file = spec_dir / "spec.md"
if spec_file.exists():
try:
content = spec_file.read_text(encoding="utf-8")
# Extract title from first H1 or H2
title_match = re.search(r"^#+ (.+)$", content, re.MULTILINE)
if title_match:
context["title"] = title_match.group(1).strip()
# Look for overview/description section
overview_match = re.search(
r"## Overview\s*\n(.+?)(?=\n##|\Z)", content, re.DOTALL
)
if overview_match:
context["description"] = overview_match.group(1).strip()[:200]
except Exception as e:
logger.debug(f"Could not read spec.md: {e}")
# Try to read requirements.json for metadata
req_file = spec_dir / "requirements.json"
if req_file.exists():
try:
req_data = json.loads(req_file.read_text(encoding="utf-8"))
if not context["title"] and req_data.get("feature"):
context["title"] = req_data["feature"]
if req_data.get("workflow_type"):
context["category"] = req_data["workflow_type"]
if req_data.get("task_description") and not context["description"]:
context["description"] = req_data["task_description"][:200]
except Exception as e:
logger.debug(f"Could not read requirements.json: {e}")
# Try to read implementation_plan.json for GitHub issue
plan_file = spec_dir / "implementation_plan.json"
if plan_file.exists():
try:
plan_data = json.loads(plan_file.read_text(encoding="utf-8"))
# Check for GitHub metadata
metadata = plan_data.get("metadata", {})
if metadata.get("githubIssueNumber"):
context["github_issue"] = metadata["githubIssueNumber"]
# Fallback title
if not context["title"]:
context["title"] = plan_data.get("feature") or plan_data.get(
"title", ""
)
except Exception as e:
logger.debug(f"Could not read implementation_plan.json: {e}")
return context
def _build_prompt(
spec_context: dict,
diff_summary: str,
files_changed: list[str],
) -> str:
"""Build the prompt for Claude."""
commit_type = CATEGORY_TO_COMMIT_TYPE.get(
spec_context.get("category", "").lower(), "chore"
)
github_ref = ""
if spec_context.get("github_issue"):
github_ref = f"\nGitHub Issue: #{spec_context['github_issue']} (include 'Fixes #{spec_context['github_issue']}' at the end)"
# Truncate file list if too long
if len(files_changed) > 20:
files_display = (
"\n".join(files_changed[:20])
+ f"\n... and {len(files_changed) - 20} more files"
)
else:
files_display = (
"\n".join(files_changed) if files_changed else "(no files listed)"
)
prompt = f"""Generate a commit message for this change.
Task: {spec_context.get("title", "Unknown task")}
Type: {commit_type}
Files changed: {len(files_changed)}
{github_ref}
Description: {spec_context.get("description", "No description available")}
Changed files:
{files_display}
Diff summary:
{diff_summary[:2000] if diff_summary else "(no diff available)"}
Generate ONLY the commit message, nothing else. Follow the format exactly:
type(scope): short description
Body explaining changes.
Fixes #N (if applicable)"""
return prompt
async def _call_claude_haiku(prompt: str) -> str:
"""Call Claude Haiku with low thinking for fast commit message generation."""
from core.auth import ensure_claude_code_oauth_token, get_auth_token
if not get_auth_token():
logger.warning("No authentication token found")
return ""
ensure_claude_code_oauth_token()
try:
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
except ImportError:
logger.warning("claude_agent_sdk not installed")
return ""
client = ClaudeSDKClient(
options=ClaudeAgentOptions(
model="claude-haiku-4-5-20251001",
system_prompt=SYSTEM_PROMPT,
allowed_tools=[],
max_turns=1,
max_thinking_tokens=1024, # Low thinking for speed
)
)
try:
async with client:
await client.query(prompt)
response_text = ""
async for msg in client.receive_response():
msg_type = type(msg).__name__
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
for block in msg.content:
if hasattr(block, "text"):
response_text += block.text
logger.info(f"Generated commit message: {len(response_text)} chars")
return response_text.strip()
except Exception as e:
logger.error(f"Claude SDK call failed: {e}")
print(f" [WARN] Commit message generation failed: {e}", file=sys.stderr)
return ""
def generate_commit_message_sync(
project_dir: Path,
spec_name: str,
diff_summary: str = "",
files_changed: list[str] | None = None,
github_issue: int | None = None,
) -> str:
"""
Generate a commit message synchronously.
Args:
project_dir: Project root directory
spec_name: Spec identifier (e.g., "001-add-feature")
diff_summary: Git diff stat or summary
files_changed: List of changed file paths
github_issue: GitHub issue number if linked (overrides spec metadata)
Returns:
Generated commit message or fallback message
"""
# Find spec directory
spec_dir = project_dir / ".auto-claude" / "specs" / spec_name
if not spec_dir.exists():
# Try alternative location
spec_dir = project_dir / "auto-claude" / "specs" / spec_name
# Get context from spec files
spec_context = _get_spec_context(spec_dir) if spec_dir.exists() else {}
# Override with provided github_issue
if github_issue:
spec_context["github_issue"] = github_issue
# Build prompt
prompt = _build_prompt(
spec_context,
diff_summary,
files_changed or [],
)
# Call Claude
try:
# Check if we're already in an async context
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop and loop.is_running():
# Already in an async context - run in a new thread
# Use lambda to ensure coroutine is created inside the worker thread
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as pool:
result = pool.submit(
lambda: asyncio.run(_call_claude_haiku(prompt))
).result()
else:
result = asyncio.run(_call_claude_haiku(prompt))
if result:
return result
except Exception as e:
logger.error(f"Failed to generate commit message: {e}")
# Fallback message
commit_type = CATEGORY_TO_COMMIT_TYPE.get(
spec_context.get("category", "").lower(), "chore"
)
title = spec_context.get("title", spec_name)
fallback = f"{commit_type}: {title}"
if github_issue or spec_context.get("github_issue"):
issue_num = github_issue or spec_context.get("github_issue")
fallback += f"\n\nFixes #{issue_num}"
return fallback
async def generate_commit_message(
project_dir: Path,
spec_name: str,
diff_summary: str = "",
files_changed: list[str] | None = None,
github_issue: int | None = None,
) -> str:
"""
Generate a commit message asynchronously.
Args:
project_dir: Project root directory
spec_name: Spec identifier (e.g., "001-add-feature")
diff_summary: Git diff stat or summary
files_changed: List of changed file paths
github_issue: GitHub issue number if linked (overrides spec metadata)
Returns:
Generated commit message or fallback message
"""
# Find spec directory
spec_dir = project_dir / ".auto-claude" / "specs" / spec_name
if not spec_dir.exists():
spec_dir = project_dir / "auto-claude" / "specs" / spec_name
# Get context from spec files
spec_context = _get_spec_context(spec_dir) if spec_dir.exists() else {}
# Override with provided github_issue
if github_issue:
spec_context["github_issue"] = github_issue
# Build prompt
prompt = _build_prompt(
spec_context,
diff_summary,
files_changed or [],
)
# Call Claude
try:
result = await _call_claude_haiku(prompt)
if result:
return result
except Exception as e:
logger.error(f"Failed to generate commit message: {e}")
# Fallback message
commit_type = CATEGORY_TO_COMMIT_TYPE.get(
spec_context.get("category", "").lower(), "chore"
)
title = spec_context.get("title", spec_name)
fallback = f"{commit_type}: {title}"
if github_issue or spec_context.get("github_issue"):
issue_num = github_issue or spec_context.get("github_issue")
fallback += f"\n\nFixes #{issue_num}"
return fallback
+7 -1
View File
@@ -47,7 +47,13 @@ def get_token_from_keychain() -> str | None:
try:
# Query macOS Keychain for Claude Code credentials
result = subprocess.run(
["/usr/bin/security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
[
"/usr/bin/security",
"find-generic-password",
"-s",
"Claude Code-credentials",
"-w",
],
capture_output=True,
text=True,
timeout=5,
+338 -7
View File
@@ -80,6 +80,7 @@ from core.workspace.display import (
show_build_summary,
)
from core.workspace.git_utils import (
MAX_PARALLEL_AI_MERGES,
_is_auto_claude_file,
get_existing_build_worktree,
)
@@ -97,6 +98,7 @@ from core.workspace.git_utils import (
from core.workspace.models import (
MergeLock,
MergeLockError,
ParallelMergeResult,
ParallelMergeTask,
)
from merge import (
@@ -858,13 +860,13 @@ def _resolve_git_conflicts_with_ai(
start_time = time.time()
# Run parallel merges
# TODO: _run_parallel_merges not yet implemented - see line 140
# parallel_results = asyncio.run(_run_parallel_merges(
# tasks=files_needing_ai_merge,
# project_dir=project_dir,
# max_concurrent=MAX_PARALLEL_AI_MERGES,
# ))
parallel_results = [] # Placeholder until function is implemented
parallel_results = asyncio.run(
_run_parallel_merges(
tasks=files_needing_ai_merge,
project_dir=project_dir,
max_concurrent=MAX_PARALLEL_AI_MERGES,
)
)
elapsed = time.time() - start_time
@@ -996,3 +998,332 @@ def _resolve_git_conflicts_with_ai(
# - Git utilities from workspace/git_utils.py
# - Display functions from workspace/display.py
# - Finalization functions from workspace/finalization.py
# =============================================================================
# Parallel AI Merge Implementation
# =============================================================================
import asyncio
import logging
import os
_merge_logger = logging.getLogger(__name__)
# System prompt for AI file merging
AI_MERGE_SYSTEM_PROMPT = """You are an expert code merge assistant. Your task is to perform a 3-way merge of code files.
RULES:
1. Preserve all functional changes from both versions (ours and theirs)
2. Maintain code style consistency
3. Resolve conflicts by understanding the semantic purpose of each change
4. When changes are independent (different functions/sections), include both
5. When changes overlap, combine them logically or prefer the more complete version
6. Preserve all imports from both versions
7. Output ONLY the merged code - no explanations, no markdown, no code fences
IMPORTANT: Output the raw merged file content only. Do not wrap in code blocks."""
def _infer_language_from_path(file_path: str) -> str:
"""Infer programming language from file extension."""
ext_map = {
".py": "python",
".js": "javascript",
".jsx": "javascript",
".ts": "typescript",
".tsx": "typescript",
".rs": "rust",
".go": "go",
".java": "java",
".cpp": "cpp",
".c": "c",
".h": "c",
".hpp": "cpp",
".rb": "ruby",
".php": "php",
".swift": "swift",
".kt": "kotlin",
".scala": "scala",
".json": "json",
".yaml": "yaml",
".yml": "yaml",
".toml": "toml",
".md": "markdown",
".html": "html",
".css": "css",
".scss": "scss",
".sql": "sql",
}
ext = os.path.splitext(file_path)[1].lower()
return ext_map.get(ext, "text")
def _try_simple_3way_merge(
base: str | None,
ours: str,
theirs: str,
) -> tuple[bool, str | None]:
"""
Attempt a simple 3-way merge without AI.
Returns:
(success, merged_content) - if success is True, merged_content is the result
"""
# If base is None, we can't do a proper 3-way merge
if base is None:
# If both are identical, no conflict
if ours == theirs:
return True, ours
# Otherwise, we need AI to decide
return False, None
# If ours equals base, theirs is the only change - take theirs
if ours == base:
return True, theirs
# If theirs equals base, ours is the only change - take ours
if theirs == base:
return True, ours
# If ours equals theirs, both made same change - take either
if ours == theirs:
return True, ours
# Both changed differently from base - need AI merge
# We could try a line-by-line merge here, but for safety let's use AI
return False, None
def _build_merge_prompt(
file_path: str,
base_content: str | None,
main_content: str,
worktree_content: str,
spec_name: str,
) -> str:
"""Build the prompt for AI file merge."""
language = _infer_language_from_path(file_path)
base_section = ""
if base_content:
# Truncate very large files
if len(base_content) > 10000:
base_content = base_content[:10000] + "\n... (truncated)"
base_section = f"""
BASE (common ancestor):
```{language}
{base_content}
```
"""
# Truncate large content
if len(main_content) > 15000:
main_content = main_content[:15000] + "\n... (truncated)"
if len(worktree_content) > 15000:
worktree_content = worktree_content[:15000] + "\n... (truncated)"
prompt = f"""Perform a 3-way merge for file: {file_path}
Task being merged: {spec_name}
{base_section}
OURS (current main branch):
```{language}
{main_content}
```
THEIRS (changes from task worktree):
```{language}
{worktree_content}
```
Merge these versions, preserving all meaningful changes from both. Output only the merged file content, no explanations."""
return prompt
def _strip_code_fences(content: str) -> str:
"""Remove markdown code fences if present."""
# Check if content starts with code fence
lines = content.strip().split("\n")
if lines and lines[0].startswith("```"):
# Remove first and last line if they're code fences
if lines[-1].strip() == "```":
return "\n".join(lines[1:-1])
else:
return "\n".join(lines[1:])
return content
async def _merge_file_with_ai_async(
task: ParallelMergeTask,
semaphore: asyncio.Semaphore,
) -> ParallelMergeResult:
"""
Merge a single file using AI.
Args:
task: The merge task with file contents
semaphore: Semaphore for concurrency control
Returns:
ParallelMergeResult with merged content or error
"""
async with semaphore:
try:
# First try simple 3-way merge
success, merged = _try_simple_3way_merge(
task.base_content,
task.main_content,
task.worktree_content,
)
if success and merged is not None:
debug(MODULE, f"Auto-merged {task.file_path} without AI")
return ParallelMergeResult(
file_path=task.file_path,
merged_content=merged,
success=True,
was_auto_merged=True,
)
# Need AI merge
debug(MODULE, f"Using AI to merge {task.file_path}")
# Import auth utilities
from core.auth import ensure_claude_code_oauth_token, get_auth_token
if not get_auth_token():
return ParallelMergeResult(
file_path=task.file_path,
merged_content=None,
success=False,
error="No authentication token available",
)
ensure_claude_code_oauth_token()
# Build prompt
prompt = _build_merge_prompt(
task.file_path,
task.base_content,
task.main_content,
task.worktree_content,
task.spec_name,
)
# Call Claude Haiku for fast merge
try:
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
except ImportError:
return ParallelMergeResult(
file_path=task.file_path,
merged_content=None,
success=False,
error="claude_agent_sdk not installed",
)
client = ClaudeSDKClient(
options=ClaudeAgentOptions(
model="claude-haiku-4-5-20251001",
system_prompt=AI_MERGE_SYSTEM_PROMPT,
allowed_tools=[],
max_turns=1,
max_thinking_tokens=1024, # Low thinking for speed
)
)
response_text = ""
async with client:
await client.query(prompt)
async for msg in client.receive_response():
msg_type = type(msg).__name__
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
for block in msg.content:
if hasattr(block, "text"):
response_text += block.text
if response_text:
# Strip any code fences the model might have added
merged_content = _strip_code_fences(response_text.strip())
debug(MODULE, f"AI merged {task.file_path} successfully")
return ParallelMergeResult(
file_path=task.file_path,
merged_content=merged_content,
success=True,
was_auto_merged=False,
)
else:
return ParallelMergeResult(
file_path=task.file_path,
merged_content=None,
success=False,
error="AI returned empty response",
)
except Exception as e:
_merge_logger.error(f"Failed to merge {task.file_path}: {e}")
return ParallelMergeResult(
file_path=task.file_path,
merged_content=None,
success=False,
error=str(e),
)
async def _run_parallel_merges(
tasks: list[ParallelMergeTask],
project_dir: Path,
max_concurrent: int = MAX_PARALLEL_AI_MERGES,
) -> list[ParallelMergeResult]:
"""
Run file merges in parallel with concurrency control.
Args:
tasks: List of merge tasks to process
project_dir: Project directory (for context, not currently used)
max_concurrent: Maximum number of concurrent merge operations
Returns:
List of ParallelMergeResult for each task
"""
if not tasks:
return []
debug(
MODULE,
f"Starting parallel merge of {len(tasks)} files (max concurrent: {max_concurrent})",
)
# Create semaphore for concurrency control
semaphore = asyncio.Semaphore(max_concurrent)
# Create tasks
merge_coroutines = [_merge_file_with_ai_async(task, semaphore) for task in tasks]
# Run all merges concurrently
results = await asyncio.gather(*merge_coroutines, return_exceptions=True)
# Process results, converting exceptions to error results
final_results: list[ParallelMergeResult] = []
for i, result in enumerate(results):
if isinstance(result, Exception):
final_results.append(
ParallelMergeResult(
file_path=tasks[i].file_path,
merged_content=None,
success=False,
error=str(result),
)
)
else:
final_results.append(result)
debug(
MODULE,
f"Parallel merge complete: {sum(1 for r in final_results if r.success)} succeeded, "
f"{sum(1 for r in final_results if not r.success)} failed",
)
return final_results
+2 -3
View File
@@ -27,8 +27,7 @@ _spec = importlib.util.spec_from_file_location("workspace_module", _workspace_fi
_workspace_module = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_workspace_module)
merge_existing_build = _workspace_module.merge_existing_build
# TODO: _run_parallel_merges not yet implemented in workspace.py
# _run_parallel_merges = _workspace_module._run_parallel_merges
_run_parallel_merges = _workspace_module._run_parallel_merges
# Models and Enums
# Display Functions
@@ -105,7 +104,7 @@ from .setup import (
__all__ = [
# Merge Operations (from workspace.py)
"merge_existing_build",
# '_run_parallel_merges', # TODO: not yet implemented - Private but used by tests
"_run_parallel_merges", # Private but used internally
# Models
"WorkspaceMode",
"WorkspaceChoice",
@@ -196,7 +196,9 @@ class ModificationTracker:
try:
new_content = current_file.read_text(encoding="utf-8")
except UnicodeDecodeError:
new_content = current_file.read_text(encoding="utf-8", errors="replace")
new_content = current_file.read_text(
encoding="utf-8", errors="replace"
)
else:
# File was deleted
new_content = ""
+3 -1
View File
@@ -509,7 +509,9 @@ class FileTimelineTracker:
try:
content = full_path.read_text(encoding="utf-8")
except UnicodeDecodeError:
content = full_path.read_text(encoding="utf-8", errors="replace")
content = full_path.read_text(
encoding="utf-8", errors="replace"
)
self.on_task_worktree_change(task_id, file_path, content)
debug_success(MODULE, f"Captured {len(changed_files)} files from worktree")
+36 -4
View File
@@ -90,9 +90,9 @@ Find these critical sections:
cat project_index.json
```
**IF THIS FILE DOES NOT EXIST, YOU MUST CREATE IT.**
**IF THIS FILE DOES NOT EXIST, YOU MUST CREATE IT USING THE WRITE TOOL.**
Based on your Phase 0 investigation, create `project_index.json`:
Based on your Phase 0 investigation, use the Write tool to create `project_index.json`:
```json
{
@@ -130,9 +130,9 @@ This contains:
cat context.json
```
**IF THIS FILE DOES NOT EXIST, YOU MUST CREATE IT.**
**IF THIS FILE DOES NOT EXIST, YOU MUST CREATE IT USING THE WRITE TOOL.**
Based on your Phase 0 investigation and the spec.md, create `context.json`:
Based on your Phase 0 investigation and the spec.md, use the Write tool to create `context.json`:
```json
{
@@ -203,6 +203,15 @@ Minimal overhead - just subtasks, no phases.
## PHASE 3: CREATE implementation_plan.json
**🚨 CRITICAL: YOU MUST USE THE WRITE TOOL TO CREATE THIS FILE 🚨**
You MUST use the Write tool to save the implementation plan to `implementation_plan.json`.
Do NOT just describe what the file should contain - you must actually call the Write tool with the complete JSON content.
**Required action:** Call the Write tool with:
- file_path: `implementation_plan.json` (in the spec directory)
- content: The complete JSON plan structure shown below
Based on the workflow type and services involved, create the implementation plan.
### Plan Structure
@@ -631,8 +640,26 @@ Include parallelism analysis, verification strategy, and QA configuration in the
---
**🚨 END OF PHASE 4 CHECKPOINT 🚨**
Before proceeding to PHASE 5, verify you have:
1. ✅ Created the complete implementation_plan.json structure
2. ✅ Used the Write tool to save it (not just described it)
3. ✅ Added the summary section with parallelism analysis
4. ✅ Added the verification_strategy section
5. ✅ Added the qa_acceptance section
If you have NOT used the Write tool yet, STOP and do it now!
---
## PHASE 5: CREATE init.sh
**🚨 CRITICAL: YOU MUST USE THE WRITE TOOL TO CREATE THIS FILE 🚨**
You MUST use the Write tool to save the init.sh script.
Do NOT just describe what the file should contain - you must actually call the Write tool.
Create a setup script based on `project_index.json`:
```bash
@@ -735,6 +762,11 @@ Note: If the commit fails (e.g., nothing to commit, or in a special workspace),
## PHASE 7: CREATE build-progress.txt
**🚨 CRITICAL: YOU MUST USE THE WRITE TOOL TO CREATE THIS FILE 🚨**
You MUST use the Write tool to save build-progress.txt.
Do NOT just describe what the file should contain - you must actually call the Write tool with the complete content shown below.
```
=== AUTO-BUILD PROGRESS ===
+11 -5
View File
@@ -9,7 +9,8 @@ import json
from pathlib import Path
# Directory containing prompt files
PROMPTS_DIR = Path(__file__).parent / "prompts"
# prompts/ is a sibling directory of prompts_pkg/, so go up one level first
PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
def get_planner_prompt(spec_dir: Path) -> str:
@@ -38,10 +39,15 @@ def get_planner_prompt(spec_dir: Path) -> str:
Your spec file is located at: `{spec_dir}/spec.md`
Store all build artifacts in this spec directory:
- `{spec_dir}/implementation_plan.json` - Subtask-based implementation plan
- `{spec_dir}/build-progress.txt` - Progress notes
- `{spec_dir}/init.sh` - Environment setup script
🚨 CRITICAL FILE CREATION INSTRUCTIONS 🚨
You MUST use the Write tool to create these files in the spec directory:
- `{spec_dir}/implementation_plan.json` - Subtask-based implementation plan (USE WRITE TOOL!)
- `{spec_dir}/build-progress.txt` - Progress notes (USE WRITE TOOL!)
- `{spec_dir}/init.sh` - Environment setup script (USE WRITE TOOL!)
DO NOT just describe what these files should contain. You MUST actually call the Write tool
with the file path and complete content to create them.
The project root is the parent of auto-claude/. Implement code in the project root, not in the spec directory.
+15 -5
View File
@@ -331,7 +331,9 @@ def main():
parser.add_argument("--project-dir", required=True, help="Project directory path")
parser.add_argument("--message", required=True, help="User message")
parser.add_argument("--history", default="[]", help="JSON conversation history")
parser.add_argument("--history-file", help="Path to JSON file containing conversation history")
parser.add_argument(
"--history-file", help="Path to JSON file containing conversation history"
)
parser.add_argument(
"--model",
default="claude-sonnet-4-5-20250929",
@@ -364,13 +366,21 @@ def main():
# Load history from file if provided, otherwise parse inline JSON
try:
if args.history_file:
debug("insights_runner", "Loading history from file", file=args.history_file)
with open(args.history_file, 'r', encoding='utf-8') as f:
debug(
"insights_runner", "Loading history from file", file=args.history_file
)
with open(args.history_file, encoding="utf-8") as f:
history = json.load(f)
debug_detailed("insights_runner", "Loaded history from file", history_length=len(history))
debug_detailed(
"insights_runner",
"Loaded history from file",
history_length=len(history),
)
else:
history = json.loads(args.history)
debug_detailed("insights_runner", "Parsed inline history", history_length=len(history))
debug_detailed(
"insights_runner", "Parsed inline history", history_length=len(history)
)
except (json.JSONDecodeError, FileNotFoundError, OSError) as e:
debug_error("insights_runner", f"Failed to load history: {e}")
history = []
+78 -6
View File
@@ -21,9 +21,7 @@ import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "auto-claude"))
from workspace import ParallelMergeTask, ParallelMergeResult
# _run_parallel_merges is not yet implemented - tests that use it are skipped
_run_parallel_merges = None
from core.workspace import _run_parallel_merges
class TestParallelMergeDataclasses:
@@ -101,11 +99,10 @@ class TestParallelMergeDataclasses:
class TestParallelMergeRunner:
"""Tests for the parallel merge runner."""
@pytest.mark.skip(reason="_run_parallel_merges not yet implemented")
def test_run_parallel_merges_empty_list(self, project_dir):
def test_run_parallel_merges_empty_list(self, tmp_path):
"""Running with empty task list returns empty results."""
import asyncio
results = asyncio.run(_run_parallel_merges([], project_dir))
results = asyncio.run(_run_parallel_merges([], tmp_path))
assert results == []
def test_parallel_merge_task_with_data(self):
@@ -123,6 +120,81 @@ class TestParallelMergeRunner:
assert task.spec_name == "001-feature"
class TestSimple3WayMerge:
"""Tests for the simple 3-way merge logic."""
def test_identical_files_merge(self, tmp_path):
"""When both versions are identical, return that version."""
import asyncio
task = ParallelMergeTask(
file_path="src/test.py",
main_content="def main(): pass",
worktree_content="def main(): pass", # Same as main
base_content="def main(): pass", # Same as both
spec_name="001-no-change",
)
results = asyncio.run(_run_parallel_merges([task], tmp_path))
assert len(results) == 1
assert results[0].success is True
assert results[0].was_auto_merged is True
assert results[0].merged_content == "def main(): pass"
def test_only_worktree_changed(self, tmp_path):
"""When only worktree changed, take worktree version."""
import asyncio
task = ParallelMergeTask(
file_path="src/test.py",
main_content="def main(): pass", # Same as base
worktree_content="def main():\n print('new')", # Changed
base_content="def main(): pass",
spec_name="001-worktree-only",
)
results = asyncio.run(_run_parallel_merges([task], tmp_path))
assert len(results) == 1
assert results[0].success is True
assert results[0].was_auto_merged is True
assert "print('new')" in results[0].merged_content
def test_only_main_changed(self, tmp_path):
"""When only main changed, take main version."""
import asyncio
task = ParallelMergeTask(
file_path="src/test.py",
main_content="def main():\n print('main')", # Changed
worktree_content="def main(): pass", # Same as base
base_content="def main(): pass",
spec_name="001-main-only",
)
results = asyncio.run(_run_parallel_merges([task], tmp_path))
assert len(results) == 1
assert results[0].success is True
assert results[0].was_auto_merged is True
assert "print('main')" in results[0].merged_content
def test_no_base_but_identical(self, tmp_path):
"""When no base and both identical, return that version."""
import asyncio
task = ParallelMergeTask(
file_path="src/new.py",
main_content="# Same content",
worktree_content="# Same content",
base_content=None, # New file, no base
spec_name="001-new-identical",
)
results = asyncio.run(_run_parallel_merges([task], tmp_path))
assert len(results) == 1
assert results[0].success is True
assert results[0].was_auto_merged is True
class TestParallelMergeIntegration:
"""Integration tests for parallel merge flow."""