Merge main into feat/rework-keybinds-and-setting-page - resolved conflicts in settings-view.tsx

This commit is contained in:
Kacper
2025-12-11 01:35:02 +01:00
44 changed files with 2497 additions and 1669 deletions

View File

@@ -441,20 +441,9 @@ class AgentService {
return `You are an AI assistant helping users build software. You are part of the Automaker application,
which is designed to help developers plan, design, and implement software projects autonomously.
**🚨 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
- Attempt to read and rewrite .automaker/feature_list.json
**CATASTROPHIC CONSEQUENCES:**
Directly modifying .automaker/feature_list.json can erase all project features permanently.
This file is managed by specialized tools only. NEVER touch it directly.
**Feature Storage:**
Features are stored in .automaker/features/{id}/feature.json - each feature has its own folder.
Use the UpdateFeatureStatus tool to manage features, not direct file edits.
Your role is to:
- Help users define their project requirements and specifications
@@ -462,7 +451,7 @@ Your role is to:
- Suggest technical approaches and architectures
- Guide them through the development process
- Be conversational and helpful
- Write, edit, and modify code files as requested (EXCEPT .automaker/feature_list.json)
- Write, edit, and modify code files as requested
- Execute commands and tests
- Search and analyze the codebase
@@ -474,10 +463,10 @@ When discussing projects, help users think through:
- Testing strategies
You have full access to the codebase and can:
- Read files to understand existing code (including .automaker/feature_list.json for viewing only)
- Write new files (NEVER .automaker/feature_list.json)
- Edit existing files (NEVER .automaker/feature_list.json)
- Run bash commands (but never commands that modify .automaker/feature_list.json)
- Read files to understand existing code
- Write new files
- Edit existing files
- Run bash commands
- Search for code patterns
- Execute tests and builds

View File

@@ -90,7 +90,7 @@ class AutoModeService {
content: `Working in isolated branch: ${result.branchName}\n`,
});
// Update feature with worktree info in feature_list.json
// Update feature with worktree info
await featureLoader.updateFeatureWorktree(
feature.id,
projectPath,

View File

@@ -819,7 +819,10 @@ ipcMain.handle("claude:check-cli", async () => {
try {
const claudeCliDetector = require("./services/claude-cli-detector");
const path = require("path");
const credentialsPath = path.join(app.getPath("userData"), "credentials.json");
const credentialsPath = path.join(
app.getPath("userData"),
"credentials.json"
);
const fullStatus = claudeCliDetector.getFullStatus(credentialsPath);
// Return in format expected by settings view (status: "installed" | "not_installed")
@@ -833,7 +836,9 @@ ipcMain.handle("claude:check-cli", async () => {
recommendation: fullStatus.installed
? null
: "Install Claude Code CLI for optimal performance with ultrathink.",
installCommands: fullStatus.installed ? null : claudeCliDetector.getInstallCommands(),
installCommands: fullStatus.installed
? null
: claudeCliDetector.getInstallCommands(),
};
} catch (error) {
console.error("[IPC] claude:check-cli error:", error);
@@ -1389,7 +1394,10 @@ ipcMain.handle("git:get-file-diff", async (_, { projectPath, filePath }) => {
ipcMain.handle("setup:claude-status", async () => {
try {
const claudeCliDetector = require("./services/claude-cli-detector");
const credentialsPath = path.join(app.getPath("userData"), "credentials.json");
const credentialsPath = path.join(
app.getPath("userData"),
"credentials.json"
);
const result = claudeCliDetector.getFullStatus(credentialsPath);
console.log("[IPC] setup:claude-status result:", result);
return result;
@@ -1424,7 +1432,7 @@ ipcMain.handle("setup:install-claude", async (event) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("setup:install-progress", {
cli: "claude",
...progress
...progress,
});
}
};
@@ -1448,7 +1456,7 @@ ipcMain.handle("setup:install-codex", async (event) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("setup:install-progress", {
cli: "codex",
...progress
...progress,
});
}
};
@@ -1472,7 +1480,7 @@ ipcMain.handle("setup:auth-claude", async (event) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("setup:auth-progress", {
cli: "claude",
...progress
...progress,
});
}
};
@@ -1496,7 +1504,7 @@ ipcMain.handle("setup:auth-codex", async (event, { apiKey }) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("setup:auth-progress", {
cli: "codex",
...progress
...progress,
});
}
};
@@ -1532,7 +1540,11 @@ ipcMain.handle("setup:store-api-key", async (_, { provider, apiKey }) => {
credentials[provider] = apiKey;
// Write back
await fs.writeFile(configPath, JSON.stringify(credentials, null, 2), "utf-8");
await fs.writeFile(
configPath,
JSON.stringify(credentials, null, 2),
"utf-8"
);
console.log("[IPC] setup:store-api-key stored successfully for:", provider);
return { success: true };
@@ -1559,7 +1571,7 @@ ipcMain.handle("setup:get-api-keys", async () => {
hasAnthropicKey: !!credentials.anthropic,
hasAnthropicOAuthToken: !!credentials.anthropic_oauth_token,
hasOpenAIKey: !!credentials.openai,
hasGoogleKey: !!credentials.google
hasGoogleKey: !!credentials.google,
};
} catch (e) {
return {
@@ -1567,7 +1579,7 @@ ipcMain.handle("setup:get-api-keys", async () => {
hasAnthropicKey: false,
hasAnthropicOAuthToken: false,
hasOpenAIKey: false,
hasGoogleKey: false
hasGoogleKey: false,
};
}
} catch (error) {
@@ -1582,9 +1594,16 @@ ipcMain.handle("setup:get-api-keys", async () => {
ipcMain.handle("setup:configure-codex-mcp", async (_, { projectPath }) => {
try {
const codexConfigManager = require("./services/codex-config-manager");
const mcpServerPath = path.join(__dirname, "services", "mcp-server-factory.js");
const mcpServerPath = path.join(
__dirname,
"services",
"mcp-server-factory.js"
);
const configPath = await codexConfigManager.configureMcpServer(projectPath, mcpServerPath);
const configPath = await codexConfigManager.configureMcpServer(
projectPath,
mcpServerPath
);
return { success: true, configPath };
} catch (error) {
@@ -1605,6 +1624,155 @@ ipcMain.handle("setup:get-platform", async () => {
homeDir: os.homedir(),
isWindows: process.platform === "win32",
isMac: process.platform === "darwin",
isLinux: process.platform === "linux"
isLinux: process.platform === "linux",
};
});
// ============================================================================
// Features IPC Handlers
// ============================================================================
/**
* Get all features for a project
*/
ipcMain.handle("features:getAll", async (_, { projectPath }) => {
try {
// Security check
if (!isPathAllowed(projectPath)) {
return {
success: false,
error: "Access denied: Path is outside allowed project directories",
};
}
const featureLoader = require("./services/feature-loader");
const features = await featureLoader.getAll(projectPath);
return { success: true, features };
} catch (error) {
console.error("[IPC] features:getAll error:", error);
return { success: false, error: error.message };
}
});
/**
* Get a single feature by ID
*/
ipcMain.handle("features:get", async (_, { projectPath, featureId }) => {
try {
// Security check
if (!isPathAllowed(projectPath)) {
return {
success: false,
error: "Access denied: Path is outside allowed project directories",
};
}
const featureLoader = require("./services/feature-loader");
const feature = await featureLoader.get(projectPath, featureId);
if (!feature) {
return { success: false, error: "Feature not found" };
}
return { success: true, feature };
} catch (error) {
console.error("[IPC] features:get error:", error);
return { success: false, error: error.message };
}
});
/**
* Create a new feature
*/
ipcMain.handle("features:create", async (_, { projectPath, feature }) => {
try {
// Security check
if (!isPathAllowed(projectPath)) {
return {
success: false,
error: "Access denied: Path is outside allowed project directories",
};
}
const featureLoader = require("./services/feature-loader");
const createdFeature = await featureLoader.create(projectPath, feature);
return { success: true, feature: createdFeature };
} catch (error) {
console.error("[IPC] features:create error:", error);
return { success: false, error: error.message };
}
});
/**
* Update a feature (partial updates supported)
*/
ipcMain.handle(
"features:update",
async (_, { projectPath, featureId, updates }) => {
try {
// Security check
if (!isPathAllowed(projectPath)) {
return {
success: false,
error: "Access denied: Path is outside allowed project directories",
};
}
const featureLoader = require("./services/feature-loader");
const updatedFeature = await featureLoader.update(
projectPath,
featureId,
updates
);
return { success: true, feature: updatedFeature };
} catch (error) {
console.error("[IPC] features:update error:", error);
return { success: false, error: error.message };
}
}
);
/**
* Delete a feature and its folder
*/
ipcMain.handle("features:delete", async (_, { projectPath, featureId }) => {
try {
// Security check
if (!isPathAllowed(projectPath)) {
return {
success: false,
error: "Access denied: Path is outside allowed project directories",
};
}
const featureLoader = require("./services/feature-loader");
await featureLoader.delete(projectPath, featureId);
return { success: true };
} catch (error) {
console.error("[IPC] features:delete error:", error);
return { success: false, error: error.message };
}
});
/**
* Get agent output for a feature
*/
ipcMain.handle(
"features:getAgentOutput",
async (_, { projectPath, featureId }) => {
try {
// Security check
if (!isPathAllowed(projectPath)) {
return {
success: false,
error: "Access denied: Path is outside allowed project directories",
};
}
const featureLoader = require("./services/feature-loader");
const content = await featureLoader.getAgentOutput(projectPath, featureId);
return { success: true, content };
} catch (error) {
console.error("[IPC] features:getAgentOutput error:", error);
return { success: false, error: error.message };
}
}
);

View File

@@ -24,7 +24,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
// App APIs
getPath: (name) => ipcRenderer.invoke("app:getPath", name),
saveImageToTemp: (data, filename, mimeType, projectPath) =>
ipcRenderer.invoke("app:saveImageToTemp", { data, filename, mimeType, projectPath }),
ipcRenderer.invoke("app:saveImageToTemp", {
data,
filename,
mimeType,
projectPath,
}),
// Agent APIs
agent: {
@@ -34,19 +39,22 @@ contextBridge.exposeInMainWorld("electronAPI", {
// Send a message to the agent
send: (sessionId, message, workingDirectory, imagePaths) =>
ipcRenderer.invoke("agent:send", { sessionId, message, workingDirectory, imagePaths }),
ipcRenderer.invoke("agent:send", {
sessionId,
message,
workingDirectory,
imagePaths,
}),
// Get conversation history
getHistory: (sessionId) =>
ipcRenderer.invoke("agent:getHistory", { sessionId }),
// Stop current execution
stop: (sessionId) =>
ipcRenderer.invoke("agent:stop", { sessionId }),
stop: (sessionId) => ipcRenderer.invoke("agent:stop", { sessionId }),
// Clear conversation
clear: (sessionId) =>
ipcRenderer.invoke("agent:clear", { sessionId }),
clear: (sessionId) => ipcRenderer.invoke("agent:clear", { sessionId }),
// Subscribe to streaming events
onStream: (callback) => {
@@ -65,7 +73,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
// Create a new session
create: (name, projectPath, workingDirectory) =>
ipcRenderer.invoke("sessions:create", { name, projectPath, workingDirectory }),
ipcRenderer.invoke("sessions:create", {
name,
projectPath,
workingDirectory,
}),
// Update session metadata
update: (sessionId, name, tags) =>
@@ -80,8 +92,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.invoke("sessions:unarchive", { sessionId }),
// Delete a session permanently
delete: (sessionId) =>
ipcRenderer.invoke("sessions:delete", { sessionId }),
delete: (sessionId) => ipcRenderer.invoke("sessions:delete", { sessionId }),
},
// Auto Mode API
@@ -98,19 +109,32 @@ contextBridge.exposeInMainWorld("electronAPI", {
// Run a specific feature
runFeature: (projectPath, featureId, useWorktrees) =>
ipcRenderer.invoke("auto-mode:run-feature", { projectPath, featureId, useWorktrees }),
ipcRenderer.invoke("auto-mode:run-feature", {
projectPath,
featureId,
useWorktrees,
}),
// Verify a specific feature by running its tests
verifyFeature: (projectPath, featureId) =>
ipcRenderer.invoke("auto-mode:verify-feature", { projectPath, featureId }),
ipcRenderer.invoke("auto-mode:verify-feature", {
projectPath,
featureId,
}),
// Resume a specific feature with previous context
resumeFeature: (projectPath, featureId) =>
ipcRenderer.invoke("auto-mode:resume-feature", { projectPath, featureId }),
ipcRenderer.invoke("auto-mode:resume-feature", {
projectPath,
featureId,
}),
// Check if context file exists for a feature
contextExists: (projectPath, featureId) =>
ipcRenderer.invoke("auto-mode:context-exists", { projectPath, featureId }),
ipcRenderer.invoke("auto-mode:context-exists", {
projectPath,
featureId,
}),
// Analyze a new project - kicks off an agent to analyze codebase
analyzeProject: (projectPath) =>
@@ -122,11 +146,19 @@ contextBridge.exposeInMainWorld("electronAPI", {
// Follow-up on a feature with additional prompt
followUpFeature: (projectPath, featureId, prompt, imagePaths) =>
ipcRenderer.invoke("auto-mode:follow-up-feature", { projectPath, featureId, prompt, imagePaths }),
ipcRenderer.invoke("auto-mode:follow-up-feature", {
projectPath,
featureId,
prompt,
imagePaths,
}),
// Commit changes for a feature
commitFeature: (projectPath, featureId) =>
ipcRenderer.invoke("auto-mode:commit-feature", { projectPath, featureId }),
ipcRenderer.invoke("auto-mode:commit-feature", {
projectPath,
featureId,
}),
// Listen for auto mode events
onEvent: (callback) => {
@@ -167,7 +199,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
// Merge feature worktree changes back to main branch
mergeFeature: (projectPath, featureId, options) =>
ipcRenderer.invoke("worktree:merge-feature", { projectPath, featureId, options }),
ipcRenderer.invoke("worktree:merge-feature", {
projectPath,
featureId,
options,
}),
// Get worktree info for a feature
getInfo: (projectPath, featureId) =>
@@ -178,8 +214,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.invoke("worktree:get-status", { projectPath, featureId }),
// List all feature worktrees
list: (projectPath) =>
ipcRenderer.invoke("worktree:list", { projectPath }),
list: (projectPath) => ipcRenderer.invoke("worktree:list", { projectPath }),
// Get file diffs for a feature worktree
getDiffs: (projectPath, featureId) =>
@@ -187,7 +222,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
// Get diff for a specific file in a worktree
getFileDiff: (projectPath, featureId, filePath) =>
ipcRenderer.invoke("worktree:get-file-diff", { projectPath, featureId, filePath }),
ipcRenderer.invoke("worktree:get-file-diff", {
projectPath,
featureId,
filePath,
}),
},
// Git Operations APIs (for non-worktree operations)
@@ -229,11 +268,18 @@ contextBridge.exposeInMainWorld("electronAPI", {
specRegeneration: {
// Create initial app spec for a new project
create: (projectPath, projectOverview, generateFeatures = true) =>
ipcRenderer.invoke("spec-regeneration:create", { projectPath, projectOverview, generateFeatures }),
ipcRenderer.invoke("spec-regeneration:create", {
projectPath,
projectOverview,
generateFeatures,
}),
// Regenerate the app spec
generate: (projectPath, projectDefinition) =>
ipcRenderer.invoke("spec-regeneration:generate", { projectPath, projectDefinition }),
ipcRenderer.invoke("spec-regeneration:generate", {
projectPath,
projectDefinition,
}),
// Stop regenerating spec
stop: () => ipcRenderer.invoke("spec-regeneration:stop"),
@@ -305,6 +351,37 @@ contextBridge.exposeInMainWorld("electronAPI", {
};
},
},
// Features API
features: {
// Get all features for a project
getAll: (projectPath) =>
ipcRenderer.invoke("features:getAll", { projectPath }),
// Get a single feature by ID
get: (projectPath, featureId) =>
ipcRenderer.invoke("features:get", { projectPath, featureId }),
// Create a new feature
create: (projectPath, feature) =>
ipcRenderer.invoke("features:create", { projectPath, feature }),
// Update a feature (partial updates supported)
update: (projectPath, featureId, updates) =>
ipcRenderer.invoke("features:update", {
projectPath,
featureId,
updates,
}),
// Delete a feature and its folder
delete: (projectPath, featureId) =>
ipcRenderer.invoke("features:delete", { projectPath, featureId }),
// Get agent output for a feature
getAgentOutput: (projectPath, featureId) =>
ipcRenderer.invoke("features:getAgentOutput", { projectPath, featureId }),
},
});
// Also expose a flag to detect if we're in Electron

View File

@@ -12,16 +12,21 @@ class ContextManager {
if (!projectPath) return;
try {
const contextDir = path.join(projectPath, ".automaker", "agents-context");
const featureDir = path.join(
projectPath,
".automaker",
"features",
featureId
);
// Ensure directory exists
// Ensure feature directory exists
try {
await fs.access(contextDir);
await fs.access(featureDir);
} catch {
await fs.mkdir(contextDir, { recursive: true });
await fs.mkdir(featureDir, { recursive: true });
}
const filePath = path.join(contextDir, `${featureId}.md`);
const filePath = path.join(featureDir, "agent-output.md");
// Append to existing file or create new one
try {
@@ -43,8 +48,9 @@ class ContextManager {
const contextPath = path.join(
projectPath,
".automaker",
"agents-context",
`${featureId}.md`
"features",
featureId,
"agent-output.md"
);
const content = await fs.readFile(contextPath, "utf-8");
return content;
@@ -64,8 +70,9 @@ class ContextManager {
const contextPath = path.join(
projectPath,
".automaker",
"agents-context",
`${featureId}.md`
"features",
featureId,
"agent-output.md"
);
await fs.unlink(contextPath);
console.log(
@@ -213,13 +220,18 @@ This helps future agent runs avoid the same pitfalls.
try {
const { execSync } = require("child_process");
const contextDir = path.join(projectPath, ".automaker", "agents-context");
const featureDir = path.join(
projectPath,
".automaker",
"features",
featureId
);
// Ensure directory exists
// Ensure feature directory exists
try {
await fs.access(contextDir);
await fs.access(featureDir);
} catch {
await fs.mkdir(contextDir, { recursive: true });
await fs.mkdir(featureDir, { recursive: true });
}
// Get list of modified files (both staged and unstaged)
@@ -233,25 +245,34 @@ This helps future agent runs avoid the same pitfalls.
modifiedFiles = modifiedOutput.split("\n").filter(Boolean);
}
} catch (error) {
console.log("[ContextManager] No modified files or git error:", error.message);
console.log(
"[ContextManager] No modified files or git error:",
error.message
);
}
// Get list of untracked files
let untrackedFiles = [];
try {
const untrackedOutput = execSync("git ls-files --others --exclude-standard", {
cwd: projectPath,
encoding: "utf-8",
}).trim();
const untrackedOutput = execSync(
"git ls-files --others --exclude-standard",
{
cwd: projectPath,
encoding: "utf-8",
}
).trim();
if (untrackedOutput) {
untrackedFiles = untrackedOutput.split("\n").filter(Boolean);
}
} catch (error) {
console.log("[ContextManager] Error getting untracked files:", error.message);
console.log(
"[ContextManager] Error getting untracked files:",
error.message
);
}
// Save the initial state to a JSON file
const stateFile = path.join(contextDir, `${featureId}-git-state.json`);
const stateFile = path.join(featureDir, "git-state.json");
const state = {
timestamp: new Date().toISOString(),
modifiedFiles,
@@ -259,14 +280,20 @@ This helps future agent runs avoid the same pitfalls.
};
await fs.writeFile(stateFile, JSON.stringify(state, null, 2), "utf-8");
console.log(`[ContextManager] Saved initial git state for ${featureId}:`, {
modifiedCount: modifiedFiles.length,
untrackedCount: untrackedFiles.length,
});
console.log(
`[ContextManager] Saved initial git state for ${featureId}:`,
{
modifiedCount: modifiedFiles.length,
untrackedCount: untrackedFiles.length,
}
);
return state;
} catch (error) {
console.error("[ContextManager] Failed to save initial git state:", error);
console.error(
"[ContextManager] Failed to save initial git state:",
error
);
return { modifiedFiles: [], untrackedFiles: [] };
}
}
@@ -284,13 +311,16 @@ This helps future agent runs avoid the same pitfalls.
const stateFile = path.join(
projectPath,
".automaker",
"agents-context",
`${featureId}-git-state.json`
"features",
featureId,
"git-state.json"
);
const content = await fs.readFile(stateFile, "utf-8");
return JSON.parse(content);
} catch (error) {
console.log(`[ContextManager] No initial git state found for ${featureId}`);
console.log(
`[ContextManager] No initial git state found for ${featureId}`
);
return null;
}
}
@@ -307,15 +337,19 @@ This helps future agent runs avoid the same pitfalls.
const stateFile = path.join(
projectPath,
".automaker",
"agents-context",
`${featureId}-git-state.json`
"features",
featureId,
"git-state.json"
);
await fs.unlink(stateFile);
console.log(`[ContextManager] Deleted git state file for ${featureId}`);
} catch (error) {
// File might not exist, which is fine
if (error.code !== "ENOENT") {
console.error("[ContextManager] Failed to delete git state file:", error);
console.error(
"[ContextManager] Failed to delete git state file:",
error
);
}
}
}
@@ -334,7 +368,10 @@ This helps future agent runs avoid the same pitfalls.
const { execSync } = require("child_process");
// Get initial state
const initialState = await this.getInitialGitState(projectPath, featureId);
const initialState = await this.getInitialGitState(
projectPath,
featureId
);
// Get current state
let currentModified = [];
@@ -352,10 +389,13 @@ This helps future agent runs avoid the same pitfalls.
let currentUntracked = [];
try {
const untrackedOutput = execSync("git ls-files --others --exclude-standard", {
cwd: projectPath,
encoding: "utf-8",
}).trim();
const untrackedOutput = execSync(
"git ls-files --others --exclude-standard",
{
cwd: projectPath,
encoding: "utf-8",
}
).trim();
if (untrackedOutput) {
currentUntracked = untrackedOutput.split("\n").filter(Boolean);
}
@@ -365,7 +405,9 @@ This helps future agent runs avoid the same pitfalls.
if (!initialState) {
// No initial state - all current changes are considered from this session
console.log("[ContextManager] No initial state found, returning all current changes");
console.log(
"[ContextManager] No initial state found, returning all current changes"
);
return {
newFiles: currentUntracked,
modifiedFiles: currentModified,
@@ -377,21 +419,31 @@ This helps future agent runs avoid the same pitfalls.
const initialUntrackedSet = new Set(initialState.untrackedFiles || []);
// New files = current untracked - initial untracked
const newFiles = currentUntracked.filter(f => !initialUntrackedSet.has(f));
const newFiles = currentUntracked.filter(
(f) => !initialUntrackedSet.has(f)
);
// Modified files = current modified - initial modified
const modifiedFiles = currentModified.filter(f => !initialModifiedSet.has(f));
const modifiedFiles = currentModified.filter(
(f) => !initialModifiedSet.has(f)
);
console.log(`[ContextManager] Files changed during session for ${featureId}:`, {
newFilesCount: newFiles.length,
modifiedFilesCount: modifiedFiles.length,
newFiles,
modifiedFiles,
});
console.log(
`[ContextManager] Files changed during session for ${featureId}:`,
{
newFilesCount: newFiles.length,
modifiedFilesCount: modifiedFiles.length,
newFiles,
modifiedFiles,
}
);
return { newFiles, modifiedFiles };
} catch (error) {
console.error("[ContextManager] Failed to calculate changed files:", error);
console.error(
"[ContextManager] Failed to calculate changed files:",
error
);
return { newFiles: [], modifiedFiles: [] };
}
}

View File

@@ -2,36 +2,343 @@ const path = require("path");
const fs = require("fs/promises");
/**
* Feature Loader - Handles loading and selecting features from feature_list.json
* Feature Loader - Handles loading and managing features from individual feature folders
* Each feature is stored in .automaker/features/{featureId}/feature.json
*/
class FeatureLoader {
/**
* Load features from .automaker/feature_list.json
* Get the features directory path
*/
async loadFeatures(projectPath) {
const featuresPath = path.join(
projectPath,
".automaker",
"feature_list.json"
getFeaturesDir(projectPath) {
return path.join(projectPath, ".automaker", "features");
}
/**
* Get the path to a specific feature folder
*/
getFeatureDir(projectPath, featureId) {
return path.join(this.getFeaturesDir(projectPath), featureId);
}
/**
* Get the path to a feature's feature.json file
*/
getFeatureJsonPath(projectPath, featureId) {
return path.join(
this.getFeatureDir(projectPath, featureId),
"feature.json"
);
}
/**
* Get the path to a feature's agent-output.md file
*/
getAgentOutputPath(projectPath, featureId) {
return path.join(
this.getFeatureDir(projectPath, featureId),
"agent-output.md"
);
}
/**
* Generate a new feature ID
*/
generateFeatureId() {
return `feature-${Date.now()}-${Math.random()
.toString(36)
.substring(2, 11)}`;
}
/**
* Ensure all image paths for a feature are stored within the feature directory
*/
async ensureFeatureImages(projectPath, featureId, feature) {
if (
!feature ||
!Array.isArray(feature.imagePaths) ||
feature.imagePaths.length === 0
) {
return;
}
const featureDir = this.getFeatureDir(projectPath, featureId);
const featureImagesDir = path.join(featureDir, "images");
await fs.mkdir(featureImagesDir, { recursive: true });
const updatedImagePaths = [];
for (const entry of feature.imagePaths) {
const isStringEntry = typeof entry === "string";
const currentPathValue = isStringEntry ? entry : entry.path;
if (!currentPathValue) {
updatedImagePaths.push(entry);
continue;
}
let resolvedCurrentPath = currentPathValue;
if (!path.isAbsolute(resolvedCurrentPath)) {
resolvedCurrentPath = path.join(projectPath, resolvedCurrentPath);
}
resolvedCurrentPath = path.normalize(resolvedCurrentPath);
// Skip if file doesn't exist
try {
await fs.access(resolvedCurrentPath);
} catch {
console.warn(
`[FeatureLoader] Image file missing for ${featureId}: ${resolvedCurrentPath}`
);
updatedImagePaths.push(entry);
continue;
}
const relativeToFeatureImages = path.relative(
featureImagesDir,
resolvedCurrentPath
);
const alreadyInFeatureDir =
relativeToFeatureImages === "" ||
(!relativeToFeatureImages.startsWith("..") &&
!path.isAbsolute(relativeToFeatureImages));
let finalPath = resolvedCurrentPath;
if (!alreadyInFeatureDir) {
const originalName = path.basename(resolvedCurrentPath);
let targetPath = path.join(featureImagesDir, originalName);
// Avoid overwriting files by appending a counter if needed
let counter = 1;
while (true) {
try {
await fs.access(targetPath);
const parsed = path.parse(originalName);
targetPath = path.join(
featureImagesDir,
`${parsed.name}-${counter}${parsed.ext}`
);
counter += 1;
} catch {
break;
}
}
try {
await fs.rename(resolvedCurrentPath, targetPath);
finalPath = targetPath;
} catch (error) {
console.warn(
`[FeatureLoader] Failed to move image ${resolvedCurrentPath}: ${error.message}`
);
updatedImagePaths.push(entry);
continue;
}
}
updatedImagePaths.push(
isStringEntry ? finalPath : { ...entry, path: finalPath }
);
}
feature.imagePaths = updatedImagePaths;
}
/**
* Get all features for a project
*/
async getAll(projectPath) {
try {
const content = await fs.readFile(featuresPath, "utf-8");
const features = JSON.parse(content);
const featuresDir = this.getFeaturesDir(projectPath);
// Ensure each feature has an ID
return features.map((f, index) => ({
...f,
id: f.id || `feature-${index}-${Date.now()}`,
}));
// Check if features directory exists
try {
await fs.access(featuresDir);
} catch {
// Directory doesn't exist, return empty array
return [];
}
// Read all feature directories
const entries = await fs.readdir(featuresDir, { withFileTypes: true });
const featureDirs = entries.filter((entry) => entry.isDirectory());
// Load each feature
const features = [];
for (const dir of featureDirs) {
const featureId = dir.name;
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
try {
const content = await fs.readFile(featureJsonPath, "utf-8");
const feature = JSON.parse(content);
features.push(feature);
} catch (error) {
console.error(
`[FeatureLoader] Failed to load feature ${featureId}:`,
error
);
// Continue loading other features
}
}
// Sort by creation order (feature IDs contain timestamp)
features.sort((a, b) => {
const aTime = a.id ? parseInt(a.id.split("-")[1] || "0") : 0;
const bTime = b.id ? parseInt(b.id.split("-")[1] || "0") : 0;
return aTime - bTime;
});
return features;
} catch (error) {
console.error("[FeatureLoader] Failed to load features:", error);
console.error("[FeatureLoader] Failed to get all features:", error);
return [];
}
}
/**
* Update feature status in .automaker/feature_list.json
* Get a single feature by ID
*/
async get(projectPath, featureId) {
try {
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
const content = await fs.readFile(featureJsonPath, "utf-8");
return JSON.parse(content);
} catch (error) {
if (error.code === "ENOENT") {
return null;
}
console.error(
`[FeatureLoader] Failed to get feature ${featureId}:`,
error
);
throw error;
}
}
/**
* Create a new feature
*/
async create(projectPath, featureData) {
const featureId = featureData.id || this.generateFeatureId();
const featureDir = this.getFeatureDir(projectPath, featureId);
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
// Ensure features directory exists
const featuresDir = this.getFeaturesDir(projectPath);
await fs.mkdir(featuresDir, { recursive: true });
// Create feature directory
await fs.mkdir(featureDir, { recursive: true });
// Ensure feature has an ID
const feature = { ...featureData, id: featureId };
// Move any uploaded images into the feature directory
await this.ensureFeatureImages(projectPath, featureId, feature);
// Write feature.json
await fs.writeFile(
featureJsonPath,
JSON.stringify(feature, null, 2),
"utf-8"
);
console.log(`[FeatureLoader] Created feature ${featureId}`);
return feature;
}
/**
* Update a feature (partial updates supported)
*/
async update(projectPath, featureId, updates) {
try {
const feature = await this.get(projectPath, featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
// Merge updates
const updatedFeature = { ...feature, ...updates };
// Move any new images into the feature directory
await this.ensureFeatureImages(projectPath, featureId, updatedFeature);
// Write back to file
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
await fs.writeFile(
featureJsonPath,
JSON.stringify(updatedFeature, null, 2),
"utf-8"
);
console.log(`[FeatureLoader] Updated feature ${featureId}`);
return updatedFeature;
} catch (error) {
console.error(
`[FeatureLoader] Failed to update feature ${featureId}:`,
error
);
throw error;
}
}
/**
* Delete a feature and its entire folder
*/
async delete(projectPath, featureId) {
try {
const featureDir = this.getFeatureDir(projectPath, featureId);
await fs.rm(featureDir, { recursive: true, force: true });
console.log(`[FeatureLoader] Deleted feature ${featureId}`);
} catch (error) {
if (error.code === "ENOENT") {
// Feature doesn't exist, that's fine
return;
}
console.error(
`[FeatureLoader] Failed to delete feature ${featureId}:`,
error
);
throw error;
}
}
/**
* Get agent output for a feature
*/
async getAgentOutput(projectPath, featureId) {
try {
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
const content = await fs.readFile(agentOutputPath, "utf-8");
return content;
} catch (error) {
if (error.code === "ENOENT") {
return null;
}
console.error(
`[FeatureLoader] Failed to get agent output for ${featureId}:`,
error
);
return null;
}
}
// ============================================================================
// Legacy methods for backward compatibility (used by backend services)
// ============================================================================
/**
* Load all features for a project (legacy API)
* Features are stored in .automaker/features/{id}/feature.json
*/
async loadFeatures(projectPath) {
return await this.getAll(projectPath);
}
/**
* Update feature status (legacy API)
* Features are stored in .automaker/features/{id}/feature.json
* @param {string} featureId - The ID of the feature to update
* @param {string} status - The new status
* @param {string} projectPath - Path to the project
@@ -39,126 +346,26 @@ class FeatureLoader {
* @param {string} [error] - Optional error message if feature errored
*/
async updateFeatureStatus(featureId, status, projectPath, summary, error) {
const featuresPath = path.join(
projectPath,
".automaker",
"feature_list.json"
);
// 🛡️ SAFETY: Create backup before any modification
const backupPath = path.join(
projectPath,
".automaker",
"feature_list.backup.json"
);
try {
const originalContent = await fs.readFile(featuresPath, "utf-8");
await fs.writeFile(backupPath, originalContent, "utf-8");
console.log(`[FeatureLoader] Created backup at ${backupPath}`);
} catch (error) {
console.warn(`[FeatureLoader] Could not create backup: ${error.message}`);
const updates = { status };
if (summary !== undefined) {
updates.summary = summary;
}
const features = await this.loadFeatures(projectPath);
// 🛡️ VALIDATION: Ensure we loaded features successfully
if (!Array.isArray(features)) {
throw new Error("CRITICAL: features is not an array - aborting to prevent data loss");
}
if (features.length === 0) {
console.warn(`[FeatureLoader] WARNING: Feature list is empty. This may indicate corruption.`);
// Try to restore from backup
try {
const backupContent = await fs.readFile(backupPath, "utf-8");
const backupFeatures = JSON.parse(backupContent);
if (Array.isArray(backupFeatures) && backupFeatures.length > 0) {
console.log(`[FeatureLoader] Restored ${backupFeatures.length} features from backup`);
// Use backup features instead
features.length = 0;
features.push(...backupFeatures);
}
} catch (backupError) {
console.error(`[FeatureLoader] Could not restore from backup: ${backupError.message}`);
}
}
const feature = features.find((f) => f.id === featureId);
if (!feature) {
console.error(`[FeatureLoader] Feature ${featureId} not found`);
return;
}
// Update the status field
feature.status = status;
// Update the summary field if provided
if (summary) {
feature.summary = summary;
}
// Update the error field (set or clear)
if (error) {
feature.error = error;
if (error !== undefined) {
updates.error = error;
} else {
// Clear any previous error when status changes without error
delete feature.error;
// Clear error if not provided
const feature = await this.get(projectPath, featureId);
if (feature && feature.error) {
updates.error = undefined;
}
}
// Save back to file
const toSave = features.map((f) => {
const featureData = {
id: f.id,
category: f.category,
description: f.description,
steps: f.steps,
status: f.status,
};
// Preserve optional fields if they exist
if (f.skipTests !== undefined) {
featureData.skipTests = f.skipTests;
}
if (f.images !== undefined) {
featureData.images = f.images;
}
if (f.imagePaths !== undefined) {
featureData.imagePaths = f.imagePaths;
}
if (f.startedAt !== undefined) {
featureData.startedAt = f.startedAt;
}
if (f.summary !== undefined) {
featureData.summary = f.summary;
}
if (f.model !== undefined) {
featureData.model = f.model;
}
if (f.thinkingLevel !== undefined) {
featureData.thinkingLevel = f.thinkingLevel;
}
if (f.error !== undefined) {
featureData.error = f.error;
}
// Preserve worktree info
if (f.worktreePath !== undefined) {
featureData.worktreePath = f.worktreePath;
}
if (f.branchName !== undefined) {
featureData.branchName = f.branchName;
}
return featureData;
});
// 🛡️ FINAL VALIDATION: Ensure we're not writing an empty array
if (!Array.isArray(toSave) || toSave.length === 0) {
throw new Error("CRITICAL: Attempted to save empty feature list - aborting to prevent data loss");
}
await fs.writeFile(featuresPath, JSON.stringify(toSave, null, 2), "utf-8");
console.log(`[FeatureLoader] Updated feature ${featureId}: status=${status}${summary ? `, summary="${summary}"` : ""}`);
console.log(`[FeatureLoader] Successfully saved ${toSave.length} features to feature_list.json`);
await this.update(projectPath, featureId, updates);
console.log(
`[FeatureLoader] Updated feature ${featureId}: status=${status}${
summary ? `, summary="${summary}"` : ""
}`
);
}
/**
@@ -168,70 +375,38 @@ class FeatureLoader {
selectNextFeature(features) {
// Find first feature that is in backlog or in_progress status
// Skip verified and waiting_approval (which needs user input)
return features.find((f) => f.status !== "verified" && f.status !== "waiting_approval");
return features.find(
(f) => f.status !== "verified" && f.status !== "waiting_approval"
);
}
/**
* Update worktree info for a feature
* Update worktree info for a feature (legacy API)
* Features are stored in .automaker/features/{id}/feature.json
* @param {string} featureId - The ID of the feature to update
* @param {string} projectPath - Path to the project
* @param {string|null} worktreePath - Path to the worktree (null to clear)
* @param {string|null} branchName - Name of the feature branch (null to clear)
*/
async updateFeatureWorktree(featureId, projectPath, worktreePath, branchName) {
const featuresPath = path.join(
projectPath,
".automaker",
"feature_list.json"
);
const features = await this.loadFeatures(projectPath);
if (!Array.isArray(features) || features.length === 0) {
console.error("[FeatureLoader] Cannot update worktree: feature list is empty");
return;
}
const feature = features.find((f) => f.id === featureId);
if (!feature) {
console.error(`[FeatureLoader] Feature ${featureId} not found`);
return;
}
// Update or clear worktree info
async updateFeatureWorktree(
featureId,
projectPath,
worktreePath,
branchName
) {
const updates = {};
if (worktreePath) {
feature.worktreePath = worktreePath;
feature.branchName = branchName;
updates.worktreePath = worktreePath;
updates.branchName = branchName;
} else {
delete feature.worktreePath;
delete feature.branchName;
updates.worktreePath = null;
updates.branchName = null;
}
// Save back to file (reuse the same mapping logic)
const toSave = features.map((f) => {
const featureData = {
id: f.id,
category: f.category,
description: f.description,
steps: f.steps,
status: f.status,
};
if (f.skipTests !== undefined) featureData.skipTests = f.skipTests;
if (f.images !== undefined) featureData.images = f.images;
if (f.imagePaths !== undefined) featureData.imagePaths = f.imagePaths;
if (f.startedAt !== undefined) featureData.startedAt = f.startedAt;
if (f.summary !== undefined) featureData.summary = f.summary;
if (f.model !== undefined) featureData.model = f.model;
if (f.thinkingLevel !== undefined) featureData.thinkingLevel = f.thinkingLevel;
if (f.error !== undefined) featureData.error = f.error;
if (f.worktreePath !== undefined) featureData.worktreePath = f.worktreePath;
if (f.branchName !== undefined) featureData.branchName = f.branchName;
return featureData;
});
await fs.writeFile(featuresPath, JSON.stringify(toSave, null, 2), "utf-8");
console.log(`[FeatureLoader] Updated feature ${featureId}: worktreePath=${worktreePath}, branchName=${branchName}`);
await this.update(projectPath, featureId, updates);
console.log(
`[FeatureLoader] Updated feature ${featureId}: worktreePath=${worktreePath}, branchName=${branchName}`
);
}
}

View File

@@ -9,8 +9,8 @@ class McpServerFactory {
/**
* Create a custom MCP server with the UpdateFeatureStatus tool
* This tool allows Claude Code to safely update feature status without
* directly modifying the feature_list.json file, preventing race conditions
* and accidental state restoration.
* directly modifying feature files, preventing race conditions
* and accidental state corruption.
*/
createFeatureToolsServer(updateFeatureStatusCallback, projectPath) {
return createSdkMcpServer({
@@ -19,7 +19,7 @@ class McpServerFactory {
tools: [
tool(
"UpdateFeatureStatus",
"Update the status of a feature in the feature list. Use this tool instead of directly modifying feature_list.json to safely update feature status. IMPORTANT: If the feature has skipTests=true, you should NOT mark it as verified - instead it will automatically go to waiting_approval status for manual review. Always include a summary of what was done.",
"Update the status of a feature. Use this tool instead of directly modifying feature files to safely update feature status. IMPORTANT: If the feature has skipTests=true, you should NOT mark it as verified - instead it will automatically go to waiting_approval status for manual review. Always include a summary of what was done.",
{
featureId: z.string().describe("The ID of the feature to update"),
status: z.enum(["backlog", "in_progress", "verified"]).describe("The new status for the feature. Note: If skipTests=true, verified will be converted to waiting_approval automatically."),

View File

@@ -144,7 +144,7 @@ async function handleToolsList(params, id) {
tools: [
{
name: 'UpdateFeatureStatus',
description: 'Update the status of a feature in the feature list. Use this tool instead of directly modifying feature_list.json to safely update feature status. IMPORTANT: If the feature has skipTests=true, you should NOT mark it as verified - instead it will automatically go to waiting_approval status for manual review. Always include a summary of what was done.',
description: 'Update the status of a feature. Use this tool instead of directly modifying feature files to safely update feature status. IMPORTANT: If the feature has skipTests=true, you should NOT mark it as verified - instead it will automatically go to waiting_approval status for manual review. Always include a summary of what was done.',
inputSchema: {
type: 'object',
properties: {

View File

@@ -69,7 +69,7 @@ ${
}
${
feature.skipTests ? "4" : "6"
}. **CRITICAL: Use the UpdateFeatureStatus tool to mark this feature as verified** - DO NOT manually edit .automaker/feature_list.json
}. **CRITICAL: Use the UpdateFeatureStatus tool to mark this feature as verified**
${
feature.skipTests
? "5. **DO NOT commit changes** - the user will review and commit manually"
@@ -83,7 +83,7 @@ When you have completed the feature${
}, you MUST use the \`mcp__automaker-tools__UpdateFeatureStatus\` tool to update the feature status:
- Call the tool with: featureId="${feature.id}" and status="verified"
- **You can also include a summary parameter** to describe what was done: summary="Brief summary of changes"
- **DO NOT manually edit the .automaker/feature_list.json file** - this can cause race conditions
- **DO NOT manually edit feature files** - this can cause race conditions
- The UpdateFeatureStatus tool safely updates the feature status without risk of corrupting other data
- **If skipTests=true, the tool will automatically convert "verified" to "waiting_approval"** - this is correct behavior
@@ -113,7 +113,7 @@ ${
? "- Skip automated testing (skipTests=true) - user will manually verify"
: "- Write comprehensive Playwright tests\n- Ensure all existing tests still pass\n- Mark the feature as passing only when all tests are green\n- **CRITICAL: Delete test files after verification** - tests accumulate and become brittle"
}
- **CRITICAL: Use UpdateFeatureStatus tool instead of editing feature_list.json directly**
- **CRITICAL: Use UpdateFeatureStatus tool instead of editing feature files directly**
- **CRITICAL: Always include a summary when marking feature as verified**
${
feature.skipTests
@@ -223,7 +223,7 @@ ${
}
${
feature.skipTests ? "4" : "8"
}. **CRITICAL: Use the UpdateFeatureStatus tool to mark this feature as verified** - DO NOT manually edit .automaker/feature_list.json
}. **CRITICAL: Use the UpdateFeatureStatus tool to mark this feature as verified**
${
feature.skipTests
? "5. **DO NOT commit changes** - the user will review and commit manually"
@@ -237,7 +237,7 @@ When you have completed the feature${
}, you MUST use the \`mcp__automaker-tools__UpdateFeatureStatus\` tool to update the feature status:
- Call the tool with: featureId="${feature.id}" and status="verified"
- **You can also include a summary parameter** to describe what was done: summary="Brief summary of changes"
- **DO NOT manually edit the .automaker/feature_list.json file** - this can cause race conditions
- **DO NOT manually edit feature files** - this can cause race conditions
- The UpdateFeatureStatus tool safely updates the feature status without risk of corrupting other data
- **If skipTests=true, the tool will automatically convert "verified" to "waiting_approval"** - this is correct behavior
@@ -275,7 +275,7 @@ ${
? "- Skip automated testing (skipTests=true) - user will manually verify\n- **DO NOT commit changes** - user will review and commit manually"
: "- **CONTINUE IMPLEMENTING until all tests pass** - don't stop at the first failure\n- Only mark as verified if Playwright tests pass\n- **CRITICAL: Delete test files after they pass** - tests should not accumulate\n- Update test utilities if functionality changed\n- Make a git commit when the feature is complete\n- Be thorough and persistent in fixing issues"
}
- **CRITICAL: Use UpdateFeatureStatus tool instead of editing feature_list.json directly**
- **CRITICAL: Use UpdateFeatureStatus tool instead of editing feature files directly**
- **CRITICAL: Always include a summary when marking feature as verified**
Begin by reading the project structure and understanding what needs to be implemented or fixed.`;
@@ -358,7 +358,7 @@ ${
}
${
feature.skipTests ? "4" : "6"
}. **CRITICAL: Use the UpdateFeatureStatus tool to mark this feature as verified** - DO NOT manually edit .automaker/feature_list.json
}. **CRITICAL: Use the UpdateFeatureStatus tool to mark this feature as verified**
${
feature.skipTests
? "5. **DO NOT commit changes** - the user will review and commit manually"
@@ -372,7 +372,7 @@ When you have completed the feature${
}, you MUST use the \`mcp__automaker-tools__UpdateFeatureStatus\` tool to update the feature status:
- Call the tool with: featureId="${feature.id}" and status="verified"
- **You can also include a summary parameter** to describe what was done: summary="Brief summary of changes"
- **DO NOT manually edit the .automaker/feature_list.json file** - this can cause race conditions
- **DO NOT manually edit feature files** - this can cause race conditions
- The UpdateFeatureStatus tool safely updates the feature status without risk of corrupting other data
- **If skipTests=true, the tool will automatically convert "verified" to "waiting_approval"** - this is correct behavior
@@ -402,7 +402,7 @@ ${
? "- Skip automated testing (skipTests=true) - user will manually verify"
: "- Write comprehensive Playwright tests if not already done\n- Ensure all tests pass before marking as verified\n- **CRITICAL: Delete test files after verification**"
}
- **CRITICAL: Use UpdateFeatureStatus tool instead of editing feature_list.json directly**
- **CRITICAL: Use UpdateFeatureStatus tool instead of editing feature files directly**
- **CRITICAL: Always include a summary when marking feature as verified**
${
feature.skipTests
@@ -491,38 +491,16 @@ Analyze this project's codebase and update the .automaker/app_spec.txt file with
</project_specification>
\`\`\`
4. **IMPORTANT - Generate Feature List:**
After writing the app_spec.txt, you MUST update .automaker/feature_list.json with features from the implementation_roadmap section:
- Read the app_spec.txt you just created
- For EVERY feature in each phase of the implementation_roadmap, create an entry
- Write ALL features to .automaker/feature_list.json
4. Ensure .automaker/context/ directory exists
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
}
]
\`\`\`
Generate unique IDs using the current timestamp and index (e.g., "feature-1234567890-0", "feature-1234567890-1", etc.)
5. Ensure .automaker/context/ directory exists
6. Ensure .automaker/agents-context/ directory exists
5. Ensure .automaker/features/ directory exists
**Important:**
- Be concise but accurate
- Only include information you can verify from the codebase
- If unsure about something, note it as "to be determined"
- Don't make up features that don't exist
- Include EVERY feature from the roadmap in feature_list.json - do not skip any
- Features are stored in .automaker/features/{id}/feature.json - each feature gets its own folder
Begin by exploring the project structure.`;
}
@@ -563,27 +541,12 @@ You are implementing features for manual user review. This means:
${modeHeader}
${memoryContent}
**🚨 CRITICAL FILE PROTECTION - READ THIS FIRST 🚨**
THE FOLLOWING FILE IS ABSOLUTELY FORBIDDEN FROM DIRECT MODIFICATION:
- .automaker/feature_list.json
**YOU MUST NEVER:**
- Use the Write tool on feature_list.json
- Use the Edit tool on feature_list.json
- Use any Bash command that writes to feature_list.json (echo, sed, awk, etc.)
- Attempt to read and rewrite feature_list.json
- UNDER ANY CIRCUMSTANCES touch this file directly
**CATASTROPHIC CONSEQUENCES:**
Directly modifying feature_list.json can:
- Erase all project features permanently
- Corrupt the project state beyond recovery
- Destroy hours/days of planning work
- This is a FIREABLE OFFENSE - you will be terminated if you do this
**Feature Storage:**
Features are stored in .automaker/features/{id}/feature.json - each feature has its own folder.
**THE ONLY WAY to update features:**
Use the mcp__automaker-tools__UpdateFeatureStatus tool with featureId, status, and summary parameters.
Do NOT manually edit feature.json files directly.
${contextFilesPreview}
@@ -594,7 +557,7 @@ Your role is to:
- Create comprehensive Playwright tests using testing utilities (only if skipTests is false)
- Ensure all tests pass before marking features complete (only if skipTests is false)
- **DELETE test files after successful verification** - tests are only for immediate feature verification (only if skipTests is false)
- **Use the UpdateFeatureStatus tool to mark features as verified** - NEVER manually edit feature_list.json
- **Use the UpdateFeatureStatus tool to mark features as verified** - NEVER manually edit feature files
- **Always include a summary parameter when calling UpdateFeatureStatus** - describe what was done
- Commit working code to git (only if skipTests is false - skipTests features require manual review)
- Be thorough and detail-oriented
@@ -609,7 +572,7 @@ If a feature has skipTests=true:
**IMPORTANT - UpdateFeatureStatus Tool:**
You have access to the \`mcp__automaker-tools__UpdateFeatureStatus\` tool. When the feature is complete (and all tests pass if skipTests is false), use this tool to update the feature status:
- Call with featureId, status="verified", and summary="Description of what was done"
- **DO NOT manually edit .automaker/feature_list.json** - this can cause race conditions and restore old state
- **DO NOT manually edit feature files** - this can cause race conditions and restore old state
- The tool safely updates the status without corrupting other feature data
- **If skipTests=true, the tool will automatically convert "verified" to "waiting_approval"** - this is correct
@@ -704,27 +667,12 @@ You are completing features for manual user review. This means:
${modeHeader}
${memoryContent}
**🚨 CRITICAL FILE PROTECTION - READ THIS FIRST 🚨**
THE FOLLOWING FILE IS ABSOLUTELY FORBIDDEN FROM DIRECT MODIFICATION:
- .automaker/feature_list.json
**YOU MUST NEVER:**
- Use the Write tool on feature_list.json
- Use the Edit tool on feature_list.json
- Use any Bash command that writes to feature_list.json (echo, sed, awk, etc.)
- Attempt to read and rewrite feature_list.json
- UNDER ANY CIRCUMSTANCES touch this file directly
**CATASTROPHIC CONSEQUENCES:**
Directly modifying feature_list.json can:
- Erase all project features permanently
- Corrupt the project state beyond recovery
- Destroy hours/days of planning work
- This is a FIREABLE OFFENSE - you will be terminated if you do this
**Feature Storage:**
Features are stored in .automaker/features/{id}/feature.json - each feature has its own folder.
**THE ONLY WAY to update features:**
Use the mcp__automaker-tools__UpdateFeatureStatus tool with featureId, status, and summary parameters.
Do NOT manually edit feature.json files directly.
${contextFilesPreview}
@@ -737,7 +685,7 @@ Your role is to:
- If other tests fail, verify if those tests are still accurate or should be updated or deleted (only if skipTests is false)
- Continue rerunning tests and fixing issues until ALL tests pass (only if skipTests is false)
- **DELETE test files after successful verification** - tests are only for immediate feature verification (only if skipTests is false)
- **Use the UpdateFeatureStatus tool to mark features as verified** - NEVER manually edit feature_list.json
- **Use the UpdateFeatureStatus tool to mark features as verified** - NEVER manually edit feature files
- **Always include a summary parameter when calling UpdateFeatureStatus** - describe what was done
- **Update test utilities (tests/utils.ts) if functionality changed** - keep helpers in sync with code (only if skipTests is false)
- Commit working code to git (only if skipTests is false - skipTests features require manual review)
@@ -752,7 +700,7 @@ If a feature has skipTests=true:
**IMPORTANT - UpdateFeatureStatus Tool:**
You have access to the \`mcp__automaker-tools__UpdateFeatureStatus\` tool. When the feature is complete (and all tests pass if skipTests is false), use this tool to update the feature status:
- Call with featureId, status="verified", and summary="Description of what was done"
- **DO NOT manually edit .automaker/feature_list.json** - this can cause race conditions and restore old state
- **DO NOT manually edit feature files** - this can cause race conditions and restore old state
- The tool safely updates the status without corrupting other feature data
- **If skipTests=true, the tool will automatically convert "verified" to "waiting_approval"** - this is correct
@@ -820,7 +768,6 @@ Your goal is to:
- Identify programming languages, frameworks, and libraries
- Detect existing features and capabilities
- Update the .automaker/app_spec.txt with accurate information
- Generate a feature list in .automaker/feature_list.json based on the implementation roadmap
- Ensure all required .automaker files and directories exist
Be efficient - don't read every file, focus on:
@@ -829,11 +776,9 @@ Be efficient - don't read every file, focus on:
- Directory structure
- README and documentation
**CRITICAL - Feature List Generation:**
After creating/updating the app_spec.txt, you MUST also update .automaker/feature_list.json:
1. Read the app_spec.txt you just wrote
2. Extract all features from the implementation_roadmap section
3. Write them to .automaker/feature_list.json in the correct format
**Feature Storage:**
Features are stored in .automaker/features/{id}/feature.json - each feature has its own folder.
Use the UpdateFeatureStatus tool to manage features, not direct file edits.
You have access to Read, Write, Edit, Glob, Grep, and Bash tools. Use them to explore the structure and write the necessary files.`;
}

View File

@@ -92,7 +92,7 @@ class SpecRegenerationService {
* @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
* @param {boolean} generateFeatures - Whether to generate feature entries in features folder
*/
async createInitialSpec(projectPath, projectOverview, sendToRenderer, execution, generateFeatures = true) {
console.log(`[SpecRegeneration] Creating initial spec for: ${projectPath}, generateFeatures: ${generateFeatures}`);
@@ -187,43 +187,6 @@ class SpecRegenerationService {
* @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:
@@ -241,10 +204,13 @@ When analyzing, look at:
- Framework-specific patterns (Next.js, React, Django, etc.)
- Database configurations and schemas
- API structures and patterns
${featureListInstructions}
**Feature Storage:**
Features are stored in .automaker/features/{id}/feature.json - each feature has its own folder.
Do NOT manually create feature files. Use the UpdateFeatureStatus tool to manage features.
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)' : ''}
- .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.`;
}
@@ -252,20 +218,9 @@ You have access to file reading, writing, and search tools. Use them to understa
/**
* Build the prompt for initial spec creation
* @param {string} projectOverview - User's project description
* @param {boolean} generateFeatures - Whether to generate feature_list.json entries
* @param {boolean} generateFeatures - Whether to generate feature entries in features folder
*/
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:**
@@ -295,7 +250,6 @@ ${APP_SPEC_XML_TEMPLATE}
- **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
@@ -420,15 +374,9 @@ When analyzing, look at:
- 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
**Feature Storage:**
Features are stored in .automaker/features/{id}/feature.json - each feature has its own folder.
Do NOT manually create feature files. Use the UpdateFeatureStatus tool to manage features.
You CAN and SHOULD modify:
- .automaker/app_spec.txt (this is your primary target)

View File

@@ -176,15 +176,8 @@ class WorktreeManager {
try {
await fs.mkdir(automakerDst, { recursive: true });
// Copy feature_list.json
const featureListSrc = path.join(automakerSrc, "feature_list.json");
const featureListDst = path.join(automakerDst, "feature_list.json");
try {
const content = await fs.readFile(featureListSrc, "utf-8");
await fs.writeFile(featureListDst, content, "utf-8");
} catch {
// Feature list might not exist yet
}
// Note: Features are stored in .automaker/features/{id}/feature.json
// These are managed by the main project, not copied to worktrees
// Copy app_spec.txt if it exists
const appSpecSrc = path.join(automakerSrc, "app_spec.txt");

73
app/package-lock.json generated
View File

@@ -17,6 +17,7 @@
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
@@ -24,6 +25,7 @@
"@tanstack/react-query": "^5.90.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dotenv": "^17.2.3",
"lucide-react": "^0.556.0",
"next": "16.0.7",
@@ -2955,6 +2957,61 @@
}
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
"integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
@@ -5737,6 +5794,22 @@
"node": ">=6"
}
},
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",

View File

@@ -24,6 +24,7 @@
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
@@ -31,6 +32,7 @@
"@tanstack/react-query": "^5.90.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dotenv": "^17.2.3",
"lucide-react": "^0.556.0",
"next": "16.0.7",

BIN
app/public/sounds/ding.mp3 Normal file

Binary file not shown.

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useCallback } from "react";
import { Sidebar } from "@/components/layout/sidebar";
import { WelcomeView } from "@/components/views/welcome-view";
import { BoardView } from "@/components/views/board-view";
@@ -20,6 +20,45 @@ export default function Home() {
const { currentView, setCurrentView, setIpcConnected, theme, currentProject } = useAppStore();
const { isFirstRun, setupComplete } = useSetupStore();
const [isMounted, setIsMounted] = useState(false);
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
// Hidden streamer panel - opens with "\" key
const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => {
// Don't trigger when typing in inputs
const activeElement = document.activeElement;
if (activeElement) {
const tagName = activeElement.tagName.toLowerCase();
if (tagName === "input" || tagName === "textarea" || tagName === "select") {
return;
}
if (activeElement.getAttribute("contenteditable") === "true") {
return;
}
const role = activeElement.getAttribute("role");
if (role === "textbox" || role === "searchbox" || role === "combobox") {
return;
}
}
// Don't trigger with modifier keys
if (event.ctrlKey || event.altKey || event.metaKey) {
return;
}
// Check for "\" key (backslash)
if (event.key === "\\") {
event.preventDefault();
setStreamerPanelOpen((prev) => !prev);
}
}, []);
// Register the "\" shortcut for streamer panel
useEffect(() => {
window.addEventListener("keydown", handleStreamerPanelShortcut);
return () => {
window.removeEventListener("keydown", handleStreamerPanelShortcut);
};
}, [handleStreamerPanelShortcut]);
// Compute the effective theme: project theme takes priority over global theme
// This is reactive because it depends on currentProject and theme from the store
@@ -162,7 +201,9 @@ export default function Home() {
return (
<main className="flex h-screen overflow-hidden" data-testid="app-container">
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">{renderView()}</div>
<div className="flex-1 flex flex-col overflow-hidden transition-all duration-300" style={{ marginRight: streamerPanelOpen ? '250px' : '0' }}>
{renderView()}
</div>
{/* Environment indicator - only show after mount to prevent hydration issues */}
{isMounted && !isElectron() && (
@@ -170,6 +211,13 @@ export default function Home() {
Web Mode (Mock IPC)
</div>
)}
{/* Hidden streamer panel - opens with "\" key, pushes content */}
<div
className={`fixed top-0 right-0 h-full w-[250px] bg-background border-l border-border transition-transform duration-300 ${
streamerPanelOpen ? 'translate-x-0' : 'translate-x-full'
}`}
/>
</main>
);
}

View File

@@ -393,7 +393,8 @@ export function Sidebar() {
if (!result.canceled && result.filePaths[0]) {
const path = result.filePaths[0];
const name = path.split("/").pop() || "Untitled Project";
// Extract folder name from path (works on both Windows and Mac/Linux)
const name = path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project";
try {
// Check if this is a brand new project (no .automaker directory)
@@ -1271,7 +1272,7 @@ export function Sidebar() {
Generate feature list
</label>
<p className="text-xs text-muted-foreground">
Automatically populate feature_list.json with all features
Automatically create features in the features folder
from the implementation roadmap after the spec is generated.
</p>
</div>

View File

@@ -1,11 +1,23 @@
"use client";
import * as React from "react";
import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Input } from "./input";
import { Check, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
interface CategoryAutocompleteProps {
value: string;
@@ -26,225 +38,54 @@ export function CategoryAutocomplete({
disabled = false,
"data-testid": testId,
}: CategoryAutocompleteProps) {
const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState(value);
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
// Update internal state when value prop changes
useEffect(() => {
setInputValue(value);
}, [value]);
// Filter suggestions based on input
useEffect(() => {
const searchTerm = inputValue.toLowerCase().trim();
if (searchTerm === "") {
setFilteredSuggestions(suggestions);
} else {
const filtered = suggestions.filter((s) =>
s.toLowerCase().includes(searchTerm)
);
setFilteredSuggestions(filtered);
}
setHighlightedIndex(-1);
}, [inputValue, suggestions]);
// Update dropdown position when open and handle scroll/resize
useEffect(() => {
const updatePosition = () => {
if (isOpen && containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY,
left: rect.left + window.scrollX,
width: rect.width,
});
}
};
updatePosition();
if (isOpen) {
window.addEventListener("scroll", updatePosition, true);
window.addEventListener("resize", updatePosition);
return () => {
window.removeEventListener("scroll", updatePosition, true);
window.removeEventListener("resize", updatePosition);
};
}
}, [isOpen]);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node) &&
listRef.current &&
!listRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Scroll highlighted item into view
useEffect(() => {
if (highlightedIndex >= 0 && listRef.current) {
const items = listRef.current.querySelectorAll("li");
const highlightedItem = items[highlightedIndex];
if (highlightedItem) {
highlightedItem.scrollIntoView({ block: "nearest" });
}
}
}, [highlightedIndex]);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setInputValue(newValue);
onChange(newValue);
setIsOpen(true);
},
[onChange]
);
const handleSelect = useCallback(
(suggestion: string) => {
setInputValue(suggestion);
onChange(suggestion);
setIsOpen(false);
inputRef.current?.focus();
},
[onChange]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!isOpen) {
if (e.key === "ArrowDown" || e.key === "Enter") {
e.preventDefault();
setIsOpen(true);
}
return;
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setHighlightedIndex((prev) =>
prev < filteredSuggestions.length - 1 ? prev + 1 : prev
);
break;
case "ArrowUp":
e.preventDefault();
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : -1));
break;
case "Enter":
e.preventDefault();
if (highlightedIndex >= 0 && filteredSuggestions[highlightedIndex]) {
handleSelect(filteredSuggestions[highlightedIndex]);
} else {
setIsOpen(false);
}
break;
case "Escape":
e.preventDefault();
setIsOpen(false);
break;
case "Tab":
setIsOpen(false);
break;
}
},
[isOpen, highlightedIndex, filteredSuggestions, handleSelect]
);
const handleFocus = useCallback(() => {
setIsOpen(true);
}, []);
const [open, setOpen] = React.useState(false);
return (
<div ref={containerRef} className={cn("relative", className)}>
<div className="relative">
<Input
ref={inputRef}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
placeholder={placeholder}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn("w-full justify-between", className)}
data-testid={testId}
className="pr-8"
/>
<button
type="button"
className={cn(
"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors",
disabled && "pointer-events-none opacity-50"
)}
onClick={() => setIsOpen(!isOpen)}
tabIndex={-1}
>
<ChevronDown
className={cn(
"h-4 w-4 transition-transform duration-200",
isOpen && "rotate-180"
)}
/>
</button>
</div>
{isOpen && filteredSuggestions.length > 0 && typeof document !== "undefined" &&
createPortal(
<ul
ref={listRef}
className="fixed z-[9999] max-h-60 overflow-auto rounded-md border bg-background p-1 shadow-md animate-in fade-in-0 zoom-in-95"
role="listbox"
data-testid="category-autocomplete-list"
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
width: dropdownPosition.width,
}}
>
{filteredSuggestions.map((suggestion, index) => (
<li
key={suggestion}
role="option"
aria-selected={highlightedIndex === index}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors",
highlightedIndex === index && "bg-accent text-accent-foreground",
inputValue === suggestion && "font-medium"
)}
onMouseDown={(e) => {
e.preventDefault();
handleSelect(suggestion);
}}
onMouseEnter={() => setHighlightedIndex(index)}
data-testid={`category-option-${suggestion.toLowerCase().replace(/\s+/g, "-")}`}
>
{inputValue === suggestion && (
<Check className="mr-2 h-4 w-4 text-primary" />
)}
<span className={cn(inputValue !== suggestion && "ml-6")}>
{value
? suggestions.find((s) => s === value) ?? value
: placeholder}
<ChevronsUpDown className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search category..." className="h-9" />
<CommandList>
<CommandEmpty>No category found.</CommandEmpty>
<CommandGroup>
{suggestions.map((suggestion) => (
<CommandItem
key={suggestion}
value={suggestion}
onSelect={(currentValue) => {
onChange(currentValue === value ? "" : currentValue);
setOpen(false);
}}
data-testid={`category-option-${suggestion.toLowerCase().replace(/\s+/g, "-")}`}
>
{suggestion}
</span>
</li>
))}
</ul>,
document.body
)}
</div>
<Check
className={cn(
"ml-auto",
value === suggestion ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,184 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -20,6 +20,8 @@ interface AgentOutputModalProps {
onClose: () => void;
featureDescription: string;
featureId: string;
/** The status of the feature - used to determine if spinner should be shown */
featureStatus?: string;
/** Called when a number key (0-9) is pressed while the modal is open */
onNumberKeyPress?: (key: string) => void;
}
@@ -31,6 +33,7 @@ export function AgentOutputModal({
onClose,
featureDescription,
featureId,
featureStatus,
onNumberKeyPress,
}: AgentOutputModalProps) {
const [output, setOutput] = useState<string>("");
@@ -70,16 +73,18 @@ export function AgentOutputModal({
projectPathRef.current = currentProject.path;
setProjectPath(currentProject.path);
// Ensure context directory exists
const contextDir = `${currentProject.path}/.automaker/agents-context`;
await api.mkdir(contextDir);
// Use features API to get agent output
if (api.features) {
const result = await api.features.getAgentOutput(
currentProject.path,
featureId
);
// Try to read existing output file
const outputPath = `${contextDir}/${featureId}.md`;
const result = await api.readFile(outputPath);
if (result.success && result.content) {
setOutput(result.content);
if (result.success) {
setOutput(result.content || "");
} else {
setOutput("");
}
} else {
setOutput("");
}
@@ -102,9 +107,10 @@ export function AgentOutputModal({
if (!api) return;
try {
const contextDir = `${projectPathRef.current}/.automaker/agents-context`;
const outputPath = `${contextDir}/${featureId}.md`;
// Use features API - agent output is stored in features/{id}/agent-output.md
// We need to write it directly since there's no updateAgentOutput method
// The context-manager handles this on the backend, but for frontend edits we write directly
const outputPath = `${projectPathRef.current}/.automaker/features/${featureId}/agent-output.md`;
await api.writeFile(outputPath, newContent);
} catch (error) {
console.error("Failed to save output:", error);
@@ -250,7 +256,10 @@ export function AgentOutputModal({
<DialogHeader className="flex-shrink-0">
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2">
<Loader2 className="w-5 h-5 text-primary animate-spin" />
{featureStatus !== "verified" &&
featureStatus !== "waiting_approval" && (
<Loader2 className="w-5 h-5 text-primary animate-spin" />
)}
Agent Output
</DialogTitle>
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">

View File

@@ -155,7 +155,7 @@ export function AgentToolsView() {
// In mock mode, simulate terminal output
// In real Electron mode, this would use child_process
const mockOutputs: Record<string, string> = {
ls: "app_spec.txt\nfeature_list.json\nnode_modules\npackage.json\nsrc\ntests\ntsconfig.json",
ls: "app_spec.txt\nfeatures\nnode_modules\npackage.json\nsrc\ntests\ntsconfig.json",
pwd: currentProject?.path || "/Users/demo/project",
"echo hello": "hello",
whoami: "automaker-agent",

View File

@@ -594,11 +594,11 @@ export function AgentView() {
className={cn(
"max-w-[80%]",
message.role === "user"
? "bg-primary text-primary-foreground"
? "bg-transparent border border-primary text-foreground"
: "border-l-4 border-primary bg-card"
)}
>
<CardContent className="p-3">
<CardContent className="px-3 py-2">
{message.role === "assistant" ? (
<Markdown className="text-sm text-primary prose-headings:text-primary prose-strong:text-primary prose-code:text-primary">
{message.content}
@@ -610,9 +610,9 @@ export function AgentView() {
)}
<p
className={cn(
"text-xs mt-2",
"text-xs mt-1",
message.role === "user"
? "text-primary-foreground/70"
? "text-muted-foreground"
: "text-primary/70"
)}
>

View File

@@ -409,7 +409,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
}
}, [currentProject, projectAnalysis]);
// Generate .automaker/feature_list.json from analysis
// Generate features from analysis and save to .automaker/features folder
const generateFeatureList = useCallback(async () => {
if (!currentProject || !projectAnalysis) return;
@@ -755,23 +755,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
});
}
// Generate the feature list content
const featureListContent = JSON.stringify(detectedFeatures, null, 2);
// Write the feature list file
const featureListPath = `${currentProject.path}/feature_list.json`;
const writeResult = await api.writeFile(
featureListPath,
featureListContent
);
if (writeResult.success) {
setFeatureListGenerated(true);
} else {
setFeatureListError(
writeResult.error || "Failed to write feature list file"
);
// Create each feature using the features API
for (const feature of detectedFeatures) {
await api.features.create(currentProject.path, feature);
}
setFeatureListGenerated(true);
} catch (error) {
console.error("Failed to generate feature list:", error);
setFeatureListError(
@@ -1041,7 +1030,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
Generate Feature List
</CardTitle>
<CardDescription>
Create .automaker/feature_list.json from analysis
Create features from analysis
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
@@ -1074,7 +1063,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
data-testid="feature-list-generated-success"
>
<CheckCircle className="w-4 h-4" />
<span>feature_list.json created successfully!</span>
<span>Features created successfully!</span>
</div>
)}
{featureListError && (

View File

@@ -85,6 +85,7 @@ import {
Minimize2,
Square,
Maximize2,
Shuffle,
} from "lucide-react";
import { toast } from "sonner";
import { Slider } from "@/components/ui/slider";
@@ -242,6 +243,8 @@ export function BoardView() {
const [followUpPreviewMap, setFollowUpPreviewMap] = useState<ImagePreviewMap>(
() => new Map()
);
const [editFeaturePreviewMap, setEditFeaturePreviewMap] =
useState<ImagePreviewMap>(() => new Map());
// Local state to temporarily show advanced options when profiles-only mode is enabled
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false);
@@ -390,7 +393,7 @@ export function BoardView() {
return rectIntersection(args);
}, []);
// Load features from file
// Load features using features API
const loadFeatures = useCallback(async () => {
if (!currentProject) return;
@@ -419,21 +422,25 @@ export function BoardView() {
try {
const api = getElectronAPI();
const result = await api.readFile(
`${currentProject.path}/.automaker/feature_list.json`
);
if (!api.features) {
console.error("[BoardView] Features API not available");
return;
}
if (result.success && result.content) {
const parsed = JSON.parse(result.content);
const featuresWithIds = parsed.map((f: any, index: number) => ({
...f,
id: f.id || `feature-${index}-${Date.now()}`,
status: f.status || "backlog",
startedAt: f.startedAt, // Preserve startedAt timestamp
// Ensure model and thinkingLevel are set for backward compatibility
model: f.model || "opus",
thinkingLevel: f.thinkingLevel || "none",
}));
const result = await api.features.getAll(currentProject.path);
if (result.success && result.features) {
const featuresWithIds = result.features.map(
(f: any, index: number) => ({
...f,
id: f.id || `feature-${index}-${Date.now()}`,
status: f.status || "backlog",
startedAt: f.startedAt, // Preserve startedAt timestamp
// Ensure model and thinkingLevel are set for backward compatibility
model: f.model || "opus",
thinkingLevel: f.thinkingLevel || "none",
})
);
setFeatures(featuresWithIds);
}
} catch (error) {
@@ -529,6 +536,9 @@ export function BoardView() {
// Reload features when a feature is completed
console.log("[Board] Feature completed, reloading features...");
loadFeatures();
// Play ding sound when feature is done
const audio = new Audio("/sounds/ding.mp3");
audio.play().catch((err) => console.warn("Could not play ding sound:", err));
} else if (event.type === "auto_mode_error") {
// Reload features when an error occurs (feature moved to waiting_approval)
console.log(
@@ -627,41 +637,75 @@ export function BoardView() {
}
}, [features, isLoading]);
// Save features to file
const saveFeatures = useCallback(async () => {
if (!currentProject) return;
// Persist feature update to API (replaces saveFeatures)
const persistFeatureUpdate = useCallback(
async (featureId: string, updates: Partial<Feature>) => {
if (!currentProject) return;
try {
const api = getElectronAPI();
const toSave = features.map((f) => ({
id: f.id,
category: f.category,
description: f.description,
steps: f.steps,
status: f.status,
startedAt: f.startedAt,
imagePaths: f.imagePaths,
skipTests: f.skipTests,
summary: f.summary,
model: f.model,
thinkingLevel: f.thinkingLevel,
error: f.error,
}));
await api.writeFile(
`${currentProject.path}/.automaker/feature_list.json`,
JSON.stringify(toSave, null, 2)
);
} catch (error) {
console.error("Failed to save features:", error);
}
}, [currentProject, features]);
try {
const api = getElectronAPI();
if (!api.features) {
console.error("[BoardView] Features API not available");
return;
}
// Save when features change (after initial load is complete)
useEffect(() => {
if (!isLoading && !isSwitchingProjectRef.current) {
saveFeatures();
}
}, [features, saveFeatures, isLoading]);
const result = await api.features.update(
currentProject.path,
featureId,
updates
);
if (result.success && result.feature) {
updateFeature(result.feature.id, result.feature);
}
} catch (error) {
console.error("Failed to persist feature update:", error);
}
},
[currentProject, updateFeature]
);
// Persist feature creation to API
const persistFeatureCreate = useCallback(
async (feature: Feature) => {
if (!currentProject) return;
try {
const api = getElectronAPI();
if (!api.features) {
console.error("[BoardView] Features API not available");
return;
}
const result = await api.features.create(currentProject.path, feature);
if (result.success && result.feature) {
updateFeature(result.feature.id, result.feature);
}
} catch (error) {
console.error("Failed to persist feature creation:", error);
}
},
[currentProject, updateFeature]
);
// Persist feature deletion to API
const persistFeatureDelete = useCallback(
async (featureId: string) => {
if (!currentProject) return;
try {
const api = getElectronAPI();
if (!api.features) {
console.error("[BoardView] Features API not available");
return;
}
await api.features.delete(currentProject.path, featureId);
} catch (error) {
console.error("Failed to persist feature deletion:", error);
}
},
[currentProject]
);
const handleDragStart = (event: DragStartEvent) => {
const { active } = event;
@@ -690,13 +734,15 @@ export function BoardView() {
// Determine if dragging is allowed based on status and skipTests
// - Backlog items can always be dragged
// - waiting_approval items can always be dragged (to allow manual verification via drag)
// - verified items can always be dragged (to allow moving back to waiting_approval)
// - skipTests (non-TDD) items can be dragged between in_progress and verified
// - Non-skipTests (TDD) items that are in progress or verified cannot be dragged
// - Non-skipTests (TDD) items that are in progress cannot be dragged (they are running)
if (
draggedFeature.status !== "backlog" &&
draggedFeature.status !== "waiting_approval"
draggedFeature.status !== "waiting_approval" &&
draggedFeature.status !== "verified"
) {
// Only allow dragging in_progress/verified if it's a skipTests feature and not currently running
// Only allow dragging in_progress if it's a skipTests feature and not currently running
if (!draggedFeature.skipTests || isRunningTask) {
console.log(
"[Board] Cannot drag feature - TDD feature or currently running"
@@ -744,14 +790,17 @@ export function BoardView() {
// From backlog
if (targetStatus === "in_progress") {
// Update with startedAt timestamp
updateFeature(featureId, {
const updates = {
status: targetStatus,
startedAt: new Date().toISOString(),
});
};
updateFeature(featureId, updates);
persistFeatureUpdate(featureId, updates);
console.log("[Board] Feature moved to in_progress, starting agent...");
await handleRunFeature(draggedFeature);
} else {
moveFeature(featureId, targetStatus);
persistFeatureUpdate(featureId, { status: targetStatus });
}
} else if (draggedFeature.status === "waiting_approval") {
// waiting_approval features can be dragged to verified for manual verification
@@ -759,6 +808,7 @@ export function BoardView() {
// features often have skipTests=true, and we want status-based handling first
if (targetStatus === "verified") {
moveFeature(featureId, "verified");
persistFeatureUpdate(featureId, { status: "verified" });
toast.success("Feature verified", {
description: `Manually verified: ${draggedFeature.description.slice(
0,
@@ -768,6 +818,7 @@ export function BoardView() {
} else if (targetStatus === "backlog") {
// Allow moving waiting_approval cards back to backlog
moveFeature(featureId, "backlog");
persistFeatureUpdate(featureId, { status: "backlog" });
toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
@@ -783,6 +834,7 @@ export function BoardView() {
) {
// Manual verify via drag
moveFeature(featureId, "verified");
persistFeatureUpdate(featureId, { status: "verified" });
toast.success("Feature verified", {
description: `Marked as verified: ${draggedFeature.description.slice(
0,
@@ -790,16 +842,14 @@ export function BoardView() {
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
});
} else if (
targetStatus === "in_progress" &&
targetStatus === "waiting_approval" &&
draggedFeature.status === "verified"
) {
// Move back to in_progress
updateFeature(featureId, {
status: "in_progress",
startedAt: new Date().toISOString(),
});
// Move verified feature back to waiting_approval
moveFeature(featureId, "waiting_approval");
persistFeatureUpdate(featureId, { status: "waiting_approval" });
toast.info("Feature moved back", {
description: `Moved back to In Progress: ${draggedFeature.description.slice(
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
@@ -807,6 +857,30 @@ export function BoardView() {
} else if (targetStatus === "backlog") {
// Allow moving skipTests cards back to backlog
moveFeature(featureId, "backlog");
persistFeatureUpdate(featureId, { status: "backlog" });
toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
});
}
} else if (draggedFeature.status === "verified") {
// Handle verified TDD (non-skipTests) features being moved back
if (targetStatus === "waiting_approval") {
// Move verified feature back to waiting_approval
moveFeature(featureId, "waiting_approval");
persistFeatureUpdate(featureId, { status: "waiting_approval" });
toast.info("Feature moved back", {
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
0,
50
)}${draggedFeature.description.length > 50 ? "..." : ""}`,
});
} else if (targetStatus === "backlog") {
// Allow moving verified cards back to backlog
moveFeature(featureId, "backlog");
persistFeatureUpdate(featureId, { status: "backlog" });
toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
@@ -828,17 +902,19 @@ export function BoardView() {
const normalizedThinking = modelSupportsThinking(selectedModel)
? newFeature.thinkingLevel
: "none";
addFeature({
const newFeatureData = {
category,
description: newFeature.description,
steps: newFeature.steps.filter((s) => s.trim()),
status: "backlog",
status: "backlog" as const,
images: newFeature.images,
imagePaths: newFeature.imagePaths,
skipTests: newFeature.skipTests,
model: selectedModel,
thinkingLevel: normalizedThinking,
});
};
const createdFeature = addFeature(newFeatureData);
persistFeatureCreate(createdFeature);
// Persist the category
saveCategory(category);
setNewFeature({
@@ -864,14 +940,19 @@ export function BoardView() {
? editingFeature.thinkingLevel
: "none";
updateFeature(editingFeature.id, {
const updates = {
category: editingFeature.category,
description: editingFeature.description,
steps: editingFeature.steps,
skipTests: editingFeature.skipTests,
model: selectedModel,
thinkingLevel: normalizedThinking,
});
imagePaths: editingFeature.imagePaths,
};
updateFeature(editingFeature.id, updates);
persistFeatureUpdate(editingFeature.id, updates);
// Clear the preview map after saving
setEditFeaturePreviewMap(new Map());
// Persist the category if it's new
if (editingFeature.category) {
saveCategory(editingFeature.category);
@@ -904,13 +985,14 @@ export function BoardView() {
}
}
// Delete agent context file if it exists
// Note: Agent context file will be deleted automatically when feature folder is deleted
// via persistFeatureDelete, so no manual deletion needed
if (currentProject) {
try {
const api = getElectronAPI();
const contextPath = `${currentProject.path}/.automaker/agents-context/${featureId}.md`;
await api.deleteFile(contextPath);
console.log(`[Board] Deleted agent context for feature ${featureId}`);
// Feature folder deletion handles agent-output.md automatically
console.log(
`[Board] Feature ${featureId} will be deleted (including agent context)`
);
} catch (error) {
// Context file might not exist, which is fine
console.log(
@@ -944,6 +1026,7 @@ export function BoardView() {
// Remove the feature immediately without confirmation
removeFeature(featureId);
persistFeatureDelete(featureId);
};
const handleRunFeature = async (feature: Feature) => {
@@ -1056,6 +1139,7 @@ export function BoardView() {
description: feature.description,
});
moveFeature(feature.id, "verified");
persistFeatureUpdate(feature.id, { status: "verified" });
toast.success("Feature verified", {
description: `Marked as verified: ${feature.description.slice(0, 50)}${
feature.description.length > 50 ? "..." : ""
@@ -1069,10 +1153,12 @@ export function BoardView() {
id: feature.id,
description: feature.description,
});
updateFeature(feature.id, {
status: "in_progress",
const updates = {
status: "in_progress" as const,
startedAt: new Date().toISOString(),
});
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.info("Feature moved back", {
description: `Moved back to In Progress: ${feature.description.slice(
0,
@@ -1119,10 +1205,12 @@ export function BoardView() {
}
// Move feature back to in_progress before sending follow-up
updateFeature(featureId, {
status: "in_progress",
const updates = {
status: "in_progress" as const,
startedAt: new Date().toISOString(),
});
};
updateFeature(featureId, updates);
persistFeatureUpdate(featureId, updates);
// Reset follow-up state immediately (close dialog, clear form)
setShowFollowUpDialog(false);
@@ -1181,6 +1269,7 @@ export function BoardView() {
console.log("[Board] Feature committed successfully");
// Move to verified status
moveFeature(feature.id, "verified");
persistFeatureUpdate(feature.id, { status: "verified" });
toast.success("Feature committed", {
description: `Committed and verified: ${feature.description.slice(
0,
@@ -1210,7 +1299,9 @@ export function BoardView() {
id: feature.id,
description: feature.description,
});
updateFeature(feature.id, { status: "waiting_approval" });
const updates = { status: "waiting_approval" as const };
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.info("Feature ready for review", {
description: `Ready for approval: ${feature.description.slice(0, 50)}${
feature.description.length > 50 ? "..." : ""
@@ -1426,6 +1517,7 @@ export function BoardView() {
if (targetStatus !== feature.status) {
moveFeature(feature.id, targetStatus);
persistFeatureUpdate(feature.id, { status: targetStatus });
}
toast.success("Agent stopped", {
@@ -1473,10 +1565,12 @@ export function BoardView() {
for (const feature of featuresToStart) {
// Update the feature status with startedAt timestamp
updateFeature(feature.id, {
status: "in_progress",
const updates = {
status: "in_progress" as const,
startedAt: new Date().toISOString(),
});
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
// Start the agent for this feature
await handleRunFeature(feature);
}
@@ -1885,7 +1979,24 @@ export function BoardView() {
}
}}
>
<DialogContent compact={!isMaximized} data-testid="add-feature-dialog">
<DialogContent
compact={!isMaximized}
data-testid="add-feature-dialog"
onPointerDownOutside={(e) => {
// Prevent dialog from closing when clicking on category autocomplete dropdown
const target = e.target as HTMLElement;
if (target.closest('[data-testid="category-autocomplete-list"]')) {
e.preventDefault();
}
}}
onInteractOutside={(e) => {
// Prevent dialog from closing when clicking on category autocomplete dropdown
const target = e.target as HTMLElement;
if (target.closest('[data-testid="category-autocomplete-list"]')) {
e.preventDefault();
}
}}
>
<DialogHeader>
<DialogTitle>Add New Feature</DialogTitle>
<DialogDescription>
@@ -2276,10 +2387,28 @@ export function BoardView() {
if (!open) {
setEditingFeature(null);
setShowEditAdvancedOptions(false);
setEditFeaturePreviewMap(new Map());
}
}}
>
<DialogContent compact={!isMaximized} data-testid="edit-feature-dialog">
<DialogContent
compact={!isMaximized}
data-testid="edit-feature-dialog"
onPointerDownOutside={(e) => {
// Prevent dialog from closing when clicking on category autocomplete dropdown
const target = e.target as HTMLElement;
if (target.closest('[data-testid="category-autocomplete-list"]')) {
e.preventDefault();
}
}}
onInteractOutside={(e) => {
// Prevent dialog from closing when clicking on category autocomplete dropdown
const target = e.target as HTMLElement;
if (target.closest('[data-testid="category-autocomplete-list"]')) {
e.preventDefault();
}
}}
>
<DialogHeader>
<DialogTitle>Edit Feature</DialogTitle>
<DialogDescription>Modify the feature details.</DialogDescription>
@@ -2308,16 +2437,24 @@ export function BoardView() {
<TabsContent value="prompt" className="space-y-4 overflow-y-auto">
<div className="space-y-2">
<Label htmlFor="edit-description">Description</Label>
<Textarea
id="edit-description"
placeholder="Describe the feature..."
<DescriptionImageDropZone
value={editingFeature.description}
onChange={(e) =>
onChange={(value) =>
setEditingFeature({
...editingFeature,
description: e.target.value,
description: value,
})
}
images={editingFeature.imagePaths ?? []}
onImagesChange={(images) =>
setEditingFeature({
...editingFeature,
imagePaths: images,
})
}
placeholder="Describe the feature..."
previewMap={editFeaturePreviewMap}
onPreviewMapChange={setEditFeaturePreviewMap}
data-testid="edit-feature-description"
/>
</div>
@@ -2669,6 +2806,7 @@ export function BoardView() {
onClose={() => setShowOutputModal(false)}
featureDescription={outputFeature?.description || ""}
featureId={outputFeature?.id || ""}
featureStatus={outputFeature?.status}
onNumberKeyPress={handleOutputModalNumberKeyPress}
/>
@@ -2720,12 +2858,12 @@ export function BoardView() {
}
}
// Delete agent context file if it exists
// Note: Agent context file will be deleted automatically when feature folder is deleted
// via persistFeatureDelete, so no manual deletion needed
try {
const contextPath = `${currentProject.path}/.automaker/agents-context/${feature.id}.md`;
await api.deleteFile(contextPath);
// Feature folder deletion handles agent-output.md automatically
console.log(
`[Board] Deleted agent context for feature ${feature.id}`
`[Board] Feature ${feature.id} will be deleted (including agent context)`
);
} catch (error) {
// Context file might not exist, which is fine
@@ -2737,6 +2875,7 @@ export function BoardView() {
// Remove the feature
removeFeature(feature.id);
persistFeatureDelete(feature.id);
}
setShowDeleteAllVerifiedDialog(false);

View File

@@ -202,12 +202,13 @@ export function FeatureSuggestionsDialog({
skipTests: true, // As specified, testing mode true
}));
// Merge with existing features
const updatedFeatures = [...features, ...newFeatures];
// Create each new feature using the features API
for (const feature of newFeatures) {
await api.features.create(projectPath, feature);
}
// Save to file
const featureListPath = `${projectPath}/.automaker/feature_list.json`;
await api.writeFile(featureListPath, JSON.stringify(updatedFeatures, null, 2));
// Merge with existing features for store update
const updatedFeatures = [...features, ...newFeatures];
// Update store
setFeatures(updatedFeatures);

View File

@@ -17,6 +17,7 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getElectronAPI } from "@/lib/electron";
import { Markdown } from "@/components/ui/markdown";
interface InterviewMessage {
id: string;
@@ -99,12 +100,23 @@ export function InterviewView() {
// Auto-scroll to bottom when messages change
useEffect(() => {
let timeoutId: NodeJS.Timeout | undefined;
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTo({
top: messagesContainerRef.current.scrollHeight,
behavior: "smooth",
});
// Use a small delay to ensure DOM is updated
timeoutId = setTimeout(() => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTo({
top: messagesContainerRef.current.scrollHeight,
behavior: "smooth",
});
}
}, 100);
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [messages]);
// Auto-focus input
@@ -300,26 +312,20 @@ export function InterviewView() {
generatedSpec
);
// Create initial .automaker/feature_list.json
await api.writeFile(
`${fullProjectPath}/.automaker/feature_list.json`,
JSON.stringify(
[
{
category: "Core",
description: "Initial project setup",
steps: [
"Step 1: Review app_spec.txt",
"Step 2: Set up development environment",
"Step 3: Start implementing features",
],
passes: false,
},
],
null,
2
)
);
// Create initial feature in the features folder
const initialFeature = {
id: `feature-${Date.now()}-0`,
category: "Core",
description: "Initial project setup",
status: "backlog",
steps: [
"Step 1: Review app_spec.txt",
"Step 2: Set up development environment",
"Step 3: Start implementing features",
],
skipTests: true,
};
await api.features.create(fullProjectPath, initialFeature);
const project = {
id: `project-${Date.now()}`,
@@ -353,7 +359,7 @@ export function InterviewView() {
return (
<div
className="flex-1 flex flex-col content-bg"
className="flex-1 flex flex-col content-bg min-h-0"
data-testid="interview-view"
>
{/* Header */}
@@ -432,20 +438,25 @@ export function InterviewView() {
className={cn(
"max-w-[80%]",
message.role === "user"
? "bg-primary text-primary-foreground"
? "bg-transparent border border-primary text-foreground"
: "border-l-4 border-primary bg-card"
)}
>
<CardContent className="p-3">
<p className={cn(
"text-sm whitespace-pre-wrap",
message.role === "assistant" && "text-primary"
)}>{message.content}</p>
<CardContent className="px-3 py-2">
{message.role === "assistant" ? (
<Markdown className="text-sm text-primary prose-headings:text-primary prose-strong:text-primary prose-code:text-primary">
{message.content}
</Markdown>
) : (
<p className="text-sm whitespace-pre-wrap">
{message.content}
</p>
)}
<p
className={cn(
"text-xs mt-2",
"text-xs mt-1",
message.role === "user"
? "text-primary-foreground/70"
? "text-muted-foreground"
: "text-primary/70"
)}
>

View File

@@ -12,6 +12,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import {
Dialog,
DialogContent,
@@ -52,6 +53,8 @@ import {
GitBranch,
Undo2,
GitMerge,
ChevronDown,
ChevronUp,
} from "lucide-react";
import { CountUpTimer } from "@/components/ui/count-up-timer";
import { getElectronAPI } from "@/lib/electron";
@@ -116,6 +119,7 @@ export const KanbanCard = memo(function KanbanCard({
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const [isRevertDialogOpen, setIsRevertDialogOpen] = useState(false);
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const { kanbanCardDetailLevel } = useAppStore();
// Check if feature has worktree
@@ -149,12 +153,26 @@ export const KanbanCard = memo(function KanbanCard({
const currentProject = (window as any).__currentProject;
if (!currentProject?.path) return;
const contextPath = `${currentProject.path}/.automaker/agents-context/${feature.id}.md`;
// Use features API to get agent output
if (api.features) {
const result = await api.features.getAgentOutput(
currentProject.path,
feature.id
);
if (result.success && result.content) {
const info = parseAgentContext(result.content);
setAgentInfo(info);
}
} else {
// Fallback to direct file read for backward compatibility
const contextPath = `${currentProject.path}/.automaker/features/${feature.id}/agent-output.md`;
const result = await api.readFile(contextPath);
if (result.success && result.content) {
const info = parseAgentContext(result.content);
setAgentInfo(info);
}
}
} catch {
// Context file might not exist
@@ -216,16 +234,19 @@ export const KanbanCard = memo(function KanbanCard({
ref={setNodeRef}
style={style}
className={cn(
"cursor-grab active:cursor-grabbing transition-all backdrop-blur-sm border-border relative kanban-card-content",
"cursor-grab active:cursor-grabbing transition-all backdrop-blur-sm border-border relative kanban-card-content select-none",
isDragging && "opacity-50 scale-105 shadow-lg",
isCurrentAutoTask &&
"border-running-indicator border-2 shadow-running-indicator/50 shadow-lg animate-pulse",
feature.error &&
!isCurrentAutoTask &&
"border-red-500 border-2 shadow-red-500/30 shadow-lg"
"border-red-500 border-2 shadow-red-500/30 shadow-lg",
!isDraggable && "cursor-default"
)}
data-testid={`kanban-card-${feature.id}`}
onDoubleClick={onEdit}
{...attributes}
{...(isDraggable ? listeners : {})}
>
{/* Shortcut key badge for in-progress cards */}
{shortcutKey && (
@@ -323,6 +344,7 @@ export const KanbanCard = memo(function KanbanCard({
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`menu-${feature.id}`}
>
<MoreVertical className="w-4 h-4" />
@@ -369,17 +391,45 @@ export const KanbanCard = memo(function KanbanCard({
<div className="flex items-start gap-2">
{isDraggable && (
<div
{...listeners}
className="mt-0.5 touch-none cursor-grab"
className="-ml-2 -mt-1 p-2 touch-none"
data-testid={`drag-handle-${feature.id}`}
>
<GripVertical className="w-4 h-4 text-muted-foreground" />
</div>
)}
<div className="flex-1 min-w-0 overflow-hidden">
<CardTitle className="text-sm leading-tight break-words hyphens-auto line-clamp-3 overflow-hidden">
<CardTitle
className={cn(
"text-sm leading-tight break-words hyphens-auto overflow-hidden",
!isDescriptionExpanded && "line-clamp-3"
)}
>
{feature.description}
</CardTitle>
{/* Show More/Less toggle - only show when description is likely truncated */}
{feature.description.length > 100 && (
<button
onClick={(e) => {
e.stopPropagation();
setIsDescriptionExpanded(!isDescriptionExpanded);
}}
onPointerDown={(e) => e.stopPropagation()}
className="flex items-center gap-0.5 text-[10px] text-muted-foreground hover:text-foreground mt-1 transition-colors"
data-testid={`toggle-description-${feature.id}`}
>
{isDescriptionExpanded ? (
<>
<ChevronUp className="w-3 h-3" />
<span>Show Less</span>
</>
) : (
<>
<ChevronDown className="w-3 h-3" />
<span>Show More</span>
</>
)}
</button>
)}
<CardDescription className="text-xs mt-1 truncate">
{feature.category}
</CardDescription>
@@ -504,6 +554,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
setIsSummaryDialogOpen(true);
}}
onPointerDown={(e) => e.stopPropagation()}
className="p-0.5 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground shrink-0"
title="View full summary"
data-testid={`expand-summary-${feature.id}`}
@@ -557,6 +608,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-output-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1" />
@@ -572,6 +624,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onForceStop();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`force-stop-${feature.id}`}
>
<StopCircle className="w-3 h-3 mr-1" />
@@ -592,6 +645,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onManualVerify();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`manual-verify-${feature.id}`}
>
<CheckCircle2 className="w-3 h-3 mr-1" />
@@ -606,6 +660,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onResume();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`resume-feature-${feature.id}`}
>
<RotateCcw className="w-3 h-3 mr-1" />
@@ -620,6 +675,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onVerify();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`verify-feature-${feature.id}`}
>
<PlayCircle className="w-3 h-3 mr-1" />
@@ -635,6 +691,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-output-inprogress-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1" />
@@ -655,6 +712,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-output-verified-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1" />
@@ -678,6 +736,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
setIsRevertDialogOpen(true);
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`revert-${feature.id}`}
>
<Undo2 className="w-3.5 h-3.5" />
@@ -699,6 +758,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onFollowUp();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`follow-up-${feature.id}`}
>
<MessageSquare className="w-3 h-3 mr-1 shrink-0" />
@@ -715,6 +775,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onMerge();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`merge-${feature.id}`}
title="Merge changes into main branch"
>
@@ -732,6 +793,7 @@ export const KanbanCard = memo(function KanbanCard({
e.stopPropagation();
onCommit();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`commit-${feature.id}`}
>
<GitCommit className="w-3 h-3 mr-1" />
@@ -753,7 +815,7 @@ export const KanbanCard = memo(function KanbanCard({
be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogFooter className="mt-6">
<Button
variant="ghost"
onClick={handleCancelDelete}
@@ -761,13 +823,15 @@ export const KanbanCard = memo(function KanbanCard({
>
Cancel
</Button>
<Button
<HotkeyButton
variant="destructive"
onClick={handleConfirmDelete}
data-testid="confirm-delete-button"
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={isDeleteDialogOpen}
>
Delete
</Button>
</HotkeyButton>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -287,7 +287,7 @@ export function SpecView() {
Generate feature list
</label>
<p className="text-xs text-muted-foreground">
Automatically populate feature_list.json with all features from the
Automatically create features in the features folder from the
implementation roadmap after the spec is generated.
</p>
</div>

View File

@@ -157,7 +157,8 @@ export function WelcomeView() {
if (!result.canceled && result.filePaths[0]) {
const path = result.filePaths[0];
const name = path.split("/").pop() || "Untitled Project";
// Extract folder name from path (works on both Windows and Mac/Linux)
const name = path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project";
await initializeAndOpenProject(path, name);
}
}, [initializeAndOpenProject]);
@@ -309,9 +310,9 @@ export function WelcomeView() {
data-testid="new-project-card"
>
<div className="absolute inset-0 bg-gradient-to-br from-brand-500/5 to-purple-600/5 opacity-0 group-hover:opacity-100 transition-opacity"></div>
<div className="relative p-6">
<div className="flex items-start gap-4 mb-4">
<div className="w-12 h-12 rounded-lg bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/20 flex items-center justify-center group-hover:scale-110 transition-transform">
<div className="relative p-6 h-full flex flex-col">
<div className="flex items-start gap-4 flex-1">
<div className="w-12 h-12 rounded-lg bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/20 flex items-center justify-center group-hover:scale-110 transition-transform shrink-0">
<Plus className="w-6 h-6 text-white" />
</div>
<div className="flex-1">
@@ -327,7 +328,7 @@ export function WelcomeView() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="w-full bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-primary-foreground border-0"
className="w-full mt-4 bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-primary-foreground border-0"
data-testid="create-new-project"
>
<Plus className="w-4 h-4 mr-2" />
@@ -361,9 +362,9 @@ export function WelcomeView() {
data-testid="open-project-card"
>
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/5 to-cyan-600/5 opacity-0 group-hover:opacity-100 transition-opacity"></div>
<div className="relative p-6">
<div className="flex items-start gap-4 mb-4">
<div className="w-12 h-12 rounded-lg bg-muted border border-border flex items-center justify-center group-hover:scale-110 transition-transform">
<div className="relative p-6 h-full flex flex-col">
<div className="flex items-start gap-4 flex-1">
<div className="w-12 h-12 rounded-lg bg-muted border border-border flex items-center justify-center group-hover:scale-110 transition-transform shrink-0">
<FolderOpen className="w-6 h-6 text-muted-foreground group-hover:text-foreground transition-colors" />
</div>
<div className="flex-1">
@@ -377,7 +378,7 @@ export function WelcomeView() {
</div>
<Button
variant="secondary"
className="w-full bg-secondary hover:bg-secondary/80 text-foreground border border-border hover:border-border-glass"
className="w-full mt-4 bg-secondary hover:bg-secondary/80 text-foreground border border-border hover:border-border-glass"
data-testid="open-existing-project"
>
<FolderOpen className="w-4 h-4 mr-2" />

File diff suppressed because it is too large Load Diff

View File

@@ -15,11 +15,6 @@ export interface ProjectInitResult {
existingFiles?: string[];
}
/**
* Default feature_list.json template for new projects
*/
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
@@ -28,12 +23,10 @@ const REQUIRED_STRUCTURE = {
directories: [
".automaker",
".automaker/context",
".automaker/agents-context",
".automaker/features",
".automaker/images",
],
files: {
".automaker/feature_list.json": DEFAULT_FEATURE_LIST,
},
files: {},
};
/**
@@ -71,9 +64,9 @@ export async function initializeProject(
}
}
// Determine if this is a new project (all files were created)
// Determine if this is a new project (no files needed to be created since features/ is empty by default)
const isNewProject =
createdFiles.length === Object.keys(REQUIRED_STRUCTURE.files).length;
createdFiles.length === 0 && existingFiles.length === 0;
return {
success: true,
@@ -103,9 +96,9 @@ export async function isProjectInitialized(
const api = getElectronAPI();
try {
// Check all required files exist
for (const relativePath of Object.keys(REQUIRED_STRUCTURE.files)) {
const fullPath = `${projectPath}/${relativePath}`;
// Check all required directories exist (no files required - features/ folder is source of truth)
for (const dir of REQUIRED_STRUCTURE.directories) {
const fullPath = `${projectPath}/${dir}`;
const exists = await api.exists(fullPath);
if (!exists) {
return false;
@@ -138,13 +131,14 @@ export async function getProjectInitStatus(projectPath: string): Promise<{
const existingFiles: string[] = [];
try {
for (const relativePath of Object.keys(REQUIRED_STRUCTURE.files)) {
const fullPath = `${projectPath}/${relativePath}`;
// Check directories (no files required - features/ folder is source of truth)
for (const dir of REQUIRED_STRUCTURE.directories) {
const fullPath = `${projectPath}/${dir}`;
const exists = await api.exists(fullPath);
if (exists) {
existingFiles.push(relativePath);
existingFiles.push(dir);
} else {
missingFiles.push(relativePath);
missingFiles.push(dir);
}
}
@@ -157,7 +151,7 @@ export async function getProjectInitStatus(projectPath: string): Promise<{
console.error("[project-init] Error getting project status:", error);
return {
initialized: false,
missingFiles: Object.keys(REQUIRED_STRUCTURE.files),
missingFiles: REQUIRED_STRUCTURE.directories,
existingFiles: [],
};
}

View File

@@ -373,7 +373,9 @@ export interface AppActions {
// Feature actions
setFeatures: (features: Feature[]) => void;
updateFeature: (id: string, updates: Partial<Feature>) => void;
addFeature: (feature: Omit<Feature, "id">) => void;
addFeature: (
feature: Omit<Feature, "id"> & Partial<Pick<Feature, "id">>
) => Feature;
removeFeature: (id: string) => void;
moveFeature: (id: string, newStatus: Feature["status"]) => void;
@@ -805,10 +807,12 @@ export const useAppStore = create<AppState & AppActions>()(
},
addFeature: (feature) => {
const id = `feature-${Date.now()}-${Math.random()
.toString(36)
.substr(2, 9)}`;
set({ features: [...get().features, { ...feature, id }] });
const id =
feature.id ||
`feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const featureWithId = { ...feature, id } as Feature;
set({ features: [...get().features, featureWithId] });
return featureWithId;
},
removeFeature: (id) => {

View File

@@ -183,7 +183,9 @@ export async function dragKanbanCard(
): Promise<void> {
const card = page.locator(`[data-testid="kanban-card-${featureId}"]`);
const dragHandle = page.locator(`[data-testid="drag-handle-${featureId}"]`);
const targetColumn = page.locator(`[data-testid="kanban-column-${targetColumnId}"]`);
const targetColumn = page.locator(
`[data-testid="kanban-column-${targetColumnId}"]`
);
// Perform drag and drop
await dragHandle.dragTo(targetColumn);
@@ -433,7 +435,13 @@ export async function setupMockProjectAtConcurrencyLimit(
runningTasks: string[] = ["running-task-1"]
): Promise<void> {
await page.addInitScript(
({ maxConcurrency, runningTasks }: { maxConcurrency: number; runningTasks: string[] }) => {
({
maxConcurrency,
runningTasks,
}: {
maxConcurrency: number;
runningTasks: string[];
}) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
@@ -570,43 +578,40 @@ export async function setupMockProjectWithFeatures(
}>;
}
): Promise<void> {
await page.addInitScript(
(opts: typeof options) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
lastOpened: new Date().toISOString(),
};
await page.addInitScript((opts: typeof options) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
lastOpened: new Date().toISOString(),
};
const mockFeatures = opts?.features || [];
const mockFeatures = opts?.features || [];
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: opts?.maxConcurrency ?? 3,
isAutoModeRunning: false,
runningAutoTasks: opts?.runningTasks ?? [],
autoModeActivityLog: [],
features: mockFeatures,
},
version: 0,
};
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: opts?.maxConcurrency ?? 3,
isAutoModeRunning: false,
runningAutoTasks: opts?.runningTasks ?? [],
autoModeActivityLog: [],
features: mockFeatures,
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
// Also store features in a global variable that the mock electron API can use
// This is needed because the board-view loads features from the file system
(window as any).__mockFeatures = mockFeatures;
},
options
);
// Also store features in a global variable that the mock electron API can use
// This is needed because the board-view loads features from the file system
(window as any).__mockFeatures = mockFeatures;
}, options);
}
/**
@@ -619,7 +624,13 @@ export async function setupMockProjectWithContextFile(
contextContent: string = "# Agent Context\n\nPrevious implementation work..."
): Promise<void> {
await page.addInitScript(
({ featureId, contextContent }: { featureId: string; contextContent: string }) => {
({
featureId,
contextContent,
}: {
featureId: string;
contextContent: string;
}) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
@@ -645,9 +656,10 @@ export async function setupMockProjectWithContextFile(
// Set up mock file system with a context file for the feature
// This will be used by the mock electron API
// Now uses features/{id}/agent-output.md path
(window as any).__mockContextFile = {
featureId,
path: `/mock/test-project/.automaker/agents-context/${featureId}.md`,
path: `/mock/test-project/.automaker/features/${featureId}/agent-output.md`,
content: contextContent,
};
},
@@ -668,14 +680,18 @@ export async function getCategoryAutocompleteInput(
/**
* Get the category autocomplete dropdown list
*/
export async function getCategoryAutocompleteList(page: Page): Promise<Locator> {
export async function getCategoryAutocompleteList(
page: Page
): Promise<Locator> {
return page.locator('[data-testid="category-autocomplete-list"]');
}
/**
* Check if the category autocomplete dropdown is visible
*/
export async function isCategoryAutocompleteListVisible(page: Page): Promise<boolean> {
export async function isCategoryAutocompleteListVisible(
page: Page
): Promise<boolean> {
const list = page.locator('[data-testid="category-autocomplete-list"]');
return await list.isVisible();
}
@@ -707,7 +723,9 @@ export async function clickCategoryOption(
page: Page,
categoryName: string
): Promise<void> {
const optionTestId = `category-option-${categoryName.toLowerCase().replace(/\s+/g, "-")}`;
const optionTestId = `category-option-${categoryName
.toLowerCase()
.replace(/\s+/g, "-")}`;
const option = page.locator(`[data-testid="${optionTestId}"]`);
await option.click();
}
@@ -719,7 +737,9 @@ export async function getCategoryOption(
page: Page,
categoryName: string
): Promise<Locator> {
const optionTestId = `category-option-${categoryName.toLowerCase().replace(/\s+/g, "-")}`;
const optionTestId = `category-option-${categoryName
.toLowerCase()
.replace(/\s+/g, "-")}`;
return page.locator(`[data-testid="${optionTestId}"]`);
}
@@ -788,7 +808,9 @@ export async function clickArchiveSession(
/**
* Check if the no session placeholder is visible
*/
export async function isNoSessionPlaceholderVisible(page: Page): Promise<boolean> {
export async function isNoSessionPlaceholderVisible(
page: Page
): Promise<boolean> {
const placeholder = page.locator('[data-testid="no-session-placeholder"]');
return await placeholder.isVisible();
}
@@ -864,43 +886,40 @@ export async function setupMockProjectWithInProgressFeatures(
}>;
}
): Promise<void> {
await page.addInitScript(
(opts: typeof options) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
lastOpened: new Date().toISOString(),
};
await page.addInitScript((opts: typeof options) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
lastOpened: new Date().toISOString(),
};
const mockFeatures = opts?.features || [];
const mockFeatures = opts?.features || [];
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: opts?.maxConcurrency ?? 3,
isAutoModeRunning: false,
runningAutoTasks: opts?.runningTasks ?? [],
autoModeActivityLog: [],
features: mockFeatures,
},
version: 0,
};
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: opts?.maxConcurrency ?? 3,
isAutoModeRunning: false,
runningAutoTasks: opts?.runningTasks ?? [],
autoModeActivityLog: [],
features: mockFeatures,
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
// Also store features in a global variable that the mock electron API can use
// This is needed because the board-view loads features from the file system
(window as any).__mockFeatures = mockFeatures;
},
options
);
// Also store features in a global variable that the mock electron API can use
// This is needed because the board-view loads features from the file system
(window as any).__mockFeatures = mockFeatures;
}, options);
}
/**
@@ -1052,7 +1071,8 @@ export async function navigateToView(
page: Page,
viewId: string
): Promise<void> {
const navSelector = viewId === "settings" ? "settings-button" : `nav-${viewId}`;
const navSelector =
viewId === "settings" ? "settings-button" : `nav-${viewId}`;
await clickElement(page, navSelector);
await page.waitForTimeout(100);
}
@@ -1126,7 +1146,9 @@ export async function setupEmptyLocalStorage(page: Page): Promise<void> {
/**
* Set up mock projects in localStorage but with no current project (for recent projects list)
*/
export async function setupMockProjectsWithoutCurrent(page: Page): Promise<void> {
export async function setupMockProjectsWithoutCurrent(
page: Page
): Promise<void> {
await page.addInitScript(() => {
const mockProjects = [
{
@@ -1191,7 +1213,9 @@ export async function closeProjectInitDialog(page: Page): Promise<void> {
/**
* Check if the project opening overlay is visible
*/
export async function isProjectOpeningOverlayVisible(page: Page): Promise<boolean> {
export async function isProjectOpeningOverlayVisible(
page: Page
): Promise<boolean> {
const overlay = page.locator('[data-testid="project-opening-overlay"]');
return await overlay.isVisible();
}
@@ -1263,7 +1287,9 @@ export async function pressShortcut(page: Page, key: string): Promise<void> {
* Count the number of session items in the session list
*/
export async function countSessionItems(page: Page): Promise<number> {
const sessionList = page.locator('[data-testid="session-list"] [data-testid^="session-item-"]');
const sessionList = page.locator(
'[data-testid="session-list"] [data-testid^="session-item-"]'
);
return await sessionList.count();
}
@@ -1369,25 +1395,31 @@ export async function waitForProjectAnalysisComplete(
): Promise<void> {
// Wait for the analyzing text to disappear
const analyzingText = page.locator('p:has-text("AI agent is analyzing")');
await analyzingText.waitFor({
timeout: options?.timeout ?? 10000,
state: "hidden",
}).catch(() => {
// It may never have been visible, that's ok
});
await analyzingText
.waitFor({
timeout: options?.timeout ?? 10000,
state: "hidden",
})
.catch(() => {
// It may never have been visible, that's ok
});
}
/**
* Get the delete confirmation dialog
*/
export async function getDeleteConfirmationDialog(page: Page): Promise<Locator> {
export async function getDeleteConfirmationDialog(
page: Page
): Promise<Locator> {
return page.locator('[data-testid="delete-confirmation-dialog"]');
}
/**
* Check if the delete confirmation dialog is visible
*/
export async function isDeleteConfirmationDialogVisible(page: Page): Promise<boolean> {
export async function isDeleteConfirmationDialogVisible(
page: Page
): Promise<boolean> {
const dialog = page.locator('[data-testid="delete-confirmation-dialog"]');
return await dialog.isVisible().catch(() => false);
}
@@ -1469,14 +1501,18 @@ export async function waitForEditFeatureDialog(
/**
* Get the edit feature description input/textarea element
*/
export async function getEditFeatureDescriptionInput(page: Page): Promise<Locator> {
export async function getEditFeatureDescriptionInput(
page: Page
): Promise<Locator> {
return page.locator('[data-testid="edit-feature-description"]');
}
/**
* Check if the edit feature description field is a textarea
*/
export async function isEditFeatureDescriptionTextarea(page: Page): Promise<boolean> {
export async function isEditFeatureDescriptionTextarea(
page: Page
): Promise<boolean> {
const element = page.locator('[data-testid="edit-feature-description"]');
const tagName = await element.evaluate((el) => el.tagName.toLowerCase());
return tagName === "textarea";
@@ -1643,39 +1679,36 @@ export async function setupMockProjectWithSkipTestsFeatures(
}>;
}
): Promise<void> {
await page.addInitScript(
(opts: typeof options) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
lastOpened: new Date().toISOString(),
};
await page.addInitScript((opts: typeof options) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
lastOpened: new Date().toISOString(),
};
const mockFeatures = opts?.features || [];
const mockFeatures = opts?.features || [];
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: opts?.maxConcurrency ?? 3,
isAutoModeRunning: false,
runningAutoTasks: opts?.runningTasks ?? [],
autoModeActivityLog: [],
features: mockFeatures,
},
version: 0,
};
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: opts?.maxConcurrency ?? 3,
isAutoModeRunning: false,
runningAutoTasks: opts?.runningTasks ?? [],
autoModeActivityLog: [],
features: mockFeatures,
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
},
options
);
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
}, options);
}
/**
@@ -1688,24 +1721,34 @@ export async function pressNumberKey(page: Page, num: number): Promise<void> {
/**
* Get the modal title/description text to verify which feature's output is being shown
*/
export async function getAgentOutputModalDescription(page: Page): Promise<string | null> {
export async function getAgentOutputModalDescription(
page: Page
): Promise<string | null> {
const modal = page.locator('[data-testid="agent-output-modal"]');
const description = modal.locator('[id="radix-\\:r.+\\:-description"]').first();
const description = modal
.locator('[id="radix-\\:r.+\\:-description"]')
.first();
return await description.textContent().catch(() => null);
}
/**
* Check the dialog description content in the agent output modal
*/
export async function getOutputModalDescription(page: Page): Promise<string | null> {
const modalDescription = page.locator('[data-testid="agent-output-modal"] [data-slot="dialog-description"]');
export async function getOutputModalDescription(
page: Page
): Promise<string | null> {
const modalDescription = page.locator(
'[data-testid="agent-output-modal"] [data-slot="dialog-description"]'
);
return await modalDescription.textContent().catch(() => null);
}
/**
* Check if the project picker dropdown is open
*/
export async function isProjectPickerDropdownOpen(page: Page): Promise<boolean> {
export async function isProjectPickerDropdownOpen(
page: Page
): Promise<boolean> {
const dropdown = page.locator('[data-testid="project-picker-dropdown"]');
return await dropdown.isVisible().catch(() => false);
}
@@ -1733,14 +1776,20 @@ export async function waitForProjectPickerDropdownHidden(
/**
* Get a project hotkey indicator element by number (1-5)
*/
export async function getProjectHotkey(page: Page, num: number): Promise<Locator> {
export async function getProjectHotkey(
page: Page,
num: number
): Promise<Locator> {
return page.locator(`[data-testid="project-hotkey-${num}"]`);
}
/**
* Check if a project hotkey indicator is visible
*/
export async function isProjectHotkeyVisible(page: Page, num: number): Promise<boolean> {
export async function isProjectHotkeyVisible(
page: Page,
num: number
): Promise<boolean> {
const hotkey = page.locator(`[data-testid="project-hotkey-${num}"]`);
return await hotkey.isVisible().catch(() => false);
}
@@ -1792,7 +1841,9 @@ export async function setupMockMultipleProjects(
/**
* Get the description image dropzone element
*/
export async function getDescriptionImageDropzone(page: Page): Promise<Locator> {
export async function getDescriptionImageDropzone(
page: Page
): Promise<Locator> {
return page.locator('[data-testid="feature-description-input"]');
}
@@ -1806,7 +1857,9 @@ export async function getDescriptionImageInput(page: Page): Promise<Locator> {
/**
* Check if the description image previews section is visible
*/
export async function isDescriptionImagePreviewsVisible(page: Page): Promise<boolean> {
export async function isDescriptionImagePreviewsVisible(
page: Page
): Promise<boolean> {
const previews = page.locator('[data-testid="description-image-previews"]');
return await previews.isVisible().catch(() => false);
}
@@ -1814,7 +1867,9 @@ export async function isDescriptionImagePreviewsVisible(page: Page): Promise<boo
/**
* Get the number of description image previews
*/
export async function getDescriptionImagePreviewCount(page: Page): Promise<number> {
export async function getDescriptionImagePreviewCount(
page: Page
): Promise<number> {
const previews = page.locator('[data-testid^="description-image-preview-"]');
return await previews.count();
}
@@ -1845,7 +1900,9 @@ export async function waitForDescriptionImagePreview(
page: Page,
options?: { timeout?: number }
): Promise<Locator> {
const preview = page.locator('[data-testid^="description-image-preview-"]').first();
const preview = page
.locator('[data-testid^="description-image-preview-"]')
.first();
await preview.waitFor({
timeout: options?.timeout ?? 5000,
state: "visible",
@@ -1913,7 +1970,9 @@ export async function scrollToBottom(locator: Locator): Promise<void> {
/**
* Get the scroll position of an element
*/
export async function getScrollPosition(locator: Locator): Promise<{ scrollTop: number; scrollHeight: number; clientHeight: number }> {
export async function getScrollPosition(
locator: Locator
): Promise<{ scrollTop: number; scrollHeight: number; clientHeight: number }> {
return await locator.evaluate((el) => ({
scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight,
@@ -2088,7 +2147,13 @@ export async function setupMockProjectWithAgentOutput(
outputContent: string
): Promise<void> {
await page.addInitScript(
({ featureId, outputContent }: { featureId: string; outputContent: string }) => {
({
featureId,
outputContent,
}: {
featureId: string;
outputContent: string;
}) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
@@ -2113,9 +2178,10 @@ export async function setupMockProjectWithAgentOutput(
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
// Set up mock file system with output content for the feature
// Now uses features/{id}/agent-output.md path
(window as any).__mockContextFile = {
featureId,
path: `/mock/test-project/.automaker/agents-context/${featureId}.md`,
path: `/mock/test-project/.automaker/features/${featureId}/agent-output.md`,
content: outputContent,
};
},
@@ -2234,7 +2300,9 @@ export async function getWaitingApprovalColumn(page: Page): Promise<Locator> {
/**
* Check if the waiting_approval column is visible
*/
export async function isWaitingApprovalColumnVisible(page: Page): Promise<boolean> {
export async function isWaitingApprovalColumnVisible(
page: Page
): Promise<boolean> {
const column = page.locator('[data-testid="kanban-column-waiting_approval"]');
return await column.isVisible().catch(() => false);
}
@@ -2242,14 +2310,18 @@ export async function isWaitingApprovalColumnVisible(page: Page): Promise<boolea
/**
* Get the agent output modal description element
*/
export async function getAgentOutputModalDescriptionElement(page: Page): Promise<Locator> {
export async function getAgentOutputModalDescriptionElement(
page: Page
): Promise<Locator> {
return page.locator('[data-testid="agent-output-description"]');
}
/**
* Check if the agent output modal description is scrollable
*/
export async function isAgentOutputDescriptionScrollable(page: Page): Promise<boolean> {
export async function isAgentOutputDescriptionScrollable(
page: Page
): Promise<boolean> {
const description = page.locator('[data-testid="agent-output-description"]');
const scrollInfo = await description.evaluate((el) => {
return {
@@ -2264,7 +2336,9 @@ export async function isAgentOutputDescriptionScrollable(page: Page): Promise<bo
/**
* Get scroll dimensions of the agent output modal description
*/
export async function getAgentOutputDescriptionScrollDimensions(page: Page): Promise<{
export async function getAgentOutputDescriptionScrollDimensions(
page: Page
): Promise<{
scrollHeight: number;
clientHeight: number;
maxHeight: string;
@@ -2301,42 +2375,39 @@ export async function setupMockProjectWithWaitingApprovalFeatures(
}>;
}
): Promise<void> {
await page.addInitScript(
(opts: typeof options) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
lastOpened: new Date().toISOString(),
};
await page.addInitScript((opts: typeof options) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
lastOpened: new Date().toISOString(),
};
const mockFeatures = opts?.features || [];
const mockFeatures = opts?.features || [];
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: opts?.maxConcurrency ?? 3,
isAutoModeRunning: false,
runningAutoTasks: opts?.runningTasks ?? [],
autoModeActivityLog: [],
features: mockFeatures,
},
version: 0,
};
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: opts?.maxConcurrency ?? 3,
isAutoModeRunning: false,
runningAutoTasks: opts?.runningTasks ?? [],
autoModeActivityLog: [],
features: mockFeatures,
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
// Also store features in a global variable that the mock electron API can use
(window as any).__mockFeatures = mockFeatures;
},
options
);
// Also store features in a global variable that the mock electron API can use
(window as any).__mockFeatures = mockFeatures;
}, options);
}
// ============================================================================
@@ -2478,7 +2549,10 @@ export async function clickSetupFinish(page: Page): Promise<void> {
/**
* Enter Anthropic API key in setup
*/
export async function enterAnthropicApiKey(page: Page, apiKey: string): Promise<void> {
export async function enterAnthropicApiKey(
page: Page,
apiKey: string
): Promise<void> {
// Click "Use Anthropic API Key Instead" button
const useApiKeyButton = await getByTestId(page, "use-api-key-button");
await useApiKeyButton.click();
@@ -2495,7 +2569,10 @@ export async function enterAnthropicApiKey(page: Page, apiKey: string): Promise<
/**
* Enter OpenAI API key in setup
*/
export async function enterOpenAIApiKey(page: Page, apiKey: string): Promise<void> {
export async function enterOpenAIApiKey(
page: Page,
apiKey: string
): Promise<void> {
// Click "Enter OpenAI API Key" button
const useApiKeyButton = await getByTestId(page, "use-openai-key-button");
await useApiKeyButton.click();