feat: enhance roadmap generation with stop functionality and debug logging

- Added stop functionality for roadmap generation, allowing users to halt the process.
- Implemented debug logging throughout the roadmap generation process for better traceability.
- Updated IPC channels to support stopping roadmap generation and added corresponding handlers.
- Enhanced UI components to include stop buttons and feedback for the stop action.

This update improves user control over the roadmap generation process and aids in debugging.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
AndyMik90
2025-12-18 20:09:44 +01:00
parent 3d24f8f59e
commit a6dad428e9
30 changed files with 982 additions and 158 deletions
+1
View File
@@ -74,6 +74,7 @@ dmypy.json
.auto-claude-security.json
.auto-claude-status
.claude_settings.json
.update-metadata.json
# Development of Auto Build with Auto Build
dev/
+48
View File
@@ -0,0 +1,48 @@
# Auto Claude UI Environment Variables
# Copy this file to .env and set your values
# ============================================
# DEBUG SETTINGS
# ============================================
# Enable general debug logging for ideation and roadmap features
# When enabled, you'll see detailed console logs for:
# - Ideation generation and stop functionality
# - Roadmap generation and stop functionality
# - IPC communication between processes
# - Store state updates
# Usage: Set to 'true' before starting the app
# DEBUG=true
# Enable debug logging for the auto-updater
# Shows detailed information about app update checks and downloads
# DEBUG_UPDATER=true
# Enable debug logging for Auto Claude features
# Affects changelog generation, project initialization, and other core features
# AUTO_CLAUDE_DEBUG=true
# ============================================
# HOW TO USE
# ============================================
# Option 1: Set in your shell before starting the app
# DEBUG=true npm start
#
# Option 2: Export in your shell profile (~/.bashrc, ~/.zshrc, etc.)
# export DEBUG=true
# export AUTO_CLAUDE_DEBUG=true
#
# Option 3: Create a .env file in this directory (auto-claude-ui/)
# Copy this file: cp .env.example .env
# Then uncomment and set the variables you need
#
# Note: The Electron app will read these from process.env
# The Python backend (auto-claude) has its own .env file
# ============================================
# DEVELOPMENT
# ============================================
# Node environment (automatically set by npm scripts)
# NODE_ENV=development
+13 -1
View File
@@ -1,6 +1,6 @@
{
"name": "auto-claude-ui",
"version": "2.3.0",
"version": "2.5.0",
"description": "Desktop UI for Auto Claude autonomous coding framework",
"main": "./out/main/index.js",
"author": "Auto Claude Team",
@@ -138,6 +138,18 @@
{
"from": "resources/icon.ico",
"to": "icon.ico"
},
{
"from": "../auto-claude",
"to": "auto-claude",
"filter": [
"!**/.git",
"!**/__pycache__",
"!**/*.pyc",
"!**/specs",
"!**/.venv",
"!**/.env"
]
}
],
"mac": {
@@ -250,6 +250,20 @@ export class AgentManager extends EventEmitter {
return this.queueManager.isIdeationRunning(projectId);
}
/**
* Stop roadmap generation for a project
*/
stopRoadmap(projectId: string): boolean {
return this.queueManager.stopRoadmap(projectId);
}
/**
* Check if roadmap is running for a project
*/
isRoadmapRunning(projectId: string): boolean {
return this.queueManager.isRoadmapRunning(projectId);
}
/**
* Kill all running processes
*/
+161 -22
View File
@@ -7,6 +7,7 @@ import { AgentEvents } from './agent-events';
import { AgentProcessManager } from './agent-process';
import { IdeationConfig } from './types';
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector';
import { debugLog, debugError } from '../../shared/utils/debug-logger';
/**
* Queue management for ideation and roadmap generation
@@ -38,16 +39,25 @@ export class AgentQueueManager {
refresh: boolean = false,
enableCompetitorAnalysis: boolean = false
): void {
debugLog('[Agent Queue] Starting roadmap generation:', {
projectId,
projectPath,
refresh,
enableCompetitorAnalysis
});
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
if (!autoBuildSource) {
debugError('[Agent Queue] Auto-build source path not found');
this.emitter.emit('roadmap-error', projectId, 'Auto-build source path not found. Please configure it in App Settings.');
return;
}
const roadmapRunnerPath = path.join(autoBuildSource, 'roadmap_runner.py');
const roadmapRunnerPath = path.join(autoBuildSource, 'runners', 'roadmap_runner.py');
if (!existsSync(roadmapRunnerPath)) {
debugError('[Agent Queue] Roadmap runner not found at:', roadmapRunnerPath);
this.emitter.emit('roadmap-error', projectId, `Roadmap runner not found at: ${roadmapRunnerPath}`);
return;
}
@@ -63,6 +73,8 @@ export class AgentQueueManager {
args.push('--competitor-analysis');
}
debugLog('[Agent Queue] Spawning roadmap process with args:', args);
// Use projectId as taskId for roadmap operations
this.spawnRoadmapProcess(projectId, projectPath, args);
}
@@ -76,16 +88,25 @@ export class AgentQueueManager {
config: IdeationConfig,
refresh: boolean = false
): void {
debugLog('[Agent Queue] Starting ideation generation:', {
projectId,
projectPath,
config,
refresh
});
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
if (!autoBuildSource) {
debugError('[Agent Queue] Auto-build source path not found');
this.emitter.emit('ideation-error', projectId, 'Auto-build source path not found. Please configure it in App Settings.');
return;
}
const ideationRunnerPath = path.join(autoBuildSource, 'ideation_runner.py');
const ideationRunnerPath = path.join(autoBuildSource, 'runners', 'ideation_runner.py');
if (!existsSync(ideationRunnerPath)) {
debugError('[Agent Queue] Ideation runner not found at:', ideationRunnerPath);
this.emitter.emit('ideation-error', projectId, `Ideation runner not found at: ${ideationRunnerPath}`);
return;
}
@@ -119,6 +140,8 @@ export class AgentQueueManager {
args.push('--append');
}
debugLog('[Agent Queue] Spawning ideation process with args:', args);
// Use projectId as taskId for ideation operations
this.spawnIdeationProcess(projectId, projectPath, args);
}
@@ -131,11 +154,17 @@ export class AgentQueueManager {
projectPath: string,
args: string[]
): void {
debugLog('[Agent Queue] Spawning ideation process:', { projectId, projectPath });
// Kill existing process for this project if any
this.processManager.killProcess(projectId);
const wasKilled = this.processManager.killProcess(projectId);
if (wasKilled) {
debugLog('[Agent Queue] Killed existing process for project:', projectId);
}
// Generate unique spawn ID for this process instance
const spawnId = this.state.generateSpawnId();
debugLog('[Agent Queue] Generated spawn ID:', spawnId);
// Run from auto-claude source directory so imports work correctly
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
@@ -144,22 +173,41 @@ export class AgentQueueManager {
// Get combined environment variables
const combinedEnv = this.processManager.getCombinedEnv(projectPath);
// Get active Claude profile environment (CLAUDE_CONFIG_DIR if not default)
// Get active Claude profile environment (CLAUDE_CODE_OAUTH_TOKEN if not default)
const profileEnv = getProfileEnv();
// Get Python path from process manager (uses venv if configured)
const pythonPath = this.processManager.getPythonPath();
// Build final environment with proper precedence:
// 1. process.env (system)
// 2. combinedEnv (auto-claude/.env for CLI usage)
// 3. profileEnv (Electron app OAuth token - highest priority)
// 4. Our specific overrides
const finalEnv = {
...process.env,
...combinedEnv,
...profileEnv,
PYTHONPATH: autoBuildSource || '', // Allow imports from auto-claude directory
PYTHONUNBUFFERED: '1',
PYTHONIOENCODING: 'utf-8',
PYTHONUTF8: '1'
};
// Debug: Show OAuth token source
const tokenSource = profileEnv.CLAUDE_CODE_OAUTH_TOKEN
? 'Electron app profile'
: (combinedEnv.CLAUDE_CODE_OAUTH_TOKEN ? 'auto-claude/.env' : 'not found');
const hasToken = !!finalEnv.CLAUDE_CODE_OAUTH_TOKEN;
debugLog('[Agent Queue] OAuth token status:', {
source: tokenSource,
hasToken,
tokenPreview: hasToken ? finalEnv.CLAUDE_CODE_OAUTH_TOKEN?.substring(0, 20) + '...' : 'none'
});
const childProcess = spawn(pythonPath, args, {
cwd,
env: {
...process.env,
...combinedEnv,
...profileEnv,
PYTHONUNBUFFERED: '1',
PYTHONIOENCODING: 'utf-8',
PYTHONUTF8: '1'
}
env: finalEnv
});
this.state.addProcess(projectId, {
@@ -206,6 +254,13 @@ export class AgentQueueManager {
const [, ideationType, ideasCount] = typeCompleteMatch;
completedTypes.add(ideationType);
debugLog('[Agent Queue] Ideation type completed:', {
projectId,
ideationType,
ideasCount: parseInt(ideasCount, 10),
totalCompleted: completedTypes.size
});
// Emit event for UI to load this type's ideas immediately
this.emitter.emit('ideation-type-complete', projectId, ideationType, parseInt(ideasCount, 10));
}
@@ -214,6 +269,8 @@ export class AgentQueueManager {
if (typeFailedMatch) {
const [, ideationType] = typeFailedMatch;
completedTypes.add(ideationType);
debugError('[Agent Queue] Ideation type failed:', { projectId, ideationType });
this.emitter.emit('ideation-type-failed', projectId, ideationType);
}
@@ -254,6 +311,8 @@ export class AgentQueueManager {
// Handle process exit
childProcess.on('exit', (code: number | null) => {
debugLog('[Agent Queue] Ideation process exited:', { projectId, code, spawnId });
// Get the stored project path before deleting from map
const processInfo = this.state.getProcess(projectId);
const storedProjectPath = processInfo?.projectPath;
@@ -261,8 +320,10 @@ export class AgentQueueManager {
// Check for rate limit if process failed
if (code !== 0) {
debugLog('[Agent Queue] Checking for rate limit (non-zero exit)');
const rateLimitDetection = detectRateLimit(allOutput);
if (rateLimitDetection.isRateLimited) {
debugLog('[Agent Queue] Rate limit detected for ideation');
const rateLimitInfo = createSDKRateLimitInfo('ideation', rateLimitDetection, {
projectId
});
@@ -271,6 +332,7 @@ export class AgentQueueManager {
}
if (code === 0) {
debugLog('[Agent Queue] Ideation generation completed successfully');
this.emitter.emit('ideation-progress', projectId, {
phase: 'complete',
progress: 100,
@@ -286,18 +348,25 @@ export class AgentQueueManager {
'ideation',
'ideation.json'
);
debugLog('[Agent Queue] Loading ideation session from:', ideationFilePath);
if (existsSync(ideationFilePath)) {
const content = readFileSync(ideationFilePath, 'utf-8');
const session = JSON.parse(content);
debugLog('[Agent Queue] Loaded ideation session:', {
totalIdeas: session.ideas?.length || 0
});
this.emitter.emit('ideation-complete', projectId, session);
} else {
debugError('[Ideation] ideation.json not found at:', ideationFilePath);
console.warn('[Ideation] ideation.json not found at:', ideationFilePath);
}
} catch (err) {
debugError('[Ideation] Failed to load ideation session:', err);
console.error('[Ideation] Failed to load ideation session:', err);
}
}
} else {
debugError('[Agent Queue] Ideation generation failed:', { projectId, code });
this.emitter.emit('ideation-error', projectId, `Ideation generation failed with exit code ${code}`);
}
});
@@ -318,11 +387,17 @@ export class AgentQueueManager {
projectPath: string,
args: string[]
): void {
debugLog('[Agent Queue] Spawning roadmap process:', { projectId, projectPath });
// Kill existing process for this project if any
this.processManager.killProcess(projectId);
const wasKilled = this.processManager.killProcess(projectId);
if (wasKilled) {
debugLog('[Agent Queue] Killed existing roadmap process for project:', projectId);
}
// Generate unique spawn ID for this process instance
const spawnId = this.state.generateSpawnId();
debugLog('[Agent Queue] Generated roadmap spawn ID:', spawnId);
// Run from auto-claude source directory so imports work correctly
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
@@ -331,22 +406,41 @@ export class AgentQueueManager {
// Get combined environment variables
const combinedEnv = this.processManager.getCombinedEnv(projectPath);
// Get active Claude profile environment (CLAUDE_CONFIG_DIR if not default)
// Get active Claude profile environment (CLAUDE_CODE_OAUTH_TOKEN if not default)
const profileEnv = getProfileEnv();
// Get Python path from process manager (uses venv if configured)
const pythonPath = this.processManager.getPythonPath();
// Build final environment with proper precedence:
// 1. process.env (system)
// 2. combinedEnv (auto-claude/.env for CLI usage)
// 3. profileEnv (Electron app OAuth token - highest priority)
// 4. Our specific overrides
const finalEnv = {
...process.env,
...combinedEnv,
...profileEnv,
PYTHONPATH: autoBuildSource || '', // Allow imports from auto-claude directory
PYTHONUNBUFFERED: '1',
PYTHONIOENCODING: 'utf-8',
PYTHONUTF8: '1'
};
// Debug: Show OAuth token source
const tokenSource = profileEnv.CLAUDE_CODE_OAUTH_TOKEN
? 'Electron app profile'
: (combinedEnv.CLAUDE_CODE_OAUTH_TOKEN ? 'auto-claude/.env' : 'not found');
const hasToken = !!finalEnv.CLAUDE_CODE_OAUTH_TOKEN;
debugLog('[Agent Queue] OAuth token status:', {
source: tokenSource,
hasToken,
tokenPreview: hasToken ? finalEnv.CLAUDE_CODE_OAUTH_TOKEN?.substring(0, 20) + '...' : 'none'
});
const childProcess = spawn(pythonPath, args, {
cwd,
env: {
...process.env,
...combinedEnv,
...profileEnv,
PYTHONUNBUFFERED: '1',
PYTHONIOENCODING: 'utf-8',
PYTHONUTF8: '1'
}
env: finalEnv
});
this.state.addProcess(projectId, {
@@ -412,6 +506,8 @@ export class AgentQueueManager {
// Handle process exit
childProcess.on('exit', (code: number | null) => {
debugLog('[Agent Queue] Roadmap process exited:', { projectId, code, spawnId });
// Get the stored project path before deleting from map
const processInfo = this.state.getProcess(projectId);
const storedProjectPath = processInfo?.projectPath;
@@ -419,8 +515,10 @@ export class AgentQueueManager {
// Check for rate limit if process failed
if (code !== 0) {
debugLog('[Agent Queue] Checking for rate limit (non-zero exit)');
const rateLimitDetection = detectRateLimit(allRoadmapOutput);
if (rateLimitDetection.isRateLimited) {
debugLog('[Agent Queue] Rate limit detected for roadmap');
const rateLimitInfo = createSDKRateLimitInfo('roadmap', rateLimitDetection, {
projectId
});
@@ -429,6 +527,7 @@ export class AgentQueueManager {
}
if (code === 0) {
debugLog('[Agent Queue] Roadmap generation completed successfully');
this.emitter.emit('roadmap-progress', projectId, {
phase: 'complete',
progress: 100,
@@ -444,18 +543,26 @@ export class AgentQueueManager {
'roadmap',
'roadmap.json'
);
debugLog('[Agent Queue] Loading roadmap from:', roadmapFilePath);
if (existsSync(roadmapFilePath)) {
const content = readFileSync(roadmapFilePath, 'utf-8');
const roadmap = JSON.parse(content);
debugLog('[Agent Queue] Loaded roadmap:', {
featuresCount: roadmap.features?.length || 0,
phasesCount: roadmap.phases?.length || 0
});
this.emitter.emit('roadmap-complete', projectId, roadmap);
} else {
debugError('[Roadmap] roadmap.json not found at:', roadmapFilePath);
console.warn('[Roadmap] roadmap.json not found at:', roadmapFilePath);
}
} catch (err) {
debugError('[Roadmap] Failed to load roadmap:', err);
console.error('[Roadmap] Failed to load roadmap:', err);
}
}
} else {
debugError('[Agent Queue] Roadmap generation failed:', { projectId, code });
this.emitter.emit('roadmap-error', projectId, `Roadmap generation failed with exit code ${code}`);
}
});
@@ -472,12 +579,18 @@ export class AgentQueueManager {
* Stop ideation generation for a project
*/
stopIdeation(projectId: string): boolean {
debugLog('[Agent Queue] Stop ideation requested:', { projectId });
const wasRunning = this.state.hasProcess(projectId);
debugLog('[Agent Queue] Process running?', { projectId, wasRunning });
if (wasRunning) {
debugLog('[Agent Queue] Killing ideation process:', projectId);
this.processManager.killProcess(projectId);
this.emitter.emit('ideation-stopped', projectId);
return true;
}
debugLog('[Agent Queue] No running process found for:', projectId);
return false;
}
@@ -487,4 +600,30 @@ export class AgentQueueManager {
isIdeationRunning(projectId: string): boolean {
return this.state.hasProcess(projectId);
}
/**
* Stop roadmap generation for a project
*/
stopRoadmap(projectId: string): boolean {
debugLog('[Agent Queue] Stop roadmap requested:', { projectId });
const wasRunning = this.state.hasProcess(projectId);
debugLog('[Agent Queue] Roadmap process running?', { projectId, wasRunning });
if (wasRunning) {
debugLog('[Agent Queue] Killing roadmap process:', projectId);
this.processManager.killProcess(projectId);
this.emitter.emit('roadmap-stopped', projectId);
return true;
}
debugLog('[Agent Queue] No running roadmap process found for:', projectId);
return false;
}
/**
* Check if roadmap is running for a project
*/
isRoadmapRunning(projectId: string): boolean {
return this.state.hasProcess(projectId);
}
}
+15
View File
@@ -139,6 +139,21 @@ app.whenReady().then(() => {
usageMonitor.start();
console.warn('[main] Usage monitor initialized and started');
// Log debug mode status
const isDebugMode = process.env.DEBUG === 'true';
const isAutoClaudeDebug = process.env.AUTO_CLAUDE_DEBUG === 'true';
if (isDebugMode || isAutoClaudeDebug) {
console.warn('[main] ========================================');
console.warn('[main] DEBUG MODE ENABLED');
if (isDebugMode) {
console.warn('[main] - DEBUG=true (Ideation/Roadmap debug logging)');
}
if (isAutoClaudeDebug) {
console.warn('[main] - AUTO_CLAUDE_DEBUG=true (Core features debug logging)');
}
console.warn('[main] ========================================');
}
// Initialize app auto-updater (only in production, or when DEBUG_UPDATER is set)
const forceUpdater = process.env.DEBUG_UPDATER === 'true';
if (app.isPackaged || forceUpdater) {
@@ -69,7 +69,7 @@ export async function githubFetch(
const response = await fetch(url, {
...options,
headers: {
'Accept': 'application/vnd.github.v3+json',
'Accept': 'application/vnd.github+json',
'Authorization': `Bearer ${token}`,
'User-Agent': 'Auto-Claude-UI',
...options.headers
@@ -7,6 +7,7 @@ import { IPC_CHANNELS } from '../../../shared/constants';
import type { IPCResult, IdeationConfig, IdeationGenerationStatus } from '../../../shared/types';
import { projectStore } from '../../project-store';
import type { AgentManager } from '../../agent';
import { debugLog } from '../../../shared/utils/debug-logger';
/**
* Start ideation generation for a project
@@ -18,10 +19,17 @@ export function startIdeationGeneration(
agentManager: AgentManager,
mainWindow: BrowserWindow | null
): void {
debugLog('[Ideation Handler] Start generation request:', {
projectId,
enabledTypes: config.enabledTypes,
maxIdeasPerType: config.maxIdeasPerType
});
if (!mainWindow) return;
const project = projectStore.getProject(projectId);
if (!project) {
debugLog('[Ideation Handler] Project not found:', projectId);
mainWindow.webContents.send(
IPC_CHANNELS.IDEATION_ERROR,
projectId,
@@ -30,6 +38,11 @@ export function startIdeationGeneration(
return;
}
debugLog('[Ideation Handler] Starting agent manager generation:', {
projectId,
projectPath: project.path
});
// Start ideation generation via agent manager
agentManager.startIdeationGeneration(projectId, project.path, config, false);
@@ -91,9 +104,14 @@ export async function stopIdeationGeneration(
agentManager: AgentManager,
mainWindow: BrowserWindow | null
): Promise<IPCResult> {
debugLog('[Ideation Handler] Stop generation request:', { projectId });
const wasStopped = agentManager.stopIdeation(projectId);
debugLog('[Ideation Handler] Stop result:', { projectId, wasStopped });
if (wasStopped && mainWindow) {
debugLog('[Ideation Handler] Sending stopped event to renderer');
mainWindow.webContents.send(IPC_CHANNELS.IDEATION_STOPPED, projectId);
}
@@ -6,6 +6,7 @@ import path from 'path';
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
import { projectStore } from '../project-store';
import { AgentManager } from '../agent';
import { debugLog, debugError } from '../../shared/utils/debug-logger';
/**
@@ -162,11 +163,17 @@ export function registerRoadmapHandlers(
ipcMain.on(
IPC_CHANNELS.ROADMAP_GENERATE,
(_, projectId: string, enableCompetitorAnalysis?: boolean) => {
debugLog('[Roadmap Handler] Generate request:', {
projectId,
enableCompetitorAnalysis
});
const mainWindow = getMainWindow();
if (!mainWindow) return;
const project = projectStore.getProject(projectId);
if (!project) {
debugError('[Roadmap Handler] Project not found:', projectId);
mainWindow.webContents.send(
IPC_CHANNELS.ROADMAP_ERROR,
projectId,
@@ -175,6 +182,11 @@ export function registerRoadmapHandlers(
return;
}
debugLog('[Roadmap Handler] Starting agent manager generation:', {
projectId,
projectPath: project.path
});
// Start roadmap generation via agent manager
agentManager.startRoadmapGeneration(projectId, project.path, false, enableCompetitorAnalysis ?? false);
@@ -223,6 +235,27 @@ export function registerRoadmapHandlers(
}
);
ipcMain.handle(
IPC_CHANNELS.ROADMAP_STOP,
async (_, projectId: string): Promise<IPCResult> => {
debugLog('[Roadmap Handler] Stop generation request:', { projectId });
const mainWindow = getMainWindow();
// Stop roadmap generation for this project
const wasStopped = agentManager.stopRoadmap(projectId);
debugLog('[Roadmap Handler] Stop result:', { projectId, wasStopped });
if (wasStopped && mainWindow) {
debugLog('[Roadmap Handler] Sending stopped event to renderer');
mainWindow.webContents.send(IPC_CHANNELS.ROADMAP_STOPPED, projectId);
}
return { success: wasStopped };
}
);
// ============================================
// Roadmap Save (full state persistence for drag-and-drop)
// ============================================
+1 -1
View File
@@ -294,7 +294,7 @@ export class ProjectStore {
id: dir.name, // Use spec directory name as ID
specId: dir.name,
projectId,
title: plan?.feature || dir.name,
title: plan?.feature || plan?.title || dir.name,
description,
status,
reviewReason,
+26 -14
View File
@@ -11,12 +11,12 @@ import { TIMEOUTS } from './config';
*/
export function fetchJson<T>(url: string): Promise<T> {
return new Promise((resolve, reject) => {
const request = https.get(url, {
headers: {
'User-Agent': 'Auto-Claude-UI',
'Accept': 'application/vnd.github.v3+json'
}
}, (response) => {
const headers = {
'User-Agent': 'Auto-Claude-UI',
'Accept': 'application/vnd.github+json'
};
const request = https.get(url, { headers }, (response) => {
// Handle redirects
if (response.statusCode === 301 || response.statusCode === 302) {
const redirectUrl = response.headers.location;
@@ -27,7 +27,13 @@ export function fetchJson<T>(url: string): Promise<T> {
}
if (response.statusCode !== 200) {
reject(new Error(`HTTP ${response.statusCode}`));
// Collect response body for error details
let errorData = '';
response.on('data', chunk => errorData += chunk);
response.on('end', () => {
const errorMsg = `HTTP ${response.statusCode}: ${errorData || response.statusMessage || 'No error details'}`;
reject(new Error(errorMsg));
});
return;
}
@@ -62,12 +68,12 @@ export function downloadFile(
return new Promise((resolve, reject) => {
const file = createWriteStream(destPath);
const request = https.get(url, {
headers: {
'User-Agent': 'Auto-Claude-UI',
'Accept': 'application/octet-stream'
}
}, (response) => {
const headers = {
'User-Agent': 'Auto-Claude-UI',
'Accept': 'application/vnd.github+json'
};
const request = https.get(url, { headers }, (response) => {
// Handle redirects
if (response.statusCode === 301 || response.statusCode === 302) {
file.close();
@@ -80,7 +86,13 @@ export function downloadFile(
if (response.statusCode !== 200) {
file.close();
reject(new Error(`HTTP ${response.statusCode}`));
// Collect response body for error details
let errorData = '';
response.on('data', chunk => errorData += chunk);
response.on('end', () => {
const errorMsg = `HTTP ${response.statusCode}: ${errorData || response.statusMessage || 'No error details'}`;
reject(new Error(errorMsg));
});
return;
}
@@ -17,6 +17,7 @@ export interface RoadmapAPI {
saveRoadmap: (projectId: string, roadmap: Roadmap) => Promise<IPCResult>;
generateRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean) => void;
refreshRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean) => void;
stopRoadmap: (projectId: string) => Promise<IPCResult>;
updateFeatureStatus: (
projectId: string,
featureId: string,
@@ -37,6 +38,9 @@ export interface RoadmapAPI {
onRoadmapError: (
callback: (projectId: string, error: string) => void
) => IpcListenerCleanup;
onRoadmapStopped: (
callback: (projectId: string) => void
) => IpcListenerCleanup;
}
/**
@@ -56,6 +60,9 @@ export const createRoadmapAPI = (): RoadmapAPI => ({
refreshRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean): void =>
sendIpc(IPC_CHANNELS.ROADMAP_REFRESH, projectId, enableCompetitorAnalysis),
stopRoadmap: (projectId: string): Promise<IPCResult> =>
invokeIpc(IPC_CHANNELS.ROADMAP_STOP, projectId),
updateFeatureStatus: (
projectId: string,
featureId: string,
@@ -83,5 +90,10 @@ export const createRoadmapAPI = (): RoadmapAPI => ({
onRoadmapError: (
callback: (projectId: string, error: string) => void
): IpcListenerCleanup =>
createIpcListener(IPC_CHANNELS.ROADMAP_ERROR, callback)
createIpcListener(IPC_CHANNELS.ROADMAP_ERROR, callback),
onRoadmapStopped: (
callback: (projectId: string) => void
): IpcListenerCleanup =>
createIpcListener(IPC_CHANNELS.ROADMAP_STOPPED, callback)
});
+3
View File
@@ -6,3 +6,6 @@ const electronAPI = createElectronAPI();
// Expose to renderer via contextBridge
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
// Expose debug flag for debug logging
contextBridge.exposeInMainWorld('DEBUG', process.env.DEBUG === 'true');
@@ -8,7 +8,10 @@ import {
Copy,
Eye,
EyeOff,
Info
Info,
LogIn,
ChevronDown,
ChevronRight
} from 'lucide-react';
import {
Dialog,
@@ -44,46 +47,168 @@ export function EnvConfigModal({
}: EnvConfigModalProps) {
const [token, setToken] = useState('');
const [showToken, setShowToken] = useState(false);
const [_isLoading, _setIsLoading] = useState(false);
const [showManualEntry, setShowManualEntry] = useState(false);
const [isAuthenticating, setIsAuthenticating] = useState(false);
const [isChecking, setIsChecking] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [sourcePath, setSourcePath] = useState<string | null>(null);
const [hasExistingToken, setHasExistingToken] = useState(false);
const [claudeProfiles, setClaudeProfiles] = useState<Array<{
id: string;
name: string;
oauthToken?: string;
email?: string;
isDefault: boolean;
}>>([]);
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
const [isLoadingProfiles, setIsLoadingProfiles] = useState(true);
// Check current token status when modal opens
// Load Claude profiles and check token status when modal opens
useEffect(() => {
const checkToken = async () => {
const loadData = async () => {
if (!open) return;
setIsChecking(true);
setIsLoadingProfiles(true);
setError(null);
setSuccess(false);
try {
const result = await window.electronAPI.checkSourceToken();
if (result.success && result.data) {
setSourcePath(result.data.sourcePath || null);
setHasExistingToken(result.data.hasToken);
// Load both token status and Claude profiles in parallel
const [tokenResult, profilesResult] = await Promise.all([
window.electronAPI.checkSourceToken(),
window.electronAPI.getClaudeProfiles()
]);
if (result.data.hasToken) {
// Handle token status
if (tokenResult.success && tokenResult.data) {
setSourcePath(tokenResult.data.sourcePath || null);
setHasExistingToken(tokenResult.data.hasToken);
if (tokenResult.data.hasToken) {
// Token exists, show success state
setSuccess(true);
}
} else {
setError(result.error || 'Failed to check token status');
setError(tokenResult.error || 'Failed to check token status');
}
// Handle Claude profiles
if (profilesResult.success && profilesResult.data) {
const authenticatedProfiles = profilesResult.data.profiles.filter(
(p: any) => p.oauthToken || (p.isDefault && p.configDir)
);
setClaudeProfiles(authenticatedProfiles);
// Auto-select first authenticated profile
if (authenticatedProfiles.length > 0 && !selectedProfileId) {
setSelectedProfileId(authenticatedProfiles[0].id);
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setIsChecking(false);
setIsLoadingProfiles(false);
}
};
checkToken();
loadData();
}, [open]);
// Listen for OAuth token from terminal
useEffect(() => {
if (!open) return;
const cleanup = window.electronAPI.onTerminalOAuthToken(async (info) => {
if (info.success && info.token) {
// Save the OAuth token
try {
const result = await window.electronAPI.updateSourceEnv({
claudeOAuthToken: info.token
});
if (result.success) {
setSuccess(true);
setHasExistingToken(true);
setIsAuthenticating(false);
// Notify parent
setTimeout(() => {
onConfigured?.();
onOpenChange(false);
}, 1500);
}
} catch (err) {
setError('Failed to save authentication token');
setIsAuthenticating(false);
}
}
});
return cleanup;
}, [open, onConfigured, onOpenChange]);
const handleUseExistingProfile = async () => {
if (!selectedProfileId) return;
setIsSaving(true);
setError(null);
try {
// Get the selected profile's token
const profile = claudeProfiles.find(p => p.id === selectedProfileId);
if (!profile?.oauthToken) {
setError('Selected profile does not have a valid token');
setIsSaving(false);
return;
}
// Save the token to auto-claude .env
const result = await window.electronAPI.updateSourceEnv({
claudeOAuthToken: profile.oauthToken
});
if (result.success) {
setSuccess(true);
setHasExistingToken(true);
// Notify parent
setTimeout(() => {
onConfigured?.();
onOpenChange(false);
}, 1500);
} else {
setError(result.error || 'Failed to save token');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setIsSaving(false);
}
};
const handleAuthenticateWithBrowser = async () => {
setIsAuthenticating(true);
setError(null);
try {
// Invoke the Claude setup-token flow in terminal
const result = await window.electronAPI.invokeClaudeSetup();
if (!result.success) {
setError(result.error || 'Failed to start authentication');
setIsAuthenticating(false);
}
// Keep isAuthenticating true - will be cleared when token is received
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to start authentication');
setIsAuthenticating(false);
}
};
const handleSave = async () => {
if (!token.trim()) {
setError('Please enter a token');
@@ -182,89 +307,258 @@ export function EnvConfigModal({
</div>
)}
{/* Info about getting a token */}
<div className="rounded-lg bg-info/10 border border-info/30 p-4">
<div className="flex items-start gap-3">
<Info className="h-5 w-5 text-info shrink-0 mt-0.5" />
<div className="flex-1 space-y-2">
<p className="text-sm text-foreground font-medium">
How to get a Claude Code OAuth token:
</p>
<ol className="text-sm text-muted-foreground space-y-1 list-decimal list-inside">
<li>Install Claude Code CLI if you haven't already</li>
<li>
Run{' '}
<code className="px-1.5 py-0.5 bg-muted rounded font-mono text-xs">
claude setup-token
</code>
{' '}
{/* Option 1: Use existing authenticated profile */}
{!isLoadingProfiles && claudeProfiles.length > 0 && (
<div className="space-y-3">
<div className="rounded-lg bg-success/10 border border-success/30 p-4">
<div className="flex items-start gap-3">
<CheckCircle2 className="h-5 w-5 text-success shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm text-foreground font-medium mb-1">
Use Existing Account
</p>
<p className="text-xs text-muted-foreground">
You have {claudeProfiles.length} authenticated Claude account{claudeProfiles.length > 1 ? 's' : ''}. Select one to use:
</p>
</div>
</div>
</div>
{/* Profile selector */}
<div className="space-y-2">
<Label className="text-sm font-medium text-foreground">
Select Account
</Label>
<div className="space-y-2">
{claudeProfiles.map((profile) => (
<button
onClick={handleCopyCommand}
className="inline-flex items-center text-info hover:text-info/80"
key={profile.id}
onClick={() => setSelectedProfileId(profile.id)}
className={cn(
"w-full flex items-center gap-3 p-3 rounded-lg border-2 transition-colors text-left",
selectedProfileId === profile.id
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
)}
>
<Copy className="h-3 w-3 ml-1" />
<div className={cn(
"h-4 w-4 rounded-full border-2 flex items-center justify-center shrink-0",
selectedProfileId === profile.id
? "border-primary"
: "border-muted-foreground"
)}>
{selectedProfileId === profile.id && (
<div className="h-2 w-2 rounded-full bg-primary" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">
{profile.name}
{profile.isDefault && (
<span className="ml-2 text-xs text-muted-foreground">(Default)</span>
)}
</p>
{profile.email && (
<p className="text-xs text-muted-foreground truncate">
{profile.email}
</p>
)}
</div>
<CheckCircle2 className={cn(
"h-4 w-4 shrink-0",
selectedProfileId === profile.id ? "text-primary" : "text-transparent"
)} />
</button>
</li>
<li>Copy the token and paste it below</li>
</ol>
<button
onClick={handleOpenDocs}
className="text-sm text-info hover:text-info/80 flex items-center gap-1"
>
<ExternalLink className="h-3 w-3" />
View documentation
</button>
))}
</div>
</div>
<Button
onClick={handleUseExistingProfile}
disabled={!selectedProfileId || isSaving}
className="w-full"
size="lg"
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Saving...
</>
) : (
<>
<Key className="mr-2 h-5 w-5" />
Use This Account
</>
)}
</Button>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border"></div>
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">or</span>
</div>
</div>
</div>
</div>
)}
{/* Token input */}
<div className="space-y-2">
<Label htmlFor="token" className="text-sm font-medium text-foreground">
Claude Code OAuth Token
</Label>
<div className="relative">
<Input
id="token"
type={showToken ? 'text' : 'password'}
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="Enter your token..."
className="pr-10 font-mono text-sm"
disabled={isSaving}
/>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setShowToken(!showToken)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showToken ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent>
{showToken ? 'Hide token' : 'Show token'}
</TooltipContent>
</Tooltip>
{/* Option 2: Authenticate new account with browser */}
{!isLoadingProfiles && (
<div className="space-y-3">
<div className="rounded-lg bg-info/10 border border-info/30 p-4">
<div className="flex items-start gap-3">
<Info className="h-5 w-5 text-info shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm text-foreground font-medium mb-1">
{claudeProfiles.length > 0 ? 'Or Authenticate New Account' : 'Authenticate with Browser'}
</p>
<p className="text-xs text-muted-foreground">
{claudeProfiles.length > 0
? 'Add a new Claude account by logging in with your browser.'
: 'Click below to open your browser and log in with your Claude account.'
}
</p>
</div>
</div>
</div>
<Button
onClick={handleAuthenticateWithBrowser}
disabled={isAuthenticating}
className="w-full"
size="lg"
variant={claudeProfiles.length > 0 ? "outline" : "default"}
>
{isAuthenticating ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Waiting for authentication...
</>
) : (
<>
<LogIn className="mr-2 h-5 w-5" />
{claudeProfiles.length > 0 ? 'Authenticate New Account' : 'Authenticate with Browser'}
</>
)}
</Button>
{isAuthenticating && (
<p className="text-xs text-muted-foreground text-center">
A browser window should open. Complete the authentication there, then return here.
</p>
)}
</div>
<p className="text-xs text-muted-foreground">
The token will be saved to{' '}
<code className="px-1 py-0.5 bg-muted rounded font-mono">
{sourcePath ? `${sourcePath}/.env` : 'auto-claude/.env'}
</code>
</p>
)}
{/* Divider before manual entry */}
{!isLoadingProfiles && (
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border"></div>
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">or</span>
</div>
</div>
)}
{/* Secondary: Manual Token Entry (Collapsible) */}
<div className="space-y-3">
<button
onClick={() => setShowManualEntry(!showManualEntry)}
className="w-full flex items-center justify-between text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<span>Enter token manually</span>
{showManualEntry ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
{showManualEntry && (
<div className="space-y-3 pl-4 border-l-2 border-border">
{/* Manual token instructions */}
<div className="text-xs text-muted-foreground space-y-1">
<p className="font-medium text-foreground">Steps:</p>
<ol className="list-decimal list-inside space-y-1">
<li>Install Claude Code CLI if you haven't already</li>
<li>
Run{' '}
<code className="px-1 py-0.5 bg-muted rounded font-mono">
claude setup-token
</code>
{' '}
<button
onClick={handleCopyCommand}
className="inline-flex items-center text-info hover:text-info/80"
>
<Copy className="h-3 w-3 ml-1" />
</button>
</li>
<li>Copy the token and paste it below</li>
</ol>
<button
onClick={handleOpenDocs}
className="text-info hover:text-info/80 flex items-center gap-1 mt-2"
>
<ExternalLink className="h-3 w-3" />
View documentation
</button>
</div>
{/* Token input */}
<div className="space-y-2">
<Label htmlFor="token" className="text-sm font-medium text-foreground">
Claude Code OAuth Token
</Label>
<div className="relative">
<Input
id="token"
type={showToken ? 'text' : 'password'}
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="Enter your token..."
className="pr-10 font-mono text-sm"
disabled={isSaving || isAuthenticating}
/>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setShowToken(!showToken)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showToken ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent>
{showToken ? 'Hide token' : 'Show token'}
</TooltipContent>
</Tooltip>
</div>
<p className="text-xs text-muted-foreground">
The token will be saved to{' '}
<code className="px-1 py-0.5 bg-muted rounded font-mono">
{sourcePath ? `${sourcePath}/.env` : 'auto-claude/.env'}
</code>
</p>
</div>
</div>
)}
</div>
{/* Existing token info */}
{hasExistingToken && (
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-sm text-muted-foreground">
A token is already configured. Enter a new token above to replace it.
A token is already configured. {showManualEntry ? 'Enter a new token above to replace it.' : 'Authenticate again to replace it.'}
</p>
</div>
)}
@@ -272,11 +566,11 @@ export function EnvConfigModal({
)}
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isSaving}>
<Button variant="outline" onClick={handleClose} disabled={isSaving || isAuthenticating}>
{success ? 'Close' : 'Cancel'}
</Button>
{!success && (
<Button onClick={handleSave} disabled={!token.trim() || isSaving}>
{!success && showManualEntry && token.trim() && (
<Button onClick={handleSave} disabled={isSaving || isAuthenticating}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -27,6 +27,7 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
handleRefresh,
handleCompetitorDialogAccept,
handleCompetitorDialogDecline,
handleStop,
} = useRoadmapGeneration(projectId);
// Event handlers
@@ -47,6 +48,7 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
<RoadmapGenerationProgress
generationStatus={generationStatus}
className="w-full max-w-md"
onStop={handleStop}
/>
</div>
);
@@ -73,6 +75,7 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
{/* Header */}
<RoadmapHeader
roadmap={roadmap}
competitorAnalysis={competitorAnalysis}
onAddFeature={() => setShowAddFeatureDialog(true)}
onRefresh={handleRefresh}
/>
@@ -1,6 +1,8 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { Search, Users, Sparkles, CheckCircle2, AlertCircle } from 'lucide-react';
import { Search, Users, Sparkles, CheckCircle2, AlertCircle, Square } from 'lucide-react';
import { Button } from './ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { cn } from '../lib/utils';
import type { RoadmapGenerationStatus } from '../../shared/types/roadmap';
@@ -36,6 +38,7 @@ function useReducedMotion(): boolean {
interface RoadmapGenerationProgressProps {
generationStatus: RoadmapGenerationStatus;
className?: string;
onStop?: () => void;
}
// Type for generation phases (excluding idle)
@@ -190,6 +193,7 @@ function PhaseStepsIndicator({
export function RoadmapGenerationProgress({
generationStatus,
className,
onStop
}: RoadmapGenerationProgressProps) {
const { phase, progress, message, error } = generationStatus;
const reducedMotion = useReducedMotion();
@@ -248,6 +252,25 @@ export function RoadmapGenerationProgress({
return (
<div className={cn('space-y-4 p-6 rounded-xl bg-card border', className)}>
{/* Header with Stop button */}
{isActivePhase && onStop && (
<div className="flex justify-end mb-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="destructive"
size="sm"
onClick={onStop}
>
<Square className="h-4 w-4 mr-1" />
Stop
</Button>
</TooltipTrigger>
<TooltipContent>Stop generation</TooltipContent>
</Tooltip>
</div>
)}
{/* Main phase display */}
<div className="flex flex-col items-center text-center space-y-3">
{/* Animated icon with pulsing animation for active phase */}
@@ -1,4 +1,4 @@
import { Target, Users, BarChart3, RefreshCw, Plus } from 'lucide-react';
import { Target, Users, BarChart3, RefreshCw, Plus, TrendingUp } from 'lucide-react';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
@@ -6,7 +6,7 @@ import { getFeatureStats } from '../../stores/roadmap-store';
import { ROADMAP_PRIORITY_COLORS } from '../../../shared/constants';
import type { RoadmapHeaderProps } from './types';
export function RoadmapHeader({ roadmap, onAddFeature, onRefresh }: RoadmapHeaderProps) {
export function RoadmapHeader({ roadmap, competitorAnalysis, onAddFeature, onRefresh }: RoadmapHeaderProps) {
const stats = getFeatureStats(roadmap);
return (
@@ -17,6 +17,27 @@ export function RoadmapHeader({ roadmap, onAddFeature, onRefresh }: RoadmapHeade
<Target className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">{roadmap.projectName}</h2>
<Badge variant="outline">{roadmap.status}</Badge>
{competitorAnalysis && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary" className="gap-1">
<TrendingUp className="h-3 w-3" />
Competitor Analysis
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-md">
<div className="space-y-2">
<div className="font-semibold">Analyzed {competitorAnalysis.competitors.length} competitors:</div>
{competitorAnalysis.competitors.map((comp, idx) => (
<div key={idx} className="text-sm">
<div className="font-medium"> {comp.name}</div>
<div className="text-muted-foreground ml-3">{comp.painPoints.length} pain points identified</div>
</div>
))}
</div>
</TooltipContent>
</Tooltip>
)}
</div>
<p className="text-sm text-muted-foreground max-w-xl">{roadmap.vision}</p>
</div>
@@ -49,9 +70,21 @@ export function RoadmapHeader({ roadmap, onAddFeature, onRefresh }: RoadmapHeade
<span className="font-medium">{roadmap.targetAudience.primary}</span>
</div>
{roadmap.targetAudience.secondary.length > 0 && (
<div className="text-muted-foreground">
+{roadmap.targetAudience.secondary.length} more personas
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-muted-foreground cursor-help underline decoration-dotted">
+{roadmap.targetAudience.secondary.length} more personas
</div>
</TooltipTrigger>
<TooltipContent className="max-w-md">
<div className="space-y-1">
<div className="font-semibold mb-2">Secondary Personas:</div>
{roadmap.targetAudience.secondary.map((persona, idx) => (
<div key={idx} className="text-sm"> {persona}</div>
))}
</div>
</TooltipContent>
</Tooltip>
)}
</div>
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { useRoadmapStore, loadRoadmap, generateRoadmap, refreshRoadmap } from '../../stores/roadmap-store';
import { useRoadmapStore, loadRoadmap, generateRoadmap, refreshRoadmap, stopRoadmap } from '../../stores/roadmap-store';
import type { RoadmapFeature } from '../../../shared/types';
/**
@@ -70,22 +70,26 @@ export function useRoadmapGeneration(projectId: string) {
const handleCompetitorDialogAccept = () => {
if (pendingAction === 'generate') {
generateRoadmap(projectId);
generateRoadmap(projectId, true); // Enable competitor analysis
} else if (pendingAction === 'refresh') {
refreshRoadmap(projectId);
refreshRoadmap(projectId, true); // Enable competitor analysis
}
setPendingAction(null);
};
const handleCompetitorDialogDecline = () => {
if (pendingAction === 'generate') {
generateRoadmap(projectId);
generateRoadmap(projectId, false); // Disable competitor analysis
} else if (pendingAction === 'refresh') {
refreshRoadmap(projectId);
refreshRoadmap(projectId, false); // Disable competitor analysis
}
setPendingAction(null);
};
const handleStop = async () => {
await stopRoadmap(projectId);
};
return {
pendingAction,
showCompetitorDialog,
@@ -94,5 +98,6 @@ export function useRoadmapGeneration(projectId: string) {
handleRefresh,
handleCompetitorDialogAccept,
handleCompetitorDialogDecline,
handleStop,
};
}
@@ -32,6 +32,7 @@ export interface FeatureDetailPanelProps {
export interface RoadmapHeaderProps {
roadmap: Roadmap;
competitorAnalysis: CompetitorAnalysis | null;
onAddFeature: () => void;
onRefresh: () => void;
}
@@ -171,7 +171,7 @@ export function AdvancedSettings({ settings, onSettingsChange, section, version
setSourceUpdateCheck(result.data);
}
} catch (err) {
console.error('Failed to check for source updates:', err);
// Silent fail - user can retry via button
} finally {
setIsCheckingSourceUpdate(false);
}
@@ -53,12 +53,29 @@ export function useIpcListeners(): void {
const cleanupRoadmapProgress = window.electronAPI.onRoadmapProgress(
(_projectId: string, status: RoadmapGenerationStatus) => {
// Debug logging
if (window.DEBUG) {
console.log('[Roadmap] Progress update:', {
projectId: _projectId,
phase: status.phase,
progress: status.progress,
message: status.message
});
}
setGenerationStatus(status);
}
);
const cleanupRoadmapComplete = window.electronAPI.onRoadmapComplete(
(_projectId: string, roadmap: Roadmap) => {
// Debug logging
if (window.DEBUG) {
console.log('[Roadmap] Generation complete:', {
projectId: _projectId,
featuresCount: roadmap.features?.length || 0,
phasesCount: roadmap.phases?.length || 0
});
}
setRoadmap(roadmap);
setGenerationStatus({
phase: 'complete',
@@ -70,6 +87,10 @@ export function useIpcListeners(): void {
const cleanupRoadmapError = window.electronAPI.onRoadmapError(
(_projectId: string, error: string) => {
// Debug logging
if (window.DEBUG) {
console.error('[Roadmap] Error received:', { projectId: _projectId, error });
}
setGenerationStatus({
phase: 'error',
progress: 0,
@@ -79,6 +100,20 @@ export function useIpcListeners(): void {
}
);
const cleanupRoadmapStopped = window.electronAPI.onRoadmapStopped(
(_projectId: string) => {
// Debug logging
if (window.DEBUG) {
console.log('[Roadmap] Generation stopped:', { projectId: _projectId });
}
setGenerationStatus({
phase: 'idle',
progress: 0,
message: 'Generation stopped'
});
}
);
// Terminal rate limit listener
const showRateLimitModal = useRateLimitStore.getState().showRateLimitModal;
const cleanupRateLimit = window.electronAPI.onTerminalRateLimit(
@@ -117,6 +152,7 @@ export function useIpcListeners(): void {
cleanupRoadmapProgress();
cleanupRoadmapComplete();
cleanupRoadmapError();
cleanupRoadmapStopped();
cleanupRateLimit();
cleanupSDKRateLimit();
};
@@ -337,6 +337,18 @@ export async function loadIdeation(projectId: string): Promise<void> {
export function generateIdeation(projectId: string): void {
const store = useIdeationStore.getState();
const config = store.config;
// Debug logging
if (window.DEBUG) {
console.log('[Ideation] Starting generation:', {
projectId,
enabledTypes: config.enabledTypes,
includeRoadmapContext: config.includeRoadmapContext,
includeKanbanContext: config.includeKanbanContext,
maxIdeasPerType: config.maxIdeasPerType
});
}
store.clearLogs();
store.clearSession(); // Clear existing session for fresh generation
store.initializeTypeStates(config.enabledTypes);
@@ -351,15 +363,35 @@ export function generateIdeation(projectId: string): void {
export async function stopIdeation(projectId: string): Promise<boolean> {
const store = useIdeationStore.getState();
const result = await window.electronAPI.stopIdeation(projectId);
if (result.success) {
store.addLog('Ideation generation stopped');
store.setGenerationStatus({
phase: 'idle',
progress: 0,
message: 'Generation stopped'
});
// Debug logging
if (window.DEBUG) {
console.log('[Ideation] Stop requested:', { projectId });
}
// Always update UI state to 'idle' when user requests stop, regardless of backend response
// This prevents the UI from getting stuck in "generating" state if the process already ended
store.addLog('Stopping ideation generation...');
store.setGenerationStatus({
phase: 'idle',
progress: 0,
message: 'Generation stopped'
});
const result = await window.electronAPI.stopIdeation(projectId);
// Debug logging
if (window.DEBUG) {
console.log('[Ideation] Stop result:', { projectId, success: result.success });
}
if (!result.success) {
// Backend couldn't find/stop the process (likely already finished/crashed)
store.addLog('Process already stopped');
} else {
store.addLog('Ideation generation stopped');
}
return result.success;
}
@@ -528,6 +560,15 @@ export function setupIdeationListeners(): () => void {
// Listen for progress updates
const unsubProgress = window.electronAPI.onIdeationProgress((_projectId, status) => {
// Debug logging
if (window.DEBUG) {
console.log('[Ideation] Progress update:', {
projectId: _projectId,
phase: status.phase,
progress: status.progress,
message: status.message
});
}
store().setGenerationStatus(status);
});
@@ -539,6 +580,16 @@ export function setupIdeationListeners(): () => void {
// Listen for individual ideation type completion (streaming)
const unsubTypeComplete = window.electronAPI.onIdeationTypeComplete(
(_projectId, ideationType, ideas) => {
// Debug logging
if (window.DEBUG) {
console.log('[Ideation] Type completed:', {
projectId: _projectId,
ideationType,
ideasCount: ideas.length,
ideas: ideas.map(i => ({ id: i.id, title: i.title, type: i.type }))
});
}
store().addIdeasForType(ideationType, ideas);
store().addLog(`${ideationType} completed with ${ideas.length} ideas`);
@@ -564,6 +615,11 @@ export function setupIdeationListeners(): () => void {
// Listen for individual ideation type failure
const unsubTypeFailed = window.electronAPI.onIdeationTypeFailed(
(_projectId, ideationType) => {
// Debug logging
if (window.DEBUG) {
console.error('[Ideation] Type failed:', { projectId: _projectId, ideationType });
}
store().setTypeState(ideationType as IdeationType, 'failed');
store().addLog(`${ideationType} failed`);
}
@@ -571,6 +627,18 @@ export function setupIdeationListeners(): () => void {
// Listen for completion (final session with all data)
const unsubComplete = window.electronAPI.onIdeationComplete((_projectId, session) => {
// Debug logging
if (window.DEBUG) {
console.log('[Ideation] Generation complete:', {
projectId: _projectId,
totalIdeas: session.ideas.length,
ideaTypes: session.ideas.reduce((acc, idea) => {
acc[idea.type] = (acc[idea.type] || 0) + 1;
return acc;
}, {} as Record<string, number>)
});
}
// Final session replaces the partial one with complete data
store().setSession(session);
store().setGenerationStatus({
@@ -583,6 +651,11 @@ export function setupIdeationListeners(): () => void {
// Listen for errors
const unsubError = window.electronAPI.onIdeationError((_projectId, error) => {
// Debug logging
if (window.DEBUG) {
console.error('[Ideation] Error received:', { projectId: _projectId, error });
}
store().setGenerationStatus({
phase: 'error',
progress: 0,
@@ -176,22 +176,58 @@ export async function loadRoadmap(projectId: string): Promise<void> {
}
}
export function generateRoadmap(projectId: string): void {
export function generateRoadmap(projectId: string, enableCompetitorAnalysis?: boolean): void {
// Debug logging
if (window.DEBUG) {
console.log('[Roadmap] Starting generation:', { projectId, enableCompetitorAnalysis });
}
useRoadmapStore.getState().setGenerationStatus({
phase: 'analyzing',
progress: 0,
message: 'Starting roadmap generation...'
});
window.electronAPI.generateRoadmap(projectId);
window.electronAPI.generateRoadmap(projectId, enableCompetitorAnalysis);
}
export function refreshRoadmap(projectId: string): void {
export function refreshRoadmap(projectId: string, enableCompetitorAnalysis?: boolean): void {
useRoadmapStore.getState().setGenerationStatus({
phase: 'analyzing',
progress: 0,
message: 'Refreshing roadmap...'
});
window.electronAPI.refreshRoadmap(projectId);
window.electronAPI.refreshRoadmap(projectId, enableCompetitorAnalysis);
}
export async function stopRoadmap(projectId: string): Promise<boolean> {
const store = useRoadmapStore.getState();
// Debug logging
if (window.DEBUG) {
console.log('[Roadmap] Stop requested:', { projectId });
}
// Always update UI state to 'idle' when user requests stop, regardless of backend response
// This prevents the UI from getting stuck in "generating" state if the process already ended
store.setGenerationStatus({
phase: 'idle',
progress: 0,
message: 'Generation stopped'
});
const result = await window.electronAPI.stopRoadmap(projectId);
// Debug logging
if (window.DEBUG) {
console.log('[Roadmap] Stop result:', { projectId, success: result.success });
}
if (!result.success) {
// Backend couldn't find/stop the process (likely already finished/crashed)
console.log('[Roadmap] Process already stopped');
}
return result.success;
}
// Selectors
@@ -120,6 +120,7 @@ export const IPC_CHANNELS = {
ROADMAP_GENERATE: 'roadmap:generate',
ROADMAP_GENERATE_WITH_COMPETITOR: 'roadmap:generateWithCompetitor',
ROADMAP_REFRESH: 'roadmap:refresh',
ROADMAP_STOP: 'roadmap:stop',
ROADMAP_UPDATE_FEATURE: 'roadmap:updateFeature',
ROADMAP_CONVERT_TO_SPEC: 'roadmap:convertToSpec',
@@ -127,6 +128,7 @@ export const IPC_CHANNELS = {
ROADMAP_PROGRESS: 'roadmap:progress',
ROADMAP_COMPLETE: 'roadmap:complete',
ROADMAP_ERROR: 'roadmap:error',
ROADMAP_STOPPED: 'roadmap:stopped',
// Context operations
CONTEXT_GET: 'context:get',
+7 -1
View File
@@ -238,7 +238,9 @@ export interface ElectronAPI {
getRoadmap: (projectId: string) => Promise<IPCResult<Roadmap | null>>;
saveRoadmap: (projectId: string, roadmap: Roadmap) => Promise<IPCResult>;
generateRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean) => void;
refreshRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean) => void; updateFeatureStatus: (
refreshRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean) => void;
stopRoadmap: (projectId: string) => Promise<IPCResult>;
updateFeatureStatus: (
projectId: string,
featureId: string,
status: RoadmapFeatureStatus
@@ -258,6 +260,9 @@ export interface ElectronAPI {
onRoadmapError: (
callback: (projectId: string, error: string) => void
) => () => void;
onRoadmapStopped: (
callback: (projectId: string) => void
) => () => void;
// Context operations
getProjectContext: (projectId: string) => Promise<IPCResult<ProjectContextData>>;
@@ -507,5 +512,6 @@ export interface ElectronAPI {
declare global {
interface Window {
electronAPI: ElectronAPI;
DEBUG: boolean;
}
}
+3 -2
View File
@@ -239,9 +239,10 @@ export interface Task {
// Implementation Plan (from auto-claude)
export interface ImplementationPlan {
feature: string;
feature?: string; // Some plans use 'feature', some use 'title'
title?: string; // Alternative to 'feature' for task name
workflow_type: string;
services_involved: string[];
services_involved?: string[];
phases: Phase[];
final_acceptance: string[];
created_at: string;
+4 -4
View File
@@ -30,13 +30,13 @@ class ProjectAnalyzer:
self,
project_dir: Path,
output_dir: Path,
include_roadmap: bool = True,
include_kanban: bool = True,
include_roadmap_context: bool = True,
include_kanban_context: bool = True,
):
self.project_dir = Path(project_dir)
self.output_dir = Path(output_dir)
self.include_roadmap = include_roadmap
self.include_kanban = include_kanban
self.include_roadmap = include_roadmap_context
self.include_kanban = include_kanban_context
def gather_context(self) -> dict:
"""Gather context from project for ideation."""
+2 -2
View File
@@ -193,8 +193,8 @@ class PhaseExecutor:
"graph_hints": graph_hints, # Include graph hints in context
"config": {
"enabled_types": self.enabled_types,
"include_roadmap_context": self.analyzer.include_roadmap_context,
"include_kanban_context": self.analyzer.include_kanban_context,
"include_roadmap_context": self.analyzer.include_roadmap,
"include_kanban_context": self.analyzer.include_kanban,
"max_ideas_per_type": self.max_ideas_per_type,
},
"created_at": datetime.now().isoformat(),
+4 -1
View File
@@ -77,8 +77,11 @@ class ImplementationPlan:
)
workflow_type = WorkflowType.FEATURE
# Support both 'feature' and 'title' fields for task name
feature_name = data.get("feature") or data.get("title") or "Unnamed Feature"
return cls(
feature=data["feature"],
feature=feature_name,
workflow_type=workflow_type,
services_involved=data.get("services_involved", []),
phases=[
+1
View File
@@ -209,6 +209,7 @@ Based on the workflow type and services involved, create the implementation plan
```json
{
"feature": "Short descriptive name for this task/feature",
"workflow_type": "feature|refactor|investigation|migration|simple",
"workflow_rationale": "Why this workflow type was chosen",
"phases": [