mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-05 09:33:07 +00:00
refactor: streamline Electron API integration and enhance UI components
- Removed unused Electron API methods and simplified the main process. - Introduced a new workspace picker modal for improved project selection. - Enhanced error handling for authentication issues across various components. - Updated UI styles for dark mode support and added new CSS variables. - Refactored session management to utilize a centralized API access method. - Added server routes for workspace management, including directory listing and configuration checks.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,404 +1,10 @@
|
|||||||
const { contextBridge, ipcRenderer } = require("electron");
|
const { contextBridge } = require("electron");
|
||||||
|
|
||||||
// Expose protected methods that allow the renderer process to use
|
// Only expose a flag to detect Electron environment
|
||||||
// the ipcRenderer without exposing the entire object
|
// All API calls go through HTTP to the backend server
|
||||||
contextBridge.exposeInMainWorld("electronAPI", {
|
|
||||||
// IPC test
|
|
||||||
ping: () => ipcRenderer.invoke("ping"),
|
|
||||||
|
|
||||||
// Shell APIs
|
|
||||||
openExternalLink: (url) => ipcRenderer.invoke("shell:openExternal", url),
|
|
||||||
|
|
||||||
// Dialog APIs
|
|
||||||
openDirectory: () => ipcRenderer.invoke("dialog:openDirectory"),
|
|
||||||
openFile: (options) => ipcRenderer.invoke("dialog:openFile", options),
|
|
||||||
|
|
||||||
// File system APIs
|
|
||||||
readFile: (filePath) => ipcRenderer.invoke("fs:readFile", filePath),
|
|
||||||
writeFile: (filePath, content) =>
|
|
||||||
ipcRenderer.invoke("fs:writeFile", filePath, content),
|
|
||||||
mkdir: (dirPath) => ipcRenderer.invoke("fs:mkdir", dirPath),
|
|
||||||
readdir: (dirPath) => ipcRenderer.invoke("fs:readdir", dirPath),
|
|
||||||
exists: (filePath) => ipcRenderer.invoke("fs:exists", filePath),
|
|
||||||
stat: (filePath) => ipcRenderer.invoke("fs:stat", filePath),
|
|
||||||
deleteFile: (filePath) => ipcRenderer.invoke("fs:deleteFile", filePath),
|
|
||||||
trashItem: (filePath) => ipcRenderer.invoke("fs:trashItem", filePath),
|
|
||||||
|
|
||||||
// App APIs
|
|
||||||
getPath: (name) => ipcRenderer.invoke("app:getPath", name),
|
|
||||||
saveImageToTemp: (data, filename, mimeType, projectPath) =>
|
|
||||||
ipcRenderer.invoke("app:saveImageToTemp", {
|
|
||||||
data,
|
|
||||||
filename,
|
|
||||||
mimeType,
|
|
||||||
projectPath,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Agent APIs
|
|
||||||
agent: {
|
|
||||||
// Start or resume a conversation
|
|
||||||
start: (sessionId, workingDirectory) =>
|
|
||||||
ipcRenderer.invoke("agent:start", { sessionId, workingDirectory }),
|
|
||||||
|
|
||||||
// Send a message to the 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 }),
|
|
||||||
|
|
||||||
// Clear conversation
|
|
||||||
clear: (sessionId) => ipcRenderer.invoke("agent:clear", { sessionId }),
|
|
||||||
|
|
||||||
// Subscribe to streaming events
|
|
||||||
onStream: (callback) => {
|
|
||||||
const subscription = (_, data) => callback(data);
|
|
||||||
ipcRenderer.on("agent:stream", subscription);
|
|
||||||
// Return unsubscribe function
|
|
||||||
return () => ipcRenderer.removeListener("agent:stream", subscription);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Session Management APIs
|
|
||||||
sessions: {
|
|
||||||
// List all sessions
|
|
||||||
list: (includeArchived) =>
|
|
||||||
ipcRenderer.invoke("sessions:list", { includeArchived }),
|
|
||||||
|
|
||||||
// Create a new session
|
|
||||||
create: (name, projectPath, workingDirectory) =>
|
|
||||||
ipcRenderer.invoke("sessions:create", {
|
|
||||||
name,
|
|
||||||
projectPath,
|
|
||||||
workingDirectory,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Update session metadata
|
|
||||||
update: (sessionId, name, tags) =>
|
|
||||||
ipcRenderer.invoke("sessions:update", { sessionId, name, tags }),
|
|
||||||
|
|
||||||
// Archive a session
|
|
||||||
archive: (sessionId) =>
|
|
||||||
ipcRenderer.invoke("sessions:archive", { sessionId }),
|
|
||||||
|
|
||||||
// Unarchive a session
|
|
||||||
unarchive: (sessionId) =>
|
|
||||||
ipcRenderer.invoke("sessions:unarchive", { sessionId }),
|
|
||||||
|
|
||||||
// Delete a session permanently
|
|
||||||
delete: (sessionId) => ipcRenderer.invoke("sessions:delete", { sessionId }),
|
|
||||||
},
|
|
||||||
|
|
||||||
// Auto Mode API
|
|
||||||
autoMode: {
|
|
||||||
// Start auto mode for a specific project
|
|
||||||
start: (projectPath, maxConcurrency) =>
|
|
||||||
ipcRenderer.invoke("auto-mode:start", { projectPath, maxConcurrency }),
|
|
||||||
|
|
||||||
// Stop auto mode for a specific project
|
|
||||||
stop: (projectPath) => ipcRenderer.invoke("auto-mode:stop", { projectPath }),
|
|
||||||
|
|
||||||
// Get auto mode status (optionally for a specific project)
|
|
||||||
status: (projectPath) => ipcRenderer.invoke("auto-mode:status", { projectPath }),
|
|
||||||
|
|
||||||
// Run a specific feature
|
|
||||||
runFeature: (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,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Resume a specific feature with previous context
|
|
||||||
resumeFeature: (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,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Analyze a new project - kicks off an agent to analyze codebase
|
|
||||||
analyzeProject: (projectPath) =>
|
|
||||||
ipcRenderer.invoke("auto-mode:analyze-project", { projectPath }),
|
|
||||||
|
|
||||||
// Stop a specific feature
|
|
||||||
stopFeature: (featureId) =>
|
|
||||||
ipcRenderer.invoke("auto-mode:stop-feature", { featureId }),
|
|
||||||
|
|
||||||
// Follow-up on a feature with additional prompt
|
|
||||||
followUpFeature: (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,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Listen for auto mode events
|
|
||||||
onEvent: (callback) => {
|
|
||||||
const subscription = (_, data) => callback(data);
|
|
||||||
ipcRenderer.on("auto-mode:event", subscription);
|
|
||||||
|
|
||||||
// Return unsubscribe function
|
|
||||||
return () => {
|
|
||||||
ipcRenderer.removeListener("auto-mode:event", subscription);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Claude CLI Detection API
|
|
||||||
checkClaudeCli: () => ipcRenderer.invoke("claude:check-cli"),
|
|
||||||
|
|
||||||
// Codex CLI Detection API
|
|
||||||
checkCodexCli: () => ipcRenderer.invoke("codex:check-cli"),
|
|
||||||
|
|
||||||
// Model Management APIs
|
|
||||||
model: {
|
|
||||||
// Get all available models from all providers
|
|
||||||
getAvailable: () => ipcRenderer.invoke("model:get-available"),
|
|
||||||
|
|
||||||
// Check all provider installation status
|
|
||||||
checkProviders: () => ipcRenderer.invoke("model:check-providers"),
|
|
||||||
},
|
|
||||||
|
|
||||||
// OpenAI API
|
|
||||||
testOpenAIConnection: (apiKey) =>
|
|
||||||
ipcRenderer.invoke("openai:test-connection", { apiKey }),
|
|
||||||
|
|
||||||
// Worktree Management APIs
|
|
||||||
worktree: {
|
|
||||||
// Revert feature changes by removing the worktree
|
|
||||||
revertFeature: (projectPath, featureId) =>
|
|
||||||
ipcRenderer.invoke("worktree:revert-feature", { projectPath, featureId }),
|
|
||||||
|
|
||||||
// Merge feature worktree changes back to main branch
|
|
||||||
mergeFeature: (projectPath, featureId, options) =>
|
|
||||||
ipcRenderer.invoke("worktree:merge-feature", {
|
|
||||||
projectPath,
|
|
||||||
featureId,
|
|
||||||
options,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Get worktree info for a feature
|
|
||||||
getInfo: (projectPath, featureId) =>
|
|
||||||
ipcRenderer.invoke("worktree:get-info", { projectPath, featureId }),
|
|
||||||
|
|
||||||
// Get worktree status (changed files, commits)
|
|
||||||
getStatus: (projectPath, featureId) =>
|
|
||||||
ipcRenderer.invoke("worktree:get-status", { projectPath, featureId }),
|
|
||||||
|
|
||||||
// List all feature worktrees
|
|
||||||
list: (projectPath) => ipcRenderer.invoke("worktree:list", { projectPath }),
|
|
||||||
|
|
||||||
// Get file diffs for a feature worktree
|
|
||||||
getDiffs: (projectPath, featureId) =>
|
|
||||||
ipcRenderer.invoke("worktree:get-diffs", { projectPath, featureId }),
|
|
||||||
|
|
||||||
// Get diff for a specific file in a worktree
|
|
||||||
getFileDiff: (projectPath, featureId, filePath) =>
|
|
||||||
ipcRenderer.invoke("worktree:get-file-diff", {
|
|
||||||
projectPath,
|
|
||||||
featureId,
|
|
||||||
filePath,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
|
|
||||||
// Git Operations APIs (for non-worktree operations)
|
|
||||||
git: {
|
|
||||||
// Get file diffs for the main project
|
|
||||||
getDiffs: (projectPath) =>
|
|
||||||
ipcRenderer.invoke("git:get-diffs", { projectPath }),
|
|
||||||
|
|
||||||
// Get diff for a specific file in the main project
|
|
||||||
getFileDiff: (projectPath, filePath) =>
|
|
||||||
ipcRenderer.invoke("git:get-file-diff", { projectPath, filePath }),
|
|
||||||
},
|
|
||||||
|
|
||||||
// Feature Suggestions API
|
|
||||||
suggestions: {
|
|
||||||
// Generate feature suggestions
|
|
||||||
// suggestionType can be: "features", "refactoring", "security", "performance"
|
|
||||||
generate: (projectPath, suggestionType = "features") =>
|
|
||||||
ipcRenderer.invoke("suggestions:generate", { projectPath, suggestionType }),
|
|
||||||
|
|
||||||
// Stop generating suggestions
|
|
||||||
stop: () => ipcRenderer.invoke("suggestions:stop"),
|
|
||||||
|
|
||||||
// Get suggestions status
|
|
||||||
status: () => ipcRenderer.invoke("suggestions:status"),
|
|
||||||
|
|
||||||
// Listen for suggestions events
|
|
||||||
onEvent: (callback) => {
|
|
||||||
const subscription = (_, data) => callback(data);
|
|
||||||
ipcRenderer.on("suggestions:event", subscription);
|
|
||||||
|
|
||||||
// Return unsubscribe function
|
|
||||||
return () => {
|
|
||||||
ipcRenderer.removeListener("suggestions:event", subscription);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Spec Regeneration API
|
|
||||||
specRegeneration: {
|
|
||||||
// Create initial app spec for a new project
|
|
||||||
create: (projectPath, projectOverview, generateFeatures = true) =>
|
|
||||||
ipcRenderer.invoke("spec-regeneration:create", {
|
|
||||||
projectPath,
|
|
||||||
projectOverview,
|
|
||||||
generateFeatures,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Regenerate the app spec
|
|
||||||
generate: (projectPath, projectDefinition) =>
|
|
||||||
ipcRenderer.invoke("spec-regeneration:generate", {
|
|
||||||
projectPath,
|
|
||||||
projectDefinition,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Generate features from existing app_spec.txt
|
|
||||||
generateFeatures: (projectPath) =>
|
|
||||||
ipcRenderer.invoke("spec-regeneration:generate-features", {
|
|
||||||
projectPath,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Stop regenerating spec
|
|
||||||
stop: () => ipcRenderer.invoke("spec-regeneration:stop"),
|
|
||||||
|
|
||||||
// Get regeneration status
|
|
||||||
status: () => ipcRenderer.invoke("spec-regeneration:status"),
|
|
||||||
|
|
||||||
// Listen for regeneration events
|
|
||||||
onEvent: (callback) => {
|
|
||||||
const subscription = (_, data) => callback(data);
|
|
||||||
ipcRenderer.on("spec-regeneration:event", subscription);
|
|
||||||
|
|
||||||
// Return unsubscribe function
|
|
||||||
return () => {
|
|
||||||
ipcRenderer.removeListener("spec-regeneration:event", subscription);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Setup & CLI Management API
|
|
||||||
setup: {
|
|
||||||
// Get comprehensive Claude CLI status
|
|
||||||
getClaudeStatus: () => ipcRenderer.invoke("setup:claude-status"),
|
|
||||||
|
|
||||||
// Get comprehensive Codex CLI status
|
|
||||||
getCodexStatus: () => ipcRenderer.invoke("setup:codex-status"),
|
|
||||||
|
|
||||||
// Install Claude CLI
|
|
||||||
installClaude: () => ipcRenderer.invoke("setup:install-claude"),
|
|
||||||
|
|
||||||
// Install Codex CLI
|
|
||||||
installCodex: () => ipcRenderer.invoke("setup:install-codex"),
|
|
||||||
|
|
||||||
// Authenticate Claude CLI
|
|
||||||
authClaude: () => ipcRenderer.invoke("setup:auth-claude"),
|
|
||||||
|
|
||||||
// Authenticate Codex CLI with optional API key
|
|
||||||
authCodex: (apiKey) => ipcRenderer.invoke("setup:auth-codex", { apiKey }),
|
|
||||||
|
|
||||||
// Store API key securely
|
|
||||||
storeApiKey: (provider, apiKey) =>
|
|
||||||
ipcRenderer.invoke("setup:store-api-key", { provider, apiKey }),
|
|
||||||
|
|
||||||
// Get stored API keys status
|
|
||||||
getApiKeys: () => ipcRenderer.invoke("setup:get-api-keys"),
|
|
||||||
|
|
||||||
// Configure Codex MCP server for a project
|
|
||||||
configureCodexMcp: (projectPath) =>
|
|
||||||
ipcRenderer.invoke("setup:configure-codex-mcp", { projectPath }),
|
|
||||||
|
|
||||||
// Get platform information
|
|
||||||
getPlatform: () => ipcRenderer.invoke("setup:get-platform"),
|
|
||||||
|
|
||||||
// Listen for installation progress
|
|
||||||
onInstallProgress: (callback) => {
|
|
||||||
const subscription = (_, data) => callback(data);
|
|
||||||
ipcRenderer.on("setup:install-progress", subscription);
|
|
||||||
return () => {
|
|
||||||
ipcRenderer.removeListener("setup:install-progress", subscription);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
// Listen for auth progress
|
|
||||||
onAuthProgress: (callback) => {
|
|
||||||
const subscription = (_, data) => callback(data);
|
|
||||||
ipcRenderer.on("setup:auth-progress", subscription);
|
|
||||||
return () => {
|
|
||||||
ipcRenderer.removeListener("setup:auth-progress", subscription);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// 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 }),
|
|
||||||
},
|
|
||||||
|
|
||||||
// Running Agents API
|
|
||||||
runningAgents: {
|
|
||||||
// Get all running agents across all projects
|
|
||||||
getAll: () => ipcRenderer.invoke("running-agents:getAll"),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also expose a flag to detect if we're in Electron
|
|
||||||
contextBridge.exposeInMainWorld("isElectron", true);
|
contextBridge.exposeInMainWorld("isElectron", true);
|
||||||
|
|
||||||
|
// Expose platform info for UI purposes
|
||||||
|
contextBridge.exposeInMainWorld("electronPlatform", process.platform);
|
||||||
|
|
||||||
|
console.log("[Preload] Electron flag exposed (HTTP-only mode)");
|
||||||
|
|||||||
@@ -143,6 +143,80 @@
|
|||||||
--running-indicator-text: oklch(0.6 0.22 265);
|
--running-indicator-text: oklch(0.6 0.22 265);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Apply dark mode immediately based on system preference (before JS runs) */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
/* Deep dark backgrounds - zinc-950 family */
|
||||||
|
--background: oklch(0.04 0 0); /* zinc-950 */
|
||||||
|
--background-50: oklch(0.04 0 0 / 0.5); /* zinc-950/50 */
|
||||||
|
--background-80: oklch(0.04 0 0 / 0.8); /* zinc-950/80 */
|
||||||
|
|
||||||
|
/* Text colors following hierarchy */
|
||||||
|
--foreground: oklch(1 0 0); /* text-white */
|
||||||
|
--foreground-secondary: oklch(0.588 0 0); /* text-zinc-400 */
|
||||||
|
--foreground-muted: oklch(0.525 0 0); /* text-zinc-500 */
|
||||||
|
|
||||||
|
/* Card and popover backgrounds */
|
||||||
|
--card: oklch(0.14 0 0);
|
||||||
|
--card-foreground: oklch(1 0 0);
|
||||||
|
--popover: oklch(0.10 0 0);
|
||||||
|
--popover-foreground: oklch(1 0 0);
|
||||||
|
|
||||||
|
/* Brand colors - purple/violet theme */
|
||||||
|
--primary: oklch(0.55 0.25 265);
|
||||||
|
--primary-foreground: oklch(1 0 0);
|
||||||
|
--brand-400: oklch(0.6 0.22 265);
|
||||||
|
--brand-500: oklch(0.55 0.25 265);
|
||||||
|
--brand-600: oklch(0.5 0.28 270);
|
||||||
|
|
||||||
|
/* Glass morphism borders and accents */
|
||||||
|
--secondary: oklch(1 0 0 / 0.05);
|
||||||
|
--secondary-foreground: oklch(1 0 0);
|
||||||
|
--muted: oklch(0.176 0 0);
|
||||||
|
--muted-foreground: oklch(0.588 0 0);
|
||||||
|
--accent: oklch(1 0 0 / 0.1);
|
||||||
|
--accent-foreground: oklch(1 0 0);
|
||||||
|
|
||||||
|
/* Borders with transparency for glass effect */
|
||||||
|
--border: oklch(0.176 0 0);
|
||||||
|
--border-glass: oklch(1 0 0 / 0.1);
|
||||||
|
--destructive: oklch(0.6 0.25 25);
|
||||||
|
--input: oklch(0.04 0 0 / 0.8);
|
||||||
|
--ring: oklch(0.55 0.25 265);
|
||||||
|
|
||||||
|
/* Chart colors with brand theme */
|
||||||
|
--chart-1: oklch(0.55 0.25 265);
|
||||||
|
--chart-2: oklch(0.65 0.2 160);
|
||||||
|
--chart-3: oklch(0.75 0.2 70);
|
||||||
|
--chart-4: oklch(0.6 0.25 300);
|
||||||
|
--chart-5: oklch(0.6 0.25 20);
|
||||||
|
|
||||||
|
/* Sidebar with glass morphism */
|
||||||
|
--sidebar: oklch(0.04 0 0 / 0.5);
|
||||||
|
--sidebar-foreground: oklch(1 0 0);
|
||||||
|
--sidebar-primary: oklch(0.55 0.25 265);
|
||||||
|
--sidebar-primary-foreground: oklch(1 0 0);
|
||||||
|
--sidebar-accent: oklch(1 0 0 / 0.05);
|
||||||
|
--sidebar-accent-foreground: oklch(1 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 0.1);
|
||||||
|
--sidebar-ring: oklch(0.55 0.25 265);
|
||||||
|
|
||||||
|
/* Action button colors */
|
||||||
|
--action-view: oklch(0.6 0.25 265);
|
||||||
|
--action-view-hover: oklch(0.55 0.27 270);
|
||||||
|
--action-followup: oklch(0.6 0.2 230);
|
||||||
|
--action-followup-hover: oklch(0.55 0.22 230);
|
||||||
|
--action-commit: oklch(0.55 0.2 140);
|
||||||
|
--action-commit-hover: oklch(0.5 0.22 140);
|
||||||
|
--action-verify: oklch(0.55 0.2 140);
|
||||||
|
--action-verify-hover: oklch(0.5 0.22 140);
|
||||||
|
|
||||||
|
/* Running indicator - Purple */
|
||||||
|
--running-indicator: oklch(0.6 0.25 265);
|
||||||
|
--running-indicator-text: oklch(0.65 0.22 265);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.light {
|
.light {
|
||||||
/* Explicit light mode - same as root but ensures it overrides any dark defaults */
|
/* Explicit light mode - same as root but ensures it overrides any dark defaults */
|
||||||
--background: oklch(1 0 0); /* White */
|
--background: oklch(1 0 0); /* White */
|
||||||
@@ -211,10 +285,10 @@
|
|||||||
--foreground-secondary: oklch(0.588 0 0); /* text-zinc-400 */
|
--foreground-secondary: oklch(0.588 0 0); /* text-zinc-400 */
|
||||||
--foreground-muted: oklch(0.525 0 0); /* text-zinc-500 */
|
--foreground-muted: oklch(0.525 0 0); /* text-zinc-500 */
|
||||||
|
|
||||||
/* Glass morphism effects */
|
/* Card and popover backgrounds */
|
||||||
--card: oklch(0.04 0 0 / 0.5); /* zinc-950/50 with transparency */
|
--card: oklch(0.14 0 0); /* slightly lighter than background for contrast */
|
||||||
--card-foreground: oklch(1 0 0);
|
--card-foreground: oklch(1 0 0);
|
||||||
--popover: oklch(0.04 0 0 / 0.8); /* zinc-950/80 for popover */
|
--popover: oklch(0.10 0 0); /* slightly lighter than background */
|
||||||
--popover-foreground: oklch(1 0 0);
|
--popover-foreground: oklch(1 0 0);
|
||||||
|
|
||||||
/* Brand colors - purple/violet theme */
|
/* Brand colors - purple/violet theme */
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { GeistSans } from "geist/font/sans";
|
import { GeistSans } from "geist/font/sans";
|
||||||
import { GeistMono } from "geist/font/mono";
|
import { GeistMono } from "geist/font/mono";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
|
import { CoursePromoBadge } from "@/components/ui/course-promo-badge";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Automaker - Autonomous AI Development Studio",
|
title: "Automaker - Autonomous AI Development Studio",
|
||||||
@@ -20,6 +21,7 @@ export default function RootLayout({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<Toaster richColors position="bottom-right" />
|
<Toaster richColors position="bottom-right" />
|
||||||
|
<CoursePromoBadge />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { SessionListItem } from "@/types/electron";
|
import type { SessionListItem } from "@/types/electron";
|
||||||
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
|
||||||
// Random session name generator
|
// Random session name generator
|
||||||
const adjectives = [
|
const adjectives = [
|
||||||
@@ -115,14 +116,15 @@ export function SessionManager({
|
|||||||
|
|
||||||
// Check running state for all sessions
|
// Check running state for all sessions
|
||||||
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
|
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
|
||||||
if (!window.electronAPI?.agent) return;
|
const api = getElectronAPI();
|
||||||
|
if (!api?.agent) return;
|
||||||
|
|
||||||
const runningIds = new Set<string>();
|
const runningIds = new Set<string>();
|
||||||
|
|
||||||
// Check each session's running state
|
// Check each session's running state
|
||||||
for (const session of sessionList) {
|
for (const session of sessionList) {
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.agent.getHistory(session.id);
|
const result = await api.agent.getHistory(session.id);
|
||||||
if (result.success && result.isRunning) {
|
if (result.success && result.isRunning) {
|
||||||
runningIds.add(session.id);
|
runningIds.add(session.id);
|
||||||
}
|
}
|
||||||
@@ -140,10 +142,11 @@ export function SessionManager({
|
|||||||
|
|
||||||
// Load sessions
|
// Load sessions
|
||||||
const loadSessions = async () => {
|
const loadSessions = async () => {
|
||||||
if (!window.electronAPI?.sessions) return;
|
const api = getElectronAPI();
|
||||||
|
if (!api?.sessions) return;
|
||||||
|
|
||||||
// Always load all sessions and filter client-side
|
// Always load all sessions and filter client-side
|
||||||
const result = await window.electronAPI.sessions.list(true);
|
const result = await api.sessions.list(true);
|
||||||
if (result.success && result.sessions) {
|
if (result.success && result.sessions) {
|
||||||
setSessions(result.sessions);
|
setSessions(result.sessions);
|
||||||
// Check running state for all sessions
|
// Check running state for all sessions
|
||||||
@@ -171,39 +174,41 @@ export function SessionManager({
|
|||||||
|
|
||||||
// Create new session with random name
|
// Create new session with random name
|
||||||
const handleCreateSession = async () => {
|
const handleCreateSession = async () => {
|
||||||
if (!window.electronAPI?.sessions) return;
|
const api = getElectronAPI();
|
||||||
|
if (!api?.sessions) return;
|
||||||
|
|
||||||
const sessionName = newSessionName.trim() || generateRandomSessionName();
|
const sessionName = newSessionName.trim() || generateRandomSessionName();
|
||||||
|
|
||||||
const result = await window.electronAPI.sessions.create(
|
const result = await api.sessions.create(
|
||||||
sessionName,
|
sessionName,
|
||||||
projectPath,
|
projectPath,
|
||||||
projectPath
|
projectPath
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success && result.sessionId) {
|
if (result.success && result.session?.id) {
|
||||||
setNewSessionName("");
|
setNewSessionName("");
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
onSelectSession(result.sessionId);
|
onSelectSession(result.session.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create new session directly with a random name (one-click)
|
// Create new session directly with a random name (one-click)
|
||||||
const handleQuickCreateSession = async () => {
|
const handleQuickCreateSession = async () => {
|
||||||
if (!window.electronAPI?.sessions) return;
|
const api = getElectronAPI();
|
||||||
|
if (!api?.sessions) return;
|
||||||
|
|
||||||
const sessionName = generateRandomSessionName();
|
const sessionName = generateRandomSessionName();
|
||||||
|
|
||||||
const result = await window.electronAPI.sessions.create(
|
const result = await api.sessions.create(
|
||||||
sessionName,
|
sessionName,
|
||||||
projectPath,
|
projectPath,
|
||||||
projectPath
|
projectPath
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success && result.sessionId) {
|
if (result.success && result.session?.id) {
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
onSelectSession(result.sessionId);
|
onSelectSession(result.session.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -221,9 +226,10 @@ export function SessionManager({
|
|||||||
|
|
||||||
// Rename session
|
// Rename session
|
||||||
const handleRenameSession = async (sessionId: string) => {
|
const handleRenameSession = async (sessionId: string) => {
|
||||||
if (!editingName.trim() || !window.electronAPI?.sessions) return;
|
const api = getElectronAPI();
|
||||||
|
if (!editingName.trim() || !api?.sessions) return;
|
||||||
|
|
||||||
const result = await window.electronAPI.sessions.update(
|
const result = await api.sessions.update(
|
||||||
sessionId,
|
sessionId,
|
||||||
editingName,
|
editingName,
|
||||||
undefined
|
undefined
|
||||||
@@ -238,9 +244,10 @@ export function SessionManager({
|
|||||||
|
|
||||||
// Archive session
|
// Archive session
|
||||||
const handleArchiveSession = async (sessionId: string) => {
|
const handleArchiveSession = async (sessionId: string) => {
|
||||||
if (!window.electronAPI?.sessions) return;
|
const api = getElectronAPI();
|
||||||
|
if (!api?.sessions) return;
|
||||||
|
|
||||||
const result = await window.electronAPI.sessions.archive(sessionId);
|
const result = await api.sessions.archive(sessionId);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// If the archived session was currently selected, deselect it
|
// If the archived session was currently selected, deselect it
|
||||||
if (currentSessionId === sessionId) {
|
if (currentSessionId === sessionId) {
|
||||||
@@ -252,9 +259,10 @@ export function SessionManager({
|
|||||||
|
|
||||||
// Unarchive session
|
// Unarchive session
|
||||||
const handleUnarchiveSession = async (sessionId: string) => {
|
const handleUnarchiveSession = async (sessionId: string) => {
|
||||||
if (!window.electronAPI?.sessions) return;
|
const api = getElectronAPI();
|
||||||
|
if (!api?.sessions) return;
|
||||||
|
|
||||||
const result = await window.electronAPI.sessions.unarchive(sessionId);
|
const result = await api.sessions.unarchive(sessionId);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
}
|
}
|
||||||
@@ -262,10 +270,11 @@ export function SessionManager({
|
|||||||
|
|
||||||
// Delete session
|
// Delete session
|
||||||
const handleDeleteSession = async (sessionId: string) => {
|
const handleDeleteSession = async (sessionId: string) => {
|
||||||
if (!window.electronAPI?.sessions) return;
|
const api = getElectronAPI();
|
||||||
|
if (!api?.sessions) return;
|
||||||
if (!confirm("Are you sure you want to delete this session?")) return;
|
if (!confirm("Are you sure you want to delete this session?")) return;
|
||||||
|
|
||||||
const result = await window.electronAPI.sessions.delete(sessionId);
|
const result = await api.sessions.delete(sessionId);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
if (currentSessionId === sessionId) {
|
if (currentSessionId === sessionId) {
|
||||||
|
|||||||
136
apps/app/src/components/ui/course-promo-badge.tsx
Normal file
136
apps/app/src/components/ui/course-promo-badge.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Sparkles, Rocket, X, ExternalLink, Code, MessageSquare, Brain, Terminal } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from "./dialog";
|
||||||
|
import { Button } from "./button";
|
||||||
|
|
||||||
|
export function CoursePromoBadge() {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [dismissed, setDismissed] = React.useState(false);
|
||||||
|
|
||||||
|
if (dismissed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="group cursor-pointer flex items-center gap-2 pl-4 pr-2 py-2 bg-primary text-primary-foreground rounded-full font-semibold text-sm shadow-lg hover:bg-primary/90 hover:scale-105 transition-all border border-border"
|
||||||
|
>
|
||||||
|
<Sparkles className="size-4" />
|
||||||
|
<span>Become a 10x Dev</span>
|
||||||
|
<span
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDismissed(true);
|
||||||
|
}}
|
||||||
|
className="p-1 rounded-full hover:bg-primary-foreground/20 transition-colors cursor-pointer"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-xl">
|
||||||
|
<Rocket className="size-5 text-primary" />
|
||||||
|
Learn Agentic AI Development
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-base">
|
||||||
|
Master the tools and techniques behind modern AI-assisted coding
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="p-3 rounded-lg bg-accent/50 border border-border">
|
||||||
|
<p className="text-sm text-foreground">
|
||||||
|
Did you know <span className="font-semibold">Automaker was built entirely through agentic coding</span>?
|
||||||
|
Want to learn how? Check out the course!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
<span className="font-semibold text-foreground">Agentic Jumpstart</span> teaches you
|
||||||
|
how to leverage AI tools to build software faster and smarter than ever before.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="p-1.5 rounded-lg bg-primary/10">
|
||||||
|
<Terminal className="size-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">Claude Code Mastery</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Learn to use Claude Code effectively for autonomous development workflows
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="p-1.5 rounded-lg bg-primary/10">
|
||||||
|
<Code className="size-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">Cursor & AI IDEs</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Master Cursor and other AI-powered development environments
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="p-1.5 rounded-lg bg-primary/10">
|
||||||
|
<MessageSquare className="size-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">Prompting Techniques</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Craft effective prompts that get you the results you need
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="p-1.5 rounded-lg bg-primary/10">
|
||||||
|
<Brain className="size-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">Context Engineering</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Structure your projects and context for optimal AI collaboration
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Maybe Later
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => window.open("https://agenticjumpstart.com", "_blank")}
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-4" />
|
||||||
|
Get Started
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -83,6 +83,13 @@ export function DescriptionImageDropZone({
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const currentProject = useAppStore((state) => state.currentProject);
|
const currentProject = useAppStore((state) => state.currentProject);
|
||||||
|
|
||||||
|
// Construct server URL for loading saved images
|
||||||
|
const getImageServerUrl = useCallback((imagePath: string): string => {
|
||||||
|
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
|
||||||
|
const projectPath = currentProject?.path || "";
|
||||||
|
return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`;
|
||||||
|
}, [currentProject?.path]);
|
||||||
|
|
||||||
const fileToBase64 = (file: File): Promise<string> => {
|
const fileToBase64 = (file: File): Promise<string> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@@ -374,7 +381,15 @@ export function DescriptionImageDropZone({
|
|||||||
className="max-w-full max-h-full object-contain"
|
className="max-w-full max-h-full object-contain"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ImageIcon className="w-6 h-6 text-muted-foreground" />
|
<img
|
||||||
|
src={getImageServerUrl(image.path)}
|
||||||
|
alt={image.filename}
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
onError={(e) => {
|
||||||
|
// If image fails to load, hide it
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Remove button */}
|
{/* Remove button */}
|
||||||
|
|||||||
@@ -583,11 +583,25 @@ export function BoardView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadFeatures();
|
loadFeatures();
|
||||||
// Show error toast
|
|
||||||
|
// Check for authentication errors and show a more helpful message
|
||||||
|
const isAuthError = event.errorType === "authentication" ||
|
||||||
|
(event.error && (
|
||||||
|
event.error.includes("Authentication failed") ||
|
||||||
|
event.error.includes("Invalid API key")
|
||||||
|
));
|
||||||
|
|
||||||
|
if (isAuthError) {
|
||||||
|
toast.error("Authentication Failed", {
|
||||||
|
description: "Your API key is invalid or expired. Please check Settings or run 'claude login' in terminal.",
|
||||||
|
duration: 10000,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
toast.error("Agent encountered an error", {
|
toast.error("Agent encountered an error", {
|
||||||
description: event.error || "Check the logs for details",
|
description: event.error || "Check the logs for details",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { WorkspacePickerModal } from "@/components/workspace-picker-modal";
|
||||||
|
import { getHttpApiClient } from "@/lib/http-api-client";
|
||||||
|
|
||||||
export function WelcomeView() {
|
export function WelcomeView() {
|
||||||
const { projects, addProject, setCurrentProject, setCurrentView } =
|
const { projects, addProject, setCurrentProject, setCurrentView } =
|
||||||
@@ -57,6 +59,7 @@ export function WelcomeView() {
|
|||||||
projectName: string;
|
projectName: string;
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [showWorkspacePicker, setShowWorkspacePicker] = useState(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kick off project analysis agent to analyze the codebase
|
* Kick off project analysis agent to analyze the codebase
|
||||||
@@ -172,6 +175,16 @@ export function WelcomeView() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenProject = useCallback(async () => {
|
const handleOpenProject = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
// Check if workspace is configured
|
||||||
|
const httpClient = getHttpApiClient();
|
||||||
|
const configResult = await httpClient.workspace.getConfig();
|
||||||
|
|
||||||
|
if (configResult.success && configResult.configured) {
|
||||||
|
// Show workspace picker modal
|
||||||
|
setShowWorkspacePicker(true);
|
||||||
|
} else {
|
||||||
|
// Fall back to current behavior (native dialog or manual input)
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
const result = await api.openDirectory();
|
const result = await api.openDirectory();
|
||||||
|
|
||||||
@@ -181,8 +194,32 @@ export function WelcomeView() {
|
|||||||
const name = path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project";
|
const name = path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project";
|
||||||
await initializeAndOpenProject(path, name);
|
await initializeAndOpenProject(path, name);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Welcome] Failed to check workspace config:", error);
|
||||||
|
// Fall back to current behavior on error
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.openDirectory();
|
||||||
|
|
||||||
|
if (!result.canceled && result.filePaths[0]) {
|
||||||
|
const path = result.filePaths[0];
|
||||||
|
const name = path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project";
|
||||||
|
await initializeAndOpenProject(path, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
}, [initializeAndOpenProject]);
|
}, [initializeAndOpenProject]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle selecting a project from workspace picker
|
||||||
|
*/
|
||||||
|
const handleWorkspaceSelect = useCallback(
|
||||||
|
async (path: string, name: string) => {
|
||||||
|
setShowWorkspacePicker(false);
|
||||||
|
await initializeAndOpenProject(path, name);
|
||||||
|
},
|
||||||
|
[initializeAndOpenProject]
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle clicking on a recent project
|
* Handle clicking on a recent project
|
||||||
*/
|
*/
|
||||||
@@ -621,6 +658,13 @@ export function WelcomeView() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Workspace Picker Modal */}
|
||||||
|
<WorkspacePickerModal
|
||||||
|
open={showWorkspacePicker}
|
||||||
|
onOpenChange={setShowWorkspacePicker}
|
||||||
|
onSelect={handleWorkspaceSelect}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Loading overlay when opening project */}
|
{/* Loading overlay when opening project */}
|
||||||
{isOpening && (
|
{isOpening && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
154
apps/app/src/components/workspace-picker-modal.tsx
Normal file
154
apps/app/src/components/workspace-picker-modal.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Folder, Loader2, FolderOpen, AlertCircle } from "lucide-react";
|
||||||
|
import { getHttpApiClient } from "@/lib/http-api-client";
|
||||||
|
|
||||||
|
interface WorkspaceDirectory {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspacePickerModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSelect: (path: string, name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspacePickerModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSelect,
|
||||||
|
}: WorkspacePickerModalProps) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [directories, setDirectories] = useState<WorkspaceDirectory[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadDirectories = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = getHttpApiClient();
|
||||||
|
const result = await client.workspace.getDirectories();
|
||||||
|
|
||||||
|
if (result.success && result.directories) {
|
||||||
|
setDirectories(result.directories);
|
||||||
|
} else {
|
||||||
|
setError(result.error || "Failed to load directories");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load directories");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load directories when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
loadDirectories();
|
||||||
|
}
|
||||||
|
}, [open, loadDirectories]);
|
||||||
|
|
||||||
|
const handleSelect = (dir: WorkspaceDirectory) => {
|
||||||
|
onSelect(dir.path, dir.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="bg-card border-border max-w-lg max-h-[80vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-foreground">
|
||||||
|
<FolderOpen className="w-5 h-5 text-brand-500" />
|
||||||
|
Select Project
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-muted-foreground">
|
||||||
|
Choose a project from your workspace directory
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto py-4 min-h-[200px]">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||||
|
<Loader2 className="w-8 h-8 text-brand-500 animate-spin" />
|
||||||
|
<p className="text-sm text-muted-foreground">Loading projects...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && !isLoading && (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-3 text-center px-4">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center">
|
||||||
|
<AlertCircle className="w-6 h-6 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={loadDirectories}
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !error && directories.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-3 text-center px-4">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center">
|
||||||
|
<Folder className="w-6 h-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No projects found in workspace directory
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !error && directories.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{directories.map((dir) => (
|
||||||
|
<button
|
||||||
|
key={dir.path}
|
||||||
|
onClick={() => handleSelect(dir)}
|
||||||
|
className="w-full flex items-center gap-3 p-3 rounded-lg border border-border bg-card hover:bg-card/70 hover:border-brand-500/50 transition-all duration-200 text-left group"
|
||||||
|
data-testid={`workspace-dir-${dir.name}`}
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-muted border border-border flex items-center justify-center group-hover:border-brand-500/50 transition-colors shrink-0">
|
||||||
|
<Folder className="w-5 h-5 text-muted-foreground group-hover:text-brand-500 transition-colors" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-foreground truncate group-hover:text-brand-500 transition-colors">
|
||||||
|
{dir.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70 truncate">
|
||||||
|
{dir.path}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -121,11 +121,26 @@ export function useAutoMode() {
|
|||||||
case "auto_mode_error":
|
case "auto_mode_error":
|
||||||
console.error("[AutoMode Error]", event.error);
|
console.error("[AutoMode Error]", event.error);
|
||||||
if (event.featureId && event.error) {
|
if (event.featureId && event.error) {
|
||||||
|
// Check for authentication errors and provide a more helpful message
|
||||||
|
const isAuthError = event.errorType === "authentication" ||
|
||||||
|
event.error.includes("Authentication failed") ||
|
||||||
|
event.error.includes("Invalid API key");
|
||||||
|
|
||||||
|
const errorMessage = isAuthError
|
||||||
|
? `Authentication failed: Please check your API key in Settings or run 'claude login' in terminal to re-authenticate.`
|
||||||
|
: event.error;
|
||||||
|
|
||||||
addAutoModeActivity({
|
addAutoModeActivity({
|
||||||
featureId: event.featureId,
|
featureId: event.featureId,
|
||||||
type: "error",
|
type: "error",
|
||||||
message: event.error,
|
message: errorMessage,
|
||||||
|
errorType: isAuthError ? "authentication" : "execution",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Remove the task from running since it failed
|
||||||
|
if (eventProjectId) {
|
||||||
|
removeRunningTask(eventProjectId, event.featureId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
|||||||
import type { Message, StreamEvent } from "@/types/electron";
|
import type { Message, StreamEvent } from "@/types/electron";
|
||||||
import { useMessageQueue } from "./use-message-queue";
|
import { useMessageQueue } from "./use-message-queue";
|
||||||
import type { ImageAttachment } from "@/store/app-store";
|
import type { ImageAttachment } from "@/store/app-store";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
|
||||||
interface UseElectronAgentOptions {
|
interface UseElectronAgentOptions {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -44,8 +45,9 @@ export function useElectronAgent({
|
|||||||
// Send message directly to the agent (bypassing queue)
|
// Send message directly to the agent (bypassing queue)
|
||||||
const sendMessageDirectly = useCallback(
|
const sendMessageDirectly = useCallback(
|
||||||
async (content: string, images?: ImageAttachment[]) => {
|
async (content: string, images?: ImageAttachment[]) => {
|
||||||
if (!window.electronAPI?.agent) {
|
const api = getElectronAPI();
|
||||||
setError("Electron API not available");
|
if (!api?.agent) {
|
||||||
|
setError("API not available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +66,10 @@ export function useElectronAgent({
|
|||||||
|
|
||||||
// Save images to .automaker/images and get paths
|
// Save images to .automaker/images and get paths
|
||||||
let imagePaths: string[] | undefined;
|
let imagePaths: string[] | undefined;
|
||||||
if (images && images.length > 0) {
|
if (images && images.length > 0 && api.saveImageToTemp) {
|
||||||
imagePaths = [];
|
imagePaths = [];
|
||||||
for (const image of images) {
|
for (const image of images) {
|
||||||
const result = await window.electronAPI.saveImageToTemp(
|
const result = await api.saveImageToTemp(
|
||||||
image.data,
|
image.data,
|
||||||
image.filename,
|
image.filename,
|
||||||
image.mimeType,
|
image.mimeType,
|
||||||
@@ -82,7 +84,7 @@ export function useElectronAgent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await window.electronAPI.agent.send(
|
const result = await api.agent!.send(
|
||||||
sessionId,
|
sessionId,
|
||||||
content,
|
content,
|
||||||
workingDirectory,
|
workingDirectory,
|
||||||
@@ -120,8 +122,9 @@ export function useElectronAgent({
|
|||||||
|
|
||||||
// Initialize connection and load history
|
// Initialize connection and load history
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.electronAPI?.agent) {
|
const api = getElectronAPI();
|
||||||
setError("Electron API not available. Please run in Electron.");
|
if (!api?.agent) {
|
||||||
|
setError("API not available.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +145,7 @@ export function useElectronAgent({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("[useElectronAgent] Starting session:", sessionId);
|
console.log("[useElectronAgent] Starting session:", sessionId);
|
||||||
const result = await window.electronAPI.agent.start(
|
const result = await api.agent!.start(
|
||||||
sessionId,
|
sessionId,
|
||||||
workingDirectory
|
workingDirectory
|
||||||
);
|
);
|
||||||
@@ -155,7 +158,7 @@ export function useElectronAgent({
|
|||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
|
|
||||||
// Check if the agent is currently running for this session
|
// Check if the agent is currently running for this session
|
||||||
const historyResult = await window.electronAPI.agent.getHistory(sessionId);
|
const historyResult = await api.agent!.getHistory(sessionId);
|
||||||
if (mounted && historyResult.success) {
|
if (mounted && historyResult.success) {
|
||||||
const isRunning = historyResult.isRunning || false;
|
const isRunning = historyResult.isRunning || false;
|
||||||
console.log("[useElectronAgent] Session running state:", isRunning);
|
console.log("[useElectronAgent] Session running state:", isRunning);
|
||||||
@@ -190,7 +193,8 @@ export function useElectronAgent({
|
|||||||
|
|
||||||
// Subscribe to streaming events
|
// Subscribe to streaming events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.electronAPI?.agent) return;
|
const api = getElectronAPI();
|
||||||
|
if (!api?.agent) return;
|
||||||
if (!sessionId) return; // Don't subscribe if no session
|
if (!sessionId) return; // Don't subscribe if no session
|
||||||
|
|
||||||
console.log("[useElectronAgent] Subscribing to stream events for session:", sessionId);
|
console.log("[useElectronAgent] Subscribing to stream events for session:", sessionId);
|
||||||
@@ -282,7 +286,7 @@ export function useElectronAgent({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
unsubscribeRef.current = window.electronAPI.agent.onStream(handleStream);
|
unsubscribeRef.current = api.agent!.onStream(handleStream as (data: unknown) => void);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (unsubscribeRef.current) {
|
if (unsubscribeRef.current) {
|
||||||
@@ -296,8 +300,9 @@ export function useElectronAgent({
|
|||||||
// Send a message to the agent
|
// Send a message to the agent
|
||||||
const sendMessage = useCallback(
|
const sendMessage = useCallback(
|
||||||
async (content: string, images?: ImageAttachment[]) => {
|
async (content: string, images?: ImageAttachment[]) => {
|
||||||
if (!window.electronAPI?.agent) {
|
const api = getElectronAPI();
|
||||||
setError("Electron API not available");
|
if (!api?.agent) {
|
||||||
|
setError("API not available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,10 +322,10 @@ export function useElectronAgent({
|
|||||||
|
|
||||||
// Save images to .automaker/images and get paths
|
// Save images to .automaker/images and get paths
|
||||||
let imagePaths: string[] | undefined;
|
let imagePaths: string[] | undefined;
|
||||||
if (images && images.length > 0) {
|
if (images && images.length > 0 && api.saveImageToTemp) {
|
||||||
imagePaths = [];
|
imagePaths = [];
|
||||||
for (const image of images) {
|
for (const image of images) {
|
||||||
const result = await window.electronAPI.saveImageToTemp(
|
const result = await api.saveImageToTemp(
|
||||||
image.data,
|
image.data,
|
||||||
image.filename,
|
image.filename,
|
||||||
image.mimeType,
|
image.mimeType,
|
||||||
@@ -335,7 +340,7 @@ export function useElectronAgent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await window.electronAPI.agent.send(
|
const result = await api.agent!.send(
|
||||||
sessionId,
|
sessionId,
|
||||||
content,
|
content,
|
||||||
workingDirectory,
|
workingDirectory,
|
||||||
@@ -359,14 +364,15 @@ export function useElectronAgent({
|
|||||||
|
|
||||||
// Stop current execution
|
// Stop current execution
|
||||||
const stopExecution = useCallback(async () => {
|
const stopExecution = useCallback(async () => {
|
||||||
if (!window.electronAPI?.agent) {
|
const api = getElectronAPI();
|
||||||
setError("Electron API not available");
|
if (!api?.agent) {
|
||||||
|
setError("API not available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("[useElectronAgent] Stopping execution");
|
console.log("[useElectronAgent] Stopping execution");
|
||||||
const result = await window.electronAPI.agent.stop(sessionId);
|
const result = await api.agent!.stop(sessionId);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setError(result.error || "Failed to stop execution");
|
setError(result.error || "Failed to stop execution");
|
||||||
@@ -381,14 +387,15 @@ export function useElectronAgent({
|
|||||||
|
|
||||||
// Clear conversation history
|
// Clear conversation history
|
||||||
const clearHistory = useCallback(async () => {
|
const clearHistory = useCallback(async () => {
|
||||||
if (!window.electronAPI?.agent) {
|
const api = getElectronAPI();
|
||||||
setError("Electron API not available");
|
if (!api?.agent) {
|
||||||
|
setError("API not available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("[useElectronAgent] Clearing history");
|
console.log("[useElectronAgent] Clearing history");
|
||||||
const result = await window.electronAPI.agent.clear(sessionId);
|
const result = await api.agent!.clear(sessionId);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Type definitions for Electron IPC API
|
// Type definitions for Electron IPC API
|
||||||
|
import type { SessionListItem, Message } from "@/types/electron";
|
||||||
|
|
||||||
export interface FileEntry {
|
export interface FileEntry {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -413,6 +414,59 @@ export interface ElectronAPI {
|
|||||||
onInstallProgress?: (callback: (progress: any) => void) => () => void;
|
onInstallProgress?: (callback: (progress: any) => void) => () => void;
|
||||||
onAuthProgress?: (callback: (progress: any) => void) => () => void;
|
onAuthProgress?: (callback: (progress: any) => void) => () => void;
|
||||||
};
|
};
|
||||||
|
agent?: {
|
||||||
|
start: (sessionId: string, workingDirectory?: string) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
messages?: Message[];
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
send: (
|
||||||
|
sessionId: string,
|
||||||
|
message: string,
|
||||||
|
workingDirectory?: string,
|
||||||
|
imagePaths?: string[]
|
||||||
|
) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
getHistory: (sessionId: string) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
messages?: Message[];
|
||||||
|
isRunning?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
stop: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
clear: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
onStream: (callback: (data: unknown) => void) => () => void;
|
||||||
|
};
|
||||||
|
sessions?: {
|
||||||
|
list: (includeArchived?: boolean) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
sessions?: SessionListItem[];
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
create: (
|
||||||
|
name: string,
|
||||||
|
projectPath: string,
|
||||||
|
workingDirectory?: string
|
||||||
|
) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
session?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
projectPath: string;
|
||||||
|
workingDirectory?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
update: (
|
||||||
|
sessionId: string,
|
||||||
|
name?: string,
|
||||||
|
tags?: string[]
|
||||||
|
) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
archive: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
unarchive: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
delete: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Window interface is declared in @/types/electron.d.ts
|
// Note: Window interface is declared in @/types/electron.d.ts
|
||||||
@@ -438,7 +492,7 @@ const STORAGE_KEYS = {
|
|||||||
// Mock file system using localStorage
|
// Mock file system using localStorage
|
||||||
const mockFileSystem: Record<string, string> = {};
|
const mockFileSystem: Record<string, string> = {};
|
||||||
|
|
||||||
// Check if we're in Electron
|
// Check if we're in Electron (for UI indicators only)
|
||||||
export const isElectron = (): boolean => {
|
export const isElectron = (): boolean => {
|
||||||
return typeof window !== "undefined" && window.isElectron === true;
|
return typeof window !== "undefined" && window.isElectron === true;
|
||||||
};
|
};
|
||||||
@@ -478,72 +532,50 @@ export const resetServerCheck = (): void => {
|
|||||||
// Cached HTTP client instance
|
// Cached HTTP client instance
|
||||||
let httpClientInstance: ElectronAPI | null = null;
|
let httpClientInstance: ElectronAPI | null = null;
|
||||||
|
|
||||||
// Check if we're in simplified Electron mode (HTTP backend instead of IPC)
|
/**
|
||||||
const isSimplifiedElectronMode = (): boolean => {
|
* Get the HTTP API client
|
||||||
if (typeof window === "undefined") return false;
|
*
|
||||||
const api = window.electronAPI as any;
|
* All API calls go through HTTP to the backend server.
|
||||||
// Simplified mode has isElectron flag and getServerUrl but NOT readFile
|
* This is the only transport mode supported.
|
||||||
return api?.isElectron === true &&
|
*/
|
||||||
typeof api?.getServerUrl === "function" &&
|
|
||||||
typeof api?.readFile !== "function";
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get the Electron API or HTTP client for web mode
|
|
||||||
// In simplified Electron mode and web mode, uses HTTP client
|
|
||||||
export const getElectronAPI = (): ElectronAPI => {
|
export const getElectronAPI = (): ElectronAPI => {
|
||||||
// Check if we're in simplified Electron mode (uses HTTP backend)
|
if (typeof window === "undefined") {
|
||||||
if (isSimplifiedElectronMode()) {
|
throw new Error("Cannot get API during SSR");
|
||||||
if (typeof window !== "undefined" && !httpClientInstance) {
|
|
||||||
const { getHttpApiClient } = require("./http-api-client");
|
|
||||||
httpClientInstance = getHttpApiClient();
|
|
||||||
}
|
|
||||||
return httpClientInstance!;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full Electron API with IPC
|
|
||||||
if (isElectron() && window.electronAPI) {
|
|
||||||
return window.electronAPI;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Web mode: use HTTP API client
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
if (!httpClientInstance) {
|
if (!httpClientInstance) {
|
||||||
const { getHttpApiClient } = require("./http-api-client");
|
const { getHttpApiClient } = require("./http-api-client");
|
||||||
httpClientInstance = getHttpApiClient();
|
httpClientInstance = getHttpApiClient();
|
||||||
}
|
}
|
||||||
return httpClientInstance!;
|
return httpClientInstance!;
|
||||||
}
|
|
||||||
|
|
||||||
// SSR fallback - this shouldn't be called during actual operation
|
|
||||||
throw new Error("Cannot get Electron API during SSR");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Async version that checks server availability first
|
// Async version (same as sync since HTTP client is synchronously instantiated)
|
||||||
export const getElectronAPIAsync = async (): Promise<ElectronAPI> => {
|
export const getElectronAPIAsync = async (): Promise<ElectronAPI> => {
|
||||||
// Simplified Electron mode or web mode: use HTTP client
|
return getElectronAPI();
|
||||||
if (isSimplifiedElectronMode() || !isElectron()) {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const { getHttpApiClient } = await import("./http-api-client");
|
|
||||||
return getHttpApiClient();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full Electron API with IPC
|
|
||||||
if (isElectron() && window.electronAPI) {
|
|
||||||
return window.electronAPI;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Cannot get Electron API during SSR");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if backend is connected (for showing connection status in UI)
|
// Check if backend is connected (for showing connection status in UI)
|
||||||
export const isBackendConnected = async (): Promise<boolean> => {
|
export const isBackendConnected = async (): Promise<boolean> => {
|
||||||
// Full Electron mode: backend is built-in
|
|
||||||
if (isElectron() && !isSimplifiedElectronMode()) return true;
|
|
||||||
// Simplified Electron or web mode: check server availability
|
|
||||||
return await checkServerAvailable();
|
return await checkServerAvailable();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current API mode being used
|
||||||
|
* Always returns "http" since that's the only mode now
|
||||||
|
*/
|
||||||
|
export const getCurrentApiMode = (): "http" => {
|
||||||
|
return "http";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debug helpers
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
(window as any).__checkApiMode = () => {
|
||||||
|
console.log("Current API mode:", getCurrentApiMode());
|
||||||
|
console.log("isElectron():", isElectron());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Mock API for development/fallback when no backend is available
|
// Mock API for development/fallback when no backend is available
|
||||||
const getMockElectronAPI = (): ElectronAPI => {
|
const getMockElectronAPI = (): ElectronAPI => {
|
||||||
return {
|
return {
|
||||||
@@ -1962,7 +1994,9 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mockSpecRegenerationRunning = true;
|
mockSpecRegenerationRunning = true;
|
||||||
console.log(`[Mock] Generating features from existing spec for: ${projectPath}`);
|
console.log(
|
||||||
|
`[Mock] Generating features from existing spec for: ${projectPath}`
|
||||||
|
);
|
||||||
|
|
||||||
// Simulate async feature generation
|
// Simulate async feature generation
|
||||||
simulateFeatureGeneration(projectPath);
|
simulateFeatureGeneration(projectPath);
|
||||||
@@ -2149,7 +2183,8 @@ async function simulateFeatureGeneration(projectPath: string) {
|
|||||||
mockSpecRegenerationPhase = "initialization";
|
mockSpecRegenerationPhase = "initialization";
|
||||||
emitSpecRegenerationEvent({
|
emitSpecRegenerationEvent({
|
||||||
type: "spec_regeneration_progress",
|
type: "spec_regeneration_progress",
|
||||||
content: "[Phase: initialization] Starting feature generation from existing app_spec.txt...\n",
|
content:
|
||||||
|
"[Phase: initialization] Starting feature generation from existing app_spec.txt...\n",
|
||||||
});
|
});
|
||||||
|
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type {
|
|||||||
FeatureSuggestion,
|
FeatureSuggestion,
|
||||||
SuggestionType,
|
SuggestionType,
|
||||||
} from "./electron";
|
} from "./electron";
|
||||||
|
import type { Message, SessionListItem } from "@/types/electron";
|
||||||
import type { Feature } from "@/store/app-store";
|
import type { Feature } from "@/store/app-store";
|
||||||
import type {
|
import type {
|
||||||
WorktreeAPI,
|
WorktreeAPI,
|
||||||
@@ -31,46 +32,9 @@ import type {
|
|||||||
ProviderStatus,
|
ProviderStatus,
|
||||||
} from "@/types/electron";
|
} from "@/types/electron";
|
||||||
|
|
||||||
// Check if we're in simplified Electron mode (Electron with HTTP backend)
|
|
||||||
const isSimplifiedElectronMode = (): boolean => {
|
|
||||||
if (typeof window === "undefined") return false;
|
|
||||||
const api = (window as any).electronAPI;
|
|
||||||
// Simplified mode has isElectron flag but limited methods
|
|
||||||
return api?.isElectron === true && typeof api?.getServerUrl === "function";
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if native Electron dialogs are available
|
// Server URL - configurable via environment variable
|
||||||
const hasNativeDialogs = (): boolean => {
|
const getServerUrl = (): string => {
|
||||||
if (typeof window === "undefined") return false;
|
|
||||||
const api = (window as any).electronAPI;
|
|
||||||
return typeof api?.openDirectory === "function";
|
|
||||||
};
|
|
||||||
|
|
||||||
// Server URL - configurable via environment variable or Electron
|
|
||||||
const getServerUrl = async (): Promise<string> => {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
// In simplified Electron mode, get URL from main process
|
|
||||||
const api = (window as any).electronAPI;
|
|
||||||
if (api?.getServerUrl) {
|
|
||||||
try {
|
|
||||||
return await api.getServerUrl();
|
|
||||||
} catch {
|
|
||||||
// Fall through to defaults
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for environment variable
|
|
||||||
const envUrl = process.env.NEXT_PUBLIC_SERVER_URL;
|
|
||||||
if (envUrl) return envUrl;
|
|
||||||
|
|
||||||
// Default to localhost for development
|
|
||||||
return "http://localhost:3008";
|
|
||||||
}
|
|
||||||
return "http://localhost:3008";
|
|
||||||
};
|
|
||||||
|
|
||||||
// Synchronous version for constructor (uses default, then updates)
|
|
||||||
const getServerUrlSync = (): string => {
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const envUrl = process.env.NEXT_PUBLIC_SERVER_URL;
|
const envUrl = process.env.NEXT_PUBLIC_SERVER_URL;
|
||||||
if (envUrl) return envUrl;
|
if (envUrl) return envUrl;
|
||||||
@@ -78,6 +42,7 @@ const getServerUrlSync = (): string => {
|
|||||||
return "http://localhost:3008";
|
return "http://localhost:3008";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Get API key from environment variable
|
// Get API key from environment variable
|
||||||
const getApiKey = (): string | null => {
|
const getApiKey = (): string | null => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
@@ -105,25 +70,10 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
private isConnecting = false;
|
private isConnecting = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.serverUrl = getServerUrlSync();
|
this.serverUrl = getServerUrl();
|
||||||
// Update server URL asynchronously if in Electron
|
|
||||||
this.initServerUrl();
|
|
||||||
this.connectWebSocket();
|
this.connectWebSocket();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async initServerUrl(): Promise<void> {
|
|
||||||
const url = await getServerUrl();
|
|
||||||
if (url !== this.serverUrl) {
|
|
||||||
this.serverUrl = url;
|
|
||||||
// Reconnect WebSocket with new URL
|
|
||||||
if (this.ws) {
|
|
||||||
this.ws.close();
|
|
||||||
this.ws = null;
|
|
||||||
}
|
|
||||||
this.connectWebSocket();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private connectWebSocket(): void {
|
private connectWebSocket(): void {
|
||||||
if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
|
if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
|
||||||
return;
|
return;
|
||||||
@@ -222,6 +172,23 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async put<T>(endpoint: string, body?: unknown): Promise<T> {
|
||||||
|
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async httpDelete<T>(endpoint: string): Promise<T> {
|
||||||
|
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
// Basic operations
|
// Basic operations
|
||||||
async ping(): Promise<string> {
|
async ping(): Promise<string> {
|
||||||
const result = await this.get<{ status: string }>("/api/health");
|
const result = await this.get<{ status: string }>("/api/health");
|
||||||
@@ -229,27 +196,13 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async openExternalLink(url: string): Promise<{ success: boolean; error?: string }> {
|
async openExternalLink(url: string): Promise<{ success: boolean; error?: string }> {
|
||||||
// Use native Electron shell if available (better UX)
|
// Open in new tab
|
||||||
if (hasNativeDialogs()) {
|
|
||||||
const api = (window as any).electronAPI;
|
|
||||||
if (api.openExternalLink) {
|
|
||||||
return api.openExternalLink(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Web mode: open in new tab
|
|
||||||
window.open(url, "_blank", "noopener,noreferrer");
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// File picker - uses native Electron dialogs when available, otherwise prompt
|
// File picker - uses prompt for path input
|
||||||
async openDirectory(): Promise<DialogResult> {
|
async openDirectory(): Promise<DialogResult> {
|
||||||
// Use native Electron dialog if available
|
|
||||||
if (hasNativeDialogs()) {
|
|
||||||
const api = (window as any).electronAPI;
|
|
||||||
return api.openDirectory();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Web mode: show a modal to let user type/paste path
|
|
||||||
const path = prompt("Enter project directory path:");
|
const path = prompt("Enter project directory path:");
|
||||||
if (!path) {
|
if (!path) {
|
||||||
return { canceled: true, filePaths: [] };
|
return { canceled: true, filePaths: [] };
|
||||||
@@ -271,13 +224,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async openFile(options?: object): Promise<DialogResult> {
|
async openFile(options?: object): Promise<DialogResult> {
|
||||||
// Use native Electron dialog if available
|
// Prompt for file path
|
||||||
if (hasNativeDialogs()) {
|
|
||||||
const api = (window as any).electronAPI;
|
|
||||||
return api.openFile(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Web mode: prompt for file path
|
|
||||||
const path = prompt("Enter file path:");
|
const path = prompt("Enter file path:");
|
||||||
if (!path) {
|
if (!path) {
|
||||||
return { canceled: true, filePaths: [] };
|
return { canceled: true, filePaths: [] };
|
||||||
@@ -651,6 +598,98 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}> => this.get("/api/running-agents"),
|
}> => this.get("/api/running-agents"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Workspace API
|
||||||
|
workspace = {
|
||||||
|
getConfig: (): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
configured: boolean;
|
||||||
|
workspaceDir?: string;
|
||||||
|
error?: string;
|
||||||
|
}> => this.get("/api/workspace/config"),
|
||||||
|
|
||||||
|
getDirectories: (): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
directories?: Array<{ name: string; path: string }>;
|
||||||
|
error?: string;
|
||||||
|
}> => this.get("/api/workspace/directories"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Agent API
|
||||||
|
agent = {
|
||||||
|
start: (sessionId: string, workingDirectory?: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
messages?: Message[];
|
||||||
|
error?: string;
|
||||||
|
}> => this.post("/api/agent/start", { sessionId, workingDirectory }),
|
||||||
|
|
||||||
|
send: (
|
||||||
|
sessionId: string,
|
||||||
|
message: string,
|
||||||
|
workingDirectory?: string,
|
||||||
|
imagePaths?: string[]
|
||||||
|
): Promise<{ success: boolean; error?: string }> =>
|
||||||
|
this.post("/api/agent/send", { sessionId, message, workingDirectory, imagePaths }),
|
||||||
|
|
||||||
|
getHistory: (sessionId: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
messages?: Message[];
|
||||||
|
isRunning?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}> => this.post("/api/agent/history", { sessionId }),
|
||||||
|
|
||||||
|
stop: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
|
||||||
|
this.post("/api/agent/stop", { sessionId }),
|
||||||
|
|
||||||
|
clear: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
|
||||||
|
this.post("/api/agent/clear", { sessionId }),
|
||||||
|
|
||||||
|
onStream: (callback: (data: unknown) => void): (() => void) => {
|
||||||
|
return this.subscribeToEvent("agent:stream", callback as EventCallback);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sessions API
|
||||||
|
sessions = {
|
||||||
|
list: (includeArchived?: boolean): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
sessions?: SessionListItem[];
|
||||||
|
error?: string;
|
||||||
|
}> => this.get(`/api/sessions?includeArchived=${includeArchived || false}`),
|
||||||
|
|
||||||
|
create: (
|
||||||
|
name: string,
|
||||||
|
projectPath: string,
|
||||||
|
workingDirectory?: string
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
session?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
projectPath: string;
|
||||||
|
workingDirectory?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}> => this.post("/api/sessions", { name, projectPath, workingDirectory }),
|
||||||
|
|
||||||
|
update: (
|
||||||
|
sessionId: string,
|
||||||
|
name?: string,
|
||||||
|
tags?: string[]
|
||||||
|
): Promise<{ success: boolean; error?: string }> =>
|
||||||
|
this.put(`/api/sessions/${sessionId}`, { name, tags }),
|
||||||
|
|
||||||
|
archive: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
|
||||||
|
this.post(`/api/sessions/${sessionId}/archive`, {}),
|
||||||
|
|
||||||
|
unarchive: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
|
||||||
|
this.post(`/api/sessions/${sessionId}/unarchive`, {}),
|
||||||
|
|
||||||
|
delete: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
|
||||||
|
this.httpDelete(`/api/sessions/${sessionId}`),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance
|
// Singleton instance
|
||||||
|
|||||||
1
apps/app/src/types/electron.d.ts
vendored
1
apps/app/src/types/electron.d.ts
vendored
@@ -192,6 +192,7 @@ export type AutoModeEvent =
|
|||||||
| {
|
| {
|
||||||
type: "auto_mode_error";
|
type: "auto_mode_error";
|
||||||
error: string;
|
error: string;
|
||||||
|
errorType?: "authentication" | "execution";
|
||||||
featureId?: string;
|
featureId?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
|
|||||||
1
apps/server/.gitignore
vendored
Normal file
1
apps/server/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.env
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "msg_1765523527444_d8xyk8rh9",
|
||||||
|
"role": "user",
|
||||||
|
"content": "this is a test",
|
||||||
|
"timestamp": "2025-12-12T07:12:07.444Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "msg_1765523534075_351i0rcft",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Hello! This is a test response. I'm working correctly and ready to help you with your software development projects.\n\nIs there something specific you'd like to work on today? I can help you with:\n\n- **Planning a new project** - Define requirements, architecture, and technical approach\n- **Writing code** - Create new files, implement features, or build components\n- **Editing existing code** - Modify, refactor, or fix bugs in your codebase\n- **Running commands** - Execute tests, builds, or other terminal commands\n- **Exploring your codebase** - Search for patterns, understand existing code structure\n\nJust let me know what you need!",
|
||||||
|
"timestamp": "2025-12-12T07:12:14.075Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
10
apps/server/data/sessions-metadata.json
Normal file
10
apps/server/data/sessions-metadata.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"msg_1765523524581_xhk6u45v2": {
|
||||||
|
"id": "msg_1765523524581_xhk6u45v2",
|
||||||
|
"name": "Bright Agent 2",
|
||||||
|
"projectPath": "/Users/webdevcody/Workspace/automaker",
|
||||||
|
"workingDirectory": "/Users/webdevcody/Workspace/automaker",
|
||||||
|
"createdAt": "2025-12-12T07:12:04.582Z",
|
||||||
|
"updatedAt": "2025-12-12T07:12:14.382Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import { createSuggestionsRoutes } from "./routes/suggestions.js";
|
|||||||
import { createModelsRoutes } from "./routes/models.js";
|
import { createModelsRoutes } from "./routes/models.js";
|
||||||
import { createSpecRegenerationRoutes } from "./routes/spec-regeneration.js";
|
import { createSpecRegenerationRoutes } from "./routes/spec-regeneration.js";
|
||||||
import { createRunningAgentsRoutes } from "./routes/running-agents.js";
|
import { createRunningAgentsRoutes } from "./routes/running-agents.js";
|
||||||
|
import { createWorkspaceRoutes } from "./routes/workspace.js";
|
||||||
import { AgentService } from "./services/agent-service.js";
|
import { AgentService } from "./services/agent-service.js";
|
||||||
import { FeatureLoader } from "./services/feature-loader.js";
|
import { FeatureLoader } from "./services/feature-loader.js";
|
||||||
|
|
||||||
@@ -47,7 +48,11 @@ if (!hasAnthropicKey) {
|
|||||||
║ ⚠️ WARNING: ANTHROPIC_API_KEY not set ║
|
║ ⚠️ WARNING: ANTHROPIC_API_KEY not set ║
|
||||||
║ ║
|
║ ║
|
||||||
║ The Claude Agent SDK requires ANTHROPIC_API_KEY to function. ║
|
║ The Claude Agent SDK requires ANTHROPIC_API_KEY to function. ║
|
||||||
║ ${hasOAuthToken ? ' You have CLAUDE_CODE_OAUTH_TOKEN set - this is for CLI auth only.' : ''}
|
║ ${
|
||||||
|
hasOAuthToken
|
||||||
|
? " You have CLAUDE_CODE_OAUTH_TOKEN set - this is for CLI auth only."
|
||||||
|
: ""
|
||||||
|
}
|
||||||
║ ║
|
║ ║
|
||||||
║ Set your API key: ║
|
║ Set your API key: ║
|
||||||
║ export ANTHROPIC_API_KEY="sk-ant-..." ║
|
║ export ANTHROPIC_API_KEY="sk-ant-..." ║
|
||||||
@@ -106,6 +111,7 @@ app.use("/api/suggestions", createSuggestionsRoutes(events));
|
|||||||
app.use("/api/models", createModelsRoutes());
|
app.use("/api/models", createModelsRoutes());
|
||||||
app.use("/api/spec-regeneration", createSpecRegenerationRoutes(events));
|
app.use("/api/spec-regeneration", createSpecRegenerationRoutes(events));
|
||||||
app.use("/api/running-agents", createRunningAgentsRoutes());
|
app.use("/api/running-agents", createRunningAgentsRoutes());
|
||||||
|
app.use("/api/workspace", createWorkspaceRoutes());
|
||||||
|
|
||||||
// Create HTTP server
|
// Create HTTP server
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
|
|||||||
@@ -217,5 +217,103 @@ export function createFsRoutes(_events: EventEmitter): Router {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Save image to .automaker/images directory
|
||||||
|
router.post("/save-image", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { data, filename, mimeType, projectPath } = req.body as {
|
||||||
|
data: string;
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
projectPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data || !filename || !projectPath) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "data, filename, and projectPath are required",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create .automaker/images directory if it doesn't exist
|
||||||
|
const imagesDir = path.join(projectPath, ".automaker", "images");
|
||||||
|
await fs.mkdir(imagesDir, { recursive: true });
|
||||||
|
|
||||||
|
// Decode base64 data (remove data URL prefix if present)
|
||||||
|
const base64Data = data.replace(/^data:image\/\w+;base64,/, "");
|
||||||
|
const buffer = Buffer.from(base64Data, "base64");
|
||||||
|
|
||||||
|
// Generate unique filename with timestamp
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const ext = path.extname(filename) || ".png";
|
||||||
|
const baseName = path.basename(filename, ext);
|
||||||
|
const uniqueFilename = `${baseName}-${timestamp}${ext}`;
|
||||||
|
const filePath = path.join(imagesDir, uniqueFilename);
|
||||||
|
|
||||||
|
// Write file
|
||||||
|
await fs.writeFile(filePath, buffer);
|
||||||
|
|
||||||
|
// Add project path to allowed paths if not already
|
||||||
|
addAllowedPath(projectPath);
|
||||||
|
|
||||||
|
res.json({ success: true, path: filePath });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve image files
|
||||||
|
router.get("/image", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { path: imagePath, projectPath } = req.query as {
|
||||||
|
path?: string;
|
||||||
|
projectPath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!imagePath) {
|
||||||
|
res.status(400).json({ success: false, error: "path is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve full path
|
||||||
|
const fullPath = path.isAbsolute(imagePath)
|
||||||
|
? imagePath
|
||||||
|
: projectPath
|
||||||
|
? path.join(projectPath, imagePath)
|
||||||
|
: imagePath;
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
try {
|
||||||
|
await fs.access(fullPath);
|
||||||
|
} catch {
|
||||||
|
res.status(404).json({ success: false, error: "Image not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the file
|
||||||
|
const buffer = await fs.readFile(fullPath);
|
||||||
|
|
||||||
|
// Determine MIME type from extension
|
||||||
|
const ext = path.extname(fullPath).toLowerCase();
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".bmp": "image/bmp",
|
||||||
|
};
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", mimeTypes[ext] || "application/octet-stream");
|
||||||
|
res.setHeader("Cache-Control", "public, max-age=3600");
|
||||||
|
res.send(buffer);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,46 @@ import fs from "fs/promises";
|
|||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
// Storage for API keys (in-memory for now, should be persisted)
|
// Storage for API keys (in-memory cache)
|
||||||
const apiKeys: Record<string, string> = {};
|
const apiKeys: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Helper to persist API keys to .env file
|
||||||
|
async function persistApiKeyToEnv(key: string, value: string): Promise<void> {
|
||||||
|
const envPath = path.join(process.cwd(), ".env");
|
||||||
|
|
||||||
|
try {
|
||||||
|
let envContent = "";
|
||||||
|
try {
|
||||||
|
envContent = await fs.readFile(envPath, "utf-8");
|
||||||
|
} catch {
|
||||||
|
// .env file doesn't exist, we'll create it
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse existing env content
|
||||||
|
const lines = envContent.split("\n");
|
||||||
|
const keyRegex = new RegExp(`^${key}=`);
|
||||||
|
let found = false;
|
||||||
|
const newLines = lines.map((line) => {
|
||||||
|
if (keyRegex.test(line)) {
|
||||||
|
found = true;
|
||||||
|
return `${key}=${value}`;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
// Add the key at the end
|
||||||
|
newLines.push(`${key}=${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(envPath, newLines.join("\n"));
|
||||||
|
console.log(`[Setup] Persisted ${key} to .env file`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Setup] Failed to persist ${key} to .env:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createSetupRoutes(): Router {
|
export function createSetupRoutes(): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -301,13 +338,16 @@ export function createSetupRoutes(): Router {
|
|||||||
|
|
||||||
apiKeys[provider] = apiKey;
|
apiKeys[provider] = apiKey;
|
||||||
|
|
||||||
// Also set as environment variable
|
// Also set as environment variable and persist to .env
|
||||||
if (provider === "anthropic") {
|
if (provider === "anthropic" || provider === "anthropic_oauth_token") {
|
||||||
process.env.ANTHROPIC_API_KEY = apiKey;
|
process.env.ANTHROPIC_API_KEY = apiKey;
|
||||||
|
await persistApiKeyToEnv("ANTHROPIC_API_KEY", apiKey);
|
||||||
} else if (provider === "openai") {
|
} else if (provider === "openai") {
|
||||||
process.env.OPENAI_API_KEY = apiKey;
|
process.env.OPENAI_API_KEY = apiKey;
|
||||||
|
await persistApiKeyToEnv("OPENAI_API_KEY", apiKey);
|
||||||
} else if (provider === "google") {
|
} else if (provider === "google") {
|
||||||
process.env.GOOGLE_API_KEY = apiKey;
|
process.env.GOOGLE_API_KEY = apiKey;
|
||||||
|
await persistApiKeyToEnv("GOOGLE_API_KEY", apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
|||||||
113
apps/server/src/routes/workspace.ts
Normal file
113
apps/server/src/routes/workspace.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Workspace routes
|
||||||
|
* Provides API endpoints for workspace directory management
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, type Request, type Response } from "express";
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import { addAllowedPath } from "../lib/security.js";
|
||||||
|
|
||||||
|
export function createWorkspaceRoutes(): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Get workspace configuration status
|
||||||
|
router.get("/config", async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const workspaceDir = process.env.WORKSPACE_DIR;
|
||||||
|
|
||||||
|
if (!workspaceDir) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
configured: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the directory exists
|
||||||
|
try {
|
||||||
|
const stats = await fs.stat(workspaceDir);
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
configured: false,
|
||||||
|
error: "WORKSPACE_DIR is not a valid directory",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add workspace dir to allowed paths
|
||||||
|
addAllowedPath(workspaceDir);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
configured: true,
|
||||||
|
workspaceDir,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
configured: false,
|
||||||
|
error: "WORKSPACE_DIR path does not exist",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// List directories in workspace
|
||||||
|
router.get("/directories", async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const workspaceDir = process.env.WORKSPACE_DIR;
|
||||||
|
|
||||||
|
if (!workspaceDir) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "WORKSPACE_DIR is not configured",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if directory exists
|
||||||
|
try {
|
||||||
|
await fs.stat(workspaceDir);
|
||||||
|
} catch {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "WORKSPACE_DIR path does not exist",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add workspace dir to allowed paths
|
||||||
|
addAllowedPath(workspaceDir);
|
||||||
|
|
||||||
|
// Read directory contents
|
||||||
|
const entries = await fs.readdir(workspaceDir, { withFileTypes: true });
|
||||||
|
|
||||||
|
// Filter to directories only and map to result format
|
||||||
|
const directories = entries
|
||||||
|
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
|
||||||
|
.map((entry) => ({
|
||||||
|
name: entry.name,
|
||||||
|
path: path.join(workspaceDir, entry.name),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
// Add each directory to allowed paths
|
||||||
|
directories.forEach((dir) => addAllowedPath(dir.path));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
directories,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -220,11 +220,17 @@ export class AutoModeService {
|
|||||||
projectPath,
|
projectPath,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
const errorMessage = (error as Error).message || "Unknown error";
|
||||||
|
const isAuthError = errorMessage.includes("Authentication failed") ||
|
||||||
|
errorMessage.includes("Invalid API key") ||
|
||||||
|
errorMessage.includes("authentication_failed");
|
||||||
|
|
||||||
console.error(`[AutoMode] Feature ${featureId} failed:`, error);
|
console.error(`[AutoMode] Feature ${featureId} failed:`, error);
|
||||||
await this.updateFeatureStatus(projectPath, featureId, "failed");
|
await this.updateFeatureStatus(projectPath, featureId, "failed");
|
||||||
this.emitAutoModeEvent("auto_mode_error", {
|
this.emitAutoModeEvent("auto_mode_error", {
|
||||||
featureId,
|
featureId,
|
||||||
error: (error as Error).message,
|
error: errorMessage,
|
||||||
|
errorType: isAuthError ? "authentication" : "execution",
|
||||||
projectPath,
|
projectPath,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -741,6 +747,17 @@ When done, summarize what you implemented and any notes for the developer.`;
|
|||||||
for (const block of msg.message.content) {
|
for (const block of msg.message.content) {
|
||||||
if (block.type === "text") {
|
if (block.type === "text") {
|
||||||
responseText = block.text;
|
responseText = block.text;
|
||||||
|
|
||||||
|
// Check for authentication errors in the response
|
||||||
|
if (block.text.includes("Invalid API key") ||
|
||||||
|
block.text.includes("authentication_failed") ||
|
||||||
|
block.text.includes("Fix external API key")) {
|
||||||
|
throw new Error(
|
||||||
|
"Authentication failed: Invalid or expired API key. " +
|
||||||
|
"Please check your ANTHROPIC_API_KEY or run 'claude login' to re-authenticate."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.emitAutoModeEvent("auto_mode_progress", {
|
this.emitAutoModeEvent("auto_mode_progress", {
|
||||||
featureId,
|
featureId,
|
||||||
content: block.text,
|
content: block.text,
|
||||||
@@ -753,7 +770,20 @@ When done, summarize what you implemented and any notes for the developer.`;
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (msg.type === "assistant" && (msg as { error?: string }).error === "authentication_failed") {
|
||||||
|
// Handle authentication error from the SDK
|
||||||
|
throw new Error(
|
||||||
|
"Authentication failed: Invalid or expired API key. " +
|
||||||
|
"Please set a valid ANTHROPIC_API_KEY environment variable or run 'claude login' to authenticate."
|
||||||
|
);
|
||||||
} else if (msg.type === "result" && msg.subtype === "success") {
|
} else if (msg.type === "result" && msg.subtype === "success") {
|
||||||
|
// Check if result indicates an error
|
||||||
|
if (msg.is_error && msg.result?.includes("Invalid API key")) {
|
||||||
|
throw new Error(
|
||||||
|
"Authentication failed: Invalid or expired API key. " +
|
||||||
|
"Please set a valid ANTHROPIC_API_KEY environment variable or run 'claude login' to authenticate."
|
||||||
|
);
|
||||||
|
}
|
||||||
responseText = msg.result || responseText;
|
responseText = msg.result || responseText;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,123 @@ export class FeatureLoader {
|
|||||||
return path.join(projectPath, ".automaker", "features");
|
return path.join(projectPath, ".automaker", "features");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the images directory path for a feature
|
||||||
|
*/
|
||||||
|
getFeatureImagesDir(projectPath: string, featureId: string): string {
|
||||||
|
return path.join(this.getFeatureDir(projectPath, featureId), "images");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete images that were removed from a feature
|
||||||
|
*/
|
||||||
|
private async deleteOrphanedImages(
|
||||||
|
projectPath: string,
|
||||||
|
oldPaths: Array<string | { path: string; [key: string]: unknown }> | undefined,
|
||||||
|
newPaths: Array<string | { path: string; [key: string]: unknown }> | undefined
|
||||||
|
): Promise<void> {
|
||||||
|
if (!oldPaths || oldPaths.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build sets of paths for comparison
|
||||||
|
const oldPathSet = new Set(
|
||||||
|
oldPaths.map((p) => (typeof p === "string" ? p : p.path))
|
||||||
|
);
|
||||||
|
const newPathSet = new Set(
|
||||||
|
(newPaths || []).map((p) => (typeof p === "string" ? p : p.path))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find images that were removed
|
||||||
|
for (const oldPath of oldPathSet) {
|
||||||
|
if (!newPathSet.has(oldPath)) {
|
||||||
|
try {
|
||||||
|
const fullPath = path.isAbsolute(oldPath)
|
||||||
|
? oldPath
|
||||||
|
: path.join(projectPath, oldPath);
|
||||||
|
|
||||||
|
await fs.unlink(fullPath);
|
||||||
|
console.log(`[FeatureLoader] Deleted orphaned image: ${oldPath}`);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors when deleting (file may already be gone)
|
||||||
|
console.warn(`[FeatureLoader] Failed to delete image: ${oldPath}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy images from temp directory to feature directory and update paths
|
||||||
|
*/
|
||||||
|
private async migrateImages(
|
||||||
|
projectPath: string,
|
||||||
|
featureId: string,
|
||||||
|
imagePaths?: Array<string | { path: string; [key: string]: unknown }>
|
||||||
|
): Promise<Array<string | { path: string; [key: string]: unknown }> | undefined> {
|
||||||
|
if (!imagePaths || imagePaths.length === 0) {
|
||||||
|
return imagePaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
const featureImagesDir = this.getFeatureImagesDir(projectPath, featureId);
|
||||||
|
await fs.mkdir(featureImagesDir, { recursive: true });
|
||||||
|
|
||||||
|
const updatedPaths: Array<string | { path: string; [key: string]: unknown }> = [];
|
||||||
|
|
||||||
|
for (const imagePath of imagePaths) {
|
||||||
|
try {
|
||||||
|
const originalPath = typeof imagePath === "string" ? imagePath : imagePath.path;
|
||||||
|
|
||||||
|
// Skip if already in feature directory
|
||||||
|
if (originalPath.includes(`/features/${featureId}/images/`)) {
|
||||||
|
updatedPaths.push(imagePath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the full path
|
||||||
|
const fullOriginalPath = path.isAbsolute(originalPath)
|
||||||
|
? originalPath
|
||||||
|
: path.join(projectPath, originalPath);
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
try {
|
||||||
|
await fs.access(fullOriginalPath);
|
||||||
|
} catch {
|
||||||
|
console.warn(`[FeatureLoader] Image not found, skipping: ${fullOriginalPath}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get filename and create new path
|
||||||
|
const filename = path.basename(originalPath);
|
||||||
|
const newPath = path.join(featureImagesDir, filename);
|
||||||
|
const relativePath = `.automaker/features/${featureId}/images/${filename}`;
|
||||||
|
|
||||||
|
// Copy the file
|
||||||
|
await fs.copyFile(fullOriginalPath, newPath);
|
||||||
|
console.log(`[FeatureLoader] Copied image: ${originalPath} -> ${relativePath}`);
|
||||||
|
|
||||||
|
// Try to delete the original temp file
|
||||||
|
try {
|
||||||
|
await fs.unlink(fullOriginalPath);
|
||||||
|
} catch {
|
||||||
|
// Ignore errors when deleting temp file
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the path in the result
|
||||||
|
if (typeof imagePath === "string") {
|
||||||
|
updatedPaths.push(relativePath);
|
||||||
|
} else {
|
||||||
|
updatedPaths.push({ ...imagePath, path: relativePath });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[FeatureLoader] Failed to migrate image:`, error);
|
||||||
|
// Keep original path if migration fails
|
||||||
|
updatedPaths.push(imagePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedPaths;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the path to a specific feature folder
|
* Get the path to a specific feature folder
|
||||||
*/
|
*/
|
||||||
@@ -151,12 +268,20 @@ export class FeatureLoader {
|
|||||||
// Create feature directory
|
// Create feature directory
|
||||||
await fs.mkdir(featureDir, { recursive: true });
|
await fs.mkdir(featureDir, { recursive: true });
|
||||||
|
|
||||||
|
// Migrate images from temp directory to feature directory
|
||||||
|
const migratedImagePaths = await this.migrateImages(
|
||||||
|
projectPath,
|
||||||
|
featureId,
|
||||||
|
featureData.imagePaths
|
||||||
|
);
|
||||||
|
|
||||||
// Ensure feature has required fields
|
// Ensure feature has required fields
|
||||||
const feature: Feature = {
|
const feature: Feature = {
|
||||||
category: featureData.category || "Uncategorized",
|
category: featureData.category || "Uncategorized",
|
||||||
description: featureData.description || "",
|
description: featureData.description || "",
|
||||||
...featureData,
|
...featureData,
|
||||||
id: featureId,
|
id: featureId,
|
||||||
|
imagePaths: migratedImagePaths,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Write feature.json
|
// Write feature.json
|
||||||
@@ -179,8 +304,30 @@ export class FeatureLoader {
|
|||||||
throw new Error(`Feature ${featureId} not found`);
|
throw new Error(`Feature ${featureId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle image path changes
|
||||||
|
let updatedImagePaths = updates.imagePaths;
|
||||||
|
if (updates.imagePaths !== undefined) {
|
||||||
|
// Delete orphaned images (images that were removed)
|
||||||
|
await this.deleteOrphanedImages(
|
||||||
|
projectPath,
|
||||||
|
feature.imagePaths,
|
||||||
|
updates.imagePaths
|
||||||
|
);
|
||||||
|
|
||||||
|
// Migrate any new images
|
||||||
|
updatedImagePaths = await this.migrateImages(
|
||||||
|
projectPath,
|
||||||
|
featureId,
|
||||||
|
updates.imagePaths
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Merge updates
|
// Merge updates
|
||||||
const updatedFeature: Feature = { ...feature, ...updates };
|
const updatedFeature: Feature = {
|
||||||
|
...feature,
|
||||||
|
...updates,
|
||||||
|
...(updatedImagePaths !== undefined ? { imagePaths: updatedImagePaths } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
// Write back to file
|
// Write back to file
|
||||||
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
||||||
|
|||||||
Reference in New Issue
Block a user