feat(feature-suggestions): implement feature suggestions and spec regeneration functionality

- Introduced a new `FeatureSuggestionsService` to analyze projects and generate feature suggestions based on the project structure and existing features.
- Added IPC handlers for generating and stopping feature suggestions, as well as checking their status.
- Implemented a `SpecRegenerationService` to create and regenerate application specifications based on user-defined project overviews and definitions.
- Enhanced the UI with a `FeatureSuggestionsDialog` for displaying generated suggestions and allowing users to import them into their project.
- Updated the sidebar and board view components to integrate feature suggestions and spec regeneration functionalities, improving project management capabilities.

These changes significantly enhance the application's ability to assist users in feature planning and specification management.
This commit is contained in:
Cody Seibert
2025-12-10 08:51:33 -05:00
parent e9a4dd0319
commit 72cc43d02f
16 changed files with 2923 additions and 187 deletions

View File

@@ -1,20 +1 @@
[
{
"id": "feature-1765335919754-r010d1fw5",
"category": "Uncategorized",
"description": "what does the text in the button say?\n",
"steps": [],
"status": "waiting_approval",
"startedAt": "2025-12-10T03:05:34.894Z",
"imagePaths": [
{
"id": "img-1765335919132-0x3t37l1r",
"path": "/Users/webdevcody/Library/Application Support/automaker/images/1765335919131-g4qvs053g_Screenshot_2025-12-09_at_10.05.17_PM.png",
"filename": "Screenshot 2025-12-09 at 10.05.17PM.png",
"mimeType": "image/png"
}
],
"skipTests": true,
"summary": "Investigated button text in the app. Main buttons found in welcome-view.tsx: \"Create Project\" (primary action), \"Browse Folder\" (secondary action), \"Browse\" (directory selector), \"Cancel\", \"Get Started\". No code changes made - this was an investigative question."
}
]
[]

View File

@@ -7,6 +7,8 @@ const { app, BrowserWindow, ipcMain, dialog, shell } = require("electron");
const fs = require("fs/promises");
const agentService = require("./agent-service");
const autoModeService = require("./auto-mode-service");
const featureSuggestionsService = require("./services/feature-suggestions-service");
const specRegenerationService = require("./services/spec-regeneration-service");
let mainWindow = null;
@@ -669,3 +671,224 @@ ipcMain.handle(
}
}
);
// ============================================================================
// Feature Suggestions IPC Handlers
// ============================================================================
// Track running suggestions analysis
let suggestionsExecution = null;
/**
* Generate feature suggestions by analyzing the project
*/
ipcMain.handle(
"suggestions:generate",
async (_, { projectPath }) => {
console.log("[IPC] suggestions:generate called with:", { projectPath });
try {
// Check if already running
if (suggestionsExecution && suggestionsExecution.isActive()) {
return { success: false, error: "Suggestions generation is already running" };
}
// Create execution context
suggestionsExecution = {
abortController: null,
query: null,
isActive: () => suggestionsExecution !== null,
};
const sendToRenderer = (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("suggestions:event", data);
}
};
// Start generating suggestions (runs in background)
featureSuggestionsService
.generateSuggestions(projectPath, sendToRenderer, suggestionsExecution)
.catch((error) => {
console.error("[IPC] suggestions:generate background error:", error);
sendToRenderer({
type: "suggestions_error",
error: error.message,
});
})
.finally(() => {
suggestionsExecution = null;
});
// Return immediately
return { success: true };
} catch (error) {
console.error("[IPC] suggestions:generate error:", error);
suggestionsExecution = null;
return { success: false, error: error.message };
}
}
);
/**
* Stop the current suggestions generation
*/
ipcMain.handle("suggestions:stop", async () => {
console.log("[IPC] suggestions:stop called");
try {
if (suggestionsExecution && suggestionsExecution.abortController) {
suggestionsExecution.abortController.abort();
}
suggestionsExecution = null;
return { success: true };
} catch (error) {
console.error("[IPC] suggestions:stop error:", error);
return { success: false, error: error.message };
}
});
/**
* Get suggestions generation status
*/
ipcMain.handle("suggestions:status", () => {
return {
success: true,
isRunning: suggestionsExecution !== null && suggestionsExecution.isActive(),
};
});
// ============================================================================
// Spec Regeneration IPC Handlers
// ============================================================================
// Track running spec regeneration
let specRegenerationExecution = null;
/**
* Regenerate the app spec based on project definition
*/
ipcMain.handle(
"spec-regeneration:generate",
async (_, { projectPath, projectDefinition }) => {
console.log("[IPC] spec-regeneration:generate called with:", { projectPath });
try {
// Check if already running
if (specRegenerationExecution && specRegenerationExecution.isActive()) {
return { success: false, error: "Spec regeneration is already running" };
}
// Create execution context
specRegenerationExecution = {
abortController: null,
query: null,
isActive: () => specRegenerationExecution !== null,
};
const sendToRenderer = (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("spec-regeneration:event", data);
}
};
// Start regenerating spec (runs in background)
specRegenerationService
.regenerateSpec(projectPath, projectDefinition, sendToRenderer, specRegenerationExecution)
.catch((error) => {
console.error("[IPC] spec-regeneration:generate background error:", error);
sendToRenderer({
type: "spec_regeneration_error",
error: error.message,
});
})
.finally(() => {
specRegenerationExecution = null;
});
// Return immediately
return { success: true };
} catch (error) {
console.error("[IPC] spec-regeneration:generate error:", error);
specRegenerationExecution = null;
return { success: false, error: error.message };
}
}
);
/**
* Stop the current spec regeneration
*/
ipcMain.handle("spec-regeneration:stop", async () => {
console.log("[IPC] spec-regeneration:stop called");
try {
if (specRegenerationExecution && specRegenerationExecution.abortController) {
specRegenerationExecution.abortController.abort();
}
specRegenerationExecution = null;
return { success: true };
} catch (error) {
console.error("[IPC] spec-regeneration:stop error:", error);
return { success: false, error: error.message };
}
});
/**
* Get spec regeneration status
*/
ipcMain.handle("spec-regeneration:status", () => {
return {
success: true,
isRunning: specRegenerationExecution !== null && specRegenerationExecution.isActive(),
};
});
/**
* Create initial app spec for a new project
*/
ipcMain.handle(
"spec-regeneration:create",
async (_, { projectPath, projectOverview, generateFeatures = true }) => {
console.log("[IPC] spec-regeneration:create called with:", { projectPath, generateFeatures });
try {
// Check if already running
if (specRegenerationExecution && specRegenerationExecution.isActive()) {
return { success: false, error: "Spec creation is already running" };
}
// Create execution context
specRegenerationExecution = {
abortController: null,
query: null,
isActive: () => specRegenerationExecution !== null,
};
const sendToRenderer = (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("spec-regeneration:event", data);
}
};
// Start creating spec (runs in background)
specRegenerationService
.createInitialSpec(projectPath, projectOverview, sendToRenderer, specRegenerationExecution, generateFeatures)
.catch((error) => {
console.error("[IPC] spec-regeneration:create background error:", error);
sendToRenderer({
type: "spec_regeneration_error",
error: error.message,
});
})
.finally(() => {
specRegenerationExecution = null;
});
// Return immediately
return { success: true };
} catch (error) {
console.error("[IPC] spec-regeneration:create error:", error);
specRegenerationExecution = null;
return { success: false, error: error.message };
}
}
);

View File

@@ -139,6 +139,58 @@ contextBridge.exposeInMainWorld("electronAPI", {
};
},
},
// Feature Suggestions API
suggestions: {
// Generate feature suggestions
generate: (projectPath) =>
ipcRenderer.invoke("suggestions:generate", { projectPath }),
// Stop generating suggestions
stop: () => ipcRenderer.invoke("suggestions:stop"),
// Get suggestions status
status: () => ipcRenderer.invoke("suggestions:status"),
// Listen for suggestions events
onEvent: (callback) => {
const subscription = (_, data) => callback(data);
ipcRenderer.on("suggestions:event", subscription);
// Return unsubscribe function
return () => {
ipcRenderer.removeListener("suggestions:event", subscription);
};
},
},
// Spec Regeneration API
specRegeneration: {
// Create initial app spec for a new project
create: (projectPath, projectOverview, generateFeatures = true) =>
ipcRenderer.invoke("spec-regeneration:create", { projectPath, projectOverview, generateFeatures }),
// Regenerate the app spec
generate: (projectPath, projectDefinition) =>
ipcRenderer.invoke("spec-regeneration:generate", { projectPath, projectDefinition }),
// Stop regenerating spec
stop: () => ipcRenderer.invoke("spec-regeneration:stop"),
// Get regeneration status
status: () => ipcRenderer.invoke("spec-regeneration:status"),
// Listen for regeneration events
onEvent: (callback) => {
const subscription = (_, data) => callback(data);
ipcRenderer.on("spec-regeneration:event", subscription);
// Return unsubscribe function
return () => {
ipcRenderer.removeListener("spec-regeneration:event", subscription);
};
},
},
});
// Also expose a flag to detect if we're in Electron

View File

@@ -0,0 +1,269 @@
const { query, AbortError } = require("@anthropic-ai/claude-agent-sdk");
const promptBuilder = require("./prompt-builder");
/**
* Feature Suggestions Service - Analyzes project and generates feature suggestions
*/
class FeatureSuggestionsService {
constructor() {
this.runningAnalysis = null;
}
/**
* Generate feature suggestions by analyzing the project
*/
async generateSuggestions(projectPath, sendToRenderer, execution) {
console.log(
`[FeatureSuggestions] Generating suggestions for: ${projectPath}`
);
try {
const abortController = new AbortController();
execution.abortController = abortController;
const options = {
model: "claude-sonnet-4-20250514",
systemPrompt: this.getSystemPrompt(),
maxTurns: 50,
cwd: projectPath,
allowedTools: ["Read", "Glob", "Grep", "Bash"],
permissionMode: "acceptEdits",
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
abortController: abortController,
};
const prompt = this.buildAnalysisPrompt();
sendToRenderer({
type: "suggestions_progress",
content: "Starting project analysis...\n",
});
const currentQuery = query({ prompt, options });
execution.query = currentQuery;
let fullResponse = "";
for await (const msg of currentQuery) {
if (!execution.isActive()) break;
if (msg.type === "assistant" && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
fullResponse += block.text;
sendToRenderer({
type: "suggestions_progress",
content: block.text,
});
} else if (block.type === "tool_use") {
sendToRenderer({
type: "suggestions_tool",
tool: block.name,
input: block.input,
});
}
}
}
}
execution.query = null;
execution.abortController = null;
// Parse the suggestions from the response
const suggestions = this.parseSuggestions(fullResponse);
sendToRenderer({
type: "suggestions_complete",
suggestions: suggestions,
});
return {
success: true,
suggestions: suggestions,
};
} catch (error) {
if (error instanceof AbortError || error?.name === "AbortError") {
console.log("[FeatureSuggestions] Analysis aborted");
if (execution) {
execution.abortController = null;
execution.query = null;
}
return {
success: false,
message: "Analysis aborted",
suggestions: [],
};
}
console.error(
"[FeatureSuggestions] Error generating suggestions:",
error
);
if (execution) {
execution.abortController = null;
execution.query = null;
}
throw error;
}
}
/**
* Parse suggestions from the LLM response
* Looks for JSON array in the response
*/
parseSuggestions(response) {
try {
// Try to find JSON array in the response
// Look for ```json ... ``` blocks first
const jsonBlockMatch = response.match(/```json\s*([\s\S]*?)```/);
if (jsonBlockMatch) {
const parsed = JSON.parse(jsonBlockMatch[1].trim());
if (Array.isArray(parsed)) {
return this.validateSuggestions(parsed);
}
}
// Try to find a raw JSON array
const jsonArrayMatch = response.match(/\[\s*\{[\s\S]*\}\s*\]/);
if (jsonArrayMatch) {
const parsed = JSON.parse(jsonArrayMatch[0]);
if (Array.isArray(parsed)) {
return this.validateSuggestions(parsed);
}
}
console.warn(
"[FeatureSuggestions] Could not parse suggestions from response"
);
return [];
} catch (error) {
console.error("[FeatureSuggestions] Error parsing suggestions:", error);
return [];
}
}
/**
* Validate and normalize suggestions
*/
validateSuggestions(suggestions) {
return suggestions
.filter((s) => s && typeof s === "object")
.map((s, index) => ({
id: `suggestion-${Date.now()}-${index}`,
category: s.category || "Uncategorized",
description: s.description || s.title || "No description",
steps: Array.isArray(s.steps) ? s.steps : [],
priority: typeof s.priority === "number" ? s.priority : index + 1,
reasoning: s.reasoning || "",
}))
.sort((a, b) => a.priority - b.priority);
}
/**
* Get the system prompt for feature suggestion analysis
*/
getSystemPrompt() {
return `You are an expert software architect and product manager. Your job is to analyze a codebase and suggest missing features that would improve the application.
You should:
1. Thoroughly analyze the project structure, code, and any existing documentation
2. Identify what the application does and what features it currently has (look at the .automaker/app_spec.txt file as well if it exists)
3. Generate a comprehensive list of missing features that would be valuable to users
4. Prioritize features by impact and complexity
5. Provide clear, actionable descriptions and implementation steps
When analyzing, look at:
- README files and documentation
- Package.json, cargo.toml, or similar config files for tech stack
- Source code structure and organization
- Existing features and their implementation patterns
- Common patterns in similar applications
- User experience improvements
- Developer experience improvements
- Performance optimizations
- Security enhancements
You have access to file reading and search tools. Use them to understand the codebase.`;
}
/**
* Build the prompt for analyzing the project
*/
buildAnalysisPrompt() {
return `Analyze this project and generate a list of suggested features that are missing or would improve the application.
**Your Task:**
1. First, explore the project structure:
- Read README.md, package.json, or similar config files
- Scan the source code directory structure
- Identify the tech stack and frameworks used
- Look at existing features and how they're implemented
2. Identify what the application does:
- What is the main purpose?
- What features are already implemented?
- What patterns and conventions are used?
3. Generate feature suggestions:
- Think about what's missing compared to similar applications
- Consider user experience improvements
- Consider developer experience improvements
- Think about performance, security, and reliability
- Consider testing and documentation improvements
4. **CRITICAL: Output your suggestions as a JSON array** at the end of your response, formatted like this:
\`\`\`json
[
{
"category": "User Experience",
"description": "Add dark mode support with system preference detection",
"steps": [
"Create a ThemeProvider context to manage theme state",
"Add a toggle component in the settings or header",
"Implement CSS variables for theme colors",
"Add localStorage persistence for user preference"
],
"priority": 1,
"reasoning": "Dark mode is a standard feature that improves accessibility and user comfort"
},
{
"category": "Performance",
"description": "Implement lazy loading for heavy components",
"steps": [
"Identify components that are heavy or rarely used",
"Use React.lazy() and Suspense for code splitting",
"Add loading states for lazy-loaded components"
],
"priority": 2,
"reasoning": "Improves initial load time and reduces bundle size"
}
]
\`\`\`
**Important Guidelines:**
- Generate at least 10-20 feature suggestions
- Order them by priority (1 = highest priority)
- Each feature should have clear, actionable steps
- Categories should be meaningful (e.g., "User Experience", "Performance", "Security", "Testing", "Documentation", "Developer Experience", "Accessibility", etc.)
- Be specific about what files might need to be created or modified
- Consider the existing tech stack and patterns when suggesting implementation steps
Begin by exploring the project structure.`;
}
/**
* Stop the current analysis
*/
stop() {
if (this.runningAnalysis && this.runningAnalysis.abortController) {
this.runningAnalysis.abortController.abort();
}
this.runningAnalysis = null;
}
}
module.exports = new FeatureSuggestionsService();

View File

@@ -0,0 +1,519 @@
const { query, AbortError } = require("@anthropic-ai/claude-agent-sdk");
const fs = require("fs/promises");
const path = require("path");
/**
* XML template for app_spec.txt
*/
const APP_SPEC_XML_TEMPLATE = `<project_specification>
<project_name></project_name>
<overview>
</overview>
<technology_stack>
<frontend>
<framework></framework>
<ui_library></ui_library>
<styling></styling>
<state_management></state_management>
<drag_drop></drag_drop>
<icons></icons>
</frontend>
<desktop_shell>
<framework></framework>
<language></language>
<inter_process_communication></inter_process_communication>
<file_system></file_system>
</desktop_shell>
<ai_engine>
<logic_model></logic_model>
<design_model></design_model>
<orchestration></orchestration>
</ai_engine>
<testing>
<framework></framework>
<unit></unit>
</testing>
</technology_stack>
<core_capabilities>
<project_management>
</project_management>
<intelligent_analysis>
</intelligent_analysis>
<kanban_workflow>
</kanban_workflow>
<autonomous_agent_engine>
</autonomous_agent_engine>
<extensibility>
</extensibility>
</core_capabilities>
<ui_layout>
<window_structure>
</window_structure>
<theme>
</theme>
</ui_layout>
<development_workflow>
<local_testing>
</local_testing>
</development_workflow>
<implementation_roadmap>
<phase_1_foundation>
</phase_1_foundation>
<phase_2_core_logic>
</phase_2_core_logic>
<phase_3_kanban_and_interaction>
</phase_3_kanban_and_interaction>
<phase_4_polish>
</phase_4_polish>
</implementation_roadmap>
</project_specification>`;
/**
* Spec Regeneration Service - Regenerates app spec based on project description and tech stack
*/
class SpecRegenerationService {
constructor() {
this.runningRegeneration = null;
}
/**
* Create initial app spec for a new project
* @param {string} projectPath - Path to the project
* @param {string} projectOverview - User's project description
* @param {Function} sendToRenderer - Function to send events to renderer
* @param {Object} execution - Execution context with abort controller
* @param {boolean} generateFeatures - Whether to generate feature_list.json entries
*/
async createInitialSpec(projectPath, projectOverview, sendToRenderer, execution, generateFeatures = true) {
console.log(`[SpecRegeneration] Creating initial spec for: ${projectPath}, generateFeatures: ${generateFeatures}`);
try {
const abortController = new AbortController();
execution.abortController = abortController;
const options = {
model: "claude-sonnet-4-20250514",
systemPrompt: this.getInitialCreationSystemPrompt(generateFeatures),
maxTurns: 50,
cwd: projectPath,
allowedTools: ["Read", "Write", "Edit", "Glob", "Grep", "Bash"],
permissionMode: "acceptEdits",
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
abortController: abortController,
};
const prompt = this.buildInitialCreationPrompt(projectOverview, generateFeatures);
sendToRenderer({
type: "spec_regeneration_progress",
content: "Starting project analysis and spec creation...\n",
});
const currentQuery = query({ prompt, options });
execution.query = currentQuery;
let fullResponse = "";
for await (const msg of currentQuery) {
if (!execution.isActive()) break;
if (msg.type === "assistant" && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
fullResponse += block.text;
sendToRenderer({
type: "spec_regeneration_progress",
content: block.text,
});
} else if (block.type === "tool_use") {
sendToRenderer({
type: "spec_regeneration_tool",
tool: block.name,
input: block.input,
});
}
}
}
}
execution.query = null;
execution.abortController = null;
sendToRenderer({
type: "spec_regeneration_complete",
message: "Initial spec creation complete!",
});
return {
success: true,
message: "Initial spec creation complete",
};
} catch (error) {
if (error instanceof AbortError || error?.name === "AbortError") {
console.log("[SpecRegeneration] Creation aborted");
if (execution) {
execution.abortController = null;
execution.query = null;
}
return {
success: false,
message: "Creation aborted",
};
}
console.error("[SpecRegeneration] Error creating initial spec:", error);
if (execution) {
execution.abortController = null;
execution.query = null;
}
throw error;
}
}
/**
* Get the system prompt for initial spec creation
* @param {boolean} generateFeatures - Whether features should be generated
*/
getInitialCreationSystemPrompt(generateFeatures = true) {
const featureListInstructions = generateFeatures
? `
**FEATURE LIST GENERATION**
After creating the app_spec.txt, you MUST also update the .automaker/feature_list.json file with all features from the implementation_roadmap section.
For EACH feature in each phase of the implementation_roadmap:
1. Read the app_spec.txt you just created
2. Extract every single feature from each phase (phase_1, phase_2, phase_3, phase_4, etc.)
3. Write ALL features to .automaker/feature_list.json in order
The feature_list.json format should be:
\`\`\`json
[
{
"id": "feature-<timestamp>-<index>",
"category": "<phase name, e.g., 'Phase 1: Foundation'>",
"description": "<feature description>",
"status": "backlog",
"steps": ["Step 1", "Step 2", "..."],
"skipTests": true
}
]
\`\`\`
IMPORTANT: Include EVERY feature from the implementation_roadmap. Do not skip any.`
: `
**CRITICAL FILE PROTECTION**
THE FOLLOWING FILE IS ABSOLUTELY FORBIDDEN FROM DIRECT MODIFICATION:
- .automaker/feature_list.json
**YOU MUST NEVER:**
- Use the Write tool on .automaker/feature_list.json
- Use the Edit tool on .automaker/feature_list.json
- Use any Bash command that writes to .automaker/feature_list.json`;
return `You are an expert software architect and product manager. Your job is to analyze an existing codebase and generate a comprehensive application specification based on a user's project overview.
You should:
1. First, thoroughly analyze the project structure to understand the existing tech stack
2. Read key configuration files (package.json, tsconfig.json, Cargo.toml, requirements.txt, etc.) to understand dependencies and frameworks
3. Understand the current architecture and patterns used
4. Based on the user's project overview, create a comprehensive app specification
5. Be liberal and comprehensive when defining features - include everything needed for a complete, polished application
6. Use the XML template format provided
7. Write the specification to .automaker/app_spec.txt
When analyzing, look at:
- package.json, cargo.toml, requirements.txt or similar config files for tech stack
- Source code structure and organization
- Framework-specific patterns (Next.js, React, Django, etc.)
- Database configurations and schemas
- API structures and patterns
${featureListInstructions}
You CAN and SHOULD modify:
- .automaker/app_spec.txt (this is your primary target)${generateFeatures ? '\n- .automaker/feature_list.json (to populate features from implementation_roadmap)' : ''}
You have access to file reading, writing, and search tools. Use them to understand the codebase and write the new spec.`;
}
/**
* Build the prompt for initial spec creation
* @param {string} projectOverview - User's project description
* @param {boolean} generateFeatures - Whether to generate feature_list.json entries
*/
buildInitialCreationPrompt(projectOverview, generateFeatures = true) {
const featureGenerationStep = generateFeatures
? `
5. **IMPORTANT - GENERATE FEATURE LIST**: After writing the app_spec.txt:
- Read back the app_spec.txt file you just created
- Look at the implementation_roadmap section
- For EVERY feature listed in each phase (phase_1, phase_2, phase_3, phase_4, etc.), create an entry
- Write ALL these features to \`.automaker/feature_list.json\` in the order they appear
- Each feature should have: id (feature-timestamp-index), category (phase name), description, status: "backlog", steps array, and skipTests: true
- Do NOT skip any features - include every single one from the roadmap`
: '';
return `I need you to create an initial application specification for my project. I haven't set up an app_spec.txt yet, so this will be the first one.
**My Project Overview:**
${projectOverview}
**Your Task:**
1. First, explore the project to understand the existing tech stack:
- Read package.json, Cargo.toml, requirements.txt, or similar config files
- Identify all frameworks and libraries being used
- Understand the current project structure and architecture
- Note any database, authentication, or other infrastructure in use
2. Based on my project overview and the existing tech stack, create a comprehensive app specification using this XML template:
\`\`\`xml
${APP_SPEC_XML_TEMPLATE}
\`\`\`
3. Fill out the template with:
- **project_name**: Extract from the project or derive from overview
- **overview**: A clear description based on my project overview
- **technology_stack**: All technologies you discover in the project (fill out the relevant sections, remove irrelevant ones)
- **core_capabilities**: List all the major capabilities the app should have based on my overview
- **ui_layout**: Describe the UI structure if relevant
- **development_workflow**: Note any testing or development patterns
- **implementation_roadmap**: Break down the features into phases - be VERY detailed here, listing every feature that needs to be built
4. **IMPORTANT**: Write the complete specification to the file \`.automaker/app_spec.txt\`
${featureGenerationStep}
**Guidelines:**
- Be comprehensive! Include ALL features needed for a complete application
- Only include technology_stack sections that are relevant (e.g., skip desktop_shell if it's a web-only app)
- Add new sections to core_capabilities as needed for the specific project
- The implementation_roadmap should reflect logical phases for building out the app - list EVERY feature individually
- Consider user flows, error states, and edge cases when defining features
- Each phase should have multiple specific, actionable features
Begin by exploring the project structure.`;
}
/**
* Regenerate the app spec based on user's project definition
*/
async regenerateSpec(projectPath, projectDefinition, sendToRenderer, execution) {
console.log(`[SpecRegeneration] Regenerating spec for: ${projectPath}`);
try {
const abortController = new AbortController();
execution.abortController = abortController;
const options = {
model: "claude-sonnet-4-20250514",
systemPrompt: this.getSystemPrompt(),
maxTurns: 50,
cwd: projectPath,
allowedTools: ["Read", "Write", "Edit", "Glob", "Grep", "Bash"],
permissionMode: "acceptEdits",
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
abortController: abortController,
};
const prompt = this.buildRegenerationPrompt(projectDefinition);
sendToRenderer({
type: "spec_regeneration_progress",
content: "Starting spec regeneration...\n",
});
const currentQuery = query({ prompt, options });
execution.query = currentQuery;
let fullResponse = "";
for await (const msg of currentQuery) {
if (!execution.isActive()) break;
if (msg.type === "assistant" && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
fullResponse += block.text;
sendToRenderer({
type: "spec_regeneration_progress",
content: block.text,
});
} else if (block.type === "tool_use") {
sendToRenderer({
type: "spec_regeneration_tool",
tool: block.name,
input: block.input,
});
}
}
}
}
execution.query = null;
execution.abortController = null;
sendToRenderer({
type: "spec_regeneration_complete",
message: "Spec regeneration complete!",
});
return {
success: true,
message: "Spec regeneration complete",
};
} catch (error) {
if (error instanceof AbortError || error?.name === "AbortError") {
console.log("[SpecRegeneration] Regeneration aborted");
if (execution) {
execution.abortController = null;
execution.query = null;
}
return {
success: false,
message: "Regeneration aborted",
};
}
console.error("[SpecRegeneration] Error regenerating spec:", error);
if (execution) {
execution.abortController = null;
execution.query = null;
}
throw error;
}
}
/**
* Get the system prompt for spec regeneration
*/
getSystemPrompt() {
return `You are an expert software architect and product manager. Your job is to analyze an existing codebase and generate a comprehensive application specification based on a user's project definition.
You should:
1. First, thoroughly analyze the project structure to understand the existing tech stack
2. Read key configuration files (package.json, tsconfig.json, etc.) to understand dependencies and frameworks
3. Understand the current architecture and patterns used
4. Based on the user's project definition, create a comprehensive app specification that includes ALL features needed to realize their vision
5. Be liberal and comprehensive when defining features - include everything needed for a complete, polished application
6. Write the specification to .automaker/app_spec.txt
When analyzing, look at:
- package.json, cargo.toml, or similar config files for tech stack
- Source code structure and organization
- Framework-specific patterns (Next.js, React, etc.)
- Database configurations and schemas
- API structures and patterns
**CRITICAL FILE PROTECTION**
THE FOLLOWING FILE IS ABSOLUTELY FORBIDDEN FROM DIRECT MODIFICATION:
- .automaker/feature_list.json
**YOU MUST NEVER:**
- Use the Write tool on .automaker/feature_list.json
- Use the Edit tool on .automaker/feature_list.json
- Use any Bash command that writes to .automaker/feature_list.json
You CAN and SHOULD modify:
- .automaker/app_spec.txt (this is your primary target)
You have access to file reading, writing, and search tools. Use them to understand the codebase and write the new spec.`;
}
/**
* Build the prompt for regenerating the spec
*/
buildRegenerationPrompt(projectDefinition) {
return `I need you to regenerate my application specification based on the following project definition. Be very comprehensive and liberal when defining features - I want a complete, polished application.
**My Project Definition:**
${projectDefinition}
**Your Task:**
1. First, explore the project to understand the existing tech stack:
- Read package.json or similar config files
- Identify all frameworks and libraries being used
- Understand the current project structure and architecture
- Note any database, authentication, or other infrastructure in use
2. Based on my project definition and the existing tech stack, create a comprehensive app specification that includes:
- Product Overview: A clear description of what the app does
- Tech Stack: All technologies currently in use
- Features: A COMPREHENSIVE list of all features needed to realize my vision
- Be liberal! Include all features that would make this a complete, production-ready application
- Include core features, supporting features, and nice-to-have features
- Think about user experience, error handling, edge cases, etc.
- Architecture Notes: Any important architectural decisions or patterns
3. **IMPORTANT**: Write the complete specification to the file \`.automaker/app_spec.txt\`
**Format Guidelines for the Spec:**
Use this general structure:
\`\`\`
# [App Name] - Application Specification
## Product Overview
[Description of what the app does and its purpose]
## Tech Stack
- Frontend: [frameworks, libraries]
- Backend: [frameworks, APIs]
- Database: [if applicable]
- Other: [other relevant tech]
## Features
### [Category 1]
- **[Feature Name]**: [Detailed description of the feature]
- **[Feature Name]**: [Detailed description]
...
### [Category 2]
- **[Feature Name]**: [Detailed description]
...
## Architecture Notes
[Any important architectural notes, patterns, or conventions]
\`\`\`
**Remember:**
- Be comprehensive! Include ALL features needed for a complete application
- Consider user flows, error states, loading states, etc.
- Include authentication, authorization if relevant
- Think about what would make this a polished, production-ready app
- The more detailed and complete the spec, the better
Begin by exploring the project structure.`;
}
/**
* Stop the current regeneration
*/
stop() {
if (this.runningRegeneration && this.runningRegeneration.abortController) {
this.runningRegeneration.abortController.abort();
}
this.runningRegeneration = null;
}
}
module.exports = new SpecRegenerationService();

View File

@@ -16,9 +16,12 @@ import {
PanelLeft,
PanelLeftClose,
ChevronDown,
Redo2,
Check,
BookOpen,
GripVertical,
RotateCw,
RotateCcw,
Trash2,
Undo2,
} from "lucide-react";
@@ -44,8 +47,15 @@ import {
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
import { getElectronAPI, Project, TrashedProject } from "@/lib/electron";
import { initializeProject } from "@/lib/project-init";
import {
initializeProject,
hasAppSpec,
hasAutomakerDir,
} from "@/lib/project-init";
import { toast } from "sonner";
import { Sparkles, Loader2 } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import type { SpecRegenerationEvent } from "@/types/electron";
import {
DndContext,
DragEndEvent,
@@ -155,6 +165,7 @@ export function Sidebar() {
currentProject,
currentView,
sidebarOpen,
projectHistory,
addProject,
setCurrentProject,
setCurrentView,
@@ -163,6 +174,8 @@ export function Sidebar() {
deleteTrashedProject,
emptyTrash,
reorderProjects,
cyclePrevProject,
cycleNextProject,
} = useAppStore();
// State for project picker dropdown
@@ -171,6 +184,17 @@ export function Sidebar() {
const [activeTrashId, setActiveTrashId] = useState<string | null>(null);
const [isEmptyingTrash, setIsEmptyingTrash] = useState(false);
// State for new project setup dialog
const [showSetupDialog, setShowSetupDialog] = useState(false);
const [setupProjectPath, setSetupProjectPath] = useState("");
const [projectOverview, setProjectOverview] = useState("");
const [isCreatingSpec, setIsCreatingSpec] = useState(false);
const [creatingSpecProjectPath, setCreatingSpecProjectPath] = useState<
string | null
>(null);
const [generateFeatures, setGenerateFeatures] = useState(true);
const [showSpecIndicator, setShowSpecIndicator] = useState(true);
// Sensors for drag-and-drop
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -197,6 +221,93 @@ export function Sidebar() {
[projects, reorderProjects]
);
// Subscribe to spec regeneration events
useEffect(() => {
const api = getElectronAPI();
if (!api.specRegeneration) return;
const unsubscribe = api.specRegeneration.onEvent(
(event: SpecRegenerationEvent) => {
console.log("[Sidebar] Spec regeneration event:", event.type);
if (event.type === "spec_regeneration_complete") {
setIsCreatingSpec(false);
setCreatingSpecProjectPath(null);
setShowSetupDialog(false);
setProjectOverview("");
setSetupProjectPath("");
toast.success("App specification created", {
description: "Your project is now set up and ready to go!",
});
// Navigate to spec view to show the new spec
setCurrentView("spec");
} else if (event.type === "spec_regeneration_error") {
setIsCreatingSpec(false);
setCreatingSpecProjectPath(null);
toast.error("Failed to create specification", {
description: event.error,
});
}
}
);
return () => {
unsubscribe();
};
}, [setCurrentView]);
// Handle creating initial spec for new project
const handleCreateInitialSpec = useCallback(async () => {
if (!setupProjectPath || !projectOverview.trim()) return;
setIsCreatingSpec(true);
setCreatingSpecProjectPath(setupProjectPath);
setShowSpecIndicator(true);
setShowSetupDialog(false);
try {
const api = getElectronAPI();
if (!api.specRegeneration) {
toast.error("Spec regeneration not available");
setIsCreatingSpec(false);
setCreatingSpecProjectPath(null);
return;
}
const result = await api.specRegeneration.create(
setupProjectPath,
projectOverview.trim(),
generateFeatures
);
if (!result.success) {
console.error("[Sidebar] Failed to start spec creation:", result.error);
setIsCreatingSpec(false);
setCreatingSpecProjectPath(null);
toast.error("Failed to create specification", {
description: result.error,
});
}
// If successful, we'll wait for the events to update the state
} catch (error) {
console.error("[Sidebar] Failed to create spec:", error);
setIsCreatingSpec(false);
setCreatingSpecProjectPath(null);
toast.error("Failed to create specification", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
}, [setupProjectPath, projectOverview]);
// Handle skipping setup
const handleSkipSetup = useCallback(() => {
setShowSetupDialog(false);
setProjectOverview("");
setSetupProjectPath("");
toast.info("Setup skipped", {
description: "You can set up your app_spec.txt later from the Spec view.",
});
}, []);
/**
* Opens the system folder selection dialog and initializes the selected project.
* Used by both the 'O' keyboard shortcut and the folder icon button.
@@ -210,6 +321,9 @@ export function Sidebar() {
const name = path.split("/").pop() || "Untitled Project";
try {
// Check if this is a brand new project (no .automaker directory)
const hadAutomakerDir = await hasAutomakerDir(path);
// Initialize the .automaker directory structure
const initResult = await initializeProject(path);
@@ -230,7 +344,20 @@ export function Sidebar() {
addProject(project);
setCurrentProject(project);
if (initResult.createdFiles && initResult.createdFiles.length > 0) {
// Check if app_spec.txt exists
const specExists = await hasAppSpec(path);
if (!hadAutomakerDir && !specExists) {
// This is a brand new project - show setup dialog
setSetupProjectPath(path);
setShowSetupDialog(true);
toast.success("Project opened", {
description: `Opened ${name}. Let's set up your app specification!`,
});
} else if (
initResult.createdFiles &&
initResult.createdFiles.length > 0
) {
toast.success(
initResult.isNewProject ? "Project initialized" : "Project updated",
{
@@ -422,6 +549,20 @@ export function Sidebar() {
});
}
// Project cycling shortcuts - only when we have project history
if (projectHistory.length > 1) {
shortcuts.push({
key: ACTION_SHORTCUTS.cyclePrevProject,
action: () => cyclePrevProject(),
description: "Cycle to previous project (MRU)",
});
shortcuts.push({
key: ACTION_SHORTCUTS.cycleNextProject,
action: () => cycleNextProject(),
description: "Cycle to next project (LRU)",
});
}
// Only enable nav shortcuts if there's a current project
if (currentProject) {
navSections.forEach((section) => {
@@ -451,6 +592,9 @@ export function Sidebar() {
toggleSidebar,
projects.length,
handleOpenFolder,
projectHistory.length,
cyclePrevProject,
cycleNextProject,
]);
// Register keyboard shortcuts
@@ -464,7 +608,7 @@ export function Sidebar() {
<aside
className={cn(
"flex-shrink-0 border-r border-sidebar-border bg-sidebar backdrop-blur-md flex flex-col z-30 transition-all duration-300 relative",
sidebarOpen ? "w-16 lg:w-60" : "w-16"
sidebarOpen ? "w-16 lg:w-72" : "w-16"
)}
data-testid="sidebar"
>
@@ -566,16 +710,16 @@ export function Sidebar() {
</div>
)}
{/* Project Selector */}
{/* Project Selector with Cycle Buttons */}
{sidebarOpen && projects.length > 0 && (
<div className="px-2 mt-3">
<div className="px-2 mt-3 flex items-center gap-1.5">
<DropdownMenu
open={isProjectPickerOpen}
onOpenChange={setIsProjectPickerOpen}
>
<DropdownMenuTrigger asChild>
<button
className="w-full flex items-center justify-between px-3 py-2.5 rounded-lg bg-sidebar-accent/10 border border-sidebar-border hover:bg-sidebar-accent/20 transition-all text-foreground titlebar-no-drag"
className="flex-1 flex items-center justify-between px-3 py-2.5 rounded-lg bg-sidebar-accent/10 border border-sidebar-border hover:bg-sidebar-accent/20 transition-all text-foreground titlebar-no-drag min-w-0"
data-testid="project-selector"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
@@ -625,6 +769,34 @@ export function Sidebar() {
</DndContext>
</DropdownMenuContent>
</DropdownMenu>
{/* Project Cycle Buttons - only show when there's history */}
{projectHistory.length > 1 && (
<div className="hidden lg:flex items-center gap-1">
<button
onClick={cyclePrevProject}
className="flex items-center justify-center w-8 h-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50 border border-sidebar-border transition-all titlebar-no-drag group relative"
title={`Previous project (${ACTION_SHORTCUTS.cyclePrevProject})`}
data-testid="cycle-prev-project"
>
<RotateCcw className="w-4 h-4" />
<span className="absolute -bottom-5 px-1 py-0.5 text-[9px] font-mono rounded bg-sidebar-accent/20 border border-sidebar-border text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity z-10">
{ACTION_SHORTCUTS.cyclePrevProject}
</span>
</button>
<button
onClick={cycleNextProject}
className="flex items-center justify-center w-8 h-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50 border border-sidebar-border transition-all titlebar-no-drag group relative"
title={`Next project (${ACTION_SHORTCUTS.cycleNextProject})`}
data-testid="cycle-next-project"
>
<RotateCw className="w-4 h-4" />
<span className="absolute -bottom-5 px-1 py-0.5 text-[9px] font-mono rounded bg-sidebar-accent/20 border border-sidebar-border text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity z-10">
{ACTION_SHORTCUTS.cycleNextProject}
</span>
</button>
</div>
)}
</div>
)}
@@ -865,6 +1037,103 @@ export function Sidebar() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* New Project Setup Dialog */}
<Dialog
open={showSetupDialog}
onOpenChange={(open) => {
if (!open && !isCreatingSpec) {
handleSkipSetup();
}
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Set Up Your Project</DialogTitle>
<DialogDescription className="text-muted-foreground">
We didn&apos;t find an app_spec.txt file. Let us help you generate
your app_spec.txt to help describe your project for our system.
We&apos;ll analyze your project&apos;s tech stack and create a
comprehensive specification.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Project Overview</label>
<p className="text-xs text-muted-foreground">
Describe what your project does and what features you want to
build. Be as detailed as you want - this will help us create a
better specification.
</p>
<textarea
className="w-full h-48 p-3 rounded-md border border-border bg-background font-mono text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
value={projectOverview}
onChange={(e) => setProjectOverview(e.target.value)}
placeholder="e.g., A project management tool that allows teams to track tasks, manage sprints, and visualize progress through kanban boards. It should support user authentication, real-time updates, and file attachments..."
autoFocus
/>
</div>
<div className="flex items-start space-x-3 pt-2">
<Checkbox
id="sidebar-generate-features"
checked={generateFeatures}
onCheckedChange={(checked) =>
setGenerateFeatures(checked === true)
}
/>
<div className="space-y-1">
<label
htmlFor="sidebar-generate-features"
className="text-sm font-medium cursor-pointer"
>
Generate feature list
</label>
<p className="text-xs text-muted-foreground">
Automatically populate feature_list.json with all features
from the implementation roadmap after the spec is generated.
</p>
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={handleSkipSetup}>
Skip for now
</Button>
<Button
onClick={handleCreateInitialSpec}
disabled={!projectOverview.trim()}
>
<Sparkles className="w-4 h-4 mr-2" />
Generate Spec
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Spec Creation Indicator - Bottom Right Toast */}
{isCreatingSpec &&
showSpecIndicator &&
currentProject?.path === creatingSpecProjectPath && (
<div className="fixed bottom-4 right-4 z-50 flex items-center gap-3 bg-card border border-border rounded-lg shadow-lg p-4 max-w-sm">
<Loader2 className="w-5 h-5 animate-spin text-primary flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Creating App Specification</p>
<p className="text-xs text-muted-foreground truncate">
Working on your project...
</p>
</div>
<button
onClick={() => setShowSpecIndicator(false)}
className="p-1 hover:bg-muted rounded-md transition-colors flex-shrink-0"
aria-label="Dismiss notification"
>
<X className="w-4 h-4 text-muted-foreground" />
</button>
</div>
)}
</aside>
);
}

View File

@@ -50,6 +50,7 @@ import {
import { KanbanColumn } from "./kanban-column";
import { KanbanCard } from "./kanban-card";
import { AgentOutputModal } from "./agent-output-modal";
import { FeatureSuggestionsDialog } from "./feature-suggestions-dialog";
import {
Plus,
RefreshCw,
@@ -63,6 +64,7 @@ import {
CheckCircle2,
MessageSquare,
GitCommit,
Lightbulb,
} from "lucide-react";
import { toast } from "sonner";
import { Slider } from "@/components/ui/slider";
@@ -92,7 +94,6 @@ export function BoardView() {
updateFeature,
removeFeature,
moveFeature,
runningAutoTasks,
maxConcurrency,
setMaxConcurrency,
defaultSkipTests,
@@ -124,6 +125,8 @@ export function BoardView() {
const [followUpImagePaths, setFollowUpImagePaths] = useState<
DescriptionImagePath[]
>([]);
const [showSuggestionsDialog, setShowSuggestionsDialog] = useState(false);
const [suggestionsCount, setSuggestionsCount] = useState(0);
// Make current project available globally for modal
useEffect(() => {
@@ -135,12 +138,30 @@ export function BoardView() {
};
}, [currentProject]);
// Listen for suggestions events to update count
useEffect(() => {
const api = getElectronAPI();
if (!api?.suggestions) return;
const unsubscribe = api.suggestions.onEvent((event) => {
if (event.type === "suggestions_complete" && event.suggestions) {
setSuggestionsCount(event.suggestions.length);
}
});
return () => {
unsubscribe();
};
}, []);
// Track previous project to detect switches
const prevProjectPathRef = useRef<string | null>(null);
const isSwitchingProjectRef = useRef<boolean>(false);
// Auto mode hook
const autoMode = useAutoMode();
// Get runningTasks from the hook (scoped to current project)
const runningAutoTasks = autoMode.runningTasks;
// Get in-progress features for keyboard shortcuts (memoized for shortcuts)
const inProgressFeaturesForShortcuts = useMemo(() => {
@@ -340,11 +361,15 @@ export function BoardView() {
// Listen for auto mode feature completion and errors to reload features
useEffect(() => {
const api = getElectronAPI();
if (!api?.autoMode) return;
if (!api?.autoMode || !currentProject) return;
const { removeRunningTask } = useAppStore.getState();
const projectId = currentProject.id;
const unsubscribe = api.autoMode.onEvent((event) => {
// Use event's projectId if available, otherwise use current project
const eventProjectId = event.projectId || projectId;
if (event.type === "auto_mode_feature_complete") {
// Reload features when a feature is completed
console.log("[Board] Feature completed, reloading features...");
@@ -358,7 +383,7 @@ export function BoardView() {
// Remove from running tasks so it moves to the correct column
if (event.featureId) {
removeRunningTask(event.featureId);
removeRunningTask(eventProjectId, event.featureId);
}
loadFeatures();
@@ -370,7 +395,7 @@ export function BoardView() {
});
return unsubscribe;
}, [loadFeatures]);
}, [loadFeatures, currentProject]);
useEffect(() => {
loadFeatures();
@@ -383,6 +408,8 @@ export function BoardView() {
// Sync running tasks from electron backend on mount
useEffect(() => {
if (!currentProject) return;
const syncRunningTasks = async () => {
try {
const api = getElectronAPI();
@@ -395,13 +422,14 @@ export function BoardView() {
status.runningFeatures
);
// Clear existing running tasks and add the actual running ones
// Clear existing running tasks for this project and add the actual running ones
const { clearRunningTasks, addRunningTask } = useAppStore.getState();
clearRunningTasks();
const projectId = currentProject.id;
clearRunningTasks(projectId);
// Add each running feature to the store
status.runningFeatures.forEach((featureId: string) => {
addRunningTask(featureId);
addRunningTask(projectId, featureId);
});
}
} catch (error) {
@@ -410,7 +438,7 @@ export function BoardView() {
};
syncRunningTasks();
}, []);
}, [currentProject]);
// Check which features have context files
useEffect(() => {
@@ -1272,21 +1300,42 @@ export function BoardView() {
<Trash2 className="w-3 h-3 mr-1" />
Delete All
</Button>
) : column.id === "backlog" &&
columnFeatures.length > 0 ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10"
onClick={handleStartNextFeatures}
data-testid="start-next-button"
>
<FastForward className="w-3 h-3 mr-1" />
Start Next
<span className="ml-1 px-1 py-0.5 text-[9px] font-mono rounded bg-white/10 border border-white/20">
{ACTION_SHORTCUTS.startNext}
</span>
</Button>
) : column.id === "backlog" ? (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10 relative"
onClick={() => setShowSuggestionsDialog(true)}
title="Feature Suggestions"
data-testid="feature-suggestions-button"
>
<Lightbulb className="w-3.5 h-3.5" />
{suggestionsCount > 0 && (
<span
className="absolute -top-1 -right-1 w-4 h-4 text-[9px] font-mono rounded-full bg-yellow-500 text-black flex items-center justify-center"
data-testid="suggestions-count"
>
{suggestionsCount}
</span>
)}
</Button>
{columnFeatures.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10"
onClick={handleStartNextFeatures}
data-testid="start-next-button"
>
<FastForward className="w-3 h-3 mr-1" />
Start Next
<span className="ml-1 px-1 py-0.5 text-[9px] font-mono rounded bg-white/10 border border-white/20">
{ACTION_SHORTCUTS.startNext}
</span>
</Button>
)}
</div>
) : undefined
}
>
@@ -1754,6 +1803,16 @@ export function BoardView() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Feature Suggestions Dialog */}
<FeatureSuggestionsDialog
open={showSuggestionsDialog}
onClose={() => {
setShowSuggestionsDialog(false);
// Clear the count when dialog is closed (suggestions were either imported or dismissed)
}}
projectPath={currentProject.path}
/>
</div>
);
}

View File

@@ -371,7 +371,7 @@ export function ContextView() {
<Plus className="w-4 h-4 mr-2" />
Add File
<span
className="ml-2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-white/10 border border-white/20"
className="ml-2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-secondary border border-border"
data-testid="shortcut-add-context-file"
>
{ACTION_SHORTCUTS.addContextFile}
@@ -387,9 +387,9 @@ export function ContextView() {
onDragOver={handleDragOver}
>
{/* Left Panel - File List */}
<div className="w-64 border-r border-white/10 flex flex-col overflow-hidden">
<div className="p-3 border-b border-white/10">
<h2 className="text-sm font-semibold text-zinc-400">
<div className="w-64 border-r border-border flex flex-col overflow-hidden">
<div className="p-3 border-b border-border">
<h2 className="text-sm font-semibold text-muted-foreground">
Context Files ({contextFiles.length})
</h2>
</div>
@@ -399,8 +399,8 @@ export function ContextView() {
>
{contextFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-4">
<Upload className="w-8 h-8 text-zinc-500 mb-2" />
<p className="text-sm text-zinc-500">
<Upload className="w-8 h-8 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">
No context files yet.
<br />
Drop files here or click Add File.
@@ -415,8 +415,8 @@ export function ContextView() {
className={cn(
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors",
selectedFile?.path === file.path
? "bg-brand-500/20 text-white border border-brand-500/30"
: "text-zinc-400 hover:bg-white/5 hover:text-white"
? "bg-primary/20 text-foreground border border-primary/30"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
)}
data-testid={`context-file-${file.name}`}
>
@@ -438,12 +438,12 @@ export function ContextView() {
{selectedFile ? (
<>
{/* File toolbar */}
<div className="flex items-center justify-between p-3 border-b border-white/10 bg-zinc-900/50">
<div className="flex items-center justify-between p-3 border-b border-border bg-card">
<div className="flex items-center gap-2">
{selectedFile.type === "image" ? (
<ImageIcon className="w-4 h-4 text-zinc-400" />
<ImageIcon className="w-4 h-4 text-muted-foreground" />
) : (
<FileText className="w-4 h-4 text-zinc-400" />
<FileText className="w-4 h-4 text-muted-foreground" />
)}
<span className="text-sm font-medium">
{selectedFile.name}
@@ -477,7 +477,7 @@ export function ContextView() {
<div className="flex-1 overflow-hidden p-4">
{selectedFile.type === "image" ? (
<div
className="h-full flex items-center justify-center bg-zinc-900/50 rounded-lg"
className="h-full flex items-center justify-center bg-card rounded-lg"
data-testid="image-preview"
>
<img
@@ -503,9 +503,9 @@ export function ContextView() {
) : (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<File className="w-12 h-12 text-zinc-600 mx-auto mb-3" />
<p className="text-zinc-500">Select a file to view or edit</p>
<p className="text-zinc-600 text-sm mt-1">
<File className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
<p className="text-foreground-secondary">Select a file to view or edit</p>
<p className="text-muted-foreground text-sm mt-1">
Or drop files here to add them
</p>
</div>
@@ -516,7 +516,10 @@ export function ContextView() {
{/* Add File Dialog */}
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogContent data-testid="add-context-dialog">
<DialogContent
data-testid="add-context-dialog"
className="w-[60vw] max-w-[60vw] max-h-[80vh] flex flex-col"
>
<DialogHeader>
<DialogTitle>Add Context File</DialogTitle>
<DialogDescription>
@@ -564,7 +567,7 @@ export function ContextView() {
<div
className={cn(
"relative rounded-lg transition-colors",
isDropHovering && "ring-2 ring-brand-500"
isDropHovering && "ring-2 ring-primary"
)}
>
<textarea
@@ -576,15 +579,15 @@ export function ContextView() {
onDragLeave={handleTextAreaDragLeave}
placeholder="Enter context content here or drag & drop a .txt or .md file..."
className={cn(
"w-full h-40 p-3 font-mono text-sm bg-zinc-900 border border-zinc-700 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent",
isDropHovering && "border-brand-500 bg-brand-500/10"
"w-full h-40 p-3 font-mono text-sm bg-background border border-border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent",
isDropHovering && "border-primary bg-primary/10"
)}
spellCheck={false}
data-testid="new-file-content"
/>
{isDropHovering && (
<div className="absolute inset-0 flex items-center justify-center bg-brand-500/20 rounded-lg pointer-events-none">
<div className="flex flex-col items-center text-brand-400">
<div className="absolute inset-0 flex items-center justify-center bg-primary/20 rounded-lg pointer-events-none">
<div className="flex flex-col items-center text-primary">
<Upload className="w-8 h-8 mb-2" />
<span className="text-sm font-medium">
Drop .txt or .md file here
@@ -593,7 +596,7 @@ export function ContextView() {
</div>
)}
</div>
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted-foreground">
Drag & drop .txt or .md files to import their content
</p>
</div>
@@ -602,7 +605,7 @@ export function ContextView() {
{newFileType === "image" && (
<div className="space-y-2">
<Label>Upload Image</Label>
<div className="border-2 border-dashed border-zinc-700 rounded-lg p-4 text-center">
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center">
<input
type="file"
accept="image/*"
@@ -622,9 +625,9 @@ export function ContextView() {
className="max-w-32 max-h-32 object-contain mb-2"
/>
) : (
<Upload className="w-8 h-8 text-zinc-500 mb-2" />
<Upload className="w-8 h-8 text-muted-foreground mb-2" />
)}
<span className="text-sm text-zinc-400">
<span className="text-sm text-muted-foreground">
{uploadedImageData
? "Click to change"
: "Click to upload"}

View File

@@ -0,0 +1,433 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
Loader2,
Lightbulb,
Download,
StopCircle,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { getElectronAPI, FeatureSuggestion, SuggestionsEvent } from "@/lib/electron";
import { useAppStore, Feature } from "@/store/app-store";
import { toast } from "sonner";
interface FeatureSuggestionsDialogProps {
open: boolean;
onClose: () => void;
projectPath: string;
}
export function FeatureSuggestionsDialog({
open,
onClose,
projectPath,
}: FeatureSuggestionsDialogProps) {
const [isGenerating, setIsGenerating] = useState(false);
const [progress, setProgress] = useState<string[]>([]);
const [suggestions, setSuggestions] = useState<FeatureSuggestion[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [isImporting, setIsImporting] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const { features, setFeatures } = useAppStore();
// Auto-scroll progress when new content arrives
useEffect(() => {
if (autoScrollRef.current && scrollRef.current && isGenerating) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [progress, isGenerating]);
// Listen for suggestion events
useEffect(() => {
if (!open) return;
const api = getElectronAPI();
if (!api?.suggestions) return;
const unsubscribe = api.suggestions.onEvent((event: SuggestionsEvent) => {
if (event.type === "suggestions_progress") {
setProgress((prev) => [...prev, event.content || ""]);
} else if (event.type === "suggestions_tool") {
const toolName = event.tool || "Unknown Tool";
setProgress((prev) => [...prev, `Using tool: ${toolName}\n`]);
} else if (event.type === "suggestions_complete") {
setIsGenerating(false);
if (event.suggestions && event.suggestions.length > 0) {
setSuggestions(event.suggestions);
// Select all by default
setSelectedIds(new Set(event.suggestions.map((s) => s.id)));
toast.success(`Generated ${event.suggestions.length} feature suggestions!`);
} else {
toast.info("No suggestions generated. Try again.");
}
} else if (event.type === "suggestions_error") {
setIsGenerating(false);
toast.error(`Error: ${event.error}`);
}
});
return () => {
unsubscribe();
};
}, [open]);
// Start generating suggestions
const handleGenerate = useCallback(async () => {
const api = getElectronAPI();
if (!api?.suggestions) {
toast.error("Suggestions API not available");
return;
}
setIsGenerating(true);
setProgress([]);
setSuggestions([]);
setSelectedIds(new Set());
try {
const result = await api.suggestions.generate(projectPath);
if (!result.success) {
toast.error(result.error || "Failed to start generation");
setIsGenerating(false);
}
} catch (error) {
console.error("Failed to generate suggestions:", error);
toast.error("Failed to start generation");
setIsGenerating(false);
}
}, [projectPath]);
// Stop generating
const handleStop = useCallback(async () => {
const api = getElectronAPI();
if (!api?.suggestions) return;
try {
await api.suggestions.stop();
setIsGenerating(false);
toast.info("Generation stopped");
} catch (error) {
console.error("Failed to stop generation:", error);
}
}, []);
// Toggle suggestion selection
const toggleSelection = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
// Toggle expand/collapse for a suggestion
const toggleExpanded = useCallback((id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
// Select/deselect all
const toggleSelectAll = useCallback(() => {
if (selectedIds.size === suggestions.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(suggestions.map((s) => s.id)));
}
}, [selectedIds.size, suggestions]);
// Import selected suggestions as features
const handleImport = useCallback(async () => {
if (selectedIds.size === 0) {
toast.warning("No suggestions selected");
return;
}
setIsImporting(true);
try {
const api = getElectronAPI();
const selectedSuggestions = suggestions.filter((s) =>
selectedIds.has(s.id)
);
// Create new features from selected suggestions
const newFeatures: Feature[] = selectedSuggestions.map((s) => ({
id: `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
category: s.category,
description: s.description,
steps: s.steps,
status: "backlog" as const,
skipTests: true, // As specified, testing mode true
}));
// Merge with existing features
const updatedFeatures = [...features, ...newFeatures];
// Save to file
const featureListPath = `${projectPath}/.automaker/feature_list.json`;
await api.writeFile(featureListPath, JSON.stringify(updatedFeatures, null, 2));
// Update store
setFeatures(updatedFeatures);
toast.success(`Imported ${newFeatures.length} features to backlog!`);
onClose();
} catch (error) {
console.error("Failed to import features:", error);
toast.error("Failed to import features");
} finally {
setIsImporting(false);
}
}, [selectedIds, suggestions, features, setFeatures, projectPath, onClose]);
// Handle scroll to detect if user scrolled up
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
autoScrollRef.current = isAtBottom;
};
// Reset state when dialog closes
useEffect(() => {
if (!open) {
// Don't reset immediately - allow re-open to see results
// Only reset if explicitly closed without importing
}
}, [open]);
const hasStarted = progress.length > 0 || suggestions.length > 0;
const hasSuggestions = suggestions.length > 0;
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent
className="w-[70vw] max-w-[70vw] max-h-[85vh] flex flex-col"
data-testid="feature-suggestions-dialog"
>
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
<Lightbulb className="w-5 h-5 text-yellow-500" />
Feature Suggestions
</DialogTitle>
<DialogDescription>
Analyze your project to discover missing features and improvements.
The AI will scan your codebase and suggest features ordered by priority.
</DialogDescription>
</DialogHeader>
{!hasStarted ? (
// Initial state - show explanation and generate button
<div className="flex-1 flex flex-col items-center justify-center py-8 text-center">
<Lightbulb className="w-16 h-16 text-yellow-500/50 mb-4" />
<h3 className="text-lg font-semibold mb-2">
Discover Missing Features
</h3>
<p className="text-muted-foreground max-w-md mb-6">
Our AI will analyze your project structure, code patterns, and
existing features to generate a prioritized list of suggestions
for new features you could add.
</p>
<Button onClick={handleGenerate} size="lg">
<Lightbulb className="w-4 h-4 mr-2" />
Generate Suggestions
</Button>
</div>
) : isGenerating ? (
// Generating state - show progress
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
Analyzing project...
</div>
<Button variant="destructive" size="sm" onClick={handleStop}>
<StopCircle className="w-4 h-4 mr-2" />
Stop
</Button>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[200px] max-h-[400px]"
>
<div className="whitespace-pre-wrap break-words text-zinc-300">
{progress.join("")}
</div>
</div>
</div>
) : hasSuggestions ? (
// Results state - show suggestions list
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">
{suggestions.length} suggestions generated
</span>
<Button variant="ghost" size="sm" onClick={toggleSelectAll}>
{selectedIds.size === suggestions.length
? "Deselect All"
: "Select All"}
</Button>
</div>
<span className="text-sm font-medium">
{selectedIds.size} selected
</span>
</div>
<div
ref={scrollRef}
className="flex-1 overflow-y-auto space-y-2 min-h-[200px] max-h-[400px] pr-2"
>
{suggestions.map((suggestion) => {
const isSelected = selectedIds.has(suggestion.id);
const isExpanded = expandedIds.has(suggestion.id);
return (
<div
key={suggestion.id}
className={`border rounded-lg p-3 transition-colors ${
isSelected
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
}`}
data-testid={`suggestion-${suggestion.id}`}
>
<div className="flex items-start gap-3">
<Checkbox
id={suggestion.id}
checked={isSelected}
onCheckedChange={() => toggleSelection(suggestion.id)}
className="mt-1"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<button
onClick={() => toggleExpanded(suggestion.id)}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/20 text-primary font-medium">
#{suggestion.priority}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-secondary text-secondary-foreground">
{suggestion.category}
</span>
</div>
<Label
htmlFor={suggestion.id}
className="text-sm font-medium cursor-pointer"
>
{suggestion.description}
</Label>
{isExpanded && (
<div className="mt-3 space-y-2 text-sm">
{suggestion.reasoning && (
<p className="text-muted-foreground italic">
{suggestion.reasoning}
</p>
)}
{suggestion.steps.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
Implementation Steps:
</p>
<ul className="list-disc list-inside text-xs text-muted-foreground space-y-0.5">
{suggestion.steps.map((step, i) => (
<li key={i}>{step}</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
) : (
// No results state
<div className="flex-1 flex flex-col items-center justify-center py-8 text-center">
<p className="text-muted-foreground mb-4">
No suggestions were generated. Try running the analysis again.
</p>
<Button onClick={handleGenerate}>
<Lightbulb className="w-4 h-4 mr-2" />
Try Again
</Button>
</div>
)}
<DialogFooter className="flex-shrink-0">
{hasSuggestions && (
<div className="flex gap-2 w-full justify-between">
<Button variant="outline" onClick={handleGenerate}>
<Lightbulb className="w-4 h-4 mr-2" />
Regenerate
</Button>
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
onClick={handleImport}
disabled={selectedIds.size === 0 || isImporting}
>
{isImporting ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Download className="w-4 h-4 mr-2" />
)}
Import {selectedIds.size} Feature
{selectedIds.size !== 1 ? "s" : ""}
</Button>
</div>
</div>
)}
{!hasSuggestions && !isGenerating && hasStarted && (
<Button variant="ghost" onClick={onClose}>
Close
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -5,13 +5,35 @@ import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Save, RefreshCw, FileText } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Save, RefreshCw, FileText, Sparkles, Loader2, FilePlus2 } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import type { SpecRegenerationEvent } from "@/types/electron";
export function SpecView() {
const { currentProject, appSpec, setAppSpec } = useAppStore();
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [specExists, setSpecExists] = useState(true);
// Regeneration state
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false);
const [projectDefinition, setProjectDefinition] = useState("");
const [isRegenerating, setIsRegenerating] = useState(false);
// Create spec state
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [projectOverview, setProjectOverview] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [generateFeatures, setGenerateFeatures] = useState(true);
// Load spec from file
const loadSpec = useCallback(async () => {
@@ -26,10 +48,16 @@ export function SpecView() {
if (result.success && result.content) {
setAppSpec(result.content);
setSpecExists(true);
setHasChanges(false);
} else {
// File doesn't exist
setAppSpec("");
setSpecExists(false);
}
} catch (error) {
console.error("Failed to load spec:", error);
setSpecExists(false);
} finally {
setIsLoading(false);
}
@@ -39,6 +67,35 @@ export function SpecView() {
loadSpec();
}, [loadSpec]);
// Subscribe to spec regeneration events
useEffect(() => {
const api = getElectronAPI();
if (!api.specRegeneration) return;
const unsubscribe = api.specRegeneration.onEvent((event: SpecRegenerationEvent) => {
console.log("[SpecView] Regeneration event:", event.type);
if (event.type === "spec_regeneration_complete") {
setIsRegenerating(false);
setIsCreating(false);
setShowRegenerateDialog(false);
setShowCreateDialog(false);
setProjectDefinition("");
setProjectOverview("");
// Reload the spec to show the new content
loadSpec();
} else if (event.type === "spec_regeneration_error") {
setIsRegenerating(false);
setIsCreating(false);
console.error("[SpecView] Regeneration error:", event.error);
}
});
return () => {
unsubscribe();
};
}, [loadSpec]);
// Save spec to file
const saveSpec = async () => {
if (!currentProject) return;
@@ -63,6 +120,62 @@ export function SpecView() {
setHasChanges(true);
};
const handleRegenerate = async () => {
if (!currentProject || !projectDefinition.trim()) return;
setIsRegenerating(true);
try {
const api = getElectronAPI();
if (!api.specRegeneration) {
console.error("[SpecView] Spec regeneration not available");
setIsRegenerating(false);
return;
}
const result = await api.specRegeneration.generate(
currentProject.path,
projectDefinition.trim()
);
if (!result.success) {
console.error("[SpecView] Failed to start regeneration:", result.error);
setIsRegenerating(false);
}
// If successful, we'll wait for the events to update the state
} catch (error) {
console.error("[SpecView] Failed to regenerate spec:", error);
setIsRegenerating(false);
}
};
const handleCreateSpec = async () => {
if (!currentProject || !projectOverview.trim()) return;
setIsCreating(true);
setShowCreateDialog(false);
try {
const api = getElectronAPI();
if (!api.specRegeneration) {
console.error("[SpecView] Spec regeneration not available");
setIsCreating(false);
return;
}
const result = await api.specRegeneration.create(
currentProject.path,
projectOverview.trim(),
generateFeatures
);
if (!result.success) {
console.error("[SpecView] Failed to start spec creation:", result.error);
setIsCreating(false);
}
// If successful, we'll wait for the events to update the state
} catch (error) {
console.error("[SpecView] Failed to create spec:", error);
setIsCreating(false);
}
};
if (!currentProject) {
return (
<div
@@ -85,6 +198,121 @@ export function SpecView() {
);
}
// Show empty state when no spec exists (isCreating is handled by bottom-right indicator in sidebar)
if (!specExists) {
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="spec-view-empty"
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-3">
<FileText className="w-5 h-5 text-muted-foreground" />
<div>
<h1 className="text-xl font-bold">App Specification</h1>
<p className="text-sm text-muted-foreground">
{currentProject.path}/.automaker/app_spec.txt
</p>
</div>
</div>
</div>
{/* Empty State */}
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-center max-w-md">
<div className="mb-6 flex justify-center">
<div className="p-4 rounded-full bg-primary/10">
<FilePlus2 className="w-12 h-12 text-primary" />
</div>
</div>
<h2 className="text-2xl font-semibold mb-3">No App Specification Found</h2>
<p className="text-muted-foreground mb-6">
Create an app specification to help our system understand your project.
We&apos;ll analyze your codebase and generate a comprehensive spec based on your description.
</p>
<Button
size="lg"
onClick={() => setShowCreateDialog(true)}
>
<FilePlus2 className="w-5 h-5 mr-2" />
Create app_spec
</Button>
</div>
</div>
{/* Create Dialog */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create App Specification</DialogTitle>
<DialogDescription className="text-muted-foreground">
We didn&apos;t find an app_spec.txt file. Let us help you generate your app_spec.txt
to help describe your project for our system. We&apos;ll analyze your project&apos;s
tech stack and create a comprehensive specification.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">
Project Overview
</label>
<p className="text-xs text-muted-foreground">
Describe what your project does and what features you want to build.
Be as detailed as you want - this will help us create a better specification.
</p>
<textarea
className="w-full h-48 p-3 rounded-md border border-border bg-background font-mono text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
value={projectOverview}
onChange={(e) => setProjectOverview(e.target.value)}
placeholder="e.g., A project management tool that allows teams to track tasks, manage sprints, and visualize progress through kanban boards. It should support user authentication, real-time updates, and file attachments..."
autoFocus
/>
</div>
<div className="flex items-start space-x-3 pt-2">
<Checkbox
id="generate-features"
checked={generateFeatures}
onCheckedChange={(checked) => setGenerateFeatures(checked === true)}
/>
<div className="space-y-1">
<label
htmlFor="generate-features"
className="text-sm font-medium cursor-pointer"
>
Generate feature list
</label>
<p className="text-xs text-muted-foreground">
Automatically populate feature_list.json with all features from the
implementation roadmap after the spec is generated.
</p>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setShowCreateDialog(false)}
>
Cancel
</Button>
<Button
onClick={handleCreateSpec}
disabled={!projectOverview.trim()}
>
<Sparkles className="w-4 h-4 mr-2" />
Generate Spec
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
@@ -102,6 +330,20 @@ export function SpecView() {
</div>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setShowRegenerateDialog(true)}
disabled={isRegenerating}
data-testid="regenerate-spec"
>
{isRegenerating ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Sparkles className="w-4 h-4 mr-2" />
)}
{isRegenerating ? "Regenerating..." : "Regenerate"}
</Button>
<Button
size="sm"
onClick={saveSpec}
@@ -127,6 +369,65 @@ export function SpecView() {
/>
</Card>
</div>
{/* Regenerate Dialog */}
<Dialog open={showRegenerateDialog} onOpenChange={setShowRegenerateDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Regenerate App Specification</DialogTitle>
<DialogDescription className="text-muted-foreground">
We will regenerate your app spec based on a short project definition and the
current tech stack found in your project. The agent will analyze your codebase
to understand your existing technologies and create a comprehensive specification.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">
Describe your project
</label>
<p className="text-xs text-muted-foreground">
Provide a clear description of what your app should do. Be as detailed as you
want - the more context you provide, the more comprehensive the spec will be.
</p>
<textarea
className="w-full h-40 p-3 rounded-md border border-border bg-background font-mono text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
value={projectDefinition}
onChange={(e) => setProjectDefinition(e.target.value)}
placeholder="e.g., A task management app where users can create projects, add tasks with due dates, assign tasks to team members, track progress with a kanban board, and receive notifications for upcoming deadlines..."
disabled={isRegenerating}
/>
</div>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setShowRegenerateDialog(false)}
disabled={isRegenerating}
>
Cancel
</Button>
<Button
onClick={handleRegenerate}
disabled={!projectDefinition.trim() || isRegenerating}
>
{isRegenerating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Regenerating...
</>
) : (
<>
<Sparkles className="w-4 h-4 mr-2" />
Regenerate Spec
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,17 +1,15 @@
import { useEffect, useCallback } from "react";
import { useEffect, useCallback, useMemo } from "react";
import { useShallow } from "zustand/react/shallow";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import type { AutoModeEvent } from "@/types/electron";
import { getElectronAPI, type AutoModeEvent } from "@/lib/electron";
/**
* Hook for managing auto mode
* Hook for managing auto mode (scoped per project)
*/
export function useAutoMode() {
const {
isAutoModeRunning,
autoModeByProject,
setAutoModeRunning,
runningAutoTasks,
addRunningTask,
removeRunningTask,
clearRunningTasks,
@@ -20,9 +18,8 @@ export function useAutoMode() {
maxConcurrency,
} = useAppStore(
useShallow((state) => ({
isAutoModeRunning: state.isAutoModeRunning,
autoModeByProject: state.autoModeByProject,
setAutoModeRunning: state.setAutoModeRunning,
runningAutoTasks: state.runningAutoTasks,
addRunningTask: state.addRunningTask,
removeRunningTask: state.removeRunningTask,
clearRunningTasks: state.clearRunningTasks,
@@ -32,56 +29,74 @@ export function useAutoMode() {
}))
);
// Get project-specific auto mode state
const projectId = currentProject?.id;
const projectAutoModeState = useMemo(() => {
if (!projectId) return { isRunning: false, runningTasks: [] };
return autoModeByProject[projectId] || { isRunning: false, runningTasks: [] };
}, [autoModeByProject, projectId]);
const isAutoModeRunning = projectAutoModeState.isRunning;
const runningAutoTasks = projectAutoModeState.runningTasks;
// Check if we can start a new task based on concurrency limit
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
// Handle auto mode events
useEffect(() => {
const api = getElectronAPI();
if (!api?.autoMode) return;
if (!api?.autoMode || !projectId) return;
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
console.log("[AutoMode Event]", event);
// Events include projectId from backend, use it to scope updates
// Fall back to current projectId if not provided in event
const eventProjectId = event.projectId ?? projectId;
switch (event.type) {
case "auto_mode_feature_start":
addRunningTask(event.featureId);
addAutoModeActivity({
featureId: event.featureId,
type: "start",
message: `Started working on feature`,
});
if (event.featureId) {
addRunningTask(eventProjectId, event.featureId);
addAutoModeActivity({
featureId: event.featureId,
type: "start",
message: `Started working on feature`,
});
}
break;
case "auto_mode_feature_complete":
// Feature completed - remove from running tasks and UI will reload features on its own
console.log(
"[AutoMode] Feature completed:",
event.featureId,
"passes:",
event.passes
);
removeRunningTask(event.featureId);
addAutoModeActivity({
featureId: event.featureId,
type: "complete",
message: event.passes
? "Feature completed successfully"
: "Feature completed with failures",
passes: event.passes,
});
if (event.featureId) {
console.log(
"[AutoMode] Feature completed:",
event.featureId,
"passes:",
event.passes
);
removeRunningTask(eventProjectId, event.featureId);
addAutoModeActivity({
featureId: event.featureId,
type: "complete",
message: event.passes
? "Feature completed successfully"
: "Feature completed with failures",
passes: event.passes,
});
}
break;
case "auto_mode_complete":
// All features completed
setAutoModeRunning(false);
clearRunningTasks();
// All features completed for this project
setAutoModeRunning(eventProjectId, false);
clearRunningTasks(eventProjectId);
console.log("[AutoMode] All features completed!");
break;
case "auto_mode_error":
console.error("[AutoMode Error]", event.error);
if (event.featureId) {
if (event.featureId && event.error) {
addAutoModeActivity({
featureId: event.featureId,
type: "error",
@@ -92,7 +107,7 @@ export function useAutoMode() {
case "auto_mode_progress":
// Log progress updates (throttle to avoid spam)
if (event.content && event.content.length > 10) {
if (event.featureId && event.content && event.content.length > 10) {
addAutoModeActivity({
featureId: event.featureId,
type: "progress",
@@ -103,31 +118,36 @@ export function useAutoMode() {
case "auto_mode_tool":
// Log tool usage
addAutoModeActivity({
featureId: event.featureId,
type: "tool",
message: `Using tool: ${event.tool}`,
tool: event.tool,
});
if (event.featureId && event.tool) {
addAutoModeActivity({
featureId: event.featureId,
type: "tool",
message: `Using tool: ${event.tool}`,
tool: event.tool,
});
}
break;
case "auto_mode_phase":
// Log phase transitions (Planning, Action, Verification)
console.log(
`[AutoMode] Phase: ${event.phase} for ${event.featureId}`
);
addAutoModeActivity({
featureId: event.featureId,
type: event.phase,
message: event.message,
phase: event.phase,
});
if (event.featureId && event.phase && event.message) {
console.log(
`[AutoMode] Phase: ${event.phase} for ${event.featureId}`
);
addAutoModeActivity({
featureId: event.featureId,
type: event.phase,
message: event.message,
phase: event.phase,
});
}
break;
}
});
return unsubscribe;
}, [
projectId,
addRunningTask,
removeRunningTask,
clearRunningTasks,
@@ -151,7 +171,7 @@ export function useAutoMode() {
const result = await api.autoMode.start(currentProject.path, maxConcurrency);
if (result.success) {
setAutoModeRunning(true);
setAutoModeRunning(currentProject.id, true);
console.log(`[AutoMode] Started successfully with maxConcurrency: ${maxConcurrency}`);
} else {
console.error("[AutoMode] Failed to start:", result.error);
@@ -159,13 +179,20 @@ export function useAutoMode() {
}
} catch (error) {
console.error("[AutoMode] Error starting:", error);
setAutoModeRunning(false);
if (currentProject) {
setAutoModeRunning(currentProject.id, false);
}
throw error;
}
}, [currentProject, setAutoModeRunning, maxConcurrency]);
// Stop auto mode
const stop = useCallback(async () => {
if (!currentProject) {
console.error("No project selected");
return;
}
try {
const api = getElectronAPI();
if (!api?.autoMode) {
@@ -175,8 +202,8 @@ export function useAutoMode() {
const result = await api.autoMode.stop();
if (result.success) {
setAutoModeRunning(false);
clearRunningTasks();
setAutoModeRunning(currentProject.id, false);
clearRunningTasks(currentProject.id);
console.log("[AutoMode] Stopped successfully");
} else {
console.error("[AutoMode] Failed to stop:", result.error);
@@ -186,11 +213,16 @@ export function useAutoMode() {
console.error("[AutoMode] Error stopping:", error);
throw error;
}
}, [setAutoModeRunning, clearRunningTasks]);
}, [currentProject, setAutoModeRunning, clearRunningTasks]);
// Stop a specific feature
const stopFeature = useCallback(
async (featureId: string) => {
if (!currentProject) {
console.error("No project selected");
return;
}
try {
const api = getElectronAPI();
if (!api?.autoMode?.stopFeature) {
@@ -200,7 +232,7 @@ export function useAutoMode() {
const result = await api.autoMode.stopFeature(featureId);
if (result.success) {
removeRunningTask(featureId);
removeRunningTask(currentProject.id, featureId);
console.log("[AutoMode] Feature stopped successfully:", featureId);
addAutoModeActivity({
featureId,
@@ -217,7 +249,7 @@ export function useAutoMode() {
throw error;
}
},
[removeRunningTask, addAutoModeActivity]
[currentProject, removeRunningTask, addAutoModeActivity]
);
return {

View File

@@ -102,7 +102,7 @@ export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
export const NAV_SHORTCUTS: Record<string, string> = {
board: "K", // K for Kanban
agent: "A", // A for Agent
spec: "E", // E for Editor (Spec)
spec: "D", // D for Document (Spec)
context: "C", // C for Context
tools: "T", // T for Tools
settings: "S", // S for Settings
@@ -121,8 +121,10 @@ export const UI_SHORTCUTS: Record<string, string> = {
export const ACTION_SHORTCUTS: Record<string, string> = {
addFeature: "N", // N for New feature
addContextFile: "F", // F for File (add context file)
startNext: "Q", // Q for Queue (start next features from backlog)
startNext: "G", // G for Grab (start next features from backlog)
newSession: "W", // W for new session (in agent view)
openProject: "O", // O for Open project (navigate to welcome view)
projectPicker: "P", // P for Project picker
cyclePrevProject: "Q", // Q for previous project (cycle back through MRU)
cycleNextProject: "E", // E for next project (cycle forward through MRU)
};

View File

@@ -47,6 +47,7 @@ export type AutoModePhase = "planning" | "action" | "verification";
export interface AutoModeEvent {
type: "auto_mode_feature_start" | "auto_mode_progress" | "auto_mode_tool" | "auto_mode_feature_complete" | "auto_mode_error" | "auto_mode_complete" | "auto_mode_phase";
featureId?: string;
projectId?: string;
feature?: object;
content?: string;
tool?: string;
@@ -57,6 +58,47 @@ export interface AutoModeEvent {
phase?: AutoModePhase;
}
// Feature Suggestions types
export interface FeatureSuggestion {
id: string;
category: string;
description: string;
steps: string[];
priority: number;
reasoning: string;
}
export interface SuggestionsEvent {
type: "suggestions_progress" | "suggestions_tool" | "suggestions_complete" | "suggestions_error";
content?: string;
tool?: string;
input?: unknown;
suggestions?: FeatureSuggestion[];
error?: string;
}
export interface SuggestionsAPI {
generate: (projectPath: string) => Promise<{ success: boolean; error?: string }>;
stop: () => Promise<{ success: boolean; error?: string }>;
status: () => Promise<{ success: boolean; isRunning?: boolean; error?: string }>;
onEvent: (callback: (event: SuggestionsEvent) => void) => () => void;
}
// Spec Regeneration types
export type SpecRegenerationEvent =
| { type: "spec_regeneration_progress"; content: string }
| { type: "spec_regeneration_tool"; tool: string; input: unknown }
| { type: "spec_regeneration_complete"; message: string }
| { type: "spec_regeneration_error"; error: string };
export interface SpecRegenerationAPI {
create: (projectPath: string, projectOverview: string, generateFeatures?: boolean) => Promise<{ success: boolean; error?: string }>;
generate: (projectPath: string, projectDefinition: string) => Promise<{ success: boolean; error?: string }>;
stop: () => Promise<{ success: boolean; error?: string }>;
status: () => Promise<{ success: boolean; isRunning?: boolean; error?: string }>;
onEvent: (callback: (event: SpecRegenerationEvent) => void) => () => void;
}
export interface AutoModeAPI {
start: (projectPath: string, maxConcurrency?: number) => Promise<{ success: boolean; error?: string }>;
stop: () => Promise<{ success: boolean; error?: string }>;
@@ -93,12 +135,15 @@ export interface ElectronAPI {
getPath: (name: string) => Promise<string>;
saveImageToTemp?: (data: string, filename: string, mimeType: string) => Promise<SaveImageResult>;
autoMode?: AutoModeAPI;
suggestions?: SuggestionsAPI;
specRegeneration?: SpecRegenerationAPI;
}
// Augment global Window interface
declare global {
interface Window {
electronAPI?: ElectronAPI;
isElectron?: boolean;
electronAPI: ElectronAPI | undefined;
isElectron: boolean | undefined;
}
}
@@ -365,6 +410,12 @@ export const getElectronAPI = (): ElectronAPI => {
// Mock Auto Mode API
autoMode: createMockAutoModeAPI(),
// Mock Suggestions API
suggestions: createMockSuggestionsAPI(),
// Mock Spec Regeneration API
specRegeneration: createMockSpecRegenerationAPI(),
};
};
@@ -768,6 +819,381 @@ function delay(ms: number, featureId: string): Promise<void> {
});
}
// Mock Suggestions state and implementation
let mockSuggestionsRunning = false;
let mockSuggestionsCallbacks: ((event: SuggestionsEvent) => void)[] = [];
let mockSuggestionsTimeout: NodeJS.Timeout | null = null;
function createMockSuggestionsAPI(): SuggestionsAPI {
return {
generate: async (projectPath: string) => {
if (mockSuggestionsRunning) {
return { success: false, error: "Suggestions generation is already running" };
}
mockSuggestionsRunning = true;
console.log(`[Mock] Generating suggestions for: ${projectPath}`);
// Simulate async suggestion generation
simulateSuggestionsGeneration();
return { success: true };
},
stop: async () => {
mockSuggestionsRunning = false;
if (mockSuggestionsTimeout) {
clearTimeout(mockSuggestionsTimeout);
mockSuggestionsTimeout = null;
}
return { success: true };
},
status: async () => {
return {
success: true,
isRunning: mockSuggestionsRunning,
};
},
onEvent: (callback: (event: SuggestionsEvent) => void) => {
mockSuggestionsCallbacks.push(callback);
return () => {
mockSuggestionsCallbacks = mockSuggestionsCallbacks.filter(cb => cb !== callback);
};
},
};
}
function emitSuggestionsEvent(event: SuggestionsEvent) {
mockSuggestionsCallbacks.forEach(cb => cb(event));
}
async function simulateSuggestionsGeneration() {
// Emit progress events
emitSuggestionsEvent({
type: "suggestions_progress",
content: "Starting project analysis...\n",
});
await new Promise(resolve => {
mockSuggestionsTimeout = setTimeout(resolve, 500);
});
if (!mockSuggestionsRunning) return;
emitSuggestionsEvent({
type: "suggestions_tool",
tool: "Glob",
input: { pattern: "**/*.{ts,tsx,js,jsx}" },
});
await new Promise(resolve => {
mockSuggestionsTimeout = setTimeout(resolve, 500);
});
if (!mockSuggestionsRunning) return;
emitSuggestionsEvent({
type: "suggestions_progress",
content: "Analyzing codebase structure...\n",
});
await new Promise(resolve => {
mockSuggestionsTimeout = setTimeout(resolve, 500);
});
if (!mockSuggestionsRunning) return;
emitSuggestionsEvent({
type: "suggestions_progress",
content: "Identifying missing features...\n",
});
await new Promise(resolve => {
mockSuggestionsTimeout = setTimeout(resolve, 500);
});
if (!mockSuggestionsRunning) return;
// Generate mock suggestions
const mockSuggestions: FeatureSuggestion[] = [
{
id: `suggestion-${Date.now()}-0`,
category: "User Experience",
description: "Add dark mode toggle with system preference detection",
steps: [
"Create a ThemeProvider context to manage theme state",
"Add a toggle component in the settings or header",
"Implement CSS variables for theme colors",
"Add localStorage persistence for user preference"
],
priority: 1,
reasoning: "Dark mode is a standard feature that improves accessibility and user comfort"
},
{
id: `suggestion-${Date.now()}-1`,
category: "Performance",
description: "Implement lazy loading for heavy components",
steps: [
"Identify components that are heavy or rarely used",
"Use React.lazy() and Suspense for code splitting",
"Add loading states for lazy-loaded components"
],
priority: 2,
reasoning: "Improves initial load time and reduces bundle size"
},
{
id: `suggestion-${Date.now()}-2`,
category: "Accessibility",
description: "Add keyboard navigation support throughout the app",
steps: [
"Implement focus management for modals and dialogs",
"Add keyboard shortcuts for common actions",
"Ensure all interactive elements are focusable",
"Add ARIA labels and roles where needed"
],
priority: 3,
reasoning: "Improves accessibility for users who rely on keyboard navigation"
},
{
id: `suggestion-${Date.now()}-3`,
category: "Testing",
description: "Add comprehensive unit test coverage",
steps: [
"Set up Jest and React Testing Library",
"Create tests for all utility functions",
"Add component tests for critical UI elements",
"Set up CI pipeline for automated testing"
],
priority: 4,
reasoning: "Ensures code quality and prevents regressions"
},
{
id: `suggestion-${Date.now()}-4`,
category: "Developer Experience",
description: "Add Storybook for component documentation",
steps: [
"Install and configure Storybook",
"Create stories for all UI components",
"Add interaction tests using play functions",
"Set up Chromatic for visual regression testing"
],
priority: 5,
reasoning: "Improves component development workflow and documentation"
}
];
emitSuggestionsEvent({
type: "suggestions_complete",
suggestions: mockSuggestions,
});
mockSuggestionsRunning = false;
mockSuggestionsTimeout = null;
}
// Mock Spec Regeneration state and implementation
let mockSpecRegenerationRunning = false;
let mockSpecRegenerationCallbacks: ((event: SpecRegenerationEvent) => void)[] = [];
let mockSpecRegenerationTimeout: NodeJS.Timeout | null = null;
function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
return {
create: async (projectPath: string, projectOverview: string, generateFeatures = true) => {
if (mockSpecRegenerationRunning) {
return { success: false, error: "Spec creation is already running" };
}
mockSpecRegenerationRunning = true;
console.log(`[Mock] Creating initial spec for: ${projectPath}, generateFeatures: ${generateFeatures}`);
// Simulate async spec creation
simulateSpecCreation(projectPath, projectOverview, generateFeatures);
return { success: true };
},
generate: async (projectPath: string, projectDefinition: string) => {
if (mockSpecRegenerationRunning) {
return { success: false, error: "Spec regeneration is already running" };
}
mockSpecRegenerationRunning = true;
console.log(`[Mock] Regenerating spec for: ${projectPath}`);
// Simulate async spec regeneration
simulateSpecRegeneration(projectPath, projectDefinition);
return { success: true };
},
stop: async () => {
mockSpecRegenerationRunning = false;
if (mockSpecRegenerationTimeout) {
clearTimeout(mockSpecRegenerationTimeout);
mockSpecRegenerationTimeout = null;
}
return { success: true };
},
status: async () => {
return {
success: true,
isRunning: mockSpecRegenerationRunning,
};
},
onEvent: (callback: (event: SpecRegenerationEvent) => void) => {
mockSpecRegenerationCallbacks.push(callback);
return () => {
mockSpecRegenerationCallbacks = mockSpecRegenerationCallbacks.filter(cb => cb !== callback);
};
},
};
}
function emitSpecRegenerationEvent(event: SpecRegenerationEvent) {
mockSpecRegenerationCallbacks.forEach(cb => cb(event));
}
async function simulateSpecCreation(projectPath: string, projectOverview: string, generateFeatures = true) {
emitSpecRegenerationEvent({
type: "spec_regeneration_progress",
content: "Starting project analysis...\n",
});
await new Promise(resolve => {
mockSpecRegenerationTimeout = setTimeout(resolve, 500);
});
if (!mockSpecRegenerationRunning) return;
emitSpecRegenerationEvent({
type: "spec_regeneration_tool",
tool: "Glob",
input: { pattern: "**/*.{json,ts,tsx}" },
});
await new Promise(resolve => {
mockSpecRegenerationTimeout = setTimeout(resolve, 500);
});
if (!mockSpecRegenerationRunning) return;
emitSpecRegenerationEvent({
type: "spec_regeneration_progress",
content: "Detecting tech stack...\n",
});
await new Promise(resolve => {
mockSpecRegenerationTimeout = setTimeout(resolve, 500);
});
if (!mockSpecRegenerationRunning) return;
// Write mock app_spec.txt
mockFileSystem[`${projectPath}/.automaker/app_spec.txt`] = `<project_specification>
<project_name>Demo Project</project_name>
<overview>
${projectOverview}
</overview>
<technology_stack>
<frontend>
<framework>Next.js</framework>
<ui_library>React</ui_library>
<styling>Tailwind CSS</styling>
</frontend>
</technology_stack>
<core_capabilities>
<feature_1>Core functionality based on overview</feature_1>
</core_capabilities>
<implementation_roadmap>
<phase_1_foundation>Setup and basic structure</phase_1_foundation>
<phase_2_core_logic>Core features implementation</phase_2_core_logic>
</implementation_roadmap>
</project_specification>`;
// If generateFeatures is true, also write feature_list.json
if (generateFeatures) {
const timestamp = Date.now();
mockFileSystem[`${projectPath}/.automaker/feature_list.json`] = JSON.stringify([
{
id: `feature-${timestamp}-0`,
category: "Phase 1: Foundation",
description: "Setup and basic structure",
status: "backlog",
steps: ["Initialize project", "Configure dependencies"],
skipTests: true,
},
{
id: `feature-${timestamp}-1`,
category: "Phase 2: Core Logic",
description: "Core features implementation",
status: "backlog",
steps: ["Implement core functionality", "Add business logic"],
skipTests: true,
},
], null, 2);
}
emitSpecRegenerationEvent({
type: "spec_regeneration_complete",
message: "Initial spec creation complete!",
});
mockSpecRegenerationRunning = false;
mockSpecRegenerationTimeout = null;
}
async function simulateSpecRegeneration(projectPath: string, projectDefinition: string) {
emitSpecRegenerationEvent({
type: "spec_regeneration_progress",
content: "Starting spec regeneration...\n",
});
await new Promise(resolve => {
mockSpecRegenerationTimeout = setTimeout(resolve, 500);
});
if (!mockSpecRegenerationRunning) return;
emitSpecRegenerationEvent({
type: "spec_regeneration_progress",
content: "Analyzing codebase...\n",
});
await new Promise(resolve => {
mockSpecRegenerationTimeout = setTimeout(resolve, 500);
});
if (!mockSpecRegenerationRunning) return;
// Write regenerated spec
mockFileSystem[`${projectPath}/.automaker/app_spec.txt`] = `<project_specification>
<project_name>Regenerated Project</project_name>
<overview>
${projectDefinition}
</overview>
<technology_stack>
<frontend>
<framework>Next.js</framework>
<ui_library>React</ui_library>
<styling>Tailwind CSS</styling>
</frontend>
</technology_stack>
<core_capabilities>
<feature_1>Regenerated features based on definition</feature_1>
</core_capabilities>
</project_specification>`;
emitSpecRegenerationEvent({
type: "spec_regeneration_complete",
message: "Spec regeneration complete!",
});
mockSpecRegenerationRunning = false;
mockSpecRegenerationTimeout = null;
}
// Utility functions for project management
export interface Project {

View File

@@ -15,31 +15,6 @@ export interface ProjectInitResult {
existingFiles?: string[];
}
/**
* Default app_spec.txt template for new projects
*/
const DEFAULT_APP_SPEC = `<project_specification>
<project_name>Untitled Project</project_name>
<overview>
Describe your project here. This file will be analyzed by an AI agent
to understand your project structure and tech stack.
</overview>
<technology_stack>
<!-- The AI agent will fill this in after analyzing your project -->
</technology_stack>
<core_capabilities>
<!-- List core features and capabilities -->
</core_capabilities>
<implemented_features>
<!-- The AI agent will populate this based on code analysis -->
</implemented_features>
</project_specification>
`;
/**
* Default feature_list.json template for new projects
*/
@@ -47,15 +22,16 @@ const DEFAULT_FEATURE_LIST = JSON.stringify([], null, 2);
/**
* Required files and directories in the .automaker directory
* Note: app_spec.txt is NOT created automatically - user must set it up via the spec editor
*/
const REQUIRED_STRUCTURE = {
directories: [
".automaker",
".automaker/context",
".automaker/agents-context",
".automaker/images",
],
files: {
".automaker/app_spec.txt": DEFAULT_APP_SPEC,
".automaker/feature_list.json": DEFAULT_FEATURE_LIST,
},
};
@@ -186,3 +162,37 @@ export async function getProjectInitStatus(projectPath: string): Promise<{
};
}
}
/**
* Checks if the app_spec.txt file exists for a project
*
* @param projectPath - The root path of the project
* @returns true if app_spec.txt exists
*/
export async function hasAppSpec(projectPath: string): Promise<boolean> {
const api = getElectronAPI();
try {
const fullPath = `${projectPath}/.automaker/app_spec.txt`;
return await api.exists(fullPath);
} catch (error) {
console.error("[project-init] Error checking app_spec.txt:", error);
return false;
}
}
/**
* Checks if the .automaker directory exists for a project
*
* @param projectPath - The root path of the project
* @returns true if .automaker directory exists
*/
export async function hasAutomakerDir(projectPath: string): Promise<boolean> {
const api = getElectronAPI();
try {
const fullPath = `${projectPath}/.automaker`;
return await api.exists(fullPath);
} catch (error) {
console.error("[project-init] Error checking .automaker dir:", error);
return false;
}
}

View File

@@ -94,6 +94,8 @@ export interface AppState {
projects: Project[];
currentProject: Project | null;
trashedProjects: TrashedProject[];
projectHistory: string[]; // Array of project IDs in MRU order (most recent first)
projectHistoryIndex: number; // Current position in project history for cycling
// View state
currentView: ViewMode;
@@ -119,9 +121,11 @@ export interface AppState {
currentChatSession: ChatSession | null;
chatHistoryOpen: boolean;
// Auto Mode
isAutoModeRunning: boolean;
runningAutoTasks: string[]; // Feature IDs being worked on (supports concurrent tasks)
// Auto Mode (per-project state, keyed by project ID)
autoModeByProject: Record<string, {
isRunning: boolean;
runningTasks: string[]; // Feature IDs being worked on
}>;
autoModeActivityLog: AutoModeActivity[];
maxConcurrency: number; // Maximum number of concurrent agent tasks
@@ -162,6 +166,8 @@ export interface AppActions {
emptyTrash: () => void;
setCurrentProject: (project: Project | null) => void;
reorderProjects: (oldIndex: number, newIndex: number) => void;
cyclePrevProject: () => void; // Cycle back through project history (Q)
cycleNextProject: () => void; // Cycle forward through project history (E)
// View actions
setCurrentView: (view: ViewMode) => void;
@@ -198,11 +204,12 @@ export interface AppActions {
setChatHistoryOpen: (open: boolean) => void;
toggleChatHistory: () => void;
// Auto Mode actions
setAutoModeRunning: (running: boolean) => void;
addRunningTask: (taskId: string) => void;
removeRunningTask: (taskId: string) => void;
clearRunningTasks: () => void;
// Auto Mode actions (per-project)
setAutoModeRunning: (projectId: string, running: boolean) => void;
addRunningTask: (projectId: string, taskId: string) => void;
removeRunningTask: (projectId: string, taskId: string) => void;
clearRunningTasks: (projectId: string) => void;
getAutoModeState: (projectId: string) => { isRunning: boolean; runningTasks: string[] };
addAutoModeActivity: (
activity: Omit<AutoModeActivity, "id" | "timestamp">
) => void;
@@ -223,6 +230,8 @@ const initialState: AppState = {
projects: [],
currentProject: null,
trashedProjects: [],
projectHistory: [],
projectHistoryIndex: -1,
currentView: "welcome",
sidebarOpen: true,
theme: "dark",
@@ -236,8 +245,7 @@ const initialState: AppState = {
chatSessions: [],
currentChatSession: null,
chatHistoryOpen: false,
isAutoModeRunning: false,
runningAutoTasks: [],
autoModeByProject: {},
autoModeActivityLog: [],
maxConcurrency: 3, // Default to 3 concurrent agents
kanbanCardDetailLevel: "standard", // Default to standard detail level
@@ -363,11 +371,59 @@ export const useAppStore = create<AppState & AppActions>()(
set({ currentProject: project });
if (project) {
set({ currentView: "board" });
// Add to project history (MRU order)
const currentHistory = get().projectHistory;
// Remove this project if it's already in history
const filteredHistory = currentHistory.filter((id) => id !== project.id);
// Add to the front (most recent)
const newHistory = [project.id, ...filteredHistory];
// Reset history index to 0 (current project)
set({ projectHistory: newHistory, projectHistoryIndex: 0 });
} else {
set({ currentView: "welcome" });
}
},
cyclePrevProject: () => {
const { projectHistory, projectHistoryIndex, projects } = get();
if (projectHistory.length <= 1) return; // Need at least 2 projects to cycle
// Move to the next index (going back in history = higher index)
const newIndex = (projectHistoryIndex + 1) % projectHistory.length;
const targetProjectId = projectHistory[newIndex];
const targetProject = projects.find((p) => p.id === targetProjectId);
if (targetProject) {
// Update the index but don't modify history order when cycling
set({
currentProject: targetProject,
projectHistoryIndex: newIndex,
currentView: "board"
});
}
},
cycleNextProject: () => {
const { projectHistory, projectHistoryIndex, projects } = get();
if (projectHistory.length <= 1) return; // Need at least 2 projects to cycle
// Move to the previous index (going forward = lower index, wrapping around)
const newIndex = projectHistoryIndex <= 0
? projectHistory.length - 1
: projectHistoryIndex - 1;
const targetProjectId = projectHistory[newIndex];
const targetProject = projects.find((p) => p.id === targetProjectId);
if (targetProject) {
// Update the index but don't modify history order when cycling
set({
currentProject: targetProject,
projectHistoryIndex: newIndex,
currentView: "board"
});
}
},
// View actions
setCurrentView: (view) => set({ currentView: view }),
toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }),
@@ -522,25 +578,63 @@ export const useAppStore = create<AppState & AppActions>()(
toggleChatHistory: () => set({ chatHistoryOpen: !get().chatHistoryOpen }),
// Auto Mode actions
setAutoModeRunning: (running) => set({ isAutoModeRunning: running }),
addRunningTask: (taskId) => {
const current = get().runningAutoTasks;
if (!current.includes(taskId)) {
set({ runningAutoTasks: [...current, taskId] });
}
},
removeRunningTask: (taskId) => {
// Auto Mode actions (per-project)
setAutoModeRunning: (projectId, running) => {
const current = get().autoModeByProject;
const projectState = current[projectId] || { isRunning: false, runningTasks: [] };
set({
runningAutoTasks: get().runningAutoTasks.filter(
(id) => id !== taskId
),
autoModeByProject: {
...current,
[projectId]: { ...projectState, isRunning: running },
},
});
},
clearRunningTasks: () => set({ runningAutoTasks: [] }),
addRunningTask: (projectId, taskId) => {
const current = get().autoModeByProject;
const projectState = current[projectId] || { isRunning: false, runningTasks: [] };
if (!projectState.runningTasks.includes(taskId)) {
set({
autoModeByProject: {
...current,
[projectId]: {
...projectState,
runningTasks: [...projectState.runningTasks, taskId],
},
},
});
}
},
removeRunningTask: (projectId, taskId) => {
const current = get().autoModeByProject;
const projectState = current[projectId] || { isRunning: false, runningTasks: [] };
set({
autoModeByProject: {
...current,
[projectId]: {
...projectState,
runningTasks: projectState.runningTasks.filter((id) => id !== taskId),
},
},
});
},
clearRunningTasks: (projectId) => {
const current = get().autoModeByProject;
const projectState = current[projectId] || { isRunning: false, runningTasks: [] };
set({
autoModeByProject: {
...current,
[projectId]: { ...projectState, runningTasks: [] },
},
});
},
getAutoModeState: (projectId) => {
const projectState = get().autoModeByProject[projectId];
return projectState || { isRunning: false, runningTasks: [] };
},
addAutoModeActivity: (activity) => {
const id = `activity-${Date.now()}-${Math.random()
@@ -579,6 +673,8 @@ export const useAppStore = create<AppState & AppActions>()(
projects: state.projects,
currentProject: state.currentProject,
trashedProjects: state.trashedProjects,
projectHistory: state.projectHistory,
projectHistoryIndex: state.projectHistoryIndex,
currentView: state.currentView,
theme: state.theme,
sidebarOpen: state.sidebarOpen,

View File

@@ -162,22 +162,26 @@ export type AutoModeEvent =
| {
type: "auto_mode_feature_start";
featureId: string;
projectId?: string;
feature: unknown;
}
| {
type: "auto_mode_progress";
featureId: string;
projectId?: string;
content: string;
}
| {
type: "auto_mode_tool";
featureId: string;
projectId?: string;
tool: string;
input: unknown;
}
| {
type: "auto_mode_feature_complete";
featureId: string;
projectId?: string;
passes: boolean;
message: string;
}
@@ -185,18 +189,72 @@ export type AutoModeEvent =
type: "auto_mode_error";
error: string;
featureId?: string;
projectId?: string;
}
| {
type: "auto_mode_complete";
message: string;
projectId?: string;
}
| {
type: "auto_mode_phase";
featureId: string;
projectId?: string;
phase: "planning" | "action" | "verification";
message: string;
};
export type SpecRegenerationEvent =
| {
type: "spec_regeneration_progress";
content: string;
}
| {
type: "spec_regeneration_tool";
tool: string;
input: unknown;
}
| {
type: "spec_regeneration_complete";
message: string;
}
| {
type: "spec_regeneration_error";
error: string;
};
export interface SpecRegenerationAPI {
create: (
projectPath: string,
projectOverview: string,
generateFeatures?: boolean
) => Promise<{
success: boolean;
error?: string;
}>;
generate: (
projectPath: string,
projectDefinition: string
) => Promise<{
success: boolean;
error?: string;
}>;
stop: () => Promise<{
success: boolean;
error?: string;
}>;
status: () => Promise<{
success: boolean;
isRunning?: boolean;
error?: string;
}>;
onEvent: (callback: (event: SpecRegenerationEvent) => void) => () => void;
}
export interface AutoModeAPI {
start: (projectPath: string) => Promise<{
success: boolean;
@@ -316,6 +374,9 @@ export interface ElectronAPI {
// Auto Mode APIs
autoMode: AutoModeAPI;
// Spec Regeneration APIs
specRegeneration: SpecRegenerationAPI;
}
declare global {