Insights functionality + ideation upgrade

This commit is contained in:
AndyMik90
2025-12-12 10:49:19 +01:00
parent bc5ad38e05
commit bda3ab816f
39 changed files with 10643 additions and 762 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+162
View File
@@ -0,0 +1,162 @@
# Auto Claude UI
A desktop application for managing AI-driven development tasks using the Auto Claude autonomous coding framework.
## Overview
Auto Claude UI provides a visual Kanban board interface for creating, monitoring, and managing auto-build tasks. It replaces the terminal-based workflow with an intuitive GUI while preserving all CLI functionality.
## Features
- **Project Management**: Add, configure, and switch between multiple projects
- **Kanban Board**: Visual task board with columns for Backlog, In Progress, AI Review, Human Review, and Done
- **Task Creation Wizard**: Form-based interface for creating new tasks
- **Real-Time Progress**: Live updates from implementation_plan.json during agent execution
- **Human Review Workflow**: Review QA results and provide feedback
- **Theme Support**: Light and dark mode with system preference detection
- **Settings**: Per-project and app-wide configuration options
## Tech Stack
- **Framework**: Electron with React 18 (TypeScript)
- **Build Tool**: electron-vite with electron-builder
- **UI Components**: Radix UI primitives (shadcn/ui pattern)
- **Styling**: TailwindCSS with dark mode support
- **State Management**: Zustand
- **File Watching**: chokidar
## Project Structure
```
auto-build-ui/
├── src/
│ ├── main/ # Electron main process
│ │ ├── index.ts # App entry point
│ │ ├── agent-manager.ts # Python subprocess management
│ │ ├── file-watcher.ts # Implementation plan watching
│ │ ├── ipc-handlers.ts # IPC message handlers
│ │ └── project-store.ts # JSON project persistence
│ ├── preload/ # Preload scripts
│ │ └── index.ts # Secure contextBridge API
│ ├── renderer/ # React application
│ │ ├── components/ # React components
│ │ │ ├── ui/ # Wrapped Radix UI components
│ │ │ ├── Sidebar.tsx
│ │ │ ├── KanbanBoard.tsx
│ │ │ ├── TaskCard.tsx
│ │ │ ├── TaskDetailPanel.tsx
│ │ │ ├── TaskCreationWizard.tsx
│ │ │ ├── ProjectSettings.tsx
│ │ │ └── AppSettings.tsx
│ │ ├── stores/ # Zustand state stores
│ │ ├── hooks/ # Custom React hooks
│ │ ├── styles/ # Global CSS
│ │ └── App.tsx # Root component
│ └── shared/ # Shared code
│ ├── types.ts # TypeScript interfaces
│ └── constants.ts # Constants and IPC channels
├── electron.vite.config.ts # Build configuration
├── package.json
├── tsconfig.json
├── tailwind.config.js
└── postcss.config.js
```
## Getting Started
### Prerequisites
- Node.js 18+
- npm or pnpm
- Python 3.10+ (for auto-build backend)
### Installation
```bash
# Navigate to auto-build-ui directory
cd auto-build-ui
# Install dependencies
npm install
```
### Development
```bash
# Start development server with hot reload
npm run dev
```
### Build
```bash
# Build for production
npm run build
# Package for macOS
npm run package:mac
# Package for Windows
npm run package:win
# Package for Linux
npm run package:linux
```
### Type Checking
```bash
npm run typecheck
```
### Linting
```bash
npm run lint
```
### Testing
```bash
npm run test
```
## Architecture
### Main Process
The main process handles:
- Window management
- Python subprocess spawning (agent-manager.ts)
- File system watching (file-watcher.ts)
- Project data persistence (project-store.ts)
- IPC communication with renderer
### Preload Script
Provides a secure bridge between main and renderer processes using Electron's contextBridge. All IPC channels are explicitly defined and typed.
### Renderer Process
A React application with:
- Zustand stores for state management
- Custom hooks for IPC event handling
- Radix UI components wrapped in the shadcn/ui pattern
- TailwindCSS for styling
## Security
The application follows Electron security best practices:
- `contextIsolation: true`
- `nodeIntegration: false`
- Minimal API surface via contextBridge
- No direct ipcRenderer exposure
## Environment Variables
- `CLAUDE_CODE_OAUTH_TOKEN`: OAuth token for Claude Code SDK (from auto-build/.env)
- `FALKORDB_URL`: FalkorDB connection URL (optional, defaults to localhost:6379)
## License
MIT
+2 -1
View File
@@ -27,6 +27,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.15",
@@ -56,9 +57,9 @@
"zustand": "^5.0.9"
},
"devDependencies": {
"@electron/rebuild": "^3.7.1",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@electron/rebuild": "^3.7.1",
"@eslint/js": "^9.39.1",
"@playwright/test": "^1.52.0",
"@tailwindcss/postcss": "^4.1.17",
+448 -418
View File
File diff suppressed because it is too large Load Diff
+333 -39
View File
@@ -8,12 +8,24 @@ interface AgentProcess {
taskId: string;
process: ChildProcess;
startedAt: Date;
projectPath?: string; // For ideation processes to load session on completion
}
export interface ExecutionProgressData {
phase: 'idle' | 'planning' | 'coding' | 'qa_review' | 'qa_fixing' | 'complete' | 'failed';
phaseProgress: number;
overallProgress: number;
currentChunk?: string;
message?: string;
}
export type ProcessType = 'spec-creation' | 'task-execution' | 'qa-process';
export interface AgentManagerEvents {
log: (taskId: string, log: string) => void;
error: (taskId: string, error: string) => void;
exit: (taskId: string, code: number | null) => void;
exit: (taskId: string, code: number | null, processType: ProcessType) => void;
'execution-progress': (taskId: string, progress: ExecutionProgressData) => void;
}
/**
@@ -124,12 +136,30 @@ export class AgentManager extends EventEmitter {
projectPath: string,
taskDescription: string
): void {
const autoBuildDir = path.join(projectPath, 'auto-claude');
const specRunnerPath = path.join(autoBuildDir, 'spec_runner.py');
// Use source auto-claude path (the repo), not the project's auto-claude
const autoBuildSource = this.getAutoBuildSourcePath();
const args = [specRunnerPath, '--task', taskDescription];
if (!autoBuildSource) {
this.emit('error', taskId, 'Auto-build source path not found. Please configure it in App Settings.');
return;
}
this.spawnProcess(taskId, projectPath, args);
const specRunnerPath = path.join(autoBuildSource, 'spec_runner.py');
if (!existsSync(specRunnerPath)) {
this.emit('error', taskId, `Spec runner not found at: ${specRunnerPath}`);
return;
}
// Load environment variables from auto-claude .env file
const autoBuildEnv = this.loadAutoBuildEnv();
// spec_runner.py will auto-start run.py after spec creation completes
const args = [specRunnerPath, '--task', taskDescription, '--project-dir', projectPath];
// Note: This is spec-creation but it chains to task-execution via run.py
// So we treat the whole thing as task-execution for status purposes
this.spawnProcess(taskId, autoBuildSource, args, autoBuildEnv, 'task-execution');
}
/**
@@ -141,16 +171,36 @@ export class AgentManager extends EventEmitter {
specId: string,
options: { parallel?: boolean; workers?: number } = {}
): void {
const autoBuildDir = path.join(projectPath, 'auto-claude');
const runPath = path.join(autoBuildDir, 'run.py');
console.log('[AgentManager] startTaskExecution called for:', taskId, specId);
// Use source auto-claude path (the repo), not the project's auto-claude
const autoBuildSource = this.getAutoBuildSourcePath();
const args = [runPath, '--spec', specId];
if (!autoBuildSource) {
console.log('[AgentManager] ERROR: Auto-build source path not found');
this.emit('error', taskId, 'Auto-build source path not found. Please configure it in App Settings.');
return;
}
const runPath = path.join(autoBuildSource, 'run.py');
console.log('[AgentManager] runPath:', runPath);
if (!existsSync(runPath)) {
console.log('[AgentManager] ERROR: Run script not found at:', runPath);
this.emit('error', taskId, `Run script not found at: ${runPath}`);
return;
}
// Load environment variables from auto-claude .env file
const autoBuildEnv = this.loadAutoBuildEnv();
const args = [runPath, '--spec', specId, '--project-dir', projectPath];
if (options.parallel && options.workers) {
args.push('--parallel', options.workers.toString());
}
this.spawnProcess(taskId, projectPath, args);
console.log('[AgentManager] Spawning process with args:', args);
this.spawnProcess(taskId, autoBuildSource, args, autoBuildEnv, 'task-execution');
}
/**
@@ -161,12 +211,27 @@ export class AgentManager extends EventEmitter {
projectPath: string,
specId: string
): void {
const autoBuildDir = path.join(projectPath, 'auto-claude');
const runPath = path.join(autoBuildDir, 'run.py');
// Use source auto-claude path (the repo), not the project's auto-claude
const autoBuildSource = this.getAutoBuildSourcePath();
const args = [runPath, '--spec', specId, '--qa'];
if (!autoBuildSource) {
this.emit('error', taskId, 'Auto-build source path not found. Please configure it in App Settings.');
return;
}
this.spawnProcess(taskId, projectPath, args);
const runPath = path.join(autoBuildSource, 'run.py');
if (!existsSync(runPath)) {
this.emit('error', taskId, `Run script not found at: ${runPath}`);
return;
}
// Load environment variables from auto-claude .env file
const autoBuildEnv = this.loadAutoBuildEnv();
const args = [runPath, '--spec', specId, '--project-dir', projectPath, '--qa'];
this.spawnProcess(taskId, autoBuildSource, args, autoBuildEnv, 'qa-process');
}
/**
@@ -213,6 +278,7 @@ export class AgentManager extends EventEmitter {
includeRoadmapContext: boolean;
includeKanbanContext: boolean;
maxIdeasPerType: number;
append?: boolean;
},
refresh: boolean = false
): void {
@@ -255,6 +321,11 @@ export class AgentManager extends EventEmitter {
args.push('--refresh');
}
// Add append flag to preserve existing ideas
if (config.append) {
args.push('--append');
}
// Use projectId as taskId for ideation operations
this.spawnIdeationProcess(projectId, projectPath, args);
}
@@ -264,7 +335,7 @@ export class AgentManager extends EventEmitter {
*/
private spawnIdeationProcess(
projectId: string,
_projectPath: string,
projectPath: string,
args: string[]
): void {
// Kill existing process for this project if any
@@ -289,7 +360,8 @@ export class AgentManager extends EventEmitter {
this.processes.set(projectId, {
taskId: projectId,
process: childProcess,
startedAt: new Date()
startedAt: new Date(),
projectPath // Store project path for loading session on completion
});
// Track progress through output
@@ -314,6 +386,10 @@ export class AgentManager extends EventEmitter {
console.log('[Ideation] Env vars loaded:', Object.keys(autoBuildEnv));
console.log('[Ideation] Has CLAUDE_CODE_OAUTH_TOKEN:', !!autoBuildEnv['CLAUDE_CODE_OAUTH_TOKEN']);
// Track completed types for progress calculation
const completedTypes = new Set<string>();
const totalTypes = args.filter(a => a !== '--types').length > 0 ? 7 : 7; // Default all types
// Handle stdout
childProcess.stdout?.on('data', (data: Buffer) => {
const log = data.toString();
@@ -321,45 +397,56 @@ export class AgentManager extends EventEmitter {
// Emit all log lines for the activity log
emitLogs(log);
// Check for streaming type completion signals
const typeCompleteMatch = log.match(/IDEATION_TYPE_COMPLETE:(\w+):(\d+)/);
if (typeCompleteMatch) {
const [, ideationType, ideasCount] = typeCompleteMatch;
completedTypes.add(ideationType);
console.log(`[Ideation] Type complete: ${ideationType} with ${ideasCount} ideas`);
// Emit event for UI to load this type's ideas immediately
this.emit('ideation-type-complete', projectId, ideationType, parseInt(ideasCount, 10));
}
const typeFailedMatch = log.match(/IDEATION_TYPE_FAILED:(\w+)/);
if (typeFailedMatch) {
const [, ideationType] = typeFailedMatch;
completedTypes.add(ideationType);
console.log(`[Ideation] Type failed: ${ideationType}`);
this.emit('ideation-type-failed', projectId, ideationType);
}
// Parse progress from output - track phase transitions
if (log.includes('PROJECT INDEX') || log.includes('PROJECT ANALYSIS')) {
progressPhase = 'analyzing';
progressPercent = 15;
progressPercent = 10;
} else if (log.includes('CONTEXT GATHERING')) {
progressPhase = 'discovering';
progressPercent = 25;
} else if (log.includes('LOW_HANGING_FRUIT') || log.includes('LOW-HANGING FRUIT')) {
progressPercent = 20;
} else if (log.includes('GENERATING IDEAS (PARALLEL)') || log.includes('Starting') && log.includes('ideation agents in parallel')) {
progressPhase = 'generating';
progressPercent = 35;
} else if (log.includes('UI_UX_IMPROVEMENTS') || log.includes('UI/UX')) {
progressPhase = 'generating';
progressPercent = 45;
} else if (log.includes('HIGH_VALUE_FEATURES') || log.includes('HIGH VALUE') || log.includes('HIGH-VALUE')) {
progressPhase = 'generating';
progressPercent = 55;
} else if (log.includes('DOCUMENTATION_GAPS') || log.includes('DOCUMENTATION')) {
progressPhase = 'generating';
progressPercent = 65;
} else if (log.includes('SECURITY_HARDENING') || log.includes('SECURITY')) {
progressPhase = 'generating';
progressPercent = 75;
} else if (log.includes('PERFORMANCE_OPTIMIZATIONS') || log.includes('PERFORMANCE')) {
progressPhase = 'generating';
progressPercent = 85;
progressPercent = 30;
} else if (log.includes('MERGE') || log.includes('FINALIZE')) {
progressPhase = 'generating';
progressPercent = 92;
progressPhase = 'finalizing';
progressPercent = 90;
} else if (log.includes('IDEATION COMPLETE')) {
progressPhase = 'complete';
progressPercent = 100;
}
// Update progress based on completed types during generation phase
if (progressPhase === 'generating' && completedTypes.size > 0) {
// Progress from 30% to 90% based on completed types
progressPercent = 30 + Math.floor((completedTypes.size / totalTypes) * 60);
}
// Emit progress update with a clean message for the status bar
const statusMessage = log.trim().split('\n')[0].substring(0, 200);
this.emit('ideation-progress', projectId, {
phase: progressPhase,
progress: progressPercent,
message: statusMessage
message: statusMessage,
completedTypes: Array.from(completedTypes)
});
});
@@ -378,6 +465,10 @@ export class AgentManager extends EventEmitter {
// Handle process exit
childProcess.on('exit', (code: number | null) => {
console.log('[Ideation] Process exited with code:', code);
// Get the stored project path before deleting from map
const processInfo = this.processes.get(projectId);
const storedProjectPath = processInfo?.projectPath;
this.processes.delete(projectId);
if (code === 0) {
@@ -386,6 +477,28 @@ export class AgentManager extends EventEmitter {
progress: 100,
message: 'Ideation generation complete'
});
// Load and emit the complete ideation session
if (storedProjectPath) {
try {
const ideationFilePath = path.join(
storedProjectPath,
'auto-claude',
'ideation',
'ideation.json'
);
if (existsSync(ideationFilePath)) {
const content = readFileSync(ideationFilePath, 'utf-8');
const session = JSON.parse(content);
console.log('[Ideation] Emitting ideation-complete with session data');
this.emit('ideation-complete', projectId, session);
} else {
console.warn('[Ideation] ideation.json not found at:', ideationFilePath);
}
} catch (err) {
console.error('[Ideation] Failed to load ideation session:', err);
}
}
} else {
this.emit('ideation-error', projectId, `Ideation generation failed with exit code ${code}`);
}
@@ -495,54 +608,235 @@ export class AgentManager extends EventEmitter {
});
}
/**
* Parse log output to detect execution phase transitions
*/
private parseExecutionPhase(
log: string,
currentPhase: ExecutionProgressData['phase'],
isSpecRunner: boolean
): { phase: ExecutionProgressData['phase']; message?: string; currentChunk?: string } | null {
const lowerLog = log.toLowerCase();
// Spec runner phase detection (all part of "planning")
if (isSpecRunner) {
if (lowerLog.includes('discovering') || lowerLog.includes('discovery')) {
return { phase: 'planning', message: 'Discovering project context...' };
}
if (lowerLog.includes('requirements') || lowerLog.includes('gathering')) {
return { phase: 'planning', message: 'Gathering requirements...' };
}
if (lowerLog.includes('writing spec') || lowerLog.includes('spec writer')) {
return { phase: 'planning', message: 'Writing specification...' };
}
if (lowerLog.includes('validating') || lowerLog.includes('validation')) {
return { phase: 'planning', message: 'Validating specification...' };
}
if (lowerLog.includes('spec complete') || lowerLog.includes('specification complete')) {
return { phase: 'planning', message: 'Specification complete' };
}
}
// Run.py phase detection
// Planner agent running
if (lowerLog.includes('planner agent') || lowerLog.includes('creating implementation plan')) {
return { phase: 'planning', message: 'Creating implementation plan...' };
}
// Coder agent running
if (lowerLog.includes('coder agent') || lowerLog.includes('starting coder')) {
return { phase: 'coding', message: 'Implementing code changes...' };
}
// Chunk progress detection
const chunkMatch = log.match(/chunk[:\s]+(\d+(?:\/\d+)?|\w+[-_]\w+)/i);
if (chunkMatch && currentPhase === 'coding') {
return { phase: 'coding', currentChunk: chunkMatch[1], message: `Working on chunk ${chunkMatch[1]}...` };
}
// Chunk completion detection
if (lowerLog.includes('chunk completed') || lowerLog.includes('chunk done')) {
const completedChunk = log.match(/chunk[:\s]+"?([^"]+)"?\s+completed/i);
return {
phase: 'coding',
currentChunk: completedChunk?.[1],
message: `Chunk ${completedChunk?.[1] || ''} completed`
};
}
// QA Review phase
if (lowerLog.includes('qa reviewer') || lowerLog.includes('qa_reviewer') || lowerLog.includes('starting qa')) {
return { phase: 'qa_review', message: 'Running QA review...' };
}
// QA Fixer phase
if (lowerLog.includes('qa fixer') || lowerLog.includes('qa_fixer') || lowerLog.includes('fixing issues')) {
return { phase: 'qa_fixing', message: 'Fixing QA issues...' };
}
// Completion detection
if (lowerLog.includes('build complete') || lowerLog.includes('all chunks completed') || lowerLog.includes('qa passed')) {
return { phase: 'complete', message: 'Build completed successfully' };
}
// Error/failure detection
if (lowerLog.includes('build failed') || lowerLog.includes('error:') || lowerLog.includes('fatal')) {
return { phase: 'failed', message: log.trim().substring(0, 200) };
}
return null;
}
/**
* Calculate overall progress based on phase and phase progress
*/
private calculateOverallProgress(phase: ExecutionProgressData['phase'], phaseProgress: number): number {
// Phase weight ranges (same as in constants.ts)
const weights: Record<string, { start: number; end: number }> = {
idle: { start: 0, end: 0 },
planning: { start: 0, end: 20 },
coding: { start: 20, end: 80 },
qa_review: { start: 80, end: 95 },
qa_fixing: { start: 80, end: 95 },
complete: { start: 100, end: 100 },
failed: { start: 0, end: 0 }
};
const phaseWeight = weights[phase] || { start: 0, end: 0 };
const phaseRange = phaseWeight.end - phaseWeight.start;
return Math.round(phaseWeight.start + (phaseRange * phaseProgress / 100));
}
/**
* Spawn a Python process
*/
private spawnProcess(
taskId: string,
cwd: string,
args: string[]
args: string[],
extraEnv: Record<string, string> = {},
processType: ProcessType = 'task-execution'
): void {
const isSpecRunner = processType === 'spec-creation';
// Kill existing process for this task if any
this.killTask(taskId);
console.log('[spawnProcess] Spawning with pythonPath:', this.pythonPath);
console.log('[spawnProcess] cwd:', cwd);
console.log('[spawnProcess] processType:', processType);
const childProcess = spawn(this.pythonPath, args, {
cwd,
env: {
...process.env,
...extraEnv,
PYTHONUNBUFFERED: '1' // Ensure real-time output
}
});
console.log('[spawnProcess] Process spawned, pid:', childProcess.pid);
this.processes.set(taskId, {
taskId,
process: childProcess,
startedAt: new Date()
});
// Track execution progress
let currentPhase: ExecutionProgressData['phase'] = isSpecRunner ? 'planning' : 'planning';
let phaseProgress = 0;
let currentChunk: string | undefined;
let lastMessage: string | undefined;
// Emit initial progress
this.emit('execution-progress', taskId, {
phase: currentPhase,
phaseProgress: 0,
overallProgress: this.calculateOverallProgress(currentPhase, 0),
message: isSpecRunner ? 'Starting spec creation...' : 'Starting build process...'
});
const processLog = (log: string) => {
// Parse for phase transitions
const phaseUpdate = this.parseExecutionPhase(log, currentPhase, isSpecRunner);
if (phaseUpdate) {
const phaseChanged = phaseUpdate.phase !== currentPhase;
currentPhase = phaseUpdate.phase;
if (phaseUpdate.currentChunk) {
currentChunk = phaseUpdate.currentChunk;
}
if (phaseUpdate.message) {
lastMessage = phaseUpdate.message;
}
// Reset phase progress on phase change, otherwise increment
if (phaseChanged) {
phaseProgress = 10; // Start new phase at 10%
} else {
phaseProgress = Math.min(90, phaseProgress + 5); // Increment within phase
}
const overallProgress = this.calculateOverallProgress(currentPhase, phaseProgress);
this.emit('execution-progress', taskId, {
phase: currentPhase,
phaseProgress,
overallProgress,
currentChunk,
message: lastMessage
});
}
};
// Handle stdout
childProcess.stdout?.on('data', (data: Buffer) => {
const log = data.toString();
console.log('[spawnProcess] stdout:', log.substring(0, 200));
this.emit('log', taskId, log);
processLog(log);
});
// Handle stderr
childProcess.stderr?.on('data', (data: Buffer) => {
const log = data.toString();
console.log('[spawnProcess] stderr:', log.substring(0, 200));
// Some Python output goes to stderr (like progress bars)
// so we treat it as log, not error
this.emit('log', taskId, log);
processLog(log);
});
// Handle process exit
childProcess.on('exit', (code: number | null) => {
console.log('[spawnProcess] Process exited with code:', code);
this.processes.delete(taskId);
this.emit('exit', taskId, code);
// Emit final progress
const finalPhase = code === 0 ? 'complete' : 'failed';
this.emit('execution-progress', taskId, {
phase: finalPhase,
phaseProgress: 100,
overallProgress: code === 0 ? 100 : this.calculateOverallProgress(currentPhase, phaseProgress),
message: code === 0 ? 'Process completed successfully' : `Process exited with code ${code}`
});
this.emit('exit', taskId, code, processType);
});
// Handle process error
childProcess.on('error', (err: Error) => {
console.log('[spawnProcess] Process error:', err.message);
this.processes.delete(taskId);
this.emit('execution-progress', taskId, {
phase: 'failed',
phaseProgress: 0,
overallProgress: 0,
message: `Error: ${err.message}`
});
this.emit('error', taskId, err.message);
});
}
-1
View File
@@ -24,7 +24,6 @@ function getIconPath(): string {
}
const iconPath = join(resourcesPath, iconName);
console.log('[Auto-Claude] Icon path:', iconPath);
return iconPath;
}
+375
View File
@@ -0,0 +1,375 @@
import { EventEmitter } from 'events';
import path from 'path';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { spawn, ChildProcess } from 'child_process';
import { app } from 'electron';
import type {
InsightsSession,
InsightsChatMessage,
InsightsChatStatus,
InsightsStreamChunk
} from '../shared/types';
const INSIGHTS_DIR = 'auto-claude/insights';
const SESSION_FILE = 'session.json';
/**
* Service for AI-powered codebase insights chat
*/
export class InsightsService extends EventEmitter {
private pythonPath: string = 'python3';
private autoBuildSourcePath: string = '';
private activeSessions: Map<string, ChildProcess> = new Map();
private sessions: Map<string, InsightsSession> = new Map();
constructor() {
super();
}
/**
* Configure paths for Python and auto-claude source
*/
configure(pythonPath?: string, autoBuildSourcePath?: string): void {
if (pythonPath) {
this.pythonPath = pythonPath;
}
if (autoBuildSourcePath) {
this.autoBuildSourcePath = autoBuildSourcePath;
}
}
/**
* Get the auto-claude source path (detects automatically if not configured)
*/
private getAutoBuildSourcePath(): string | null {
if (this.autoBuildSourcePath && existsSync(this.autoBuildSourcePath)) {
return this.autoBuildSourcePath;
}
const possiblePaths = [
path.resolve(__dirname, '..', '..', '..', 'auto-claude'),
path.resolve(app.getAppPath(), '..', 'auto-claude'),
path.resolve(process.cwd(), 'auto-claude')
];
for (const p of possiblePaths) {
if (existsSync(p) && existsSync(path.join(p, 'VERSION'))) {
return p;
}
}
return null;
}
/**
* Load environment variables from auto-claude .env file
*/
private loadAutoBuildEnv(): Record<string, string> {
const autoBuildSource = this.getAutoBuildSourcePath();
if (!autoBuildSource) return {};
const envPath = path.join(autoBuildSource, '.env');
if (!existsSync(envPath)) return {};
try {
const envContent = readFileSync(envPath, 'utf-8');
const envVars: Record<string, string> = {};
for (const line of envContent.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIndex = trimmed.indexOf('=');
if (eqIndex > 0) {
const key = trimmed.substring(0, eqIndex).trim();
let value = trimmed.substring(eqIndex + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
envVars[key] = value;
}
}
return envVars;
} catch {
return {};
}
}
/**
* Get insights directory path for a project
*/
private getInsightsDir(projectPath: string): string {
return path.join(projectPath, INSIGHTS_DIR);
}
/**
* Get session file path for a project
*/
private getSessionPath(projectPath: string): string {
return path.join(this.getInsightsDir(projectPath), SESSION_FILE);
}
/**
* Load session from disk
*/
loadSession(projectId: string, projectPath: string): InsightsSession | null {
// Check in-memory cache first
if (this.sessions.has(projectId)) {
return this.sessions.get(projectId)!;
}
const sessionPath = this.getSessionPath(projectPath);
if (!existsSync(sessionPath)) {
return null;
}
try {
const content = readFileSync(sessionPath, 'utf-8');
const session = JSON.parse(content) as InsightsSession;
// Convert date strings back to Date objects
session.createdAt = new Date(session.createdAt);
session.updatedAt = new Date(session.updatedAt);
session.messages = session.messages.map(m => ({
...m,
timestamp: new Date(m.timestamp)
}));
this.sessions.set(projectId, session);
return session;
} catch {
return null;
}
}
/**
* Save session to disk
*/
private saveSession(projectPath: string, session: InsightsSession): void {
const insightsDir = this.getInsightsDir(projectPath);
if (!existsSync(insightsDir)) {
mkdirSync(insightsDir, { recursive: true });
}
const sessionPath = this.getSessionPath(projectPath);
writeFileSync(sessionPath, JSON.stringify(session, null, 2));
this.sessions.set(session.projectId, session);
}
/**
* Clear session
*/
clearSession(projectId: string, projectPath: string): void {
this.sessions.delete(projectId);
const sessionPath = this.getSessionPath(projectPath);
if (existsSync(sessionPath)) {
try {
// Just reset the session file, not the directory
writeFileSync(sessionPath, JSON.stringify({
id: `session-${Date.now()}`,
projectId,
messages: [],
createdAt: new Date(),
updatedAt: new Date()
}, null, 2));
} catch {
// Ignore errors
}
}
}
/**
* Send a message and get AI response
*/
async sendMessage(projectId: string, projectPath: string, message: string): Promise<void> {
// Cancel any existing session for this project
if (this.activeSessions.has(projectId)) {
const existingProcess = this.activeSessions.get(projectId);
existingProcess?.kill();
this.activeSessions.delete(projectId);
}
const autoBuildSource = this.getAutoBuildSourcePath();
if (!autoBuildSource) {
this.emit('error', projectId, 'Auto Claude source not found');
return;
}
// Load or create session
let session = this.loadSession(projectId, projectPath);
if (!session) {
session = {
id: `session-${Date.now()}`,
projectId,
messages: [],
createdAt: new Date(),
updatedAt: new Date()
};
}
// Add user message
const userMessage: InsightsChatMessage = {
id: `msg-${Date.now()}`,
role: 'user',
content: message,
timestamp: new Date()
};
session.messages.push(userMessage);
session.updatedAt = new Date();
this.saveSession(projectPath, session);
// Emit thinking status
this.emit('status', projectId, {
phase: 'thinking',
message: 'Processing your message...'
} as InsightsChatStatus);
// Load environment
const envVars = this.loadAutoBuildEnv();
// Build conversation history for context
const conversationHistory = session.messages.map(m => ({
role: m.role,
content: m.content
}));
// Create the insights runner script path
const runnerPath = path.join(autoBuildSource, 'insights_runner.py');
// Check if runner exists
if (!existsSync(runnerPath)) {
this.emit('error', projectId, 'insights_runner.py not found in auto-claude directory');
return;
}
// Spawn Python process
const proc = spawn(this.pythonPath, [
runnerPath,
'--project-dir', projectPath,
'--message', message,
'--history', JSON.stringify(conversationHistory)
], {
cwd: autoBuildSource,
env: {
...process.env,
...envVars,
PYTHONUNBUFFERED: '1'
}
});
this.activeSessions.set(projectId, proc);
let fullResponse = '';
let suggestedTask: InsightsChatMessage['suggestedTask'] | undefined;
proc.stdout?.on('data', (data: Buffer) => {
const text = data.toString();
// Check for special markers
const lines = text.split('\n');
for (const line of lines) {
if (line.startsWith('__TASK_SUGGESTION__:')) {
try {
const taskJson = line.substring('__TASK_SUGGESTION__:'.length);
suggestedTask = JSON.parse(taskJson);
this.emit('stream-chunk', projectId, {
type: 'task_suggestion',
suggestedTask
} as InsightsStreamChunk);
} catch {
// Not valid JSON, treat as normal text
fullResponse += line + '\n';
this.emit('stream-chunk', projectId, {
type: 'text',
content: line + '\n'
} as InsightsStreamChunk);
}
} else if (line.startsWith('__TOOL_START__:')) {
// Tool execution started
try {
const toolJson = line.substring('__TOOL_START__:'.length);
const toolData = JSON.parse(toolJson);
this.emit('stream-chunk', projectId, {
type: 'tool_start',
tool: {
name: toolData.name,
input: toolData.input
}
} as InsightsStreamChunk);
} catch {
// Ignore parse errors for tool markers
}
} else if (line.startsWith('__TOOL_END__:')) {
// Tool execution finished
try {
const toolJson = line.substring('__TOOL_END__:'.length);
const toolData = JSON.parse(toolJson);
this.emit('stream-chunk', projectId, {
type: 'tool_end',
tool: {
name: toolData.name
}
} as InsightsStreamChunk);
} catch {
// Ignore parse errors for tool markers
}
} else if (line.trim()) {
fullResponse += line + '\n';
this.emit('stream-chunk', projectId, {
type: 'text',
content: line + '\n'
} as InsightsStreamChunk);
}
}
});
proc.stderr?.on('data', (data: Buffer) => {
const text = data.toString();
console.error('[Insights]', text);
});
proc.on('close', (code) => {
this.activeSessions.delete(projectId);
if (code === 0) {
// Add assistant message to session
const assistantMessage: InsightsChatMessage = {
id: `msg-${Date.now()}`,
role: 'assistant',
content: fullResponse.trim(),
timestamp: new Date(),
suggestedTask
};
session!.messages.push(assistantMessage);
session!.updatedAt = new Date();
this.saveSession(projectPath, session!);
this.emit('stream-chunk', projectId, {
type: 'done'
} as InsightsStreamChunk);
this.emit('status', projectId, {
phase: 'complete'
} as InsightsChatStatus);
} else {
this.emit('stream-chunk', projectId, {
type: 'error',
error: `Process exited with code ${code}`
} as InsightsStreamChunk);
this.emit('error', projectId, `Process exited with code ${code}`);
}
});
proc.on('error', (err) => {
this.activeSessions.delete(projectId);
this.emit('error', projectId, err.message);
});
}
}
// Singleton instance
export const insightsService = new InsightsService();
File diff suppressed because it is too large Load Diff
+43 -4
View File
@@ -2,7 +2,7 @@ import { app } from 'electron';
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import type { Project, ProjectSettings, Task, TaskStatus, ImplementationPlan } from '../shared/types';
import type { Project, ProjectSettings, Task, TaskStatus, TaskMetadata, ImplementationPlan } from '../shared/types';
import { DEFAULT_PROJECT_SETTINGS, AUTO_BUILD_PATHS } from '../shared/constants';
import { getAutoBuildPath } from './project-initializer';
@@ -200,6 +200,18 @@ export class ProjectStore {
}
}
// Try to read task metadata
const metadataPath = path.join(specPath, 'task_metadata.json');
let metadata: TaskMetadata | undefined;
if (existsSync(metadataPath)) {
try {
const content = readFileSync(metadataPath, 'utf-8');
metadata = JSON.parse(content);
} catch {
// Ignore parse errors
}
}
// Determine task status from plan
const status = this.determineTaskStatus(plan, specPath);
@@ -223,6 +235,7 @@ export class ProjectStore {
status,
chunks,
logs: [],
metadata,
createdAt: new Date(plan?.created_at || Date.now()),
updatedAt: new Date(plan?.updated_at || Date.now())
});
@@ -241,7 +254,27 @@ export class ProjectStore {
plan: ImplementationPlan | null,
specPath: string
): TaskStatus {
// Check for QA report (human review needed)
// First, check if plan has an explicit status field (set by UI)
// This is the primary source of truth for persisted status
if (plan?.status) {
// Map plan status to TaskStatus
const statusMap: Record<string, TaskStatus> = {
'pending': 'backlog',
'in_progress': 'in_progress',
'review': 'ai_review',
'completed': 'done',
'done': 'done',
'human_review': 'human_review',
'ai_review': 'ai_review',
'backlog': 'backlog'
};
const mappedStatus = statusMap[plan.status];
if (mappedStatus) {
return mappedStatus;
}
}
// Fallback: Check for QA report (human review needed)
const qaReportPath = path.join(specPath, AUTO_BUILD_PATHS.QA_REPORT);
if (existsSync(qaReportPath)) {
try {
@@ -259,8 +292,14 @@ export class ProjectStore {
if (!plan) return 'backlog';
// Count chunk statuses
const allChunks = plan.phases.flatMap((p) => p.chunks);
// Fallback: Derive from chunk statuses
const allChunks = plan.phases?.flatMap((p) => p.chunks) || [];
// No chunks yet means task is still in planning/backlog
if (allChunks.length === 0) {
return 'backlog';
}
const completed = allChunks.filter((c) => c.status === 'completed').length;
const inProgress = allChunks.filter((c) => c.status === 'in_progress').length;
const failed = allChunks.filter((c) => c.status === 'failed').length;
+149 -5
View File
@@ -8,6 +8,7 @@ import type {
IPCResult,
TaskStartOptions,
TaskStatus,
TaskRecoveryResult,
ImplementationPlan,
ElectronAPI,
TerminalCreateOptions,
@@ -38,6 +39,7 @@ import type {
IdeationConfig,
IdeationStatus,
IdeationGenerationStatus,
Idea,
AutoBuildSourceUpdateCheck,
AutoBuildSourceUpdateProgress,
SourceEnvConfig,
@@ -49,7 +51,11 @@ import type {
ChangelogSaveRequest,
ChangelogSaveResult,
ChangelogGenerationProgress,
ExistingChangelog
ExistingChangelog,
InsightsSession,
InsightsChatStatus,
InsightsStreamChunk,
TaskMetadata
} from '../shared/types';
// Expose a secure API to the renderer process
@@ -92,9 +98,10 @@ const electronAPI: ElectronAPI = {
createTask: (
projectId: string,
title: string,
description: string
description: string,
metadata?: import('../shared/types').TaskMetadata
): Promise<IPCResult<Task>> =>
ipcRenderer.invoke(IPC_CHANNELS.TASK_CREATE, projectId, title, description),
ipcRenderer.invoke(IPC_CHANNELS.TASK_CREATE, projectId, title, description, metadata),
startTask: (taskId: string, options?: TaskStartOptions): void =>
ipcRenderer.send(IPC_CHANNELS.TASK_START, taskId, options),
@@ -109,6 +116,21 @@ const electronAPI: ElectronAPI = {
): Promise<IPCResult> =>
ipcRenderer.invoke(IPC_CHANNELS.TASK_REVIEW, taskId, approved, feedback),
updateTaskStatus: (
taskId: string,
status: import('../shared/types').TaskStatus
): Promise<IPCResult> =>
ipcRenderer.invoke(IPC_CHANNELS.TASK_UPDATE_STATUS, taskId, status),
recoverStuckTask: (
taskId: string,
targetStatus?: import('../shared/types').TaskStatus
): Promise<IPCResult<import('../shared/types').TaskRecoveryResult>> =>
ipcRenderer.invoke(IPC_CHANNELS.TASK_RECOVER_STUCK, taskId, targetStatus),
checkTaskRunning: (taskId: string): Promise<IPCResult<boolean>> =>
ipcRenderer.invoke(IPC_CHANNELS.TASK_CHECK_RUNNING, taskId),
// ============================================
// Event Listeners (main → renderer)
// ============================================
@@ -178,6 +200,22 @@ const electronAPI: ElectronAPI = {
};
},
onTaskExecutionProgress: (
callback: (taskId: string, progress: import('../shared/types').ExecutionProgress) => void
): (() => void) => {
const handler = (
_event: Electron.IpcRendererEvent,
taskId: string,
progress: import('../shared/types').ExecutionProgress
): void => {
callback(taskId, progress);
};
ipcRenderer.on(IPC_CHANNELS.TASK_EXECUTION_PROGRESS, handler);
return () => {
ipcRenderer.removeListener(IPC_CHANNELS.TASK_EXECUTION_PROGRESS, handler);
};
},
// ============================================
// Terminal Operations
// ============================================
@@ -569,6 +607,39 @@ const electronAPI: ElectronAPI = {
};
},
onIdeationTypeComplete: (
callback: (projectId: string, ideationType: string, ideas: Idea[]) => void
): (() => void) => {
const handler = (
_event: Electron.IpcRendererEvent,
projectId: string,
ideationType: string,
ideas: Idea[]
): void => {
callback(projectId, ideationType, ideas);
};
ipcRenderer.on(IPC_CHANNELS.IDEATION_TYPE_COMPLETE, handler);
return () => {
ipcRenderer.removeListener(IPC_CHANNELS.IDEATION_TYPE_COMPLETE, handler);
};
},
onIdeationTypeFailed: (
callback: (projectId: string, ideationType: string) => void
): (() => void) => {
const handler = (
_event: Electron.IpcRendererEvent,
projectId: string,
ideationType: string
): void => {
callback(projectId, ideationType);
};
ipcRenderer.on(IPC_CHANNELS.IDEATION_TYPE_FAILED, handler);
return () => {
ipcRenderer.removeListener(IPC_CHANNELS.IDEATION_TYPE_FAILED, handler);
};
},
// ============================================
// Auto-Build Source Update Operations
// ============================================
@@ -614,8 +685,8 @@ const electronAPI: ElectronAPI = {
// Changelog Operations
// ============================================
getChangelogDoneTasks: (projectId: string): Promise<IPCResult<ChangelogTask[]>> =>
ipcRenderer.invoke(IPC_CHANNELS.CHANGELOG_GET_DONE_TASKS, projectId),
getChangelogDoneTasks: (projectId: string, tasks?: Task[]): Promise<IPCResult<ChangelogTask[]>> =>
ipcRenderer.invoke(IPC_CHANNELS.CHANGELOG_GET_DONE_TASKS, projectId, tasks),
loadTaskSpecs: (projectId: string, taskIds: string[]): Promise<IPCResult<TaskSpecContent[]>> =>
ipcRenderer.invoke(IPC_CHANNELS.CHANGELOG_LOAD_TASK_SPECS, projectId, taskIds),
@@ -679,6 +750,79 @@ const electronAPI: ElectronAPI = {
return () => {
ipcRenderer.removeListener(IPC_CHANNELS.CHANGELOG_GENERATION_ERROR, handler);
};
},
// ============================================
// Insights Operations
// ============================================
getInsightsSession: (projectId: string): Promise<IPCResult<InsightsSession | null>> =>
ipcRenderer.invoke(IPC_CHANNELS.INSIGHTS_GET_SESSION, projectId),
sendInsightsMessage: (projectId: string, message: string): void =>
ipcRenderer.send(IPC_CHANNELS.INSIGHTS_SEND_MESSAGE, projectId, message),
clearInsightsSession: (projectId: string): Promise<IPCResult> =>
ipcRenderer.invoke(IPC_CHANNELS.INSIGHTS_CLEAR_SESSION, projectId),
createTaskFromInsights: (
projectId: string,
title: string,
description: string,
metadata?: TaskMetadata
): Promise<IPCResult<Task>> =>
ipcRenderer.invoke(IPC_CHANNELS.INSIGHTS_CREATE_TASK, projectId, title, description, metadata),
// ============================================
// Insights Event Listeners
// ============================================
onInsightsStreamChunk: (
callback: (projectId: string, chunk: InsightsStreamChunk) => void
): (() => void) => {
const handler = (
_event: Electron.IpcRendererEvent,
projectId: string,
chunk: InsightsStreamChunk
): void => {
callback(projectId, chunk);
};
ipcRenderer.on(IPC_CHANNELS.INSIGHTS_STREAM_CHUNK, handler);
return () => {
ipcRenderer.removeListener(IPC_CHANNELS.INSIGHTS_STREAM_CHUNK, handler);
};
},
onInsightsStatus: (
callback: (projectId: string, status: InsightsChatStatus) => void
): (() => void) => {
const handler = (
_event: Electron.IpcRendererEvent,
projectId: string,
status: InsightsChatStatus
): void => {
callback(projectId, status);
};
ipcRenderer.on(IPC_CHANNELS.INSIGHTS_STATUS, handler);
return () => {
ipcRenderer.removeListener(IPC_CHANNELS.INSIGHTS_STATUS, handler);
};
},
onInsightsError: (
callback: (projectId: string, error: string) => void
): (() => void) => {
const handler = (
_event: Electron.IpcRendererEvent,
projectId: string,
error: string
): void => {
callback(projectId, error);
};
ipcRenderer.on(IPC_CHANNELS.INSIGHTS_ERROR, handler);
return () => {
ipcRenderer.removeListener(IPC_CHANNELS.INSIGHTS_ERROR, handler);
};
}
};
+4
View File
@@ -25,6 +25,7 @@ import { TerminalGrid } from './components/TerminalGrid';
import { Roadmap } from './components/Roadmap';
import { Context } from './components/Context';
import { Ideation } from './components/Ideation';
import { Insights } from './components/Insights';
import { GitHubIssues } from './components/GitHubIssues';
import { Changelog } from './components/Changelog';
import { WelcomeScreen } from './components/WelcomeScreen';
@@ -241,6 +242,9 @@ export function App() {
{activeView === 'ideation' && selectedProjectId && (
<Ideation projectId={selectedProjectId} />
)}
{activeView === 'insights' && selectedProjectId && (
<Insights projectId={selectedProjectId} />
)}
{activeView === 'github-issues' && selectedProjectId && (
<GitHubIssues onOpenSettings={() => setIsProjectSettingsOpen(true)} />
)}
@@ -30,7 +30,10 @@ import {
Wifi,
Box,
HardDrive,
Code2
Code2,
Loader2,
XCircle,
Plus
} from 'lucide-react';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
@@ -58,14 +61,17 @@ import {
loadIdeation,
generateIdeation,
refreshIdeation,
appendIdeation,
getIdeasByType,
getActiveIdeas,
getIdeationSummary,
isLowHangingFruitIdea,
isUIUXIdea,
isHighValueIdea,
setupIdeationListeners
setupIdeationListeners,
IdeationTypeState
} from '../stores/ideation-store';
import { loadTasks } from '../stores/task-store';
import {
IDEATION_TYPE_LABELS,
IDEATION_TYPE_DESCRIPTIONS,
@@ -85,6 +91,7 @@ import type {
Idea,
IdeationType,
IdeationGenerationStatus,
IdeationSession,
LowHangingFruitIdea,
UIUXImprovementIdea,
HighValueFeatureIdea,
@@ -147,73 +154,241 @@ function isPerformanceOptimizationIdea(idea: Idea): idea is PerformanceOptimizat
return idea.type === 'performance_optimizations';
}
// Generation Progress Screen Component - handles auto-scroll
// Helper to get state icon for ideation type
function TypeStateIcon({ state }: { state: IdeationTypeState }) {
switch (state) {
case 'completed':
return <CheckCircle2 className="h-4 w-4 text-success" />;
case 'failed':
return <XCircle className="h-4 w-4 text-destructive" />;
case 'generating':
return <Loader2 className="h-4 w-4 text-primary animate-spin" />;
case 'pending':
default:
return <Circle className="h-4 w-4 text-muted-foreground" />;
}
}
// Skeleton card for ideas being loaded
function IdeaSkeletonCard() {
return (
<Card className="p-4 animate-pulse">
<div className="flex items-start justify-between">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<div className="h-5 w-24 bg-muted rounded" />
<div className="h-5 w-16 bg-muted rounded" />
</div>
<div className="h-4 w-3/4 bg-muted rounded" />
<div className="h-3 w-full bg-muted rounded" />
<div className="h-3 w-2/3 bg-muted rounded" />
</div>
</div>
</Card>
);
}
// Generation Progress Screen Component - handles auto-scroll with streaming UI
interface GenerationProgressScreenProps {
generationStatus: IdeationGenerationStatus;
logs: string[];
typeStates: Record<IdeationType, IdeationTypeState>;
enabledTypes: IdeationType[];
session: IdeationSession | null;
onSelectIdea: (idea: Idea | null) => void;
selectedIdea: Idea | null;
onConvert: (idea: Idea) => void;
onDismiss: (idea: Idea) => void;
}
function GenerationProgressScreen({ generationStatus, logs }: GenerationProgressScreenProps) {
function GenerationProgressScreen({
generationStatus,
logs,
typeStates,
enabledTypes,
session,
onSelectIdea,
selectedIdea,
onConvert,
onDismiss
}: GenerationProgressScreenProps) {
const logsEndRef = useRef<HTMLDivElement>(null);
const [showLogs, setShowLogs] = useState(false);
// Auto-scroll to bottom when logs update
useEffect(() => {
if (logsEndRef.current) {
if (logsEndRef.current && showLogs) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [logs]);
}, [logs, showLogs]);
// Get ideas for a specific type from the current session
const getStreamingIdeasByType = (type: IdeationType): Idea[] => {
if (!session) return [];
return session.ideas.filter((idea) => idea.type === type);
};
// Count how many types are still generating
const generatingCount = enabledTypes.filter((t) => typeStates[t] === 'generating').length;
const completedCount = enabledTypes.filter((t) => typeStates[t] === 'completed').length;
return (
<div className="flex h-full items-center justify-center p-4">
<Card className="w-full max-w-2xl p-6">
<div className="flex items-center gap-3 mb-4">
<Sparkles className="h-6 w-6 text-primary animate-pulse" />
<h2 className="text-lg font-semibold">Generating Ideas</h2>
</div>
<Progress value={generationStatus.progress} className="mb-3" />
<p className="text-sm text-muted-foreground">{generationStatus.message}</p>
{generationStatus.currentType && (
<div className="mt-2 flex items-center gap-2">
<TypeIcon type={generationStatus.currentType} />
<span className="text-sm">{IDEATION_TYPE_LABELS[generationStatus.currentType]}</span>
</div>
)}
{/* Logs Section */}
{logs.length > 0 && (
<div className="mt-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<FileCode className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Activity Log</span>
</div>
<span className="text-xs text-muted-foreground">{logs.length} lines</span>
<div className="h-full flex flex-col overflow-hidden">
{/* Header */}
<div className="flex-shrink-0 border-b border-border p-4 bg-card/50">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<Sparkles className="h-5 w-5 text-primary animate-pulse" />
<h2 className="text-lg font-semibold">Generating Ideas</h2>
<Badge variant="outline">
{completedCount}/{enabledTypes.length} complete
</Badge>
</div>
<ScrollArea className="h-64 rounded-md border border-border bg-muted/30">
<div className="p-3 space-y-1 font-mono text-xs">
{logs.map((log, index) => (
<div
key={index}
className="text-muted-foreground leading-relaxed"
>
<span className="text-muted-foreground/50 mr-2 select-none">
{String(index + 1).padStart(3, '0')}
</span>
{log}
</div>
))}
<div ref={logsEndRef} />
</div>
</ScrollArea>
<p className="text-sm text-muted-foreground">{generationStatus.message}</p>
</div>
)}
<Button
variant="outline"
size="sm"
onClick={() => setShowLogs(!showLogs)}
>
<FileCode className="h-4 w-4 mr-1" />
{showLogs ? 'Hide' : 'Show'} Logs
</Button>
</div>
<Progress value={generationStatus.progress} className="mt-3" />
{/* Type Status Indicators */}
<div className="mt-3 flex flex-wrap gap-2">
{enabledTypes.map((type) => (
<div
key={type}
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs ${
typeStates[type] === 'completed'
? 'bg-success/10 text-success'
: typeStates[type] === 'failed'
? 'bg-destructive/10 text-destructive'
: typeStates[type] === 'generating'
? 'bg-primary/10 text-primary'
: 'bg-muted text-muted-foreground'
}`}
>
<TypeStateIcon state={typeStates[type]} />
<TypeIcon type={type} />
<span>{IDEATION_TYPE_LABELS[type]}</span>
{typeStates[type] === 'completed' && session && (
<span className="ml-1 font-medium">
({getStreamingIdeasByType(type).length})
</span>
)}
</div>
))}
</div>
</div>
{/* Logs Panel (collapsible) */}
{showLogs && logs.length > 0 && (
<div className="flex-shrink-0 border-b border-border p-4 bg-muted/20">
<ScrollArea className="h-32 rounded-md border border-border bg-muted/30">
<div className="p-3 space-y-1 font-mono text-xs">
{logs.map((log, index) => (
<div key={index} className="text-muted-foreground leading-relaxed">
<span className="text-muted-foreground/50 mr-2 select-none">
{String(index + 1).padStart(3, '0')}
</span>
{log}
</div>
))}
<div ref={logsEndRef} />
</div>
</ScrollArea>
</div>
)}
{/* Streaming Ideas View */}
<div className="flex-1 overflow-auto p-4">
{generationStatus.error && (
<div className="mt-4 p-3 bg-destructive/10 rounded-md text-destructive text-sm">
<div className="mb-4 p-3 bg-destructive/10 rounded-md text-destructive text-sm">
{generationStatus.error}
</div>
)}
</Card>
<div className="space-y-6">
{enabledTypes.map((type) => {
const ideas = getStreamingIdeasByType(type);
const state = typeStates[type];
return (
<div key={type}>
<div className="flex items-center gap-2 mb-3">
<div className={`p-1.5 rounded-md ${IDEATION_TYPE_COLORS[type]}`}>
<TypeIcon type={type} />
</div>
<h3 className="font-medium">{IDEATION_TYPE_LABELS[type]}</h3>
<TypeStateIcon state={state} />
{ideas.length > 0 && (
<Badge variant="outline" className="ml-auto">
{ideas.length} ideas
</Badge>
)}
</div>
<div className="grid gap-3">
{/* Show actual ideas if available */}
{ideas.map((idea) => (
<IdeaCard
key={idea.id}
idea={idea}
onClick={() => onSelectIdea(selectedIdea?.id === idea.id ? null : idea)}
onConvert={onConvert}
onDismiss={onDismiss}
/>
))}
{/* Show skeleton placeholders while generating */}
{state === 'generating' && (
<>
<IdeaSkeletonCard />
<IdeaSkeletonCard />
</>
)}
{/* Show pending message */}
{state === 'pending' && (
<div className="text-sm text-muted-foreground py-2">
Waiting to start...
</div>
)}
{/* Show failed message */}
{state === 'failed' && ideas.length === 0 && (
<div className="text-sm text-destructive py-2">
Failed to generate ideas for this category
</div>
)}
{/* Show empty message if completed with no ideas */}
{state === 'completed' && ideas.length === 0 && (
<div className="text-sm text-muted-foreground py-2">
No ideas generated for this category
</div>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Idea Detail Panel */}
{selectedIdea && (
<IdeaDetailPanel
idea={selectedIdea}
onClose={() => onSelectIdea(null)}
onConvert={onConvert}
onDismiss={onDismiss}
/>
)}
</div>
);
}
@@ -224,13 +399,16 @@ export function Ideation({ projectId }: IdeationProps) {
const config = useIdeationStore((state) => state.config);
const setConfig = useIdeationStore((state) => state.setConfig);
const logs = useIdeationStore((state) => state.logs);
const typeStates = useIdeationStore((state) => state.typeStates);
const [selectedIdea, setSelectedIdea] = useState<Idea | null>(null);
const [activeTab, setActiveTab] = useState<string>('all');
const [showConfigDialog, setShowConfigDialog] = useState(false);
const [showDismissed, setShowDismissed] = useState(false);
const [showEnvConfigModal, setShowEnvConfigModal] = useState(false);
const [pendingAction, setPendingAction] = useState<'generate' | 'refresh' | null>(null);
const [pendingAction, setPendingAction] = useState<'generate' | 'refresh' | 'append' | null>(null);
const [showAddMoreDialog, setShowAddMoreDialog] = useState(false);
const [typesToAdd, setTypesToAdd] = useState<IdeationType[]>([]);
// Check if Claude token is configured
const { hasToken, isLoading: isCheckingToken, checkToken } = useClaudeTokenCheck();
@@ -269,15 +447,50 @@ export function Ideation({ projectId }: IdeationProps) {
generateIdeation(projectId);
} else if (pendingAction === 'refresh') {
refreshIdeation(projectId);
} else if (pendingAction === 'append' && typesToAdd.length > 0) {
appendIdeation(projectId, typesToAdd);
setTypesToAdd([]);
}
setPendingAction(null);
};
// Get which types are not yet in the session (available to add)
const getAvailableTypesToAdd = (): IdeationType[] => {
if (!session) return ALL_IDEATION_TYPES;
const existingTypes = new Set(session.ideas.map((idea) => idea.type));
return ALL_IDEATION_TYPES.filter((type) => !existingTypes.has(type));
};
// Handle adding more ideas
const handleAddMoreIdeas = () => {
if (typesToAdd.length === 0) return;
// Check token before generating
if (hasToken === false) {
setPendingAction('append');
setShowEnvConfigModal(true);
return;
}
appendIdeation(projectId, typesToAdd);
setTypesToAdd([]);
setShowAddMoreDialog(false);
};
// Toggle a type in the typesToAdd list
const toggleTypeToAdd = (type: IdeationType) => {
setTypesToAdd((prev) =>
prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]
);
};
const handleConvertToTask = async (idea: Idea) => {
const result = await window.electronAPI.convertIdeaToTask(projectId, idea.id);
if (result.success) {
// Idea converted to task - update status
useIdeationStore.getState().updateIdeaStatus(idea.id, 'converted');
// Reload tasks so the new task appears in the kanban board
loadTasks(projectId);
}
};
@@ -302,12 +515,19 @@ export function Ideation({ projectId }: IdeationProps) {
const summary = getIdeationSummary(session);
const activeIdeas = showDismissed ? session?.ideas || [] : getActiveIdeas(session);
// Show generation progress
if (generationStatus.phase !== 'idle' && generationStatus.phase !== 'complete') {
// Show generation progress with streaming ideas
if (generationStatus.phase !== 'idle' && generationStatus.phase !== 'complete' && generationStatus.phase !== 'error') {
return (
<GenerationProgressScreen
generationStatus={generationStatus}
logs={logs}
typeStates={typeStates}
enabledTypes={config.enabledTypes}
session={session}
onSelectIdea={setSelectedIdea}
selectedIdea={selectedIdea}
onConvert={handleConvertToTask}
onDismiss={handleDismiss}
/>
);
}
@@ -423,6 +643,24 @@ export function Ideation({ projectId }: IdeationProps) {
</TooltipTrigger>
<TooltipContent>Configure</TooltipContent>
</Tooltip>
{/* Add More Ideas Button - only show if there are types not yet generated */}
{getAvailableTypesToAdd().length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
onClick={() => {
setTypesToAdd([]);
setShowAddMoreDialog(true);
}}
>
<Plus className="h-4 w-4 mr-1" />
Add More
</Button>
</TooltipTrigger>
<TooltipContent>Add more ideation types</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" onClick={handleRefresh}>
@@ -487,7 +725,7 @@ export function Ideation({ projectId }: IdeationProps) {
<IdeaCard
key={idea.id}
idea={idea}
onClick={() => setSelectedIdea(idea)}
onClick={() => setSelectedIdea(selectedIdea?.id === idea.id ? null : idea)}
onConvert={handleConvertToTask}
onDismiss={handleDismiss}
/>
@@ -515,7 +753,7 @@ export function Ideation({ projectId }: IdeationProps) {
<IdeaCard
key={idea.id}
idea={idea}
onClick={() => setSelectedIdea(idea)}
onClick={() => setSelectedIdea(selectedIdea?.id === idea.id ? null : idea)}
onConvert={handleConvertToTask}
onDismiss={handleDismiss}
/>
@@ -598,6 +836,77 @@ export function Ideation({ projectId }: IdeationProps) {
</DialogContent>
</Dialog>
{/* Add More Ideas Dialog */}
<Dialog open={showAddMoreDialog} onOpenChange={setShowAddMoreDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add More Ideas</DialogTitle>
<DialogDescription>
Select additional ideation types to generate. Your existing ideas will be preserved.
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-3 max-h-96 overflow-y-auto">
{getAvailableTypesToAdd().length === 0 ? (
<div className="text-center py-6 text-muted-foreground">
<CheckCircle2 className="h-12 w-12 mx-auto mb-2 text-success" />
<p>You've already generated all ideation types!</p>
<p className="text-sm mt-1">Use "Regenerate" to refresh existing ideas.</p>
</div>
) : (
getAvailableTypesToAdd().map((type) => (
<div
key={type}
className={`flex items-center justify-between p-3 rounded-lg cursor-pointer transition-colors ${
typesToAdd.includes(type)
? 'bg-primary/10 border border-primary'
: 'bg-muted/50 hover:bg-muted'
}`}
onClick={() => toggleTypeToAdd(type)}
>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-md ${IDEATION_TYPE_COLORS[type]}`}>
<TypeIcon type={type} />
</div>
<div>
<div className="font-medium text-sm">{IDEATION_TYPE_LABELS[type]}</div>
<div className="text-xs text-muted-foreground">
{IDEATION_TYPE_DESCRIPTIONS[type]}
</div>
</div>
</div>
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
typesToAdd.includes(type)
? 'border-primary bg-primary'
: 'border-muted-foreground'
}`}>
{typesToAdd.includes(type) && (
<CheckCircle2 className="h-4 w-4 text-primary-foreground" />
)}
</div>
</div>
))
)}
</div>
<DialogFooter className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{typesToAdd.length > 0 && `${typesToAdd.length} selected`}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setShowAddMoreDialog(false)}>
Cancel
</Button>
<Button
onClick={handleAddMoreIdeas}
disabled={typesToAdd.length === 0}
>
<Plus className="h-4 w-4 mr-1" />
Generate {typesToAdd.length > 0 ? `${typesToAdd.length} Types` : 'Ideas'}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Environment Configuration Modal */}
<EnvConfigModal
open={showEnvConfigModal}
@@ -0,0 +1,469 @@
import { useState, useEffect, useRef } from 'react';
import {
MessageSquare,
Send,
Trash2,
Loader2,
Plus,
Sparkles,
User,
Bot,
CheckCircle2,
AlertCircle,
Search,
FileText,
FolderSearch
} from 'lucide-react';
import { Button } from './ui/button';
import { Textarea } from './ui/textarea';
import { ScrollArea } from './ui/scroll-area';
import { Card, CardContent } from './ui/card';
import { Badge } from './ui/badge';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from './ui/alert-dialog';
import { cn } from '../lib/utils';
import {
useInsightsStore,
loadInsightsSession,
sendMessage,
clearSession,
createTaskFromSuggestion,
setupInsightsListeners
} from '../stores/insights-store';
import { loadTasks } from '../stores/task-store';
import type { InsightsChatMessage } from '../../shared/types';
import {
TASK_CATEGORY_LABELS,
TASK_CATEGORY_COLORS,
TASK_COMPLEXITY_LABELS,
TASK_COMPLEXITY_COLORS
} from '../../shared/constants';
interface InsightsProps {
projectId: string;
}
export function Insights({ projectId }: InsightsProps) {
const session = useInsightsStore((state) => state.session);
const status = useInsightsStore((state) => state.status);
const streamingContent = useInsightsStore((state) => state.streamingContent);
const currentTool = useInsightsStore((state) => state.currentTool);
const pendingMessage = useInsightsStore((state) => state.pendingMessage);
const setPendingMessage = useInsightsStore((state) => state.setPendingMessage);
const [inputValue, setInputValue] = useState('');
const [showClearDialog, setShowClearDialog] = useState(false);
const [creatingTask, setCreatingTask] = useState<string | null>(null);
const [taskCreated, setTaskCreated] = useState<Set<string>>(new Set());
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Load session and set up listeners on mount
useEffect(() => {
loadInsightsSession(projectId);
const cleanup = setupInsightsListeners();
return cleanup;
}, [projectId]);
// Auto-scroll to bottom when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [session?.messages, streamingContent]);
// Focus textarea on mount
useEffect(() => {
textareaRef.current?.focus();
}, []);
const handleSend = () => {
const message = inputValue.trim();
if (!message || status.phase === 'thinking' || status.phase === 'streaming') return;
setInputValue('');
sendMessage(projectId, message);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleClear = async () => {
await clearSession(projectId);
setShowClearDialog(false);
setTaskCreated(new Set());
};
const handleCreateTask = async (message: InsightsChatMessage) => {
if (!message.suggestedTask) return;
setCreatingTask(message.id);
try {
const task = await createTaskFromSuggestion(
projectId,
message.suggestedTask.title,
message.suggestedTask.description,
message.suggestedTask.metadata
);
if (task) {
setTaskCreated(prev => new Set(prev).add(message.id));
// Reload tasks to show the new task in the kanban
loadTasks(projectId);
}
} finally {
setCreatingTask(null);
}
};
const isLoading = status.phase === 'thinking' || status.phase === 'streaming';
const messages = session?.messages || [];
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Sparkles className="h-5 w-5 text-primary" />
</div>
<div>
<h2 className="font-semibold text-foreground">Insights</h2>
<p className="text-sm text-muted-foreground">
Ask questions about your codebase
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowClearDialog(true)}
disabled={messages.length === 0}
>
<Trash2 className="mr-2 h-4 w-4" />
Clear Chat
</Button>
</div>
{/* Messages */}
<ScrollArea className="flex-1 px-6 py-4">
{messages.length === 0 && !streamingContent ? (
<div className="flex h-full flex-col items-center justify-center text-center">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<MessageSquare className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="mb-2 text-lg font-medium text-foreground">
Start a Conversation
</h3>
<p className="max-w-md text-sm text-muted-foreground">
Ask questions about your codebase, get suggestions for improvements,
or discuss features you'd like to implement.
</p>
<div className="mt-6 flex flex-wrap justify-center gap-2">
{[
'What is the architecture of this project?',
'Suggest improvements for code quality',
'What features could I add next?',
'Are there any security concerns?'
].map((suggestion) => (
<Button
key={suggestion}
variant="outline"
size="sm"
className="text-xs"
onClick={() => {
setInputValue(suggestion);
textareaRef.current?.focus();
}}
>
{suggestion}
</Button>
))}
</div>
</div>
) : (
<div className="space-y-6">
{messages.map((message) => (
<MessageBubble
key={message.id}
message={message}
onCreateTask={() => handleCreateTask(message)}
isCreatingTask={creatingTask === message.id}
taskCreated={taskCreated.has(message.id)}
/>
))}
{/* Streaming message */}
{(streamingContent || currentTool) && (
<div className="flex gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10">
<Bot className="h-4 w-4 text-primary" />
</div>
<div className="flex-1">
<div className="mb-1 text-sm font-medium text-foreground">
Assistant
</div>
{streamingContent && (
<div className="prose prose-sm dark:prose-invert max-w-none">
<p className="whitespace-pre-wrap">{streamingContent}</p>
</div>
)}
{/* Tool usage indicator */}
{currentTool && (
<ToolIndicator name={currentTool.name} input={currentTool.input} />
)}
</div>
</div>
)}
{/* Thinking indicator */}
{status.phase === 'thinking' && !streamingContent && !currentTool && (
<div className="flex gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10">
<Bot className="h-4 w-4 text-primary" />
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Thinking...
</div>
</div>
)}
{/* Error message */}
{status.phase === 'error' && status.error && (
<div className="flex items-center gap-2 rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{status.error}
</div>
)}
<div ref={messagesEndRef} />
</div>
)}
</ScrollArea>
{/* Input */}
<div className="border-t border-border p-4">
<div className="flex gap-2">
<Textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask about your codebase..."
className="min-h-[80px] resize-none"
disabled={isLoading}
/>
<Button
onClick={handleSend}
disabled={!inputValue.trim() || isLoading}
className="self-end"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Press Enter to send, Shift+Enter for new line
</p>
</div>
{/* Clear confirmation dialog */}
<AlertDialog open={showClearDialog} onOpenChange={setShowClearDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear conversation?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete all messages in this conversation.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleClear}>Clear</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
interface MessageBubbleProps {
message: InsightsChatMessage;
onCreateTask: () => void;
isCreatingTask: boolean;
taskCreated: boolean;
}
function MessageBubble({
message,
onCreateTask,
isCreatingTask,
taskCreated
}: MessageBubbleProps) {
const isUser = message.role === 'user';
return (
<div className="flex gap-3">
<div
className={cn(
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full',
isUser ? 'bg-muted' : 'bg-primary/10'
)}
>
{isUser ? (
<User className="h-4 w-4 text-muted-foreground" />
) : (
<Bot className="h-4 w-4 text-primary" />
)}
</div>
<div className="flex-1 space-y-2">
<div className="text-sm font-medium text-foreground">
{isUser ? 'You' : 'Assistant'}
</div>
<div className="prose prose-sm dark:prose-invert max-w-none">
<p className="whitespace-pre-wrap">{message.content}</p>
</div>
{/* Task suggestion card */}
{message.suggestedTask && (
<Card className="mt-3 border-primary/20 bg-primary/5">
<CardContent className="p-4">
<div className="mb-2 flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
<span className="text-sm font-medium text-primary">
Suggested Task
</span>
</div>
<h4 className="mb-2 font-medium text-foreground">
{message.suggestedTask.title}
</h4>
<p className="mb-3 text-sm text-muted-foreground">
{message.suggestedTask.description}
</p>
{message.suggestedTask.metadata && (
<div className="mb-3 flex flex-wrap gap-2">
{message.suggestedTask.metadata.category && (
<Badge
variant="outline"
className={cn(
'text-xs',
TASK_CATEGORY_COLORS[message.suggestedTask.metadata.category]
)}
>
{TASK_CATEGORY_LABELS[message.suggestedTask.metadata.category] ||
message.suggestedTask.metadata.category}
</Badge>
)}
{message.suggestedTask.metadata.complexity && (
<Badge
variant="outline"
className={cn(
'text-xs',
TASK_COMPLEXITY_COLORS[message.suggestedTask.metadata.complexity]
)}
>
{TASK_COMPLEXITY_LABELS[message.suggestedTask.metadata.complexity] ||
message.suggestedTask.metadata.complexity}
</Badge>
)}
</div>
)}
<Button
size="sm"
onClick={onCreateTask}
disabled={isCreatingTask || taskCreated}
>
{isCreatingTask ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : taskCreated ? (
<>
<CheckCircle2 className="mr-2 h-4 w-4" />
Task Created
</>
) : (
<>
<Plus className="mr-2 h-4 w-4" />
Create Task
</>
)}
</Button>
</CardContent>
</Card>
)}
</div>
</div>
);
}
// Tool indicator component for showing what the AI is currently doing
interface ToolIndicatorProps {
name: string;
input?: string;
}
function ToolIndicator({ name, input }: ToolIndicatorProps) {
// Get friendly name and icon for each tool
const getToolInfo = (toolName: string) => {
switch (toolName) {
case 'Read':
return {
icon: FileText,
label: 'Reading file',
color: 'text-blue-500 bg-blue-500/10'
};
case 'Glob':
return {
icon: FolderSearch,
label: 'Searching files',
color: 'text-amber-500 bg-amber-500/10'
};
case 'Grep':
return {
icon: Search,
label: 'Searching code',
color: 'text-green-500 bg-green-500/10'
};
default:
return {
icon: Loader2,
label: toolName,
color: 'text-primary bg-primary/10'
};
}
};
const { icon: Icon, label, color } = getToolInfo(name);
return (
<div className={cn(
'mt-2 inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm',
color
)}>
<Icon className="h-4 w-4 animate-pulse" />
<span className="font-medium">{label}</span>
{input && (
<span className="text-muted-foreground truncate max-w-[300px]">
{input}
</span>
)}
</div>
);
}
@@ -24,7 +24,7 @@ import { TaskCard } from './TaskCard';
import { SortableTaskCard } from './SortableTaskCard';
import { TASK_STATUS_COLUMNS, TASK_STATUS_LABELS } from '../../shared/constants';
import { cn } from '../lib/utils';
import { useTaskStore } from '../stores/task-store';
import { persistTaskStatus } from '../stores/task-store';
import type { Task, TaskStatus } from '../../shared/types';
interface KanbanBoardProps {
@@ -133,7 +133,6 @@ function DroppableColumn({ status, tasks, onTaskClick, isOver, onAddClick }: Dro
export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick }: KanbanBoardProps) {
const [activeTask, setActiveTask] = useState<Task | null>(null);
const [overColumnId, setOverColumnId] = useState<string | null>(null);
const updateTaskStatus = useTaskStore((state) => state.updateTaskStatus);
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -211,7 +210,8 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick }: KanbanBoardP
const task = tasks.find((t) => t.id === activeTaskId);
if (task && task.status !== newStatus) {
updateTaskStatus(activeTaskId, newStatus);
// Persist status change to file and update local state
persistTaskStatus(activeTaskId, newStatus);
}
return;
}
@@ -221,7 +221,8 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick }: KanbanBoardP
if (overTask) {
const task = tasks.find((t) => t.id === activeTaskId);
if (task && task.status !== overTask.status) {
updateTaskStatus(activeTaskId, overTask.status);
// Persist status change to file and update local state
persistTaskStatus(activeTaskId, overTask.status);
}
}
};
@@ -10,13 +10,13 @@ import {
Terminal,
Map,
BookOpen,
Wrench,
Lightbulb,
AlertCircle,
Download,
RefreshCw,
Github,
FileText
FileText,
Sparkles
} from 'lucide-react';
import { Button } from './ui/button';
import { ScrollArea } from './ui/scroll-area';
@@ -54,7 +54,7 @@ import {
import { useSettingsStore, saveSettings } from '../stores/settings-store';
import type { Project, AutoBuildVersionInfo } from '../../shared/types';
export type SidebarView = 'kanban' | 'terminals' | 'roadmap' | 'context' | 'ideation' | 'agent-tools' | 'github-issues' | 'changelog';
export type SidebarView = 'kanban' | 'terminals' | 'roadmap' | 'context' | 'ideation' | 'github-issues' | 'changelog' | 'insights';
interface SidebarProps {
onSettingsClick: () => void;
@@ -72,16 +72,16 @@ interface NavItem {
const projectNavItems: NavItem[] = [
{ id: 'kanban', label: 'Kanban Board', icon: LayoutGrid, shortcut: 'K' },
{ id: 'terminals', label: 'Agent Terminals', icon: Terminal, shortcut: 'A' }
];
const toolsNavItems: NavItem[] = [
{ id: 'terminals', label: 'Agent Terminals', icon: Terminal, shortcut: 'A' },
{ id: 'insights', label: 'Insights', icon: Sparkles, shortcut: 'N' },
{ id: 'roadmap', label: 'Roadmap', icon: Map, shortcut: 'D' },
{ id: 'ideation', label: 'Ideation', icon: Lightbulb, shortcut: 'I' },
{ id: 'changelog', label: 'Changelog', icon: FileText, shortcut: 'L' },
{ id: 'github-issues', label: 'GitHub Issues', icon: Github, shortcut: 'G' },
{ id: 'context', label: 'Context', icon: BookOpen, shortcut: 'C' },
{ id: 'agent-tools', label: 'Agent Tools', icon: Wrench, shortcut: 'T' }
{ id: 'context', label: 'Context', icon: BookOpen, shortcut: 'C' }
];
const toolsNavItems: NavItem[] = [
{ id: 'github-issues', label: 'GitHub Issues', icon: Github, shortcut: 'G' }
];
export function Sidebar({
@@ -1,12 +1,38 @@
import { Play, Square, Clock } from 'lucide-react';
import { useState, useEffect } from 'react';
import { Play, Square, Clock, Zap, Target, Shield, Gauge, Palette, FileCode, Bug, Wrench, Loader2, AlertTriangle, RotateCcw } from 'lucide-react';
import { Card, CardContent } from './ui/card';
import { Badge } from './ui/badge';
import { Progress } from './ui/progress';
import { Button } from './ui/button';
import { cn, calculateProgress, formatRelativeTime } from '../lib/utils';
import { CHUNK_STATUS_COLORS } from '../../shared/constants';
import { startTask, stopTask } from '../stores/task-store';
import type { Task } from '../../shared/types';
import {
CHUNK_STATUS_COLORS,
TASK_CATEGORY_LABELS,
TASK_CATEGORY_COLORS,
TASK_COMPLEXITY_COLORS,
TASK_COMPLEXITY_LABELS,
TASK_IMPACT_COLORS,
TASK_IMPACT_LABELS,
TASK_PRIORITY_COLORS,
TASK_PRIORITY_LABELS,
EXECUTION_PHASE_LABELS,
EXECUTION_PHASE_BADGE_COLORS
} from '../../shared/constants';
import { startTask, stopTask, checkTaskRunning, recoverStuckTask } from '../stores/task-store';
import type { Task, TaskCategory, ExecutionPhase } from '../../shared/types';
// Category icon mapping
const CategoryIcon: Record<TaskCategory, typeof Zap> = {
feature: Target,
bug_fix: Bug,
refactoring: Wrench,
documentation: FileCode,
security: Shield,
performance: Gauge,
ui_ux: Palette,
infrastructure: Wrench,
testing: FileCode
};
interface TaskCardProps {
task: Task;
@@ -14,18 +40,47 @@ interface TaskCardProps {
}
export function TaskCard({ task, onClick }: TaskCardProps) {
const [isStuck, setIsStuck] = useState(false);
const [isRecovering, setIsRecovering] = useState(false);
const [hasCheckedRunning, setHasCheckedRunning] = useState(false);
const progress = calculateProgress(task.chunks);
const isRunning = task.status === 'in_progress';
const executionPhase = task.executionProgress?.phase;
const hasActiveExecution = executionPhase && executionPhase !== 'idle' && executionPhase !== 'complete' && executionPhase !== 'failed';
// Check if task is stuck (status says in_progress but no actual process)
useEffect(() => {
if (isRunning && !hasCheckedRunning) {
checkTaskRunning(task.id).then((actuallyRunning) => {
setIsStuck(!actuallyRunning);
setHasCheckedRunning(true);
});
} else if (!isRunning) {
setIsStuck(false);
setHasCheckedRunning(false);
}
}, [task.id, isRunning, hasCheckedRunning]);
const handleStartStop = (e: React.MouseEvent) => {
e.stopPropagation();
if (isRunning) {
if (isRunning && !isStuck) {
stopTask(task.id);
} else {
startTask(task.id);
}
};
const handleRecover = async (e: React.MouseEvent) => {
e.stopPropagation();
setIsRecovering(true);
const result = await recoverStuckTask(task.id, 'backlog');
if (result.success) {
setIsStuck(false);
}
setIsRecovering(false);
};
const getStatusBadgeVariant = (status: string) => {
switch (status) {
case 'in_progress':
@@ -68,9 +123,34 @@ export function TaskCard({ task, onClick }: TaskCardProps) {
{/* Header */}
<div className="flex items-start justify-between gap-2">
<h3 className="font-medium text-sm text-foreground line-clamp-2">{task.title}</h3>
<Badge variant={getStatusBadgeVariant(task.status)} className="shrink-0">
{getStatusLabel(task.status)}
</Badge>
<div className="flex items-center gap-1.5 shrink-0">
{/* Stuck indicator */}
{isStuck && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 flex items-center gap-1 bg-warning/10 text-warning border-warning/30"
>
<AlertTriangle className="h-2.5 w-2.5" />
Stuck
</Badge>
)}
{/* Execution phase badge - shown when actively running */}
{hasActiveExecution && executionPhase && !isStuck && (
<Badge
variant="outline"
className={cn(
'text-[10px] px-1.5 py-0 flex items-center gap-1',
EXECUTION_PHASE_BADGE_COLORS[executionPhase]
)}
>
<Loader2 className="h-2.5 w-2.5 animate-spin" />
{EXECUTION_PHASE_LABELS[executionPhase]}
</Badge>
)}
<Badge variant={isStuck ? 'warning' : getStatusBadgeVariant(task.status)}>
{isStuck ? 'Needs Recovery' : getStatusLabel(task.status)}
</Badge>
</div>
</div>
{/* Description */}
@@ -80,35 +160,103 @@ export function TaskCard({ task, onClick }: TaskCardProps) {
</p>
)}
{/* Metadata badges */}
{task.metadata && (
<div className="mt-2.5 flex flex-wrap gap-1.5">
{/* Category badge with icon */}
{task.metadata.category && (
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0', TASK_CATEGORY_COLORS[task.metadata.category])}
>
{CategoryIcon[task.metadata.category] && (
(() => {
const Icon = CategoryIcon[task.metadata.category!];
return <Icon className="h-2.5 w-2.5 mr-0.5" />;
})()
)}
{TASK_CATEGORY_LABELS[task.metadata.category]}
</Badge>
)}
{/* Impact badge - high visibility for important tasks */}
{task.metadata.impact && (task.metadata.impact === 'high' || task.metadata.impact === 'critical') && (
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0', TASK_IMPACT_COLORS[task.metadata.impact])}
>
{TASK_IMPACT_LABELS[task.metadata.impact]}
</Badge>
)}
{/* Complexity badge */}
{task.metadata.complexity && (
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0', TASK_COMPLEXITY_COLORS[task.metadata.complexity])}
>
{TASK_COMPLEXITY_LABELS[task.metadata.complexity]}
</Badge>
)}
{/* Priority badge - only show urgent/high */}
{task.metadata.priority && (task.metadata.priority === 'urgent' || task.metadata.priority === 'high') && (
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0', TASK_PRIORITY_COLORS[task.metadata.priority])}
>
{TASK_PRIORITY_LABELS[task.metadata.priority]}
</Badge>
)}
{/* Security severity - always show */}
{task.metadata.securitySeverity && (
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0', TASK_IMPACT_COLORS[task.metadata.securitySeverity])}
>
{task.metadata.securitySeverity} severity
</Badge>
)}
</div>
)}
{/* Progress section */}
{task.chunks.length > 0 && (
{(task.chunks.length > 0 || hasActiveExecution) && (
<div className="mt-4">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">
Progress
{hasActiveExecution && task.executionProgress?.message
? task.executionProgress.message
: 'Progress'}
</span>
<span className="text-xs font-medium text-foreground">
{hasActiveExecution
? `${task.executionProgress?.overallProgress || 0}%`
: `${progress}%`}
</span>
<span className="text-xs font-medium text-foreground">{progress}%</span>
</div>
<Progress value={progress} className="h-1.5" />
<Progress
value={hasActiveExecution ? (task.executionProgress?.overallProgress || 0) : progress}
className="h-1.5"
/>
{/* Chunk indicators */}
<div className="mt-2 flex flex-wrap gap-1">
{task.chunks.slice(0, 8).map((chunk) => (
<div
key={chunk.id}
className={cn(
'h-1.5 w-1.5 rounded-full',
CHUNK_STATUS_COLORS[chunk.status]
)}
title={`${chunk.title}: ${chunk.status}`}
/>
))}
{task.chunks.length > 8 && (
<span className="text-xs text-muted-foreground">
+{task.chunks.length - 8}
</span>
)}
</div>
{/* Chunk indicators - only show when chunks exist */}
{task.chunks.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{task.chunks.slice(0, 8).map((chunk) => (
<div
key={chunk.id}
className={cn(
'h-1.5 w-1.5 rounded-full',
CHUNK_STATUS_COLORS[chunk.status]
)}
title={`${chunk.title}: ${chunk.status}`}
/>
))}
{task.chunks.length > 8 && (
<span className="text-xs text-muted-foreground">
+{task.chunks.length - 8}
</span>
)}
</div>
)}
</div>
)}
@@ -119,8 +267,28 @@ export function TaskCard({ task, onClick }: TaskCardProps) {
<span>{formatRelativeTime(task.updatedAt)}</span>
</div>
{/* Action button */}
{(task.status === 'backlog' || task.status === 'in_progress') && (
{/* Action buttons */}
{isStuck ? (
<Button
variant="warning"
size="sm"
className="h-7 px-2.5"
onClick={handleRecover}
disabled={isRecovering}
>
{isRecovering ? (
<>
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
Recovering...
</>
) : (
<>
<RotateCcw className="mr-1.5 h-3 w-3" />
Recover
</>
)}
</Button>
) : (task.status === 'backlog' || task.status === 'in_progress') && (
<Button
variant={isRunning ? 'destructive' : 'default'}
size="sm"
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Loader2 } from 'lucide-react';
import { Loader2, ChevronDown, ChevronUp } from 'lucide-react';
import {
Dialog,
DialogContent,
@@ -12,7 +12,22 @@ import { Button } from './ui/button';
import { Input } from './ui/input';
import { Textarea } from './ui/textarea';
import { Label } from './ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from './ui/select';
import { createTask } from '../stores/task-store';
import { cn } from '../lib/utils';
import type { TaskCategory, TaskPriority, TaskComplexity, TaskImpact, TaskMetadata } from '../../shared/types';
import {
TASK_CATEGORY_LABELS,
TASK_PRIORITY_LABELS,
TASK_COMPLEXITY_LABELS,
TASK_IMPACT_LABELS
} from '../../shared/constants';
interface TaskCreationWizardProps {
projectId: string;
@@ -29,6 +44,13 @@ export function TaskCreationWizard({
const [description, setDescription] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
// Metadata fields
const [category, setCategory] = useState<TaskCategory | ''>('');
const [priority, setPriority] = useState<TaskPriority | ''>('');
const [complexity, setComplexity] = useState<TaskComplexity | ''>('');
const [impact, setImpact] = useState<TaskImpact | ''>('');
const handleCreate = async () => {
if (!title.trim() || !description.trim()) {
@@ -40,11 +62,20 @@ export function TaskCreationWizard({
setError(null);
try {
const task = await createTask(projectId, title.trim(), description.trim());
// Build metadata from selected values
const metadata: TaskMetadata = {
sourceType: 'manual'
};
if (category) metadata.category = category;
if (priority) metadata.priority = priority;
if (complexity) metadata.complexity = complexity;
if (impact) metadata.impact = impact;
const task = await createTask(projectId, title.trim(), description.trim(), metadata);
if (task) {
// Reset form and close
setTitle('');
setDescription('');
resetForm();
onOpenChange(false);
} else {
setError('Failed to create task. Please try again.');
@@ -56,18 +87,27 @@ export function TaskCreationWizard({
}
};
const resetForm = () => {
setTitle('');
setDescription('');
setCategory('');
setPriority('');
setComplexity('');
setImpact('');
setError(null);
setShowAdvanced(false);
};
const handleClose = () => {
if (!isCreating) {
setTitle('');
setDescription('');
setError(null);
resetForm();
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogContent className="sm:max-w-[550px]">
<DialogHeader>
<DialogTitle className="text-foreground">Create New Task</DialogTitle>
<DialogDescription>
@@ -101,7 +141,7 @@ export function TaskCreationWizard({
placeholder="Describe the feature, bug fix, or improvement you want to implement. Be as specific as possible about requirements, constraints, and expected behavior."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={6}
rows={5}
disabled={isCreating}
/>
<p className="text-xs text-muted-foreground">
@@ -110,6 +150,127 @@ export function TaskCreationWizard({
</p>
</div>
{/* Advanced Options Toggle */}
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className={cn(
'flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors',
'w-full justify-between py-2 px-3 rounded-md hover:bg-muted/50'
)}
disabled={isCreating}
>
<span>Classification (optional)</span>
{showAdvanced ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{/* Advanced Options */}
{showAdvanced && (
<div className="space-y-4 p-4 rounded-lg border border-border bg-muted/30">
<div className="grid grid-cols-2 gap-4">
{/* Category */}
<div className="space-y-2">
<Label htmlFor="category" className="text-xs font-medium text-muted-foreground">
Category
</Label>
<Select
value={category}
onValueChange={(value) => setCategory(value as TaskCategory)}
disabled={isCreating}
>
<SelectTrigger id="category" className="h-9">
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{Object.entries(TASK_CATEGORY_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Priority */}
<div className="space-y-2">
<Label htmlFor="priority" className="text-xs font-medium text-muted-foreground">
Priority
</Label>
<Select
value={priority}
onValueChange={(value) => setPriority(value as TaskPriority)}
disabled={isCreating}
>
<SelectTrigger id="priority" className="h-9">
<SelectValue placeholder="Select priority" />
</SelectTrigger>
<SelectContent>
{Object.entries(TASK_PRIORITY_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Complexity */}
<div className="space-y-2">
<Label htmlFor="complexity" className="text-xs font-medium text-muted-foreground">
Complexity
</Label>
<Select
value={complexity}
onValueChange={(value) => setComplexity(value as TaskComplexity)}
disabled={isCreating}
>
<SelectTrigger id="complexity" className="h-9">
<SelectValue placeholder="Select complexity" />
</SelectTrigger>
<SelectContent>
{Object.entries(TASK_COMPLEXITY_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Impact */}
<div className="space-y-2">
<Label htmlFor="impact" className="text-xs font-medium text-muted-foreground">
Impact
</Label>
<Select
value={impact}
onValueChange={(value) => setImpact(value as TaskImpact)}
disabled={isCreating}
>
<SelectTrigger id="impact" className="h-9">
<SelectValue placeholder="Select impact" />
</SelectTrigger>
<SelectContent>
{Object.entries(TASK_IMPACT_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<p className="text-xs text-muted-foreground">
These labels help organize and prioritize tasks. They&apos;re optional but useful for filtering.
</p>
</div>
)}
{/* Error */}
{error && (
<div className="rounded-lg bg-[var(--error-light)] border border-[var(--error)]/30 p-3 text-sm text-[var(--error)]">
@@ -6,9 +6,22 @@ import {
CheckCircle2,
XCircle,
AlertCircle,
AlertTriangle,
Clock,
FileCode,
Terminal
Terminal,
Target,
Bug,
Wrench,
Shield,
Gauge,
Palette,
Lightbulb,
Users,
GitBranch,
ListChecks,
Loader2,
RotateCcw
} from 'lucide-react';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
@@ -18,9 +31,36 @@ import { Separator } from './ui/separator';
import { Textarea } from './ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { cn, calculateProgress, formatRelativeTime } from '../lib/utils';
import { TASK_STATUS_LABELS } from '../../shared/constants';
import { startTask, stopTask, submitReview } from '../stores/task-store';
import type { Task } from '../../shared/types';
import {
TASK_STATUS_LABELS,
TASK_CATEGORY_LABELS,
TASK_CATEGORY_COLORS,
TASK_COMPLEXITY_LABELS,
TASK_COMPLEXITY_COLORS,
TASK_IMPACT_LABELS,
TASK_IMPACT_COLORS,
TASK_PRIORITY_LABELS,
TASK_PRIORITY_COLORS,
IDEATION_TYPE_LABELS,
EXECUTION_PHASE_LABELS,
EXECUTION_PHASE_BADGE_COLORS,
EXECUTION_PHASE_COLORS
} from '../../shared/constants';
import { startTask, stopTask, submitReview, checkTaskRunning, recoverStuckTask } from '../stores/task-store';
import type { Task, TaskCategory, ExecutionPhase } from '../../shared/types';
// Category icon mapping
const CategoryIcon: Record<TaskCategory, typeof Target> = {
feature: Target,
bug_fix: Bug,
refactoring: Wrench,
documentation: FileCode,
security: Shield,
performance: Gauge,
ui_ux: Palette,
infrastructure: Wrench,
testing: FileCode
};
interface TaskDetailPanelProps {
task: Task;
@@ -31,27 +71,70 @@ export function TaskDetailPanel({ task, onClose }: TaskDetailPanelProps) {
const [feedback, setFeedback] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState('overview');
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
const [isStuck, setIsStuck] = useState(false);
const [isRecovering, setIsRecovering] = useState(false);
const [hasCheckedRunning, setHasCheckedRunning] = useState(false);
const logsEndRef = useRef<HTMLDivElement>(null);
const logsContainerRef = useRef<HTMLDivElement>(null);
const progress = calculateProgress(task.chunks);
const isRunning = task.status === 'in_progress';
const needsReview = task.status === 'human_review';
const executionPhase = task.executionProgress?.phase;
const hasActiveExecution = executionPhase && executionPhase !== 'idle' && executionPhase !== 'complete' && executionPhase !== 'failed';
// Auto-scroll logs to bottom
// Check if task is stuck (status says in_progress but no actual process)
useEffect(() => {
if (activeTab === 'logs' && logsEndRef.current) {
if (isRunning && !hasCheckedRunning) {
checkTaskRunning(task.id).then((actuallyRunning) => {
setIsStuck(!actuallyRunning);
setHasCheckedRunning(true);
});
} else if (!isRunning) {
setIsStuck(false);
setHasCheckedRunning(false);
}
}, [task.id, isRunning, hasCheckedRunning]);
// Handle scroll events in logs to detect if user scrolled up
const handleLogsScroll = (e: React.UIEvent<HTMLDivElement>) => {
const target = e.target as HTMLDivElement;
const isNearBottom = target.scrollHeight - target.scrollTop - target.clientHeight < 100;
setIsUserScrolledUp(!isNearBottom);
};
// Auto-scroll logs to bottom only if user hasn't scrolled up
useEffect(() => {
if (activeTab === 'logs' && logsEndRef.current && !isUserScrolledUp) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [task.logs, activeTab]);
}, [task.logs, activeTab, isUserScrolledUp]);
// Reset scroll state when switching to logs tab
useEffect(() => {
if (activeTab === 'logs') {
setIsUserScrolledUp(false);
}
}, [activeTab]);
const handleStartStop = () => {
if (isRunning) {
if (isRunning && !isStuck) {
stopTask(task.id);
} else {
startTask(task.id);
}
};
const handleRecover = async () => {
setIsRecovering(true);
const result = await recoverStuckTask(task.id, 'backlog');
if (result.success) {
setIsStuck(false);
}
setIsRecovering(false);
};
const handleApprove = async () => {
setIsSubmitting(true);
await submitReview(task.id, true);
@@ -92,9 +175,16 @@ export function TaskDetailPanel({ task, onClose }: TaskDetailPanelProps) {
<Badge variant="outline" className="text-xs">
{task.specId}
</Badge>
<span className="text-xs text-muted-foreground">
{TASK_STATUS_LABELS[task.status]}
</span>
{isStuck ? (
<Badge variant="warning" className="text-xs flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
Stuck
</Badge>
) : (
<span className="text-xs text-muted-foreground">
{TASK_STATUS_LABELS[task.status]}
</span>
)}
</div>
</div>
<Button variant="ghost" size="icon" onClick={onClose}>
@@ -105,7 +195,7 @@ export function TaskDetailPanel({ task, onClose }: TaskDetailPanelProps) {
<Separator />
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0 overflow-hidden">
<TabsList className="w-full justify-start rounded-none border-b border-border bg-transparent p-0 h-auto">
<TabsTrigger
value="overview"
@@ -128,18 +218,195 @@ export function TaskDetailPanel({ task, onClose }: TaskDetailPanelProps) {
</TabsList>
{/* Overview Tab */}
<TabsContent value="overview" className="flex-1 overflow-hidden mt-0">
<TabsContent value="overview" className="flex-1 min-h-0 overflow-hidden mt-0">
<ScrollArea className="h-full">
<div className="p-4 space-y-5">
{/* Stuck Task Warning */}
{isStuck && (
<div className="rounded-xl border border-warning/30 bg-warning/10 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-medium text-sm text-foreground mb-1">
Task Appears Stuck
</h3>
<p className="text-sm text-muted-foreground mb-3">
This task is marked as running but no active process was found.
This can happen if the app crashed or the process was terminated unexpectedly.
</p>
<Button
variant="warning"
size="sm"
onClick={handleRecover}
disabled={isRecovering}
className="w-full"
>
{isRecovering ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Recovering...
</>
) : (
<>
<RotateCcw className="mr-2 h-4 w-4" />
Recover Task (Move to Planning)
</>
)}
</Button>
</div>
</div>
</div>
)}
{/* Execution Phase Indicator */}
{hasActiveExecution && executionPhase && !isStuck && (
<div className={cn(
'rounded-xl border p-3 flex items-center gap-3',
EXECUTION_PHASE_BADGE_COLORS[executionPhase]
)}>
<Loader2 className="h-5 w-5 animate-spin shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{EXECUTION_PHASE_LABELS[executionPhase]}
</span>
<span className="text-sm">
{task.executionProgress?.overallProgress || 0}%
</span>
</div>
{task.executionProgress?.message && (
<p className="text-xs mt-0.5 opacity-80 truncate">
{task.executionProgress.message}
</p>
)}
{task.executionProgress?.currentChunk && (
<p className="text-xs mt-0.5 opacity-70">
Chunk: {task.executionProgress.currentChunk}
</p>
)}
</div>
</div>
)}
{/* Progress */}
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-foreground">Progress</span>
<span className="text-sm text-foreground">{progress}%</span>
<span className="text-sm font-medium text-foreground">
{hasActiveExecution ? 'Overall Progress' : 'Progress'}
</span>
<span className="text-sm text-foreground">
{hasActiveExecution
? `${task.executionProgress?.overallProgress || 0}%`
: `${progress}%`}
</span>
</div>
<Progress value={progress} className="h-2" />
<Progress
value={hasActiveExecution ? (task.executionProgress?.overallProgress || 0) : progress}
className="h-2"
/>
{/* Phase Progress Bar Segments */}
{hasActiveExecution && (
<div className="mt-2 flex gap-0.5 h-1.5 rounded-full overflow-hidden bg-muted/30">
<div
className={cn(
'transition-all duration-300',
executionPhase === 'planning' ? 'bg-amber-500' : 'bg-amber-500/30'
)}
style={{ width: '20%' }}
title="Planning (0-20%)"
/>
<div
className={cn(
'transition-all duration-300',
executionPhase === 'coding' ? 'bg-info' : 'bg-info/30'
)}
style={{ width: '60%' }}
title="Coding (20-80%)"
/>
<div
className={cn(
'transition-all duration-300',
(executionPhase === 'qa_review' || executionPhase === 'qa_fixing') ? 'bg-purple-500' : 'bg-purple-500/30'
)}
style={{ width: '15%' }}
title="AI Review (80-95%)"
/>
<div
className={cn(
'transition-all duration-300',
executionPhase === 'complete' ? 'bg-success' : 'bg-success/30'
)}
style={{ width: '5%' }}
title="Complete (95-100%)"
/>
</div>
)}
</div>
{/* Classification Badges */}
{task.metadata && (
<div className="flex flex-wrap gap-1.5">
{/* Category */}
{task.metadata.category && (
<Badge
variant="outline"
className={cn('text-xs', TASK_CATEGORY_COLORS[task.metadata.category])}
>
{CategoryIcon[task.metadata.category] && (() => {
const Icon = CategoryIcon[task.metadata.category!];
return <Icon className="h-3 w-3 mr-1" />;
})()}
{TASK_CATEGORY_LABELS[task.metadata.category]}
</Badge>
)}
{/* Priority */}
{task.metadata.priority && (
<Badge
variant="outline"
className={cn('text-xs', TASK_PRIORITY_COLORS[task.metadata.priority])}
>
{TASK_PRIORITY_LABELS[task.metadata.priority]}
</Badge>
)}
{/* Complexity */}
{task.metadata.complexity && (
<Badge
variant="outline"
className={cn('text-xs', TASK_COMPLEXITY_COLORS[task.metadata.complexity])}
>
{TASK_COMPLEXITY_LABELS[task.metadata.complexity]}
</Badge>
)}
{/* Impact */}
{task.metadata.impact && (
<Badge
variant="outline"
className={cn('text-xs', TASK_IMPACT_COLORS[task.metadata.impact])}
>
{TASK_IMPACT_LABELS[task.metadata.impact]}
</Badge>
)}
{/* Security Severity */}
{task.metadata.securitySeverity && (
<Badge
variant="outline"
className={cn('text-xs', TASK_IMPACT_COLORS[task.metadata.securitySeverity])}
>
<Shield className="h-3 w-3 mr-1" />
{task.metadata.securitySeverity} severity
</Badge>
)}
{/* Source Type */}
{task.metadata.sourceType && (
<Badge variant="secondary" className="text-xs">
{task.metadata.sourceType === 'ideation' && task.metadata.ideationType
? IDEATION_TYPE_LABELS[task.metadata.ideationType] || task.metadata.ideationType
: task.metadata.sourceType}
</Badge>
)}
</div>
)}
{/* Description */}
{task.description && (
<div>
@@ -148,6 +415,91 @@ export function TaskDetailPanel({ task, onClose }: TaskDetailPanelProps) {
</div>
)}
{/* Metadata Details */}
{task.metadata && (
<div className="space-y-4">
{/* Rationale */}
{task.metadata.rationale && (
<div>
<h3 className="text-sm font-medium text-foreground mb-1.5 flex items-center gap-1.5">
<Lightbulb className="h-3.5 w-3.5 text-warning" />
Rationale
</h3>
<p className="text-sm text-muted-foreground">{task.metadata.rationale}</p>
</div>
)}
{/* Problem Solved */}
{task.metadata.problemSolved && (
<div>
<h3 className="text-sm font-medium text-foreground mb-1.5 flex items-center gap-1.5">
<Target className="h-3.5 w-3.5 text-success" />
Problem Solved
</h3>
<p className="text-sm text-muted-foreground">{task.metadata.problemSolved}</p>
</div>
)}
{/* Target Audience */}
{task.metadata.targetAudience && (
<div>
<h3 className="text-sm font-medium text-foreground mb-1.5 flex items-center gap-1.5">
<Users className="h-3.5 w-3.5 text-info" />
Target Audience
</h3>
<p className="text-sm text-muted-foreground">{task.metadata.targetAudience}</p>
</div>
)}
{/* Dependencies */}
{task.metadata.dependencies && task.metadata.dependencies.length > 0 && (
<div>
<h3 className="text-sm font-medium text-foreground mb-1.5 flex items-center gap-1.5">
<GitBranch className="h-3.5 w-3.5 text-purple-400" />
Dependencies
</h3>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-0.5">
{task.metadata.dependencies.map((dep, idx) => (
<li key={idx}>{dep}</li>
))}
</ul>
</div>
)}
{/* Acceptance Criteria */}
{task.metadata.acceptanceCriteria && task.metadata.acceptanceCriteria.length > 0 && (
<div>
<h3 className="text-sm font-medium text-foreground mb-1.5 flex items-center gap-1.5">
<ListChecks className="h-3.5 w-3.5 text-success" />
Acceptance Criteria
</h3>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-0.5">
{task.metadata.acceptanceCriteria.map((criteria, idx) => (
<li key={idx}>{criteria}</li>
))}
</ul>
</div>
)}
{/* Affected Files */}
{task.metadata.affectedFiles && task.metadata.affectedFiles.length > 0 && (
<div>
<h3 className="text-sm font-medium text-foreground mb-1.5 flex items-center gap-1.5">
<FileCode className="h-3.5 w-3.5 text-muted-foreground" />
Affected Files
</h3>
<div className="flex flex-wrap gap-1">
{task.metadata.affectedFiles.map((file, idx) => (
<Badge key={idx} variant="secondary" className="text-xs font-mono">
{file.split('/').pop()}
</Badge>
))}
</div>
</div>
)}
</div>
)}
{/* Timestamps */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
@@ -204,7 +556,7 @@ export function TaskDetailPanel({ task, onClose }: TaskDetailPanelProps) {
</TabsContent>
{/* Chunks Tab */}
<TabsContent value="chunks" className="flex-1 overflow-hidden mt-0">
<TabsContent value="chunks" className="flex-1 min-h-0 overflow-hidden mt-0">
<ScrollArea className="h-full">
<div className="p-4 space-y-3">
{task.chunks.length === 0 ? (
@@ -260,8 +612,12 @@ export function TaskDetailPanel({ task, onClose }: TaskDetailPanelProps) {
</TabsContent>
{/* Logs Tab */}
<TabsContent value="logs" className="flex-1 overflow-hidden mt-0">
<ScrollArea className="h-full">
<TabsContent value="logs" className="flex-1 min-h-0 overflow-hidden mt-0">
<div
ref={logsContainerRef}
className="h-full overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent"
onScroll={handleLogsScroll}
>
<div className="p-4">
{task.logs && task.logs.length > 0 ? (
<pre className="text-xs font-mono text-muted-foreground whitespace-pre-wrap break-all">
@@ -276,7 +632,7 @@ export function TaskDetailPanel({ task, onClose }: TaskDetailPanelProps) {
</div>
)}
</div>
</ScrollArea>
</div>
</TabsContent>
</Tabs>
@@ -284,7 +640,26 @@ export function TaskDetailPanel({ task, onClose }: TaskDetailPanelProps) {
{/* Actions */}
<div className="p-4">
{(task.status === 'backlog' || task.status === 'in_progress') && (
{isStuck ? (
<Button
className="w-full"
variant="warning"
onClick={handleRecover}
disabled={isRecovering}
>
{isRecovering ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Recovering...
</>
) : (
<>
<RotateCcw className="mr-2 h-4 w-4" />
Recover Task
</>
)}
</Button>
) : (task.status === 'backlog' || task.status === 'in_progress') && (
<Button
className="w-full"
variant={isRunning ? 'destructive' : 'default'}
@@ -0,0 +1,149 @@
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '../../lib/utils';
import { buttonVariants } from './button';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 backdrop-blur-sm',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 w-full max-w-lg max-h-[90vh]',
'translate-x-[-50%] translate-y-[-50%]',
'bg-card border border-border rounded-2xl p-6',
'shadow-xl',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
'duration-200',
className
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className
)}
{...props}
/>
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-3 mt-6',
className
)}
{...props}
/>
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-foreground', className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
className
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
@@ -22,6 +22,8 @@ const buttonVariants = cva(
'text-primary underline-offset-4 hover:underline',
success:
'bg-[var(--success)] text-[var(--success-foreground)] hover:bg-[var(--success)]/90 active:scale-[0.98]',
warning:
'bg-warning text-warning-foreground hover:bg-warning/90 active:scale-[0.98]',
},
size: {
default: 'h-10 px-4 py-2 text-sm rounded-lg',
+10 -2
View File
@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import { useTaskStore } from '../stores/task-store';
import { useRoadmapStore } from '../stores/roadmap-store';
import type { ImplementationPlan, TaskStatus, RoadmapGenerationStatus, Roadmap } from '../../shared/types';
import type { ImplementationPlan, TaskStatus, RoadmapGenerationStatus, Roadmap, ExecutionProgress } from '../../shared/types';
/**
* Hook to set up IPC event listeners for task updates
@@ -9,6 +9,7 @@ import type { ImplementationPlan, TaskStatus, RoadmapGenerationStatus, Roadmap }
export function useIpcListeners(): void {
const updateTaskFromPlan = useTaskStore((state) => state.updateTaskFromPlan);
const updateTaskStatus = useTaskStore((state) => state.updateTaskStatus);
const updateExecutionProgress = useTaskStore((state) => state.updateExecutionProgress);
const appendLog = useTaskStore((state) => state.appendLog);
const setError = useTaskStore((state) => state.setError);
@@ -39,6 +40,12 @@ export function useIpcListeners(): void {
}
);
const cleanupExecutionProgress = window.electronAPI.onTaskExecutionProgress(
(taskId: string, progress: ExecutionProgress) => {
updateExecutionProgress(taskId, progress);
}
);
// Roadmap event listeners
const setGenerationStatus = useRoadmapStore.getState().setGenerationStatus;
const setRoadmap = useRoadmapStore.getState().setRoadmap;
@@ -77,11 +84,12 @@ export function useIpcListeners(): void {
cleanupError();
cleanupLog();
cleanupStatus();
cleanupExecutionProgress();
cleanupRoadmapProgress();
cleanupRoadmapComplete();
cleanupRoadmapError();
};
}, [updateTaskFromPlan, updateTaskStatus, appendLog, setError]);
}, [updateTaskFromPlan, updateTaskStatus, updateExecutionProgress, appendLog, setError]);
}
/**
@@ -6,8 +6,10 @@ import type {
ChangelogAudience,
ChangelogGenerationProgress,
ChangelogGenerationResult,
ExistingChangelog
ExistingChangelog,
Task
} from '../../shared/types';
import { useTaskStore } from './task-store';
interface ChangelogState {
// Data
@@ -138,8 +140,14 @@ export async function loadChangelogData(projectId: string): Promise<void> {
const store = useChangelogStore.getState();
try {
// Load done tasks
const tasksResult = await window.electronAPI.getChangelogDoneTasks(projectId);
// Get tasks from the task store (which has the correct UI status)
// This is necessary because the Kanban board updates task status in the Zustand store,
// but the backend reads from the filesystem which doesn't reflect UI-only changes
const taskStore = useTaskStore.getState();
const tasks = taskStore.tasks;
// Load done tasks - pass the renderer's task list to get correct status
const tasksResult = await window.electronAPI.getChangelogDoneTasks(projectId, tasks);
if (tasksResult.success && tasksResult.data) {
store.setDoneTasks(tasksResult.data);
}
@@ -10,12 +10,17 @@ import type {
} from '../../shared/types';
import { DEFAULT_IDEATION_CONFIG } from '../../shared/constants';
// Tracks the state of each ideation type during parallel generation
export type IdeationTypeState = 'pending' | 'generating' | 'completed' | 'failed';
interface IdeationState {
// Data
session: IdeationSession | null;
generationStatus: IdeationGenerationStatus;
config: IdeationConfig;
logs: string[];
// Track which ideation types are pending, generating, completed, or failed
typeStates: Record<IdeationType, IdeationTypeState>;
// Actions
setSession: (session: IdeationSession | null) => void;
@@ -26,6 +31,10 @@ interface IdeationState {
clearSession: () => void;
addLog: (log: string) => void;
clearLogs: () => void;
// New actions for streaming parallel results
initializeTypeStates: (types: IdeationType[]) => void;
setTypeState: (type: IdeationType, state: IdeationTypeState) => void;
addIdeasForType: (ideationType: string, ideas: Idea[]) => void;
}
const initialGenerationStatus: IdeationGenerationStatus = {
@@ -41,12 +50,24 @@ const initialConfig: IdeationConfig = {
maxIdeasPerType: DEFAULT_IDEATION_CONFIG.maxIdeasPerType
};
// Initialize all type states to 'pending' initially (will be set when generation starts)
const initialTypeStates: Record<IdeationType, IdeationTypeState> = {
low_hanging_fruit: 'pending',
ui_ux_improvements: 'pending',
high_value_features: 'pending',
documentation_gaps: 'pending',
security_hardening: 'pending',
performance_optimizations: 'pending',
code_quality: 'pending'
};
export const useIdeationStore = create<IdeationState>((set) => ({
// Initial state
session: null,
generationStatus: initialGenerationStatus,
config: initialConfig,
logs: [],
typeStates: { ...initialTypeStates },
// Actions
setSession: (session) => set({ session }),
@@ -95,7 +116,8 @@ export const useIdeationStore = create<IdeationState>((set) => ({
clearSession: () =>
set({
session: null,
generationStatus: initialGenerationStatus
generationStatus: initialGenerationStatus,
typeStates: { ...initialTypeStates }
}),
addLog: (log) =>
@@ -103,7 +125,72 @@ export const useIdeationStore = create<IdeationState>((set) => ({
logs: [...state.logs, log].slice(-100) // Keep last 100 logs
})),
clearLogs: () => set({ logs: [] })
clearLogs: () => set({ logs: [] }),
// Initialize type states when starting generation
initializeTypeStates: (types) =>
set((state) => {
const newTypeStates = { ...initialTypeStates };
// Set all enabled types to 'generating'
types.forEach((type) => {
newTypeStates[type] = 'generating';
});
// Set all disabled types to 'pending' (they won't be generated)
Object.keys(newTypeStates).forEach((type) => {
if (!types.includes(type as IdeationType)) {
newTypeStates[type as IdeationType] = 'pending';
}
});
return { typeStates: newTypeStates };
}),
// Update individual type state
setTypeState: (type, state) =>
set((prevState) => ({
typeStates: { ...prevState.typeStates, [type]: state }
})),
// Add ideas for a specific type (streaming update)
addIdeasForType: (ideationType, ideas) =>
set((state) => {
// Update type state to completed
const newTypeStates = { ...state.typeStates };
newTypeStates[ideationType as IdeationType] = 'completed';
// If no session exists yet, create a partial one
if (!state.session) {
const config = state.config;
return {
typeStates: newTypeStates,
session: {
id: `session-${Date.now()}`,
projectId: '', // Will be set by final session
config,
ideas,
projectContext: {
existingFeatures: [],
techStack: [],
plannedFeatures: []
},
generatedAt: new Date(),
updatedAt: new Date()
}
};
}
// Merge new ideas with existing ones (avoid duplicates by id)
const existingIds = new Set(state.session.ideas.map((i) => i.id));
const newIdeas = ideas.filter((idea) => !existingIds.has(idea.id));
return {
typeStates: newTypeStates,
session: {
...state.session,
ideas: [...state.session.ideas, ...newIdeas],
updatedAt: new Date()
}
};
})
}));
// Helper functions for loading ideation
@@ -117,29 +204,71 @@ export async function loadIdeation(projectId: string): Promise<void> {
}
export function generateIdeation(projectId: string): void {
const config = useIdeationStore.getState().config;
useIdeationStore.getState().clearLogs();
useIdeationStore.getState().addLog('Starting ideation generation...');
useIdeationStore.getState().setGenerationStatus({
phase: 'analyzing',
const store = useIdeationStore.getState();
const config = store.config;
store.clearLogs();
store.clearSession(); // Clear existing session for fresh generation
store.initializeTypeStates(config.enabledTypes);
store.addLog('Starting ideation generation in parallel...');
store.setGenerationStatus({
phase: 'generating',
progress: 0,
message: 'Starting ideation generation...'
message: `Generating ${config.enabledTypes.length} ideation types in parallel...`
});
window.electronAPI.generateIdeation(projectId, config);
}
export function refreshIdeation(projectId: string): void {
const config = useIdeationStore.getState().config;
useIdeationStore.getState().clearLogs();
useIdeationStore.getState().addLog('Refreshing ideation...');
useIdeationStore.getState().setGenerationStatus({
phase: 'analyzing',
const store = useIdeationStore.getState();
const config = store.config;
store.clearLogs();
store.clearSession(); // Clear existing session for fresh generation
store.initializeTypeStates(config.enabledTypes);
store.addLog('Refreshing ideation in parallel...');
store.setGenerationStatus({
phase: 'generating',
progress: 0,
message: 'Refreshing ideation...'
message: `Refreshing ${config.enabledTypes.length} ideation types in parallel...`
});
window.electronAPI.refreshIdeation(projectId, config);
}
/**
* Append new ideation types to existing session without clearing existing ideas.
* This allows users to add more categories (like security, performance) while keeping
* their existing ideas intact.
*/
export function appendIdeation(projectId: string, typesToAdd: IdeationType[]): void {
const store = useIdeationStore.getState();
const config = store.config;
// Don't clear existing session - we're appending
store.clearLogs();
// Only initialize states for the new types we're adding
// Keep existing type states as 'completed' for types we already have
const newTypeStates = { ...store.typeStates };
typesToAdd.forEach((type) => {
newTypeStates[type] = 'generating';
});
store.initializeTypeStates(typesToAdd);
store.addLog(`Adding ${typesToAdd.length} new ideation types...`);
store.setGenerationStatus({
phase: 'generating',
progress: 0,
message: `Generating ${typesToAdd.length} additional ideation types...`
});
// Call generate with append mode and only the new types
const appendConfig = {
...config,
enabledTypes: typesToAdd,
append: true
};
window.electronAPI.generateIdeation(projectId, appendConfig);
}
// Selectors
export function getIdeasByType(
session: IdeationSession | null,
@@ -202,42 +331,80 @@ export function isHighValueIdea(idea: Idea): idea is Idea & { type: 'high_value_
// IPC listener setup - call this once when the app initializes
export function setupIdeationListeners(): () => void {
const store = useIdeationStore.getState;
// Listen for progress updates
const unsubProgress = window.electronAPI.onIdeationProgress((_projectId, status) => {
useIdeationStore.getState().setGenerationStatus(status);
store().setGenerationStatus(status);
});
// Listen for log messages
const unsubLog = window.electronAPI.onIdeationLog((_projectId, log) => {
useIdeationStore.getState().addLog(log);
store().addLog(log);
});
// Listen for completion
// Listen for individual ideation type completion (streaming)
const unsubTypeComplete = window.electronAPI.onIdeationTypeComplete(
(_projectId, ideationType, ideas) => {
store().addIdeasForType(ideationType, ideas);
store().addLog(`✓ ${ideationType} completed with ${ideas.length} ideas`);
// Update progress based on completed types
const typeStates = store().typeStates;
const config = store().config;
const completedCount = Object.entries(typeStates).filter(
([type, state]) =>
config.enabledTypes.includes(type as IdeationType) &&
(state === 'completed' || state === 'failed')
).length;
const totalTypes = config.enabledTypes.length;
const progress = Math.round((completedCount / totalTypes) * 100);
store().setGenerationStatus({
phase: 'generating',
progress,
message: `${completedCount}/${totalTypes} ideation types complete`
});
}
);
// Listen for individual ideation type failure
const unsubTypeFailed = window.electronAPI.onIdeationTypeFailed(
(_projectId, ideationType) => {
store().setTypeState(ideationType as IdeationType, 'failed');
store().addLog(`✗ ${ideationType} failed`);
}
);
// Listen for completion (final session with all data)
const unsubComplete = window.electronAPI.onIdeationComplete((_projectId, session) => {
useIdeationStore.getState().setSession(session);
useIdeationStore.getState().setGenerationStatus({
// Final session replaces the partial one with complete data
store().setSession(session);
store().setGenerationStatus({
phase: 'complete',
progress: 100,
message: 'Ideation complete'
});
useIdeationStore.getState().addLog('Ideation generation complete!');
store().addLog('Ideation generation complete!');
});
// Listen for errors
const unsubError = window.electronAPI.onIdeationError((_projectId, error) => {
useIdeationStore.getState().setGenerationStatus({
phase: 'idle',
store().setGenerationStatus({
phase: 'error',
progress: 0,
message: '',
error
});
useIdeationStore.getState().addLog(`Error: ${error}`);
store().addLog(`Error: ${error}`);
});
// Return cleanup function
return () => {
unsubProgress();
unsubLog();
unsubTypeComplete();
unsubTypeFailed();
unsubComplete();
unsubError();
};
@@ -0,0 +1,295 @@
import { create } from 'zustand';
import type {
InsightsSession,
InsightsChatMessage,
InsightsChatStatus,
InsightsStreamChunk,
TaskMetadata,
Task
} from '../../shared/types';
interface ToolUsage {
name: string;
input?: string;
}
interface InsightsState {
// Data
session: InsightsSession | null;
status: InsightsChatStatus;
pendingMessage: string;
streamingContent: string; // Accumulates streaming response
currentTool: ToolUsage | null; // Currently executing tool
// Actions
setSession: (session: InsightsSession | null) => void;
setStatus: (status: InsightsChatStatus) => void;
setPendingMessage: (message: string) => void;
addMessage: (message: InsightsChatMessage) => void;
updateLastAssistantMessage: (content: string) => void;
appendStreamingContent: (content: string) => void;
clearStreamingContent: () => void;
setCurrentTool: (tool: ToolUsage | null) => void;
finalizeStreamingMessage: (suggestedTask?: InsightsChatMessage['suggestedTask']) => void;
clearSession: () => void;
}
const initialStatus: InsightsChatStatus = {
phase: 'idle',
message: ''
};
export const useInsightsStore = create<InsightsState>((set, get) => ({
// Initial state
session: null,
status: initialStatus,
pendingMessage: '',
streamingContent: '',
currentTool: null,
// Actions
setSession: (session) => set({ session }),
setStatus: (status) => set({ status }),
setPendingMessage: (message) => set({ pendingMessage: message }),
addMessage: (message) =>
set((state) => {
if (!state.session) {
// Create new session if none exists
return {
session: {
id: `session-${Date.now()}`,
projectId: '',
messages: [message],
createdAt: new Date(),
updatedAt: new Date()
}
};
}
return {
session: {
...state.session,
messages: [...state.session.messages, message],
updatedAt: new Date()
}
};
}),
updateLastAssistantMessage: (content) =>
set((state) => {
if (!state.session || state.session.messages.length === 0) return state;
const messages = [...state.session.messages];
const lastIndex = messages.length - 1;
const lastMessage = messages[lastIndex];
if (lastMessage.role === 'assistant') {
messages[lastIndex] = { ...lastMessage, content };
}
return {
session: {
...state.session,
messages,
updatedAt: new Date()
}
};
}),
appendStreamingContent: (content) =>
set((state) => ({
streamingContent: state.streamingContent + content
})),
clearStreamingContent: () => set({ streamingContent: '' }),
setCurrentTool: (tool) => set({ currentTool: tool }),
finalizeStreamingMessage: (suggestedTask) =>
set((state) => {
const content = state.streamingContent;
if (!content && !suggestedTask) return { streamingContent: '' };
const newMessage: InsightsChatMessage = {
id: `msg-${Date.now()}`,
role: 'assistant',
content,
timestamp: new Date(),
suggestedTask
};
if (!state.session) {
return {
streamingContent: '',
session: {
id: `session-${Date.now()}`,
projectId: '',
messages: [newMessage],
createdAt: new Date(),
updatedAt: new Date()
}
};
}
return {
streamingContent: '',
session: {
...state.session,
messages: [...state.session.messages, newMessage],
updatedAt: new Date()
}
};
}),
clearSession: () =>
set({
session: null,
status: initialStatus,
pendingMessage: '',
streamingContent: '',
currentTool: null
})
}));
// Helper functions
export async function loadInsightsSession(projectId: string): Promise<void> {
const result = await window.electronAPI.getInsightsSession(projectId);
if (result.success && result.data) {
useInsightsStore.getState().setSession(result.data);
} else {
useInsightsStore.getState().setSession(null);
}
}
export function sendMessage(projectId: string, message: string): void {
const store = useInsightsStore.getState();
// Add user message to session
const userMessage: InsightsChatMessage = {
id: `msg-${Date.now()}`,
role: 'user',
content: message,
timestamp: new Date()
};
store.addMessage(userMessage);
// Clear pending and set status
store.setPendingMessage('');
store.clearStreamingContent();
store.setStatus({
phase: 'thinking',
message: 'Processing your message...'
});
// Send to main process
window.electronAPI.sendInsightsMessage(projectId, message);
}
export async function clearSession(projectId: string): Promise<void> {
const result = await window.electronAPI.clearInsightsSession(projectId);
if (result.success) {
useInsightsStore.getState().clearSession();
}
}
export async function createTaskFromSuggestion(
projectId: string,
title: string,
description: string,
metadata?: TaskMetadata
): Promise<Task | null> {
const result = await window.electronAPI.createTaskFromInsights(
projectId,
title,
description,
metadata
);
if (result.success && result.data) {
return result.data;
}
return null;
}
// IPC listener setup - call this once when the app initializes
export function setupInsightsListeners(): () => void {
const store = useInsightsStore.getState;
// Listen for streaming chunks
const unsubStreamChunk = window.electronAPI.onInsightsStreamChunk(
(_projectId, chunk: InsightsStreamChunk) => {
switch (chunk.type) {
case 'text':
if (chunk.content) {
store().appendStreamingContent(chunk.content);
store().setCurrentTool(null); // Clear tool when receiving text
store().setStatus({
phase: 'streaming',
message: 'Receiving response...'
});
}
break;
case 'tool_start':
if (chunk.tool) {
store().setCurrentTool({
name: chunk.tool.name,
input: chunk.tool.input
});
store().setStatus({
phase: 'streaming',
message: `Using ${chunk.tool.name}...`
});
}
break;
case 'tool_end':
store().setCurrentTool(null);
break;
case 'task_suggestion':
// Finalize the message with task suggestion
store().setCurrentTool(null);
store().finalizeStreamingMessage(chunk.suggestedTask);
break;
case 'done':
// Finalize any remaining content
store().setCurrentTool(null);
store().finalizeStreamingMessage();
store().setStatus({
phase: 'complete',
message: ''
});
break;
case 'error':
store().setCurrentTool(null);
store().setStatus({
phase: 'error',
error: chunk.error
});
break;
}
}
);
// Listen for status updates
const unsubStatus = window.electronAPI.onInsightsStatus((_projectId, status) => {
store().setStatus(status);
});
// Listen for errors
const unsubError = window.electronAPI.onInsightsError((_projectId, error) => {
store().setStatus({
phase: 'error',
error
});
});
// Return cleanup function
return () => {
unsubStreamChunk();
unsubStatus();
unsubError();
};
}
@@ -1,5 +1,5 @@
import { create } from 'zustand';
import type { Task, TaskStatus, ImplementationPlan, Chunk } from '../../shared/types';
import type { Task, TaskStatus, ImplementationPlan, Chunk, TaskMetadata, ExecutionProgress, ExecutionPhase } from '../../shared/types';
interface TaskState {
tasks: Task[];
@@ -13,6 +13,7 @@ interface TaskState {
updateTask: (taskId: string, updates: Partial<Task>) => void;
updateTaskStatus: (taskId: string, status: TaskStatus) => void;
updateTaskFromPlan: (taskId: string, plan: ImplementationPlan) => void;
updateExecutionProgress: (taskId: string, progress: Partial<ExecutionProgress>) => void;
appendLog: (taskId: string, log: string) => void;
selectTask: (taskId: string | null) => void;
setLoading: (loading: boolean) => void;
@@ -94,6 +95,29 @@ export const useTaskStore = create<TaskState>((set, get) => ({
})
})),
updateExecutionProgress: (taskId, progress) =>
set((state) => ({
tasks: state.tasks.map((t) => {
if (t.id !== taskId && t.specId !== taskId) return t;
// Merge with existing progress
const existingProgress = t.executionProgress || {
phase: 'idle' as ExecutionPhase,
phaseProgress: 0,
overallProgress: 0
};
return {
...t,
executionProgress: {
...existingProgress,
...progress
},
updatedAt: new Date()
};
})
})),
appendLog: (taskId, log) =>
set((state) => ({
tasks: state.tasks.map((t) =>
@@ -150,12 +174,13 @@ export async function loadTasks(projectId: string): Promise<void> {
export async function createTask(
projectId: string,
title: string,
description: string
description: string,
metadata?: TaskMetadata
): Promise<Task | null> {
const store = useTaskStore.getState();
try {
const result = await window.electronAPI.createTask(projectId, title, description);
const result = await window.electronAPI.createTask(projectId, title, description, metadata);
if (result.success && result.data) {
store.addTask(result.data);
return result.data;
@@ -204,3 +229,73 @@ export async function submitReview(
return false;
}
}
/**
* Update task status and persist to file
*/
export async function persistTaskStatus(
taskId: string,
status: TaskStatus
): Promise<boolean> {
const store = useTaskStore.getState();
try {
// Update local state first for immediate feedback
store.updateTaskStatus(taskId, status);
// Persist to file
const result = await window.electronAPI.updateTaskStatus(taskId, status);
if (!result.success) {
console.error('Failed to persist task status:', result.error);
return false;
}
return true;
} catch (error) {
console.error('Error persisting task status:', error);
return false;
}
}
/**
* Check if a task has an active running process
*/
export async function checkTaskRunning(taskId: string): Promise<boolean> {
try {
const result = await window.electronAPI.checkTaskRunning(taskId);
return result.success && result.data === true;
} catch (error) {
console.error('Error checking task running status:', error);
return false;
}
}
/**
* Recover a stuck task (status shows in_progress but no process running)
*/
export async function recoverStuckTask(
taskId: string,
targetStatus: TaskStatus = 'backlog'
): Promise<{ success: boolean; message: string }> {
const store = useTaskStore.getState();
try {
const result = await window.electronAPI.recoverStuckTask(taskId, targetStatus);
if (result.success && result.data) {
// Update local state
store.updateTaskStatus(taskId, result.data.newStatus);
return { success: true, message: result.data.message };
}
return {
success: false,
message: result.error || 'Failed to recover task'
};
} catch (error) {
console.error('Error recovering stuck task:', error);
return {
success: false,
message: error instanceof Error ? error.message : 'Unknown error'
};
}
}
+142 -1
View File
@@ -37,6 +37,50 @@ export const CHUNK_STATUS_COLORS: Record<string, string> = {
failed: 'bg-destructive'
};
// Execution phase labels
export const EXECUTION_PHASE_LABELS: Record<string, string> = {
idle: 'Idle',
planning: 'Planning',
coding: 'Coding',
qa_review: 'AI Review',
qa_fixing: 'Fixing Issues',
complete: 'Complete',
failed: 'Failed'
};
// Execution phase colors (for progress bars and indicators)
export const EXECUTION_PHASE_COLORS: Record<string, string> = {
idle: 'bg-muted text-muted-foreground',
planning: 'bg-amber-500 text-amber-50',
coding: 'bg-info text-info-foreground',
qa_review: 'bg-purple-500 text-purple-50',
qa_fixing: 'bg-warning text-warning-foreground',
complete: 'bg-success text-success-foreground',
failed: 'bg-destructive text-destructive-foreground'
};
// Execution phase badge colors (outline style)
export const EXECUTION_PHASE_BADGE_COLORS: Record<string, string> = {
idle: 'bg-muted/50 text-muted-foreground border-muted',
planning: 'bg-amber-500/10 text-amber-500 border-amber-500/30',
coding: 'bg-info/10 text-info border-info/30',
qa_review: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
qa_fixing: 'bg-warning/10 text-warning border-warning/30',
complete: 'bg-success/10 text-success border-success/30',
failed: 'bg-destructive/10 text-destructive border-destructive/30'
};
// Execution phase progress weights (for overall progress calculation)
export const EXECUTION_PHASE_WEIGHTS: Record<string, { start: number; end: number }> = {
idle: { start: 0, end: 0 },
planning: { start: 0, end: 20 },
coding: { start: 20, end: 80 },
qa_review: { start: 80, end: 95 },
qa_fixing: { start: 80, end: 95 }, // Same range as qa_review, cycles back
complete: { start: 100, end: 100 },
failed: { start: 0, end: 0 }
};
// Default app settings
export const DEFAULT_APP_SETTINGS = {
theme: 'system' as const,
@@ -88,12 +132,16 @@ export const IPC_CHANNELS = {
TASK_START: 'task:start',
TASK_STOP: 'task:stop',
TASK_REVIEW: 'task:review',
TASK_UPDATE_STATUS: 'task:updateStatus',
TASK_RECOVER_STUCK: 'task:recoverStuck',
TASK_CHECK_RUNNING: 'task:checkRunning',
// Task events (main -> renderer)
TASK_PROGRESS: 'task:progress',
TASK_ERROR: 'task:error',
TASK_LOG: 'task:log',
TASK_STATUS_CHANGE: 'task:statusChange',
TASK_EXECUTION_PROGRESS: 'task:executionProgress',
// Terminal operations
TERMINAL_CREATE: 'terminal:create',
@@ -155,6 +203,8 @@ export const IPC_CHANNELS = {
IDEATION_LOG: 'ideation:log',
IDEATION_COMPLETE: 'ideation:complete',
IDEATION_ERROR: 'ideation:error',
IDEATION_TYPE_COMPLETE: 'ideation:typeComplete',
IDEATION_TYPE_FAILED: 'ideation:typeFailed',
// Linear integration
LINEAR_GET_TEAMS: 'linear:getTeams',
@@ -197,7 +247,18 @@ export const IPC_CHANNELS = {
// Changelog events (main -> renderer)
CHANGELOG_GENERATION_PROGRESS: 'changelog:generationProgress',
CHANGELOG_GENERATION_COMPLETE: 'changelog:generationComplete',
CHANGELOG_GENERATION_ERROR: 'changelog:generationError'
CHANGELOG_GENERATION_ERROR: 'changelog:generationError',
// Insights operations
INSIGHTS_GET_SESSION: 'insights:getSession',
INSIGHTS_SEND_MESSAGE: 'insights:sendMessage',
INSIGHTS_CLEAR_SESSION: 'insights:clearSession',
INSIGHTS_CREATE_TASK: 'insights:createTask',
// Insights events (main -> renderer)
INSIGHTS_STREAM_CHUNK: 'insights:streamChunk',
INSIGHTS_STATUS: 'insights:status',
INSIGHTS_ERROR: 'insights:error'
} as const;
// File paths relative to project
@@ -414,6 +475,86 @@ export const DEFAULT_IDEATION_CONFIG = {
maxIdeasPerType: 5
};
// ============================================
// Task Metadata Constants
// ============================================
// Task category labels
export const TASK_CATEGORY_LABELS: Record<string, string> = {
feature: 'Feature',
bug_fix: 'Bug Fix',
refactoring: 'Refactoring',
documentation: 'Docs',
security: 'Security',
performance: 'Performance',
ui_ux: 'UI/UX',
infrastructure: 'Infrastructure',
testing: 'Testing'
};
// Task category colors
export const TASK_CATEGORY_COLORS: Record<string, string> = {
feature: 'bg-primary/10 text-primary border-primary/30',
bug_fix: 'bg-destructive/10 text-destructive border-destructive/30',
refactoring: 'bg-cyan-500/10 text-cyan-400 border-cyan-500/30',
documentation: 'bg-amber-500/10 text-amber-500 border-amber-500/30',
security: 'bg-red-500/10 text-red-400 border-red-500/30',
performance: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
ui_ux: 'bg-info/10 text-info border-info/30',
infrastructure: 'bg-slate-500/10 text-slate-400 border-slate-500/30',
testing: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30'
};
// Task complexity colors
export const TASK_COMPLEXITY_COLORS: Record<string, string> = {
trivial: 'bg-success/10 text-success',
small: 'bg-info/10 text-info',
medium: 'bg-warning/10 text-warning',
large: 'bg-orange-500/10 text-orange-400',
complex: 'bg-destructive/10 text-destructive'
};
// Task complexity labels
export const TASK_COMPLEXITY_LABELS: Record<string, string> = {
trivial: 'Trivial',
small: 'Small',
medium: 'Medium',
large: 'Large',
complex: 'Complex'
};
// Task impact colors
export const TASK_IMPACT_COLORS: Record<string, string> = {
low: 'bg-muted text-muted-foreground',
medium: 'bg-info/10 text-info',
high: 'bg-warning/10 text-warning',
critical: 'bg-destructive/10 text-destructive'
};
// Task impact labels
export const TASK_IMPACT_LABELS: Record<string, string> = {
low: 'Low Impact',
medium: 'Medium Impact',
high: 'High Impact',
critical: 'Critical Impact'
};
// Task priority colors
export const TASK_PRIORITY_COLORS: Record<string, string> = {
low: 'bg-muted text-muted-foreground',
medium: 'bg-info/10 text-info',
high: 'bg-warning/10 text-warning',
urgent: 'bg-destructive/10 text-destructive'
};
// Task priority labels
export const TASK_PRIORITY_LABELS: Record<string, string> = {
low: 'Low',
medium: 'Medium',
high: 'High',
urgent: 'Urgent'
};
// ============================================
// GitHub Constants
// ============================================
+166 -2
View File
@@ -35,6 +35,18 @@ export type TaskStatus = 'backlog' | 'in_progress' | 'ai_review' | 'human_review
export type ChunkStatus = 'pending' | 'in_progress' | 'completed' | 'failed';
// Execution phases for visual progress tracking
export type ExecutionPhase = 'idle' | 'planning' | 'coding' | 'qa_review' | 'qa_fixing' | 'complete' | 'failed';
export interface ExecutionProgress {
phase: ExecutionPhase;
phaseProgress: number; // 0-100 within current phase
overallProgress: number; // 0-100 overall
currentChunk?: string; // Current chunk being processed
message?: string; // Current status message
startedAt?: Date;
}
export interface Chunk {
id: string;
title: string;
@@ -62,6 +74,53 @@ export interface QAIssue {
line?: number;
}
// Task metadata from ideation or manual entry
export type TaskComplexity = 'trivial' | 'small' | 'medium' | 'large' | 'complex';
export type TaskImpact = 'low' | 'medium' | 'high' | 'critical';
export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
export type TaskCategory =
| 'feature'
| 'bug_fix'
| 'refactoring'
| 'documentation'
| 'security'
| 'performance'
| 'ui_ux'
| 'infrastructure'
| 'testing';
export interface TaskMetadata {
// Origin tracking
sourceType?: 'ideation' | 'manual' | 'imported' | 'insights';
ideationType?: string; // e.g., 'high_value_features', 'security_hardening'
ideaId?: string; // Reference to original idea if converted
// Classification
category?: TaskCategory;
complexity?: TaskComplexity;
impact?: TaskImpact;
priority?: TaskPriority;
// Context
rationale?: string; // Why this task matters
problemSolved?: string; // What problem this addresses
targetAudience?: string; // Who benefits
// Technical details
affectedFiles?: string[]; // Files likely to be modified
dependencies?: string[]; // Other features/tasks this depends on
acceptanceCriteria?: string[]; // What defines "done"
// Effort estimation
estimatedEffort?: TaskComplexity;
// Type-specific metadata (from different idea types)
securitySeverity?: 'low' | 'medium' | 'high' | 'critical';
performanceCategory?: string;
uiuxCategory?: string;
codeQualitySeverity?: 'suggestion' | 'minor' | 'major' | 'critical';
}
export interface Task {
id: string;
specId: string;
@@ -72,6 +131,8 @@ export interface Task {
chunks: Chunk[];
qaReport?: QAReport;
logs: string[];
metadata?: TaskMetadata; // Rich metadata from ideation or manual entry
executionProgress?: ExecutionProgress; // Real-time execution progress
createdAt: Date;
updatedAt: Date;
}
@@ -86,6 +147,11 @@ export interface ImplementationPlan {
created_at: string;
updated_at: string;
spec_file: string;
// Added for UI status persistence
status?: TaskStatus;
planStatus?: string;
recoveryNote?: string;
description?: string;
}
export interface Phase {
@@ -120,6 +186,23 @@ export interface TaskStartOptions {
model?: string;
}
// Stuck task recovery types
export interface StuckTaskInfo {
taskId: string;
specId: string;
title: string;
status: TaskStatus;
isActuallyRunning: boolean;
lastUpdated: Date;
}
export interface TaskRecoveryResult {
taskId: string;
recovered: boolean;
newStatus: TaskStatus;
message: string;
}
export interface TaskProgressUpdate {
taskId: string;
plan: ImplementationPlan;
@@ -393,6 +476,7 @@ export interface IdeationConfig {
includeRoadmapContext: boolean;
includeKanbanContext: boolean;
maxIdeasPerType: number;
append?: boolean; // If true, append to existing ideas instead of replacing
}
export interface IdeaBase {
@@ -801,6 +885,54 @@ export interface ExistingChangelog {
error?: string;
}
// ============================================
// Insights Chat Types
// ============================================
export type InsightsChatRole = 'user' | 'assistant';
export interface InsightsChatMessage {
id: string;
role: InsightsChatRole;
content: string;
timestamp: Date;
// For assistant messages that suggest task creation
suggestedTask?: {
title: string;
description: string;
metadata?: TaskMetadata;
};
}
export interface InsightsSession {
id: string;
projectId: string;
messages: InsightsChatMessage[];
createdAt: Date;
updatedAt: Date;
}
export interface InsightsChatStatus {
phase: 'idle' | 'thinking' | 'streaming' | 'complete' | 'error';
message?: string;
error?: string;
}
export interface InsightsStreamChunk {
type: 'text' | 'task_suggestion' | 'tool_start' | 'tool_end' | 'done' | 'error';
content?: string;
suggestedTask?: {
title: string;
description: string;
metadata?: TaskMetadata;
};
tool?: {
name: string;
input?: string; // Brief description of what's being searched/read
};
error?: string;
}
// ============================================
// Auto Claude Source Update Types
// ============================================
@@ -838,16 +970,20 @@ export interface ElectronAPI {
// Task operations
getTasks: (projectId: string) => Promise<IPCResult<Task[]>>;
createTask: (projectId: string, title: string, description: string) => Promise<IPCResult<Task>>;
createTask: (projectId: string, title: string, description: string, metadata?: TaskMetadata) => Promise<IPCResult<Task>>;
startTask: (taskId: string, options?: TaskStartOptions) => void;
stopTask: (taskId: string) => void;
submitReview: (taskId: string, approved: boolean, feedback?: string) => Promise<IPCResult>;
updateTaskStatus: (taskId: string, status: TaskStatus) => Promise<IPCResult>;
recoverStuckTask: (taskId: string, targetStatus?: TaskStatus) => Promise<IPCResult<TaskRecoveryResult>>;
checkTaskRunning: (taskId: string) => Promise<IPCResult<boolean>>;
// Event listeners
onTaskProgress: (callback: (taskId: string, plan: ImplementationPlan) => void) => () => void;
onTaskError: (callback: (taskId: string, error: string) => void) => () => void;
onTaskLog: (callback: (taskId: string, log: string) => void) => () => void;
onTaskStatusChange: (callback: (taskId: string, status: TaskStatus) => void) => () => void;
onTaskExecutionProgress: (callback: (taskId: string, progress: ExecutionProgress) => void) => () => void;
// Terminal operations
createTerminal: (options: TerminalCreateOptions) => Promise<IPCResult>;
@@ -956,6 +1092,12 @@ export interface ElectronAPI {
onIdeationError: (
callback: (projectId: string, error: string) => void
) => () => void;
onIdeationTypeComplete: (
callback: (projectId: string, ideationType: string, ideas: Idea[]) => void
) => () => void;
onIdeationTypeFailed: (
callback: (projectId: string, ideationType: string) => void
) => () => void;
// Auto Claude source update operations
checkAutoBuildSourceUpdate: () => Promise<IPCResult<AutoBuildSourceUpdateCheck>>;
@@ -973,7 +1115,7 @@ export interface ElectronAPI {
checkSourceToken: () => Promise<IPCResult<SourceEnvCheckResult>>;
// Changelog operations
getChangelogDoneTasks: (projectId: string) => Promise<IPCResult<ChangelogTask[]>>;
getChangelogDoneTasks: (projectId: string, tasks?: Task[]) => Promise<IPCResult<ChangelogTask[]>>;
loadTaskSpecs: (projectId: string, taskIds: string[]) => Promise<IPCResult<TaskSpecContent[]>>;
generateChangelog: (request: ChangelogGenerationRequest) => void; // Async with progress events
saveChangelog: (request: ChangelogSaveRequest) => Promise<IPCResult<ChangelogSaveResult>>;
@@ -989,6 +1131,28 @@ export interface ElectronAPI {
onChangelogGenerationError: (
callback: (projectId: string, error: string) => void
) => () => void;
// Insights operations
getInsightsSession: (projectId: string) => Promise<IPCResult<InsightsSession | null>>;
sendInsightsMessage: (projectId: string, message: string) => void;
clearInsightsSession: (projectId: string) => Promise<IPCResult>;
createTaskFromInsights: (
projectId: string,
title: string,
description: string,
metadata?: TaskMetadata
) => Promise<IPCResult<Task>>;
// Insights event listeners
onInsightsStreamChunk: (
callback: (projectId: string, chunk: InsightsStreamChunk) => void
) => () => void;
onInsightsStatus: (
callback: (projectId: string, status: InsightsChatStatus) => void
) => () => void;
onInsightsError: (
callback: (projectId: string, error: string) => void
) => () => void;
}
declare global {
+15
View File
@@ -9,6 +9,21 @@ CLAUDE_CODE_OAUTH_TOKEN=your-oauth-token-here
# Default: claude-opus-4-5-20251101
# AUTO_BUILD_MODEL=claude-opus-4-5-20251101
# =============================================================================
# DEBUG MODE (OPTIONAL)
# =============================================================================
# Enable debug logging for development and troubleshooting.
# Shows detailed information about runner execution, agent calls, file operations.
# Enable debug mode (default: false)
# DEBUG=true
# Debug log level: 1=basic, 2=detailed, 3=verbose (default: 1)
# DEBUG_LEVEL=1
# Log to file instead of stdout (OPTIONAL)
# DEBUG_LOG_FILE=auto-claude/debug.log
# =============================================================================
# LINEAR INTEGRATION (OPTIONAL)
# =============================================================================
+307
View File
@@ -0,0 +1,307 @@
#!/usr/bin/env python3
"""
Debug Logging Utility
=====================
Centralized debug logging for the Auto-Claude framework.
Controlled via environment variables:
- DEBUG=true Enable debug mode
- DEBUG_LEVEL=1|2|3 Log verbosity (1=basic, 2=detailed, 3=verbose)
- DEBUG_LOG_FILE=path Optional file output
Usage:
from debug import debug, debug_detailed, debug_verbose, is_debug_enabled
debug("run.py", "Starting task execution", task_id="001")
debug_detailed("agent", "Agent response received", response_length=1234)
debug_verbose("client", "Full request payload", payload=data)
"""
import os
import sys
import json
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
from functools import wraps
import time
# ANSI color codes for terminal output
class Colors:
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
# Debug colors
DEBUG = "\033[36m" # Cyan
DEBUG_DIM = "\033[96m" # Light cyan
TIMESTAMP = "\033[90m" # Gray
MODULE = "\033[33m" # Yellow
KEY = "\033[35m" # Magenta
VALUE = "\033[37m" # White
SUCCESS = "\033[32m" # Green
WARNING = "\033[33m" # Yellow
ERROR = "\033[31m" # Red
def _get_debug_enabled() -> bool:
"""Check if debug mode is enabled via environment variable."""
return os.environ.get("DEBUG", "").lower() in ("true", "1", "yes", "on")
def _get_debug_level() -> int:
"""Get debug verbosity level (1-3)."""
try:
level = int(os.environ.get("DEBUG_LEVEL", "1"))
return max(1, min(3, level)) # Clamp to 1-3
except ValueError:
return 1
def _get_log_file() -> Optional[Path]:
"""Get optional log file path."""
log_file = os.environ.get("DEBUG_LOG_FILE")
if log_file:
return Path(log_file)
return None
def is_debug_enabled() -> bool:
"""Check if debug mode is enabled."""
return _get_debug_enabled()
def get_debug_level() -> int:
"""Get current debug level."""
return _get_debug_level()
def _format_value(value: Any, max_length: int = 200) -> str:
"""Format a value for debug output, truncating if necessary."""
if value is None:
return "None"
if isinstance(value, (dict, list)):
try:
formatted = json.dumps(value, indent=2, default=str)
if len(formatted) > max_length:
formatted = formatted[:max_length] + "..."
return formatted
except (TypeError, ValueError):
return str(value)[:max_length]
str_value = str(value)
if len(str_value) > max_length:
return str_value[:max_length] + "..."
return str_value
def _write_log(message: str, to_file: bool = True) -> None:
"""Write log message to stdout and optionally to file."""
print(message, file=sys.stderr)
if to_file:
log_file = _get_log_file()
if log_file:
try:
log_file.parent.mkdir(parents=True, exist_ok=True)
# Strip ANSI codes for file output
import re
clean_message = re.sub(r'\033\[[0-9;]*m', '', message)
with open(log_file, "a") as f:
f.write(clean_message + "\n")
except Exception:
pass # Silently fail file logging
def debug(module: str, message: str, level: int = 1, **kwargs) -> None:
"""
Log a debug message.
Args:
module: Source module name (e.g., "run.py", "ideation_runner")
message: Debug message
level: Required debug level (1=basic, 2=detailed, 3=verbose)
**kwargs: Additional key-value pairs to log
"""
if not _get_debug_enabled():
return
if _get_debug_level() < level:
return
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
# Build the log line
parts = [
f"{Colors.TIMESTAMP}[{timestamp}]{Colors.RESET}",
f"{Colors.DEBUG}[DEBUG]{Colors.RESET}",
f"{Colors.MODULE}[{module}]{Colors.RESET}",
f"{Colors.DEBUG_DIM}{message}{Colors.RESET}",
]
log_line = " ".join(parts)
# Add kwargs on separate lines if present
if kwargs:
for key, value in kwargs.items():
formatted_value = _format_value(value)
if "\n" in formatted_value:
# Multi-line value
log_line += f"\n {Colors.KEY}{key}{Colors.RESET}:"
for line in formatted_value.split("\n"):
log_line += f"\n {Colors.VALUE}{line}{Colors.RESET}"
else:
log_line += f"\n {Colors.KEY}{key}{Colors.RESET}: {Colors.VALUE}{formatted_value}{Colors.RESET}"
_write_log(log_line)
def debug_detailed(module: str, message: str, **kwargs) -> None:
"""Log a detailed debug message (level 2)."""
debug(module, message, level=2, **kwargs)
def debug_verbose(module: str, message: str, **kwargs) -> None:
"""Log a verbose debug message (level 3)."""
debug(module, message, level=3, **kwargs)
def debug_success(module: str, message: str, **kwargs) -> None:
"""Log a success debug message."""
if not _get_debug_enabled():
return
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
log_line = f"{Colors.TIMESTAMP}[{timestamp}]{Colors.RESET} {Colors.SUCCESS}[OK]{Colors.RESET} {Colors.MODULE}[{module}]{Colors.RESET} {message}"
if kwargs:
for key, value in kwargs.items():
log_line += f"\n {Colors.KEY}{key}{Colors.RESET}: {Colors.VALUE}{_format_value(value)}{Colors.RESET}"
_write_log(log_line)
def debug_error(module: str, message: str, **kwargs) -> None:
"""Log an error debug message (always shown if debug enabled)."""
if not _get_debug_enabled():
return
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
log_line = f"{Colors.TIMESTAMP}[{timestamp}]{Colors.RESET} {Colors.ERROR}[ERROR]{Colors.RESET} {Colors.MODULE}[{module}]{Colors.RESET} {Colors.ERROR}{message}{Colors.RESET}"
if kwargs:
for key, value in kwargs.items():
log_line += f"\n {Colors.KEY}{key}{Colors.RESET}: {Colors.VALUE}{_format_value(value)}{Colors.RESET}"
_write_log(log_line)
def debug_warning(module: str, message: str, **kwargs) -> None:
"""Log a warning debug message."""
if not _get_debug_enabled():
return
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
log_line = f"{Colors.TIMESTAMP}[{timestamp}]{Colors.RESET} {Colors.WARNING}[WARN]{Colors.RESET} {Colors.MODULE}[{module}]{Colors.RESET} {Colors.WARNING}{message}{Colors.RESET}"
if kwargs:
for key, value in kwargs.items():
log_line += f"\n {Colors.KEY}{key}{Colors.RESET}: {Colors.VALUE}{_format_value(value)}{Colors.RESET}"
_write_log(log_line)
def debug_section(module: str, title: str) -> None:
"""Log a section header for organizing debug output."""
if not _get_debug_enabled():
return
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
separator = "─" * 60
log_line = f"\n{Colors.TIMESTAMP}[{timestamp}]{Colors.RESET} {Colors.DEBUG}{Colors.BOLD}┌{separator}┐{Colors.RESET}"
log_line += f"\n{Colors.TIMESTAMP} {Colors.RESET} {Colors.DEBUG}{Colors.BOLD}│ {module}: {title}{' ' * (58 - len(module) - len(title) - 2)}│{Colors.RESET}"
log_line += f"\n{Colors.TIMESTAMP} {Colors.RESET} {Colors.DEBUG}{Colors.BOLD}└{separator}┘{Colors.RESET}"
_write_log(log_line)
def debug_timer(module: str):
"""
Decorator to time function execution.
Usage:
@debug_timer("run.py")
def my_function():
...
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
if not _get_debug_enabled():
return func(*args, **kwargs)
start = time.time()
debug_detailed(module, f"Starting {func.__name__}()")
try:
result = func(*args, **kwargs)
elapsed = time.time() - start
debug_success(module, f"Completed {func.__name__}()", elapsed_ms=f"{elapsed*1000:.1f}ms")
return result
except Exception as e:
elapsed = time.time() - start
debug_error(module, f"Failed {func.__name__}()", error=str(e), elapsed_ms=f"{elapsed*1000:.1f}ms")
raise
return wrapper
return decorator
def debug_async_timer(module: str):
"""
Decorator to time async function execution.
Usage:
@debug_async_timer("ideation_runner")
async def my_async_function():
...
"""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
if not _get_debug_enabled():
return await func(*args, **kwargs)
start = time.time()
debug_detailed(module, f"Starting {func.__name__}()")
try:
result = await func(*args, **kwargs)
elapsed = time.time() - start
debug_success(module, f"Completed {func.__name__}()", elapsed_ms=f"{elapsed*1000:.1f}ms")
return result
except Exception as e:
elapsed = time.time() - start
debug_error(module, f"Failed {func.__name__}()", error=str(e), elapsed_ms=f"{elapsed*1000:.1f}ms")
raise
return wrapper
return decorator
def debug_env_status() -> None:
"""Print debug environment status on startup."""
if not _get_debug_enabled():
return
debug_section("debug", "Debug Mode Enabled")
debug("debug", "Environment configuration",
DEBUG=os.environ.get("DEBUG", "not set"),
DEBUG_LEVEL=_get_debug_level(),
DEBUG_LOG_FILE=os.environ.get("DEBUG_LOG_FILE", "not set"))
# Print status on import if debug is enabled
if _get_debug_enabled():
debug_env_status()
+336 -63
View File
@@ -50,6 +50,15 @@ from ui import (
print_key_value,
print_section,
)
from debug import (
debug,
debug_detailed,
debug_verbose,
debug_success,
debug_error,
debug_warning,
debug_section,
)
# Configuration
@@ -111,6 +120,7 @@ class IdeationConfig:
max_ideas_per_type: int = 5
model: str = "claude-sonnet-4-20250514"
refresh: bool = False
append: bool = False # If True, preserve existing ideas when merging
class IdeationOrchestrator:
@@ -126,10 +136,12 @@ class IdeationOrchestrator:
max_ideas_per_type: int = 5,
model: str = "claude-sonnet-4-20250514",
refresh: bool = False,
append: bool = False,
):
self.project_dir = Path(project_dir)
self.model = model
self.refresh = refresh
self.append = append # Preserve existing ideas when merging
self.enabled_types = enabled_types or IDEATION_TYPES.copy()
self.include_roadmap_context = include_roadmap_context
self.include_kanban_context = include_kanban_context
@@ -389,29 +401,37 @@ class IdeationOrchestrator:
output_file = self.output_dir / f"{ideation_type}_ideas.json"
if output_file.exists() and not self.refresh:
# Load and count existing ideas
# Load and validate existing ideas - only skip if we have valid ideas
try:
with open(output_file) as f:
data = json.load(f)
count = len(data.get(ideation_type, []))
print_status(f"{ideation_type}_ideas.json already exists ({count} ideas)", "success")
return IdeationPhaseResult(
phase="ideation",
ideation_type=ideation_type,
success=True,
output_files=[str(output_file)],
ideas_count=count,
errors=[],
retries=0,
)
if count >= 1:
# Valid ideas exist, skip regeneration
print_status(f"{ideation_type}_ideas.json already exists ({count} ideas)", "success")
return IdeationPhaseResult(
phase="ideation",
ideation_type=ideation_type,
success=True,
output_files=[str(output_file)],
ideas_count=count,
errors=[],
retries=0,
)
else:
# File exists but has no valid ideas - needs regeneration
print_status(f"{ideation_type}_ideas.json exists but has 0 ideas, regenerating...", "warning")
except (json.JSONDecodeError, KeyError):
pass
# Invalid file - will regenerate
print_status(f"{ideation_type}_ideas.json exists but is invalid, regenerating...", "warning")
errors = []
for attempt in range(MAX_RETRIES):
print_status(f"Running {IDEATION_TYPE_LABELS[ideation_type]} agent (attempt {attempt + 1})...", "progress")
context = f"""
# First attempt: run the full ideation agent
print_status(f"Running {IDEATION_TYPE_LABELS[ideation_type]} agent...", "progress")
context = f"""
**Ideation Context**: {self.output_dir / "ideation_context.json"}
**Project Index**: {self.output_dir / "project_index.json"}
**Output File**: {output_file}
@@ -421,36 +441,58 @@ Generate up to {self.max_ideas_per_type} {IDEATION_TYPE_LABELS[ideation_type]} i
Avoid duplicating features that are already planned (see ideation_context.json).
Output your ideas to {output_file.name}.
"""
success, output = await self._run_agent(
prompt_file,
additional_context=context,
success, output = await self._run_agent(
prompt_file,
additional_context=context,
)
# Validate the output
validation_result = self._validate_ideation_output(output_file, ideation_type)
if validation_result["success"]:
print_status(f"Created {output_file.name} ({validation_result['count']} ideas)", "success")
return IdeationPhaseResult(
phase="ideation",
ideation_type=ideation_type,
success=True,
output_files=[str(output_file)],
ideas_count=validation_result["count"],
errors=[],
retries=0,
)
if success and output_file.exists():
# Validate
try:
with open(output_file) as f:
data = json.load(f)
errors.append(validation_result["error"])
ideas = data.get(ideation_type, [])
# Recovery attempts: show the current state and ask AI to fix it
for recovery_attempt in range(MAX_RETRIES - 1):
print_status(f"Running recovery agent (attempt {recovery_attempt + 1})...", "warning")
if len(ideas) >= 1:
print_status(f"Created {output_file.name} ({len(ideas)} ideas)", "success")
return IdeationPhaseResult(
phase="ideation",
ideation_type=ideation_type,
success=True,
output_files=[str(output_file)],
ideas_count=len(ideas),
errors=[],
retries=attempt,
)
else:
errors.append(f"No {ideation_type} ideas generated")
except json.JSONDecodeError as e:
errors.append(f"Invalid JSON: {e}")
recovery_success = await self._run_recovery_agent(
output_file,
ideation_type,
validation_result["error"],
validation_result.get("current_content", "")
)
if recovery_success:
# Re-validate after recovery
validation_result = self._validate_ideation_output(output_file, ideation_type)
if validation_result["success"]:
print_status(f"Recovery successful: {output_file.name} ({validation_result['count']} ideas)", "success")
return IdeationPhaseResult(
phase="ideation",
ideation_type=ideation_type,
success=True,
output_files=[str(output_file)],
ideas_count=validation_result["count"],
errors=[],
retries=recovery_attempt + 1,
)
else:
errors.append(f"Recovery {recovery_attempt + 1}: {validation_result['error']}")
else:
errors.append(f"Attempt {attempt + 1}: Agent did not create output file")
errors.append(f"Recovery {recovery_attempt + 1}: Agent failed to run")
return IdeationPhaseResult(
phase="ideation",
@@ -462,12 +504,172 @@ Output your ideas to {output_file.name}.
retries=MAX_RETRIES,
)
def _validate_ideation_output(self, output_file: Path, ideation_type: str) -> dict:
"""Validate ideation output file and return validation result."""
debug_detailed("ideation_runner", f"Validating output for {ideation_type}",
output_file=str(output_file))
if not output_file.exists():
debug_warning("ideation_runner", "Output file does not exist",
output_file=str(output_file))
return {
"success": False,
"error": "Output file does not exist",
"current_content": "",
"count": 0,
}
try:
content = output_file.read_text()
data = json.loads(content)
debug_verbose("ideation_runner", "Parsed JSON successfully",
keys=list(data.keys()))
# Check for correct key
ideas = data.get(ideation_type, [])
# Also check for common incorrect key "ideas"
if not ideas and "ideas" in data:
debug_warning("ideation_runner", "Wrong JSON key detected",
expected=ideation_type, found="ideas")
return {
"success": False,
"error": f"Wrong JSON key: found 'ideas' but expected '{ideation_type}'",
"current_content": content,
"count": 0,
}
if len(ideas) >= 1:
debug_success("ideation_runner", f"Validation passed for {ideation_type}",
ideas_count=len(ideas))
return {
"success": True,
"error": None,
"current_content": content,
"count": len(ideas),
}
else:
debug_warning("ideation_runner", f"No ideas found for {ideation_type}")
return {
"success": False,
"error": f"No {ideation_type} ideas found in output",
"current_content": content,
"count": 0,
}
except json.JSONDecodeError as e:
debug_error("ideation_runner", "JSON parse error", error=str(e))
return {
"success": False,
"error": f"Invalid JSON: {e}",
"current_content": output_file.read_text() if output_file.exists() else "",
"count": 0,
}
async def _run_recovery_agent(
self,
output_file: Path,
ideation_type: str,
error: str,
current_content: str,
) -> bool:
"""Run a recovery agent to fix validation errors in the output file."""
# Truncate content if too long
max_content_length = 8000
if len(current_content) > max_content_length:
current_content = current_content[:max_content_length] + "\n... (truncated)"
recovery_prompt = f"""# Ideation Output Recovery
The ideation output file failed validation. Your task is to fix it.
## Error
{error}
## Expected Format
The output file must be valid JSON with the following structure:
```json
{{
"{ideation_type}": [
{{
"id": "...",
"type": "{ideation_type}",
"title": "...",
"description": "...",
... other fields ...
}}
]
}}
```
**CRITICAL**: The top-level key MUST be `"{ideation_type}"` (not "ideas" or anything else).
## Current File Content
File: {output_file}
```json
{current_content}
```
## Your Task
1. Read the current file content above
2. Identify what's wrong based on the error message
3. Fix the JSON structure to match the expected format
4. Write the corrected content to {output_file}
Common fixes:
- If the key is "ideas", rename it to "{ideation_type}"
- If the JSON is invalid, fix the syntax errors
- If there are no ideas, ensure the array has at least one idea object
Write the fixed JSON to the file now.
"""
client = create_client(self.project_dir, self.output_dir, self.model)
try:
async with client:
await client.query(recovery_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:
block_type = type(block).__name__
if block_type == "TextBlock" and hasattr(block, "text"):
print(block.text, end="", flush=True)
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
print(f"\n[Recovery Tool: {block.name}]", flush=True)
print()
return True
except Exception as e:
print_status(f"Recovery agent error: {e}", "error")
return False
async def phase_merge(self) -> IdeationPhaseResult:
"""Merge all ideation outputs into a single ideation.json."""
ideation_file = self.output_dir / "ideation.json"
all_ideas = []
# Load existing ideas if in append mode
existing_ideas = []
existing_session = None
if self.append and ideation_file.exists():
try:
with open(ideation_file) as f:
existing_session = json.load(f)
existing_ideas = existing_session.get("ideas", [])
print_status(f"Preserving {len(existing_ideas)} existing ideas", "info")
except json.JSONDecodeError:
pass
# Collect new ideas from the enabled types
new_ideas = []
output_files = []
for ideation_type in self.enabled_types:
@@ -477,11 +679,24 @@ Output your ideas to {output_file.name}.
with open(type_file) as f:
data = json.load(f)
ideas = data.get(ideation_type, [])
all_ideas.extend(ideas)
new_ideas.extend(ideas)
output_files.append(str(type_file))
except (json.JSONDecodeError, KeyError):
pass
# In append mode, filter out ideas from types we're regenerating
# (to avoid duplicates) and keep ideas from other types
if self.append and existing_ideas:
# Keep existing ideas that are NOT from the types we just generated
preserved_ideas = [
idea for idea in existing_ideas
if idea.get("type") not in self.enabled_types
]
all_ideas = preserved_ideas + new_ideas
print_status(f"Merged: {len(preserved_ideas)} preserved + {len(new_ideas)} new = {len(all_ideas)} total", "info")
else:
all_ideas = new_ideas
# Load context for metadata
context_file = self.output_dir / "ideation_context.json"
context_data = {}
@@ -493,8 +708,12 @@ Output your ideas to {output_file.name}.
pass
# Create merged ideation session
# Preserve session ID and generated_at if appending
session_id = existing_session.get("id") if existing_session else f"ideation-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
generated_at = existing_session.get("generated_at") if existing_session else datetime.now().isoformat()
ideation_session = {
"id": f"ideation-{datetime.now().strftime('%Y%m%d-%H%M%S')}",
"id": session_id,
"project_id": str(self.project_dir),
"config": context_data.get("config", {}),
"ideas": all_ideas,
@@ -507,22 +726,26 @@ Output your ideas to {output_file.name}.
"summary": {
"total_ideas": len(all_ideas),
"by_type": {},
"by_status": {"draft": len(all_ideas)},
"by_status": {},
},
"generated_at": datetime.now().isoformat(),
"generated_at": generated_at,
"updated_at": datetime.now().isoformat(),
}
# Count by type
# Count by type and status
for idea in all_ideas:
idea_type = idea.get("type", "unknown")
idea_status = idea.get("status", "draft")
ideation_session["summary"]["by_type"][idea_type] = \
ideation_session["summary"]["by_type"].get(idea_type, 0) + 1
ideation_session["summary"]["by_status"][idea_status] = \
ideation_session["summary"]["by_status"].get(idea_status, 0) + 1
with open(ideation_file, "w") as f:
json.dump(ideation_session, f, indent=2)
print_status(f"Created ideation.json ({len(all_ideas)} total ideas)", "success")
action = "Updated" if self.append else "Created"
print_status(f"{action} ideation.json ({len(all_ideas)} total ideas)", "success")
return IdeationPhaseResult(
phase="merge",
@@ -534,9 +757,32 @@ Output your ideas to {output_file.name}.
retries=0,
)
async def _run_ideation_type_with_streaming(self, ideation_type: str) -> IdeationPhaseResult:
"""Run a single ideation type and stream results when complete."""
result = await self.phase_ideation_type(ideation_type)
if result.success:
# Signal that this type is complete - UI can now show these ideas
print(f"IDEATION_TYPE_COMPLETE:{ideation_type}:{result.ideas_count}")
sys.stdout.flush()
else:
print(f"IDEATION_TYPE_FAILED:{ideation_type}")
sys.stdout.flush()
return result
async def run(self) -> bool:
"""Run the complete ideation generation process."""
debug_section("ideation_runner", "Starting Ideation Generation")
debug("ideation_runner", "Configuration",
project_dir=str(self.project_dir),
output_dir=str(self.output_dir),
model=self.model,
enabled_types=self.enabled_types,
refresh=self.refresh,
append=self.append)
print(box(
f"Project: {self.project_dir}\n"
f"Output: {self.output_dir}\n"
@@ -549,6 +795,7 @@ Output your ideas to {output_file.name}.
results = []
# Phase 1: Project Index
debug("ideation_runner", "Starting Phase 1: Project Analysis")
print_section("PHASE 1: PROJECT ANALYSIS", Icons.FOLDER)
result = await self.phase_project_index()
results.append(result)
@@ -564,26 +811,46 @@ Output your ideas to {output_file.name}.
print_status("Context gathering failed", "error")
return False
# Phase 3+: Run each enabled ideation type
phase_num = 3
for ideation_type in self.enabled_types:
print_section(
f"PHASE {phase_num}: {IDEATION_TYPE_LABELS[ideation_type].upper()}",
Icons.CHUNK
)
result = await self.phase_ideation_type(ideation_type)
results.append(result)
# Phase 3: Run all ideation types IN PARALLEL
debug("ideation_runner", "Starting Phase 3: Generating Ideas",
types=self.enabled_types, parallel=True)
print_section("PHASE 3: GENERATING IDEAS (PARALLEL)", Icons.CHUNK)
print_status(f"Starting {len(self.enabled_types)} ideation agents in parallel...", "progress")
if not result.success:
print_status(f"{IDEATION_TYPE_LABELS[ideation_type]} ideation failed", "warning")
for err in result.errors:
print(f" {muted('Error:')} {err}")
# Continue with other types even if one fails
# Create tasks for all enabled types
ideation_tasks = [
self._run_ideation_type_with_streaming(ideation_type)
for ideation_type in self.enabled_types
]
phase_num += 1
# Run all ideation types concurrently
ideation_results = await asyncio.gather(*ideation_tasks, return_exceptions=True)
# Process results
for i, result in enumerate(ideation_results):
ideation_type = self.enabled_types[i]
if isinstance(result, Exception):
print_status(f"{IDEATION_TYPE_LABELS[ideation_type]} ideation failed with exception: {result}", "error")
results.append(IdeationPhaseResult(
phase="ideation",
ideation_type=ideation_type,
success=False,
output_files=[],
ideas_count=0,
errors=[str(result)],
retries=0,
))
else:
results.append(result)
if result.success:
print_status(f"{IDEATION_TYPE_LABELS[ideation_type]}: {result.ideas_count} ideas", "success")
else:
print_status(f"{IDEATION_TYPE_LABELS[ideation_type]} ideation failed", "warning")
for err in result.errors:
print(f" {muted('Error:')} {err}")
# Final Phase: Merge
print_section(f"PHASE {phase_num}: MERGE & FINALIZE", Icons.SUCCESS)
print_section("PHASE 4: MERGE & FINALIZE", Icons.SUCCESS)
result = await self.phase_merge()
results.append(result)
@@ -661,6 +928,11 @@ def main():
action="store_true",
help="Force regeneration even if ideation exists",
)
parser.add_argument(
"--append",
action="store_true",
help="Append new ideas to existing session instead of replacing",
)
args = parser.parse_args()
@@ -689,6 +961,7 @@ def main():
max_ideas_per_type=args.max_ideas,
model=args.model,
refresh=args.refresh,
append=args.append,
)
try:
+297
View File
@@ -0,0 +1,297 @@
#!/usr/bin/env python3
"""
Insights Runner - AI chat for codebase insights using Claude SDK
This script provides an AI-powered chat interface for asking questions
about a codebase. It can also suggest tasks based on the conversation.
"""
import asyncio
import os
import sys
import json
import argparse
from pathlib import Path
from typing import Optional
# Add auto-claude to path
sys.path.insert(0, str(Path(__file__).parent))
try:
from claude_code_sdk import ClaudeCodeOptions, ClaudeSDKClient
SDK_AVAILABLE = True
except ImportError:
SDK_AVAILABLE = False
ClaudeCodeOptions = None
ClaudeSDKClient = None
from debug import (
debug,
debug_detailed,
debug_success,
debug_error,
debug_section,
)
def load_project_context(project_dir: str) -> str:
"""Load project context for the AI."""
context_parts = []
# Load project index if available
index_path = Path(project_dir) / "auto-claude" / "project_index.json"
if index_path.exists():
try:
with open(index_path) as f:
index = json.load(f)
# Summarize the index for context
summary = {
"project_root": index.get("project_root", ""),
"project_type": index.get("project_type", "unknown"),
"services": list(index.get("services", {}).keys()),
"infrastructure": index.get("infrastructure", {}),
}
context_parts.append(f"## Project Structure\n```json\n{json.dumps(summary, indent=2)}\n```")
except Exception as e:
pass
# Load roadmap if available
roadmap_path = Path(project_dir) / "auto-claude" / "roadmap" / "roadmap.json"
if roadmap_path.exists():
try:
with open(roadmap_path) as f:
roadmap = json.load(f)
# Summarize roadmap
features = roadmap.get("features", [])
feature_summary = [{"title": f.get("title", ""), "status": f.get("status", "")} for f in features[:10]]
context_parts.append(f"## Roadmap Features\n```json\n{json.dumps(feature_summary, indent=2)}\n```")
except Exception as e:
pass
# Load existing tasks
tasks_path = Path(project_dir) / "auto-claude" / "specs"
if tasks_path.exists():
try:
task_dirs = [d for d in tasks_path.iterdir() if d.is_dir()]
task_names = [d.name for d in task_dirs[:10]]
if task_names:
context_parts.append(f"## Existing Tasks/Specs\n- " + "\n- ".join(task_names))
except Exception as e:
pass
return "\n\n".join(context_parts) if context_parts else "No project context available yet."
def build_system_prompt(project_dir: str) -> str:
"""Build the system prompt for the insights agent."""
context = load_project_context(project_dir)
return f"""You are an AI assistant helping developers understand and work with their codebase.
You have access to the following project context:
{context}
Your capabilities:
1. Answer questions about the codebase structure, patterns, and architecture
2. Suggest improvements, features, or bug fixes based on the code
3. Help plan implementation of new features
4. Provide code examples and explanations
When the user asks you to create a task, wants to turn the conversation into a task, or when you believe creating a task would be helpful, output a task suggestion in this exact format on a SINGLE LINE:
__TASK_SUGGESTION__:{{"title": "Task title here", "description": "Detailed description of what the task involves", "metadata": {{"category": "feature", "complexity": "medium", "impact": "medium"}}}}
Valid categories: feature, bug_fix, refactoring, documentation, security, performance, ui_ux, infrastructure, testing
Valid complexity: trivial, small, medium, large, complex
Valid impact: low, medium, high, critical
Be conversational and helpful. Focus on providing actionable insights and clear explanations.
Keep responses concise but informative."""
async def run_with_sdk(project_dir: str, message: str, history: list) -> None:
"""Run the chat using Claude SDK with streaming."""
if not SDK_AVAILABLE:
print("Claude SDK not available, falling back to simple mode", file=sys.stderr)
run_simple(project_dir, message, history)
return
oauth_token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN")
if not oauth_token:
print("CLAUDE_CODE_OAUTH_TOKEN not set, falling back to simple mode", file=sys.stderr)
run_simple(project_dir, message, history)
return
system_prompt = build_system_prompt(project_dir)
project_path = Path(project_dir).resolve()
# Build conversation context from history
conversation_context = ""
for msg in history[:-1]: # Exclude the latest message
role = "User" if msg.get("role") == "user" else "Assistant"
conversation_context += f"\n{role}: {msg['content']}\n"
# Build the full prompt with conversation history
full_prompt = message
if conversation_context.strip():
full_prompt = f"""Previous conversation:
{conversation_context}
Current question: {message}"""
try:
# Create Claude SDK client with appropriate settings for insights
client = ClaudeSDKClient(
options=ClaudeCodeOptions(
model="claude-sonnet-4-20250514",
system_prompt=system_prompt,
allowed_tools=[
"Read",
"Glob",
"Grep",
],
max_turns=10, # Limit turns for insights chat
cwd=str(project_path),
)
)
# Use async context manager pattern
async with client:
# Send the query
await client.query(full_prompt)
# Stream the response
response_text = ""
current_tool = None
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:
block_type = type(block).__name__
if block_type == "TextBlock" and hasattr(block, "text"):
text = block.text
print(text, end="", flush=True)
response_text += text
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
# Emit tool start marker for UI feedback
tool_name = block.name
tool_input = ""
# Extract a brief description of what the tool is doing
if hasattr(block, "input") and block.input:
inp = block.input
if isinstance(inp, dict):
if "pattern" in inp:
tool_input = f"pattern: {inp['pattern']}"
elif "file_path" in inp:
# Shorten path for display
fp = inp["file_path"]
if len(fp) > 50:
fp = "..." + fp[-47:]
tool_input = fp
elif "path" in inp:
tool_input = inp["path"]
current_tool = tool_name
print(f"__TOOL_START__:{json.dumps({'name': tool_name, 'input': tool_input})}", flush=True)
elif msg_type == "ToolResult":
# Tool finished executing
if current_tool:
print(f"__TOOL_END__:{json.dumps({'name': current_tool})}", flush=True)
current_tool = None
# Ensure we have a newline at the end
if response_text and not response_text.endswith('\n'):
print()
except Exception as e:
print(f"Error using Claude SDK: {e}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
run_simple(project_dir, message, history)
def run_simple(project_dir: str, message: str, history: list) -> None:
"""Simple fallback mode without SDK - uses subprocess to call claude CLI."""
import subprocess
system_prompt = build_system_prompt(project_dir)
# Build conversation context
conversation_context = ""
for msg in history[:-1]:
role = "User" if msg.get("role") == "user" else "Assistant"
conversation_context += f"\n{role}: {msg['content']}\n"
# Create the full prompt
full_prompt = f"""{system_prompt}
Previous conversation:
{conversation_context}
User: {message}
Assistant:"""
try:
# Try to use claude CLI with --print for simple output
result = subprocess.run(
['claude', '--print', '-p', full_prompt],
capture_output=True,
text=True,
cwd=project_dir,
timeout=120
)
if result.returncode == 0:
print(result.stdout)
else:
# Fallback response if claude CLI fails
print(f"I apologize, but I encountered an issue processing your request. "
f"Please ensure Claude CLI is properly configured.\n\n"
f"Your question was: {message}\n\n"
f"Based on the project context available, I can help you with:\n"
f"- Understanding the codebase structure\n"
f"- Suggesting improvements\n"
f"- Planning new features\n\n"
f"Please try again or check your Claude CLI configuration.")
except subprocess.TimeoutExpired:
print("Request timed out. Please try a shorter query.")
except FileNotFoundError:
print("Claude CLI not found. Please ensure it is installed and in your PATH.")
except Exception as e:
print(f"Error: {e}")
def main():
parser = argparse.ArgumentParser(description="Insights AI Chat Runner")
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")
args = parser.parse_args()
debug_section("insights_runner", "Starting Insights Chat")
project_dir = args.project_dir
user_message = args.message
debug("insights_runner", "Arguments",
project_dir=project_dir,
message_length=len(user_message))
try:
history = json.loads(args.history)
debug_detailed("insights_runner", "Parsed history", history_length=len(history))
except json.JSONDecodeError:
debug_error("insights_runner", "Failed to parse history JSON")
history = []
# Run the async SDK function
debug("insights_runner", "Running SDK query")
asyncio.run(run_with_sdk(project_dir, user_message, history))
debug_success("insights_runner", "Query completed")
if __name__ == "__main__":
main()
+1 -1
View File
@@ -126,7 +126,7 @@ Write your findings to `{output_dir}/code_quality_ideas.json`:
```json
{
"ideas": [
"code_quality": [
{
"id": "cq-001",
"type": "code_quality",
@@ -77,11 +77,11 @@ Identify documentation gaps across these categories:
## Output Format
Write your findings to `{output_dir}/documentation_ideas.json`:
Write your findings to `{output_dir}/documentation_gaps_ideas.json`:
```json
{
"ideas": [
"documentation_gaps": [
{
"id": "doc-001",
"type": "documentation_gaps",
+2 -2
View File
@@ -99,11 +99,11 @@ Identify performance opportunities across these categories:
## Output Format
Write your findings to `{output_dir}/performance_ideas.json`:
Write your findings to `{output_dir}/performance_optimizations_ideas.json`:
```json
{
"ideas": [
"performance_optimizations": [
{
"id": "perf-001",
"type": "performance_optimizations",
+2 -2
View File
@@ -92,11 +92,11 @@ Identify security issues across these categories:
## Output Format
Write your findings to `{output_dir}/security_ideas.json`:
Write your findings to `{output_dir}/security_hardening_ideas.json`:
```json
{
"ideas": [
"security_hardening": [
{
"id": "sec-001",
"type": "security_hardening",
+54 -4
View File
@@ -87,6 +87,16 @@ from workspace import (
get_existing_build_worktree,
)
from worktree import WorktreeManager, STAGING_WORKTREE_NAME
from debug import (
debug,
debug_detailed,
debug_verbose,
debug_success,
debug_error,
debug_warning,
debug_section,
is_debug_enabled,
)
from qa_loop import (
run_qa_validation_loop,
should_run_qa,
@@ -98,17 +108,35 @@ from qa_loop import (
# Configuration
DEFAULT_MODEL = "claude-opus-4-5-20251101"
# Default specs directory (production mode)
DEFAULT_SPECS_DIR = "auto-claude/specs"
# Dev specs directory (--dev mode) - gitignored, for developing auto-claude itself
DEV_SPECS_DIR = "dev/auto-claude/specs"
def get_specs_dir(project_dir: Path, dev_mode: bool = False) -> Path:
"""Get the specs directory path based on mode."""
"""Get the specs directory path based on project and mode.
Args:
project_dir: The project root directory
dev_mode: If True, use dev/auto-claude/specs/ for framework development
Returns:
Path to the specs directory within the project
"""
if dev_mode:
return project_dir / DEV_SPECS_DIR
return project_dir / DEFAULT_SPECS_DIR
# Check for .auto-claude first (hidden folder for external projects)
hidden_auto_claude = project_dir / ".auto-claude"
if hidden_auto_claude.exists():
return hidden_auto_claude / "specs"
# Then check for auto-claude (visible folder)
visible_auto_claude = project_dir / "auto-claude"
if visible_auto_claude.exists():
return visible_auto_claude / "specs"
# Default to .auto-claude for external projects
return hidden_auto_claude / "specs"
def list_specs(project_dir: Path, dev_mode: bool = False) -> list[dict]:
@@ -464,11 +492,16 @@ def main() -> None:
"""Main entry point."""
args = parse_args()
debug_section("run.py", "Starting Auto-Build Framework")
debug("run.py", "Arguments parsed", args=vars(args))
# Determine project directory
if args.project_dir:
project_dir = args.project_dir.resolve()
debug("run.py", "Using provided project directory", project_dir=str(project_dir))
else:
project_dir = Path.cwd()
debug("run.py", "Using current working directory", project_dir=str(project_dir))
# Auto-detect if running from within auto-claude directory
# Handle both: auto-claude/ and dev/auto-claude/
@@ -505,14 +538,18 @@ def main() -> None:
sys.exit(1)
# Find the spec
debug("run.py", "Finding spec", spec_identifier=args.spec, dev_mode=args.dev)
spec_dir = find_spec(project_dir, args.spec, args.dev)
if not spec_dir:
debug_error("run.py", "Spec not found", spec=args.spec)
print_banner()
print(f"\nError: Spec '{args.spec}' not found")
print("\nAvailable specs:")
print_specs_list(project_dir, args.dev)
sys.exit(1)
debug_success("run.py", "Spec found", spec_dir=str(spec_dir))
# Handle build management commands
if args.merge:
merge_existing_build(project_dir, spec_dir.name)
@@ -626,9 +663,18 @@ def main() -> None:
spec_dir = localized_spec_dir
# Run the autonomous agent (sequential or parallel)
debug_section("run.py", "Starting Build Execution")
debug("run.py", "Build configuration",
parallel=args.parallel,
model=args.model,
workspace_mode=str(workspace_mode),
working_dir=str(working_dir),
spec_dir=str(spec_dir))
try:
if args.parallel > 1:
# Parallel mode with multiple workers (uses staging worktree)
debug("run.py", "Initializing parallel coordinator", workers=args.parallel)
coordinator = SwarmCoordinator(
spec_dir=spec_dir,
project_dir=project_dir,
@@ -636,7 +682,9 @@ def main() -> None:
model=args.model,
verbose=args.verbose,
)
debug("run.py", "Starting parallel execution")
asyncio.run(coordinator.run_parallel())
debug_success("run.py", "Parallel execution completed")
# After parallel completion, show staging worktree info
staging_manager = WorktreeManager(project_dir)
@@ -646,6 +694,7 @@ def main() -> None:
handle_workspace_choice(choice, project_dir, spec_dir.name, staging_manager)
else:
# Sequential mode
debug("run.py", "Starting sequential execution")
asyncio.run(
run_autonomous_agent(
project_dir=working_dir, # Use worktree if isolated
@@ -655,6 +704,7 @@ def main() -> None:
verbose=args.verbose,
)
)
debug_success("run.py", "Sequential execution completed")
# Run QA validation BEFORE finalization (while worktree still exists)
# QA must sign off before the build is considered complete
+43 -7
View File
@@ -77,23 +77,51 @@ from ui import (
StatusManager,
BuildState,
)
from debug import (
debug,
debug_detailed,
debug_verbose,
debug_success,
debug_error,
debug_warning,
debug_section,
)
# Configuration
MAX_RETRIES = 3
PROMPTS_DIR = Path(__file__).parent / "prompts"
# Default specs directory (production mode)
DEFAULT_SPECS_DIR = Path(__file__).parent / "specs"
# Dev specs directory (--dev mode) - gitignored, for developing auto-claude itself
DEV_SPECS_DIR = Path(__file__).parent.parent / "dev" / "auto-claude" / "specs"
def get_specs_dir(dev_mode: bool = False) -> Path:
"""Get the specs directory based on mode."""
def get_specs_dir(project_dir: Path, dev_mode: bool = False) -> Path:
"""Get the specs directory based on project and mode.
Args:
project_dir: The project root directory
dev_mode: If True, use dev/auto-claude/specs/ for framework development
Returns:
Path to the specs directory within the project
"""
if dev_mode:
return DEV_SPECS_DIR
return DEFAULT_SPECS_DIR
# Check for .auto-claude first (hidden folder for external projects)
hidden_auto_claude = project_dir / ".auto-claude"
if hidden_auto_claude.exists():
return hidden_auto_claude / "specs"
# Then check for auto-claude (visible folder)
visible_auto_claude = project_dir / "auto-claude"
if visible_auto_claude.exists():
return visible_auto_claude / "specs"
# Default to creating .auto-claude for external projects
# (auto-claude is typically only used when the project IS auto-claude itself)
return hidden_auto_claude / "specs"
class Complexity(Enum):
@@ -368,8 +396,8 @@ class SpecOrchestrator:
self.use_ai_assessment = use_ai_assessment
self.dev_mode = dev_mode
# Get the appropriate specs directory
self.specs_dir = get_specs_dir(dev_mode)
# Get the appropriate specs directory (within the project)
self.specs_dir = get_specs_dir(self.project_dir, dev_mode)
# Complexity assessment (populated during run)
self.assessment: Optional[ComplexityAssessment] = None
@@ -1440,6 +1468,14 @@ Read the failed files, understand the errors, and fix them.
async def run(self, interactive: bool = True) -> bool:
"""Run the spec creation process with dynamic phase selection."""
debug_section("spec_runner", "Starting Spec Creation")
debug("spec_runner", "Configuration",
spec_dir=str(self.spec_dir),
project_dir=str(self.project_dir),
task=self.task_description,
interactive=interactive,
model=self.model)
print(box(
f"Spec Directory: {self.spec_dir}\n"
f"Project: {self.project_dir}" +
+2
View File
@@ -439,3 +439,5 @@ async def main():
if __name__ == "__main__":
asyncio.run(main())