mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +00:00
Merge main into feat/rework-keybinds-and-setting-page - resolved conflicts in settings-view.tsx
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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.`;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
73
app/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
BIN
app/public/sounds/ding.mp3
Normal file
Binary file not shown.
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
184
app/src/components/ui/command.tsx
Normal file
184
app/src/components/ui/command.tsx
Normal 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,
|
||||
}
|
||||
48
app/src/components/ui/popover.tsx
Normal file
48
app/src/components/ui/popover.tsx
Normal 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 }
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user