Insights functionality + ideation upgrade
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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",
|
||||
|
||||
Generated
+448
-418
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ function getIconPath(): string {
|
||||
}
|
||||
|
||||
const iconPath = join(resourcesPath, iconName);
|
||||
console.log('[Auto-Claude] Icon path:', iconPath);
|
||||
return iconPath;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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'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',
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ============================================
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
# =============================================================================
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}" +
|
||||
|
||||
@@ -439,3 +439,5 @@ async def main():
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user