@@ -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';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+61
-2
@@ -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">
|
||||
|
||||
+8
-3
@@ -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');
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user