{/* Environment indicator - only show after mount to prevent hydration issues */}
{isMounted && !isElectron() && (
-
- Web Mode (Mock IPC)
+
+ Web Mode
)}
{/* Hidden streamer panel - opens with "\" key, pushes content */}
);
}
+
+export default function Home() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/app/src/components/dialogs/file-browser-dialog.tsx b/apps/app/src/components/dialogs/file-browser-dialog.tsx
new file mode 100644
index 00000000..29c183f1
--- /dev/null
+++ b/apps/app/src/components/dialogs/file-browser-dialog.tsx
@@ -0,0 +1,231 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { FolderOpen, Folder, ChevronRight, Home, ArrowLeft, HardDrive } from "lucide-react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+
+interface DirectoryEntry {
+ name: string;
+ path: string;
+}
+
+interface BrowseResult {
+ success: boolean;
+ currentPath: string;
+ parentPath: string | null;
+ directories: DirectoryEntry[];
+ drives?: string[];
+ error?: string;
+}
+
+interface FileBrowserDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSelect: (path: string) => void;
+ title?: string;
+ description?: string;
+}
+
+export function FileBrowserDialog({
+ open,
+ onOpenChange,
+ onSelect,
+ title = "Select Project Directory",
+ description = "Navigate to your project folder",
+}: FileBrowserDialogProps) {
+ const [currentPath, setCurrentPath] = useState
("");
+ const [parentPath, setParentPath] = useState(null);
+ const [directories, setDirectories] = useState([]);
+ const [drives, setDrives] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+
+ const browseDirectory = async (dirPath?: string) => {
+ setLoading(true);
+ setError("");
+
+ try {
+ // Get server URL from environment or default
+ const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
+
+ const response = await fetch(`${serverUrl}/api/fs/browse`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ dirPath }),
+ });
+
+ const result: BrowseResult = await response.json();
+
+ if (result.success) {
+ setCurrentPath(result.currentPath);
+ setParentPath(result.parentPath);
+ setDirectories(result.directories);
+ setDrives(result.drives || []);
+ } else {
+ setError(result.error || "Failed to browse directory");
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load directories");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // Load home directory on mount
+ useEffect(() => {
+ if (open && !currentPath) {
+ browseDirectory();
+ }
+ }, [open]);
+
+ const handleSelectDirectory = (dir: DirectoryEntry) => {
+ browseDirectory(dir.path);
+ };
+
+ const handleGoToParent = () => {
+ if (parentPath) {
+ browseDirectory(parentPath);
+ }
+ };
+
+ const handleGoHome = () => {
+ browseDirectory();
+ };
+
+ const handleSelectDrive = (drivePath: string) => {
+ browseDirectory(drivePath);
+ };
+
+ const handleSelect = () => {
+ if (currentPath) {
+ onSelect(currentPath);
+ onOpenChange(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/app/src/components/layout/sidebar.tsx b/apps/app/src/components/layout/sidebar.tsx
index 2c13361c..222e54e2 100644
--- a/apps/app/src/components/layout/sidebar.tsx
+++ b/apps/app/src/components/layout/sidebar.tsx
@@ -3,6 +3,7 @@
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { cn } from "@/lib/utils";
import { useAppStore, formatShortcut } from "@/store/app-store";
+import { CoursePromoBadge } from "@/components/ui/course-promo-badge";
import {
FolderOpen,
Plus,
@@ -12,7 +13,6 @@ import {
Bot,
Folder,
X,
- Wrench,
PanelLeft,
PanelLeftClose,
ChevronDown,
@@ -210,6 +210,7 @@ export function Sidebar() {
cycleNextProject,
clearProjectHistory,
setProjectTheme,
+ setTheme,
theme: globalTheme,
} = useAppStore();
@@ -413,14 +414,37 @@ export function Sidebar() {
return;
}
- const project = {
- id: `project-${Date.now()}`,
- name,
- path,
- lastOpened: new Date().toISOString(),
- };
+ // Check if project already exists (by path) to preserve theme and other settings
+ const existingProject = projects.find((p) => p.path === path);
+
+ let project: Project;
+ if (existingProject) {
+ // Update existing project, preserving theme and other properties
+ project = {
+ ...existingProject,
+ name, // Update name in case it changed
+ lastOpened: new Date().toISOString(),
+ };
+ // Update the project in the store (this will update the existing entry)
+ const updatedProjects = projects.map((p) =>
+ p.id === existingProject.id ? project : p
+ );
+ useAppStore.setState({ projects: updatedProjects });
+ } else {
+ // Create new project - check for trashed project with same path first (preserves theme if deleted/recreated)
+ // Then fall back to current effective theme, then global theme
+ const trashedProject = trashedProjects.find((p) => p.path === path);
+ const effectiveTheme = trashedProject?.theme || currentProject?.theme || globalTheme;
+ project = {
+ id: `project-${Date.now()}`,
+ name,
+ path,
+ lastOpened: new Date().toISOString(),
+ theme: effectiveTheme,
+ };
+ addProject(project);
+ }
- addProject(project);
setCurrentProject(project);
// Check if app_spec.txt exists
@@ -455,7 +479,7 @@ export function Sidebar() {
});
}
}
- }, [addProject, setCurrentProject]);
+ }, [projects, trashedProjects, addProject, setCurrentProject, currentProject, globalTheme]);
const handleRestoreProject = useCallback(
(projectId: string) => {
@@ -557,12 +581,6 @@ export function Sidebar() {
icon: BookOpen,
shortcut: shortcuts.context,
},
- {
- id: "tools",
- label: "Agent Tools",
- icon: Wrench,
- shortcut: shortcuts.tools,
- },
{
id: "profiles",
label: "AI Profiles",
@@ -950,6 +968,10 @@ export function Sidebar() {
value={currentProject.theme || ""}
onValueChange={(value) => {
if (currentProject) {
+ // If selecting an actual theme (not "Use Global"), also update global
+ if (value !== "") {
+ setTheme(value as any);
+ }
setProjectTheme(
currentProject.id,
value === "" ? null : (value as any)
@@ -1122,6 +1144,8 @@ export function Sidebar() {
{/* Bottom Section - Running Agents / Bug Report / Settings */}
+ {/* Course Promo Badge */}
+
{/* Running Agents Link */}
{/* Remove button */}
diff --git a/apps/app/src/components/ui/keyboard-map.tsx b/apps/app/src/components/ui/keyboard-map.tsx
index 76f71d3a..5ae2c4f5 100644
--- a/apps/app/src/components/ui/keyboard-map.tsx
+++ b/apps/app/src/components/ui/keyboard-map.tsx
@@ -88,7 +88,6 @@ const SHORTCUT_LABELS: Record
= {
agent: "Agent Runner",
spec: "Spec Editor",
context: "Context",
- tools: "Agent Tools",
settings: "Settings",
profiles: "AI Profiles",
toggleSidebar: "Toggle Sidebar",
@@ -109,7 +108,6 @@ const SHORTCUT_CATEGORIES: Record = {
+ // Simulated outputs for common commands (preview mode)
+ // In production, the agent executes commands via Claude SDK
+ const simulatedOutputs: Record = {
ls: "app_spec.txt\nfeatures\nnode_modules\npackage.json\nsrc\ntests\ntsconfig.json",
pwd: currentProject?.path || "/Users/demo/project",
"echo hello": "hello",
@@ -168,8 +168,8 @@ export function AgentToolsView() {
await new Promise((resolve) => setTimeout(resolve, 500));
const output =
- mockOutputs[terminalCommand.toLowerCase()] ||
- `Command executed: ${terminalCommand}\n(Mock output - real execution requires Electron mode)`;
+ simulatedOutputs[terminalCommand.toLowerCase()] ||
+ `[Preview] ${terminalCommand}\n(Terminal commands are executed by the agent during feature implementation)`;
setTerminalResult({
success: true,
diff --git a/apps/app/src/components/views/analysis-view.tsx b/apps/app/src/components/views/analysis-view.tsx
index 530ef2f9..a7a9e85f 100644
--- a/apps/app/src/components/views/analysis-view.tsx
+++ b/apps/app/src/components/views/analysis-view.tsx
@@ -399,7 +399,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
`;
// Write the spec file
- const specPath = `${currentProject.path}/app_spec.txt`;
+ const specPath = `${currentProject.path}/.automaker/app_spec.txt`;
const writeResult = await api.writeFile(specPath, specContent);
if (writeResult.success) {
diff --git a/apps/app/src/components/views/board-view.tsx b/apps/app/src/components/views/board-view.tsx
index 057d7c22..23d332a9 100644
--- a/apps/app/src/components/views/board-view.tsx
+++ b/apps/app/src/components/views/board-view.tsx
@@ -394,22 +394,25 @@ export function BoardView() {
}, []);
// Load features using features API
+ // IMPORTANT: Do NOT add 'features' to dependency array - it would cause infinite reload loop
const loadFeatures = useCallback(async () => {
if (!currentProject) return;
const currentPath = currentProject.path;
const previousPath = prevProjectPathRef.current;
+ const isProjectSwitch = previousPath !== null && currentPath !== previousPath;
- // If project switched, clear features first to prevent cross-contamination
- // Also treat this as an initial load for the new project
- if (previousPath !== null && currentPath !== previousPath) {
+ // Get cached features from store (without adding to dependencies)
+ const cachedFeatures = useAppStore.getState().features;
+
+ // If project switched, mark it but don't clear features yet
+ // We'll clear after successful API load to prevent data loss
+ if (isProjectSwitch) {
console.log(
- `[BoardView] Project switch detected: ${previousPath} -> ${currentPath}, clearing features`
+ `[BoardView] Project switch detected: ${previousPath} -> ${currentPath}`
);
isSwitchingProjectRef.current = true;
isInitialLoadRef.current = true;
- setFeatures([]);
- setPersistedCategories([]); // Also clear categories
}
// Update the ref to track current project
@@ -424,6 +427,7 @@ export function BoardView() {
const api = getElectronAPI();
if (!api.features) {
console.error("[BoardView] Features API not available");
+ // Keep cached features if API is unavailable
return;
}
@@ -441,10 +445,31 @@ export function BoardView() {
thinkingLevel: f.thinkingLevel || "none",
})
);
+ // Successfully loaded features - now safe to set them
setFeatures(featuresWithIds);
+
+ // Only clear categories on project switch AFTER successful load
+ if (isProjectSwitch) {
+ setPersistedCategories([]);
+ }
+ } else if (!result.success && result.error) {
+ console.error("[BoardView] API returned error:", result.error);
+ // If it's a new project or the error indicates no features found,
+ // that's expected - start with empty array
+ if (isProjectSwitch) {
+ setFeatures([]);
+ setPersistedCategories([]);
+ }
+ // Otherwise keep cached features
}
} catch (error) {
console.error("Failed to load features:", error);
+ // On error, keep existing cached features for the current project
+ // Only clear on project switch if we have no features from server
+ if (isProjectSwitch && cachedFeatures.length === 0) {
+ setFeatures([]);
+ setPersistedCategories([]);
+ }
} finally {
setIsLoading(false);
isInitialLoadRef.current = false;
@@ -558,10 +583,24 @@ export function BoardView() {
}
loadFeatures();
- // Show error toast
- toast.error("Agent encountered an error", {
- description: event.error || "Check the logs for details",
- });
+
+ // 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", {
+ description: event.error || "Check the logs for details",
+ });
+ }
}
});
@@ -1475,8 +1514,14 @@ export function BoardView() {
if (isRunning) {
map.in_progress.push(f);
} else {
- // Otherwise, use the feature's status
- map[f.status].push(f);
+ // Otherwise, use the feature's status (fallback to backlog for unknown statuses)
+ const status = f.status as ColumnId;
+ if (map[status]) {
+ map[status].push(f);
+ } else {
+ // Unknown status, default to backlog
+ map.backlog.push(f);
+ }
}
});
diff --git a/apps/app/src/components/views/kanban-card.tsx b/apps/app/src/components/views/kanban-card.tsx
index 7888de3e..41a35729 100644
--- a/apps/app/src/components/views/kanban-card.tsx
+++ b/apps/app/src/components/views/kanban-card.tsx
@@ -207,10 +207,12 @@ export const KanbanCard = memo(function KanbanCard({
// - Backlog items can always be dragged
// - skipTests items can be dragged even when in_progress or verified (unless currently running)
// - waiting_approval items can always be dragged (to allow manual verification via drag)
- // - Non-skipTests (TDD) items in progress or verified cannot be dragged
+ // - verified items can always be dragged (to allow moving back to waiting_approval or backlog)
+ // - Non-skipTests (TDD) items in progress cannot be dragged (they are running)
const isDraggable =
feature.status === "backlog" ||
feature.status === "waiting_approval" ||
+ feature.status === "verified" ||
(feature.skipTests && !isCurrentAutoTask);
const {
attributes,
diff --git a/apps/app/src/components/views/settings-view.tsx b/apps/app/src/components/views/settings-view.tsx
index 810df708..9b085f2a 100644
--- a/apps/app/src/components/views/settings-view.tsx
+++ b/apps/app/src/components/views/settings-view.tsx
@@ -82,12 +82,14 @@ export function SettingsView() {
// Compute the effective theme for the current project
const effectiveTheme = (settingsProject?.theme || theme) as Theme;
- // Handler to set theme - saves to project if one is selected, otherwise to global
+ // Handler to set theme - always updates global theme (user's preference),
+ // and also sets per-project theme if a project is selected
const handleSetTheme = (newTheme: typeof theme) => {
+ // Always update global theme so user's preference persists across all projects
+ setTheme(newTheme);
+ // Also set per-project theme if a project is selected
if (currentProject) {
setProjectTheme(currentProject.id, newTheme);
- } else {
- setTheme(newTheme);
}
};
diff --git a/apps/app/src/components/views/settings-view/api-keys/authentication-status-display.tsx b/apps/app/src/components/views/settings-view/api-keys/authentication-status-display.tsx
index 288c45ef..0c36b2ef 100644
--- a/apps/app/src/components/views/settings-view/api-keys/authentication-status-display.tsx
+++ b/apps/app/src/components/views/settings-view/api-keys/authentication-status-display.tsx
@@ -61,12 +61,16 @@ export function AuthenticationStatusDisplay({
{claudeAuthStatus.method === "oauth_token_env"
? "Using CLAUDE_CODE_OAUTH_TOKEN"
: claudeAuthStatus.method === "oauth_token"
- ? "Using stored OAuth token"
+ ? "Using stored OAuth token (subscription)"
: claudeAuthStatus.method === "api_key_env"
? "Using ANTHROPIC_API_KEY"
: claudeAuthStatus.method === "api_key"
? "Using stored API key"
- : "Unknown method"}
+ : claudeAuthStatus.method === "credentials_file"
+ ? "Using credentials file"
+ : claudeAuthStatus.method === "cli_authenticated"
+ ? "Using Claude CLI authentication"
+ : `Using ${claudeAuthStatus.method || "detected"} authentication`}
>
@@ -107,14 +111,16 @@ export function AuthenticationStatusDisplay({
- {codexAuthStatus.method === "cli_verified" ||
- codexAuthStatus.method === "cli_tokens"
+ {codexAuthStatus.method === "subscription"
+ ? "Using Codex subscription (Plus/Team)"
+ : codexAuthStatus.method === "cli_verified" ||
+ codexAuthStatus.method === "cli_tokens"
? "Using CLI login (OpenAI account)"
: codexAuthStatus.method === "api_key"
? "Using stored API key"
: codexAuthStatus.method === "env"
? "Using OPENAI_API_KEY"
- : "Unknown method"}
+ : `Using ${codexAuthStatus.method || "unknown"} authentication`}
>
diff --git a/apps/app/src/components/views/settings-view/hooks/use-cli-status.ts b/apps/app/src/components/views/settings-view/hooks/use-cli-status.ts
index 1fae138b..600a5f67 100644
--- a/apps/app/src/components/views/settings-view/hooks/use-cli-status.ts
+++ b/apps/app/src/components/views/settings-view/hooks/use-cli-status.ts
@@ -68,19 +68,24 @@ export function useCliStatus() {
try {
const result = await api.setup.getClaudeStatus();
if (result.success && result.auth) {
- const auth = result.auth;
- // Validate method is one of the expected values, default to "none"
- const validMethods = ["oauth_token_env", "oauth_token", "api_key", "api_key_env", "none"] as const;
+ // Cast to extended type that includes server-added fields
+ const auth = result.auth as typeof result.auth & {
+ oauthTokenValid?: boolean;
+ apiKeyValid?: boolean;
+ };
+ // Map server method names to client method types
+ // Server returns: oauth_token_env, oauth_token, api_key_env, api_key, credentials_file, cli_authenticated, none
+ const validMethods = ["oauth_token_env", "oauth_token", "api_key", "api_key_env", "credentials_file", "cli_authenticated", "none"] as const;
type AuthMethod = typeof validMethods[number];
const method: AuthMethod = validMethods.includes(auth.method as AuthMethod)
? (auth.method as AuthMethod)
- : "none";
+ : auth.authenticated ? "api_key" : "none"; // Default authenticated to api_key, not none
const authStatus = {
authenticated: auth.authenticated,
method,
hasCredentialsFile: auth.hasCredentialsFile ?? false,
- oauthTokenValid: auth.hasStoredOAuthToken || auth.hasEnvOAuthToken,
- apiKeyValid: auth.hasStoredApiKey || auth.hasEnvApiKey,
+ oauthTokenValid: auth.oauthTokenValid || auth.hasStoredOAuthToken || auth.hasEnvOAuthToken,
+ apiKeyValid: auth.apiKeyValid || auth.hasStoredApiKey || auth.hasEnvApiKey,
hasEnvOAuthToken: auth.hasEnvOAuthToken,
hasEnvApiKey: auth.hasEnvApiKey,
};
@@ -96,27 +101,30 @@ export function useCliStatus() {
try {
const result = await api.setup.getCodexStatus();
if (result.success && result.auth) {
- const auth = result.auth;
- // Determine method - prioritize cli_verified and cli_tokens over auth_file
- const method =
- auth.method === "cli_verified" || auth.method === "cli_tokens"
- ? auth.method === "cli_verified"
- ? ("cli_verified" as const)
- : ("cli_tokens" as const)
- : auth.method === "auth_file"
- ? ("api_key" as const)
- : auth.method === "env_var"
- ? ("env" as const)
- : ("none" as const);
+ // Cast to extended type that includes server-added fields
+ const auth = result.auth as typeof result.auth & {
+ hasSubscription?: boolean;
+ cliLoggedIn?: boolean;
+ hasEnvApiKey?: boolean;
+ };
+ // Map server method names to client method types
+ // Server returns: subscription, cli_verified, cli_tokens, api_key, env, none
+ const validMethods = ["subscription", "cli_verified", "cli_tokens", "api_key", "env", "none"] as const;
+ type CodexMethod = typeof validMethods[number];
+ const method: CodexMethod = validMethods.includes(auth.method as CodexMethod)
+ ? (auth.method as CodexMethod)
+ : auth.authenticated ? "api_key" : "none"; // Default authenticated to api_key
const authStatus = {
authenticated: auth.authenticated,
method,
- // Only set apiKeyValid for actual API key methods, not CLI login
+ // Only set apiKeyValid for actual API key methods, not CLI login or subscription
apiKeyValid:
- method === "cli_verified" || method === "cli_tokens"
+ method === "cli_verified" || method === "cli_tokens" || method === "subscription"
? undefined
- : auth.hasAuthFile || auth.hasEnvKey,
+ : auth.hasAuthFile || auth.hasEnvKey || auth.hasEnvApiKey,
+ hasSubscription: auth.hasSubscription,
+ cliLoggedIn: auth.cliLoggedIn,
};
setCodexAuthStatus(authStatus);
}
diff --git a/apps/app/src/components/views/setup-view/hooks/use-cli-status.ts b/apps/app/src/components/views/setup-view/hooks/use-cli-status.ts
index b7a31685..1aa0d094 100644
--- a/apps/app/src/components/views/setup-view/hooks/use-cli-status.ts
+++ b/apps/app/src/components/views/setup-view/hooks/use-cli-status.ts
@@ -40,6 +40,8 @@ export function useCliStatus({
"oauth_token",
"api_key",
"api_key_env",
+ "credentials_file",
+ "cli_authenticated",
"none",
] as const;
type AuthMethod = (typeof validMethods)[number];
diff --git a/apps/app/src/components/views/spec-view.tsx b/apps/app/src/components/views/spec-view.tsx
index 87173e9b..3fbf4ffe 100644
--- a/apps/app/src/components/views/spec-view.tsx
+++ b/apps/app/src/components/views/spec-view.tsx
@@ -14,7 +14,8 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
-import { Save, RefreshCw, FileText, Sparkles, Loader2, FilePlus2, AlertCircle, ListPlus } from "lucide-react";
+import { Save, RefreshCw, FileText, Sparkles, Loader2, FilePlus2, AlertCircle, ListPlus, CheckCircle2 } from "lucide-react";
+import { toast } from "sonner";
import { Checkbox } from "@/components/ui/checkbox";
import { XmlSyntaxEditor } from "@/components/ui/xml-syntax-editor";
import type { SpecRegenerationEvent } from "@/types/electron";
@@ -311,14 +312,22 @@ export function SpecView() {
// The backend sends explicit signals for completion:
// 1. "All tasks completed" in the message
// 2. [Phase: complete] marker in logs
+ // 3. "Spec regeneration complete!" for regeneration
+ // 4. "Initial spec creation complete!" for creation without features
const isFinalCompletionMessage = event.message?.includes("All tasks completed") ||
event.message === "All tasks completed!" ||
- event.message === "All tasks completed";
+ event.message === "All tasks completed" ||
+ event.message === "Spec regeneration complete!" ||
+ event.message === "Initial spec creation complete!";
const hasCompletePhase = logsRef.current.includes("[Phase: complete]");
+ // Intermediate completion means features are being generated after spec creation
+ const isIntermediateCompletion = event.message?.includes("Features are being generated") ||
+ event.message?.includes("features are being generated");
+
// Rely solely on explicit backend signals
- const shouldComplete = isFinalCompletionMessage || hasCompletePhase;
+ const shouldComplete = (isFinalCompletionMessage || hasCompletePhase) && !isIntermediateCompletion;
if (shouldComplete) {
// Fully complete - clear all states immediately
@@ -337,9 +346,29 @@ export function SpecView() {
setProjectOverview("");
setErrorMessage("");
stateRestoredRef.current = false;
- // Reload the spec to show the new content
- loadSpec();
- } else {
+
+ // Reload the spec with delay to ensure file is written to disk
+ setTimeout(() => {
+ loadSpec();
+ }, SPEC_FILE_WRITE_DELAY);
+
+ // Show success toast notification
+ const isRegeneration = event.message?.includes("regeneration");
+ const isFeatureGeneration = event.message?.includes("Feature generation");
+ toast.success(
+ isFeatureGeneration
+ ? "Feature Generation Complete"
+ : isRegeneration
+ ? "Spec Regeneration Complete"
+ : "Spec Creation Complete",
+ {
+ description: isFeatureGeneration
+ ? "Features have been created from the app specification."
+ : "Your app specification has been saved.",
+ icon: ,
+ }
+ );
+ } else if (isIntermediateCompletion) {
// Intermediate completion - keep state active for feature generation
setIsCreating(true);
setIsRegenerating(true);
diff --git a/apps/app/src/components/views/welcome-view.tsx b/apps/app/src/components/views/welcome-view.tsx
index 6b6e4a4e..9128c179 100644
--- a/apps/app/src/components/views/welcome-view.tsx
+++ b/apps/app/src/components/views/welcome-view.tsx
@@ -21,7 +21,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { useAppStore } from "@/store/app-store";
-import { getElectronAPI } from "@/lib/electron";
+import { getElectronAPI, type Project } from "@/lib/electron";
import { initializeProject } from "@/lib/project-init";
import {
FolderOpen,
@@ -40,6 +40,8 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { toast } from "sonner";
+import { WorkspacePickerModal } from "@/components/workspace-picker-modal";
+import { getHttpApiClient } from "@/lib/http-api-client";
export function WelcomeView() {
const { projects, addProject, setCurrentProject, setCurrentView } =
@@ -57,6 +59,7 @@ export function WelcomeView() {
projectName: string;
projectPath: string;
} | null>(null);
+ const [showWorkspacePicker, setShowWorkspacePicker] = useState(false);
/**
* Kick off project analysis agent to analyze the codebase
@@ -105,14 +108,34 @@ export function WelcomeView() {
return;
}
- const project = {
- id: `project-${Date.now()}`,
- name,
- path,
- lastOpened: new Date().toISOString(),
- };
+ // Check if project already exists (by path) to preserve theme and other settings
+ const existingProject = projects.find((p) => p.path === path);
+
+ let project: Project;
+ if (existingProject) {
+ // Update existing project, preserving theme and other properties
+ project = {
+ ...existingProject,
+ name, // Update name in case it changed
+ lastOpened: new Date().toISOString(),
+ };
+ // Update the project in the store (this will update the existing entry)
+ const updatedProjects = projects.map((p) =>
+ p.id === existingProject.id ? project : p
+ );
+ // We need to manually update projects since addProject would create a duplicate
+ useAppStore.setState({ projects: updatedProjects });
+ } else {
+ // Create new project
+ project = {
+ id: `project-${Date.now()}`,
+ name,
+ path,
+ lastOpened: new Date().toISOString(),
+ };
+ addProject(project);
+ }
- addProject(project);
setCurrentProject(project);
// Show initialization dialog if files were created
@@ -148,21 +171,55 @@ export function WelcomeView() {
setIsOpening(false);
}
},
- [addProject, setCurrentProject, analyzeProject]
+ [projects, addProject, setCurrentProject, analyzeProject]
);
const handleOpenProject = useCallback(async () => {
- const api = getElectronAPI();
- const result = await api.openDirectory();
+ try {
+ // Check if workspace is configured
+ const httpClient = getHttpApiClient();
+ const configResult = await httpClient.workspace.getConfig();
- if (!result.canceled && result.filePaths[0]) {
- const path = result.filePaths[0];
- // Extract folder name from path (works on both Windows and Mac/Linux)
- const name = path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project";
- await initializeAndOpenProject(path, name);
+ 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 result = await api.openDirectory();
+
+ if (!result.canceled && result.filePaths[0]) {
+ const path = result.filePaths[0];
+ // Extract folder name from path (works on both Windows and Mac/Linux)
+ const name = path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project";
+ await initializeAndOpenProject(path, name);
+ }
+ }
+ } 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]);
+ /**
+ * 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
*/
@@ -601,6 +658,13 @@ export function WelcomeView() {
+ {/* Workspace Picker Modal */}
+
+
{/* Loading overlay when opening project */}
{isOpening && (
void;
+ onSelect: (path: string, name: string) => void;
+}
+
+export function WorkspacePickerModal({
+ open,
+ onOpenChange,
+ onSelect,
+}: WorkspacePickerModalProps) {
+ const [isLoading, setIsLoading] = useState(false);
+ const [directories, setDirectories] = useState
([]);
+ const [error, setError] = useState(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 (
+
+ );
+}
diff --git a/apps/app/src/contexts/file-browser-context.tsx b/apps/app/src/contexts/file-browser-context.tsx
new file mode 100644
index 00000000..f54fb27f
--- /dev/null
+++ b/apps/app/src/contexts/file-browser-context.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
+import { FileBrowserDialog } from "@/components/dialogs/file-browser-dialog";
+
+interface FileBrowserContextValue {
+ openFileBrowser: () => Promise;
+}
+
+const FileBrowserContext = createContext(null);
+
+export function FileBrowserProvider({ children }: { children: ReactNode }) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [resolver, setResolver] = useState<((value: string | null) => void) | null>(null);
+
+ const openFileBrowser = useCallback((): Promise => {
+ return new Promise((resolve) => {
+ setIsOpen(true);
+ setResolver(() => resolve);
+ });
+ }, []);
+
+ const handleSelect = useCallback((path: string) => {
+ if (resolver) {
+ resolver(path);
+ setResolver(null);
+ }
+ setIsOpen(false);
+ }, [resolver]);
+
+ const handleOpenChange = useCallback((open: boolean) => {
+ if (!open && resolver) {
+ resolver(null);
+ setResolver(null);
+ }
+ setIsOpen(open);
+ }, [resolver]);
+
+ return (
+
+ {children}
+
+
+ );
+}
+
+export function useFileBrowser() {
+ const context = useContext(FileBrowserContext);
+ if (!context) {
+ throw new Error("useFileBrowser must be used within FileBrowserProvider");
+ }
+ return context;
+}
+
+// Global reference for non-React code (like HttpApiClient)
+let globalFileBrowserFn: (() => Promise) | null = null;
+
+export function setGlobalFileBrowser(fn: () => Promise) {
+ globalFileBrowserFn = fn;
+}
+
+export function getGlobalFileBrowser() {
+ return globalFileBrowserFn;
+}
diff --git a/apps/app/src/hooks/use-auto-mode.ts b/apps/app/src/hooks/use-auto-mode.ts
index b572a3fa..6852ec64 100644
--- a/apps/app/src/hooks/use-auto-mode.ts
+++ b/apps/app/src/hooks/use-auto-mode.ts
@@ -121,11 +121,26 @@ export function useAutoMode() {
case "auto_mode_error":
console.error("[AutoMode Error]", 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({
featureId: event.featureId,
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;
diff --git a/apps/app/src/hooks/use-electron-agent.ts b/apps/app/src/hooks/use-electron-agent.ts
index 2746e472..39342b83 100644
--- a/apps/app/src/hooks/use-electron-agent.ts
+++ b/apps/app/src/hooks/use-electron-agent.ts
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
import type { Message, StreamEvent } from "@/types/electron";
import { useMessageQueue } from "./use-message-queue";
import type { ImageAttachment } from "@/store/app-store";
+import { getElectronAPI } from "@/lib/electron";
interface UseElectronAgentOptions {
sessionId: string;
@@ -44,8 +45,9 @@ export function useElectronAgent({
// Send message directly to the agent (bypassing queue)
const sendMessageDirectly = useCallback(
async (content: string, images?: ImageAttachment[]) => {
- if (!window.electronAPI?.agent) {
- setError("Electron API not available");
+ const api = getElectronAPI();
+ if (!api?.agent) {
+ setError("API not available");
return;
}
@@ -64,10 +66,10 @@ export function useElectronAgent({
// Save images to .automaker/images and get paths
let imagePaths: string[] | undefined;
- if (images && images.length > 0) {
+ if (images && images.length > 0 && api.saveImageToTemp) {
imagePaths = [];
for (const image of images) {
- const result = await window.electronAPI.saveImageToTemp(
+ const result = await api.saveImageToTemp(
image.data,
image.filename,
image.mimeType,
@@ -82,7 +84,7 @@ export function useElectronAgent({
}
}
- const result = await window.electronAPI.agent.send(
+ const result = await api.agent!.send(
sessionId,
content,
workingDirectory,
@@ -120,8 +122,9 @@ export function useElectronAgent({
// Initialize connection and load history
useEffect(() => {
- if (!window.electronAPI?.agent) {
- setError("Electron API not available. Please run in Electron.");
+ const api = getElectronAPI();
+ if (!api?.agent) {
+ setError("API not available.");
return;
}
@@ -142,7 +145,7 @@ export function useElectronAgent({
try {
console.log("[useElectronAgent] Starting session:", sessionId);
- const result = await window.electronAPI.agent.start(
+ const result = await api.agent!.start(
sessionId,
workingDirectory
);
@@ -155,7 +158,7 @@ export function useElectronAgent({
setIsConnected(true);
// 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) {
const isRunning = historyResult.isRunning || false;
console.log("[useElectronAgent] Session running state:", isRunning);
@@ -190,7 +193,8 @@ export function useElectronAgent({
// Subscribe to streaming events
useEffect(() => {
- if (!window.electronAPI?.agent) return;
+ const api = getElectronAPI();
+ if (!api?.agent) return;
if (!sessionId) return; // Don't subscribe if no session
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 () => {
if (unsubscribeRef.current) {
@@ -296,8 +300,9 @@ export function useElectronAgent({
// Send a message to the agent
const sendMessage = useCallback(
async (content: string, images?: ImageAttachment[]) => {
- if (!window.electronAPI?.agent) {
- setError("Electron API not available");
+ const api = getElectronAPI();
+ if (!api?.agent) {
+ setError("API not available");
return;
}
@@ -317,10 +322,10 @@ export function useElectronAgent({
// Save images to .automaker/images and get paths
let imagePaths: string[] | undefined;
- if (images && images.length > 0) {
+ if (images && images.length > 0 && api.saveImageToTemp) {
imagePaths = [];
for (const image of images) {
- const result = await window.electronAPI.saveImageToTemp(
+ const result = await api.saveImageToTemp(
image.data,
image.filename,
image.mimeType,
@@ -335,7 +340,7 @@ export function useElectronAgent({
}
}
- const result = await window.electronAPI.agent.send(
+ const result = await api.agent!.send(
sessionId,
content,
workingDirectory,
@@ -359,14 +364,15 @@ export function useElectronAgent({
// Stop current execution
const stopExecution = useCallback(async () => {
- if (!window.electronAPI?.agent) {
- setError("Electron API not available");
+ const api = getElectronAPI();
+ if (!api?.agent) {
+ setError("API not available");
return;
}
try {
console.log("[useElectronAgent] Stopping execution");
- const result = await window.electronAPI.agent.stop(sessionId);
+ const result = await api.agent!.stop(sessionId);
if (!result.success) {
setError(result.error || "Failed to stop execution");
@@ -381,14 +387,15 @@ export function useElectronAgent({
// Clear conversation history
const clearHistory = useCallback(async () => {
- if (!window.electronAPI?.agent) {
- setError("Electron API not available");
+ const api = getElectronAPI();
+ if (!api?.agent) {
+ setError("API not available");
return;
}
try {
console.log("[useElectronAgent] Clearing history");
- const result = await window.electronAPI.agent.clear(sessionId);
+ const result = await api.agent!.clear(sessionId);
if (result.success) {
setMessages([]);
diff --git a/apps/app/src/lib/electron.ts b/apps/app/src/lib/electron.ts
index f27cd028..32f723c3 100644
--- a/apps/app/src/lib/electron.ts
+++ b/apps/app/src/lib/electron.ts
@@ -1,4 +1,5 @@
// Type definitions for Electron IPC API
+import type { SessionListItem, Message } from "@/types/electron";
export interface FileEntry {
name: string;
@@ -413,6 +414,68 @@ export interface ElectronAPI {
onInstallProgress?: (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
@@ -438,18 +501,92 @@ const STORAGE_KEYS = {
// Mock file system using localStorage
const mockFileSystem: Record = {};
-// Check if we're in Electron
+// Check if we're in Electron (for UI indicators only)
export const isElectron = (): boolean => {
return typeof window !== "undefined" && window.isElectron === true;
};
-// Get the Electron API or a mock for web development
+// Check if backend server is available
+let serverAvailable: boolean | null = null;
+let serverCheckPromise: Promise | null = null;
+
+export const checkServerAvailable = async (): Promise => {
+ if (serverAvailable !== null) return serverAvailable;
+ if (serverCheckPromise) return serverCheckPromise;
+
+ serverCheckPromise = (async () => {
+ try {
+ const serverUrl =
+ process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
+ const response = await fetch(`${serverUrl}/api/health`, {
+ method: "GET",
+ signal: AbortSignal.timeout(2000),
+ });
+ serverAvailable = response.ok;
+ } catch {
+ serverAvailable = false;
+ }
+ return serverAvailable;
+ })();
+
+ return serverCheckPromise;
+};
+
+// Reset server check (useful for retrying connection)
+export const resetServerCheck = (): void => {
+ serverAvailable = null;
+ serverCheckPromise = null;
+};
+
+// Cached HTTP client instance
+let httpClientInstance: ElectronAPI | null = null;
+
+/**
+ * Get the HTTP API client
+ *
+ * All API calls go through HTTP to the backend server.
+ * This is the only transport mode supported.
+ */
export const getElectronAPI = (): ElectronAPI => {
- if (isElectron() && window.electronAPI) {
- return window.electronAPI;
+ if (typeof window === "undefined") {
+ throw new Error("Cannot get API during SSR");
}
- // Return mock API for web development
+ if (!httpClientInstance) {
+ const { getHttpApiClient } = require("./http-api-client");
+ httpClientInstance = getHttpApiClient();
+ }
+ return httpClientInstance!;
+};
+
+// Async version (same as sync since HTTP client is synchronously instantiated)
+export const getElectronAPIAsync = async (): Promise => {
+ return getElectronAPI();
+};
+
+// Check if backend is connected (for showing connection status in UI)
+export const isBackendConnected = async (): Promise => {
+ 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
+const getMockElectronAPI = (): ElectronAPI => {
return {
ping: async () => "pong (mock)",
@@ -748,17 +885,21 @@ interface SetupAPI {
getClaudeStatus: () => Promise<{
success: boolean;
status?: string;
+ installed?: boolean;
method?: string;
version?: string;
path?: string;
auth?: {
authenticated: boolean;
method: string;
- hasCredentialsFile: boolean;
- hasToken: boolean;
+ hasCredentialsFile?: boolean;
+ hasToken?: boolean;
hasStoredOAuthToken?: boolean;
hasStoredApiKey?: boolean;
hasEnvApiKey?: boolean;
+ hasEnvOAuthToken?: boolean;
+ hasCliAuth?: boolean;
+ hasRecentActivity?: boolean;
};
error?: string;
}>;
@@ -838,11 +979,14 @@ function createMockSetupAPI(): SetupAPI {
return {
success: true,
status: "not_installed",
+ installed: false,
auth: {
authenticated: false,
method: "none",
hasCredentialsFile: false,
hasToken: false,
+ hasCliAuth: false,
+ hasRecentActivity: false,
},
};
},
@@ -1866,7 +2010,9 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
}
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
simulateFeatureGeneration(projectPath);
@@ -2053,7 +2199,8 @@ async function simulateFeatureGeneration(projectPath: string) {
mockSpecRegenerationPhase = "initialization";
emitSpecRegenerationEvent({
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) => {
diff --git a/apps/app/src/lib/file-picker.ts b/apps/app/src/lib/file-picker.ts
new file mode 100644
index 00000000..baf28d33
--- /dev/null
+++ b/apps/app/src/lib/file-picker.ts
@@ -0,0 +1,279 @@
+/**
+ * File Picker Utility for Web Browsers
+ *
+ * Provides cross-platform file and directory selection using:
+ * 1. HTML5 webkitdirectory input - primary method (works on Windows)
+ * 2. File System Access API (showDirectoryPicker) - fallback for modern browsers
+ *
+ * Note: Browsers don't expose absolute file paths for security reasons.
+ * This implementation extracts directory information and may require
+ * user confirmation or server-side path resolution.
+ */
+
+/**
+ * Directory picker result with structure information for server-side resolution
+ */
+export interface DirectoryPickerResult {
+ directoryName: string;
+ sampleFiles: string[]; // Relative paths of sample files for identification
+ fileCount: number;
+}
+
+/**
+ * Opens a directory picker dialog
+ * @returns Promise resolving to directory information, or null if canceled
+ *
+ * Note: Browsers don't expose absolute file paths for security reasons.
+ * This function returns directory structure information that the server
+ * can use to locate the actual directory path.
+ */
+export async function openDirectoryPicker(): Promise {
+ // Use webkitdirectory (works on Windows and all modern browsers)
+ return new Promise((resolve) => {
+ let resolved = false;
+ const input = document.createElement("input");
+ input.type = "file";
+ input.webkitdirectory = true;
+ input.style.display = "none";
+
+ const cleanup = () => {
+ if (input.parentNode) {
+ document.body.removeChild(input);
+ }
+ };
+
+ let changeEventFired = false;
+ let focusTimeout: ReturnType | null = null;
+
+ const safeResolve = (value: DirectoryPickerResult | null) => {
+ if (!resolved) {
+ resolved = true;
+ changeEventFired = true;
+ if (focusTimeout) {
+ clearTimeout(focusTimeout);
+ focusTimeout = null;
+ }
+ cleanup();
+ resolve(value);
+ }
+ };
+
+ input.addEventListener("change", (e) => {
+ changeEventFired = true;
+ if (focusTimeout) {
+ clearTimeout(focusTimeout);
+ focusTimeout = null;
+ }
+
+ console.log("[FilePicker] Change event fired");
+ const files = input.files;
+ console.log("[FilePicker] Files selected:", files?.length || 0);
+
+ if (!files || files.length === 0) {
+ console.log("[FilePicker] No files selected");
+ safeResolve(null);
+ return;
+ }
+
+ const firstFile = files[0];
+ console.log("[FilePicker] First file:", {
+ name: firstFile.name,
+ webkitRelativePath: firstFile.webkitRelativePath,
+ // @ts-expect-error
+ path: firstFile.path,
+ });
+
+ // Extract directory name from webkitRelativePath
+ // webkitRelativePath format: "directoryName/subfolder/file.txt" or "directoryName/file.txt"
+ let directoryName = "Selected Directory";
+
+ // Method 1: Try to get absolute path from File object (non-standard, works in Electron/Chromium)
+ // @ts-expect-error - path property is non-standard but available in some browsers
+ if (firstFile.path) {
+ // @ts-expect-error
+ const filePath = firstFile.path as string;
+ console.log("[FilePicker] Found file.path:", filePath);
+ // Extract directory path (remove filename)
+ const lastSeparator = Math.max(
+ filePath.lastIndexOf("\\"),
+ filePath.lastIndexOf("/")
+ );
+ if (lastSeparator > 0) {
+ const absolutePath = filePath.substring(0, lastSeparator);
+ console.log("[FilePicker] Found absolute path:", absolutePath);
+ // Return as directory name for now - server can validate it directly
+ directoryName = absolutePath;
+ }
+ }
+
+ // Method 2: Extract directory name from webkitRelativePath
+ if (directoryName === "Selected Directory" && firstFile.webkitRelativePath) {
+ const relativePath = firstFile.webkitRelativePath;
+ console.log("[FilePicker] Using webkitRelativePath:", relativePath);
+ const pathParts = relativePath.split("/");
+ if (pathParts.length > 0) {
+ directoryName = pathParts[0]; // Top-level directory name
+ console.log("[FilePicker] Extracted directory name:", directoryName);
+ }
+ }
+
+ // Collect sample file paths for server-side directory matching
+ // Take first 10 files to identify the directory
+ const sampleFiles: string[] = [];
+ const maxSamples = 10;
+ for (let i = 0; i < Math.min(files.length, maxSamples); i++) {
+ const file = files[i];
+ if (file.webkitRelativePath) {
+ sampleFiles.push(file.webkitRelativePath);
+ } else if (file.name) {
+ sampleFiles.push(file.name);
+ }
+ }
+
+ console.log("[FilePicker] Directory info:", {
+ directoryName,
+ fileCount: files.length,
+ sampleFiles: sampleFiles.slice(0, 5), // Log first 5
+ });
+
+ safeResolve({
+ directoryName,
+ sampleFiles,
+ fileCount: files.length,
+ });
+ });
+
+ // Handle cancellation - but be very careful not to interfere with change event
+ // On Windows, the dialog might take time to process, so we wait longer
+ const handleFocus = () => {
+ // Wait longer on Windows - the dialog might take time to process
+ // Only resolve as canceled if change event hasn't fired after a delay
+ focusTimeout = setTimeout(() => {
+ if (!resolved && !changeEventFired && (!input.files || input.files.length === 0)) {
+ console.log("[FilePicker] Dialog canceled (no files after focus and no change event)");
+ safeResolve(null);
+ }
+ }, 2000); // Increased timeout for Windows - give it time
+ };
+
+ // Add to DOM temporarily
+ document.body.appendChild(input);
+ console.log("[FilePicker] Opening directory picker...");
+
+ // Try to show picker programmatically
+ if ("showPicker" in HTMLInputElement.prototype) {
+ try {
+ (input as any).showPicker();
+ console.log("[FilePicker] Using showPicker()");
+ } catch (error) {
+ console.log("[FilePicker] showPicker() failed, using click()", error);
+ input.click();
+ }
+ } else {
+ console.log("[FilePicker] Using click()");
+ input.click();
+ }
+
+ // Set up cancellation detection with longer delay
+ // Only add focus listener if we're not already resolved
+ window.addEventListener("focus", handleFocus, { once: true });
+
+ // Also handle blur as a cancellation signal (but with delay)
+ window.addEventListener("blur", () => {
+ // Dialog opened, wait for it to close
+ setTimeout(() => {
+ window.addEventListener("focus", handleFocus, { once: true });
+ }, 100);
+ }, { once: true });
+ });
+}
+
+/**
+ * Opens a file picker dialog
+ * @param options Optional configuration (multiple files, file types, etc.)
+ * @returns Promise resolving to selected file path(s), or null if canceled
+ */
+export async function openFilePicker(
+ options?: {
+ multiple?: boolean;
+ accept?: string;
+ }
+): Promise {
+ // Use standard file input (works on all browsers including Windows)
+ return new Promise((resolve) => {
+ const input = document.createElement("input");
+ input.type = "file";
+ input.multiple = options?.multiple ?? false;
+ if (options?.accept) {
+ input.accept = options.accept;
+ }
+ input.style.display = "none";
+
+ const cleanup = () => {
+ if (input.parentNode) {
+ document.body.removeChild(input);
+ }
+ };
+
+ input.addEventListener("change", () => {
+ const files = input.files;
+ if (!files || files.length === 0) {
+ cleanup();
+ resolve(null);
+ return;
+ }
+
+ // Try to extract paths from File objects
+ const extractPath = (file: File): string => {
+ // Try to get path from File object (non-standard, but available in some browsers)
+ // @ts-expect-error - path property is non-standard
+ if (file.path) {
+ // @ts-expect-error
+ return file.path as string;
+ }
+ // Fallback to filename (server will need to resolve)
+ return file.name;
+ };
+
+ if (options?.multiple) {
+ const paths = Array.from(files).map(extractPath);
+ cleanup();
+ resolve(paths);
+ } else {
+ const path = extractPath(files[0]);
+ cleanup();
+ resolve(path);
+ }
+ });
+
+ // Handle window focus (user may have canceled)
+ const handleFocus = () => {
+ setTimeout(() => {
+ if (!input.files || input.files.length === 0) {
+ cleanup();
+ resolve(null);
+ }
+ }, 200);
+ };
+
+ // Add to DOM temporarily
+ document.body.appendChild(input);
+
+ // Try to show picker programmatically
+ // Note: showPicker() is available in modern browsers but TypeScript types it as void
+ // In practice, it may return a Promise in some implementations, but we'll handle errors via try/catch
+ if ("showPicker" in HTMLInputElement.prototype) {
+ try {
+ (input as any).showPicker();
+ } catch {
+ // Fallback to click if showPicker fails
+ input.click();
+ }
+ } else {
+ input.click();
+ }
+
+ // Set up cancellation detection
+ window.addEventListener("focus", handleFocus, { once: true });
+ });
+}
diff --git a/apps/app/src/lib/http-api-client.ts b/apps/app/src/lib/http-api-client.ts
new file mode 100644
index 00000000..76313ee1
--- /dev/null
+++ b/apps/app/src/lib/http-api-client.ts
@@ -0,0 +1,722 @@
+/**
+ * HTTP API Client for web mode
+ *
+ * This client provides the same API as the Electron IPC bridge,
+ * but communicates with the backend server via HTTP/WebSocket.
+ */
+
+import type {
+ ElectronAPI,
+ FileResult,
+ WriteResult,
+ ReaddirResult,
+ StatResult,
+ DialogResult,
+ SaveImageResult,
+ AutoModeAPI,
+ FeaturesAPI,
+ SuggestionsAPI,
+ SpecRegenerationAPI,
+ AutoModeEvent,
+ SuggestionsEvent,
+ SpecRegenerationEvent,
+ FeatureSuggestion,
+ SuggestionType,
+} from "./electron";
+import type { Message, SessionListItem } from "@/types/electron";
+import type { Feature } from "@/store/app-store";
+import type {
+ WorktreeAPI,
+ GitAPI,
+ ModelDefinition,
+ ProviderStatus,
+} from "@/types/electron";
+import { getGlobalFileBrowser } from "@/contexts/file-browser-context";
+
+
+// Server URL - configurable via environment variable
+const getServerUrl = (): string => {
+ if (typeof window !== "undefined") {
+ const envUrl = process.env.NEXT_PUBLIC_SERVER_URL;
+ if (envUrl) return envUrl;
+ }
+ return "http://localhost:3008";
+};
+
+
+// Get API key from environment variable
+const getApiKey = (): string | null => {
+ if (typeof window !== "undefined") {
+ return process.env.NEXT_PUBLIC_AUTOMAKER_API_KEY || null;
+ }
+ return null;
+};
+
+type EventType =
+ | "agent:stream"
+ | "auto-mode:event"
+ | "suggestions:event"
+ | "spec-regeneration:event";
+
+type EventCallback = (payload: unknown) => void;
+
+/**
+ * HTTP API Client that implements ElectronAPI interface
+ */
+export class HttpApiClient implements ElectronAPI {
+ private serverUrl: string;
+ private ws: WebSocket | null = null;
+ private eventCallbacks: Map> = new Map();
+ private reconnectTimer: NodeJS.Timeout | null = null;
+ private isConnecting = false;
+
+ constructor() {
+ this.serverUrl = getServerUrl();
+ this.connectWebSocket();
+ }
+
+ private connectWebSocket(): void {
+ if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
+ return;
+ }
+
+ this.isConnecting = true;
+
+ try {
+ const wsUrl = this.serverUrl.replace(/^http/, "ws") + "/api/events";
+ this.ws = new WebSocket(wsUrl);
+
+ this.ws.onopen = () => {
+ console.log("[HttpApiClient] WebSocket connected");
+ this.isConnecting = false;
+ if (this.reconnectTimer) {
+ clearTimeout(this.reconnectTimer);
+ this.reconnectTimer = null;
+ }
+ };
+
+ this.ws.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+ const callbacks = this.eventCallbacks.get(data.type);
+ if (callbacks) {
+ callbacks.forEach((cb) => cb(data.payload));
+ }
+ } catch (error) {
+ console.error("[HttpApiClient] Failed to parse WebSocket message:", error);
+ }
+ };
+
+ this.ws.onclose = () => {
+ console.log("[HttpApiClient] WebSocket disconnected");
+ this.isConnecting = false;
+ this.ws = null;
+ // Attempt to reconnect after 5 seconds
+ if (!this.reconnectTimer) {
+ this.reconnectTimer = setTimeout(() => {
+ this.reconnectTimer = null;
+ this.connectWebSocket();
+ }, 5000);
+ }
+ };
+
+ this.ws.onerror = (error) => {
+ console.error("[HttpApiClient] WebSocket error:", error);
+ this.isConnecting = false;
+ };
+ } catch (error) {
+ console.error("[HttpApiClient] Failed to create WebSocket:", error);
+ this.isConnecting = false;
+ }
+ }
+
+ private subscribeToEvent(type: EventType, callback: EventCallback): () => void {
+ if (!this.eventCallbacks.has(type)) {
+ this.eventCallbacks.set(type, new Set());
+ }
+ this.eventCallbacks.get(type)!.add(callback);
+
+ // Ensure WebSocket is connected
+ this.connectWebSocket();
+
+ return () => {
+ const callbacks = this.eventCallbacks.get(type);
+ if (callbacks) {
+ callbacks.delete(callback);
+ }
+ };
+ }
+
+ private getHeaders(): Record {
+ const headers: Record = {
+ "Content-Type": "application/json",
+ };
+ const apiKey = getApiKey();
+ if (apiKey) {
+ headers["X-API-Key"] = apiKey;
+ }
+ return headers;
+ }
+
+ private async post(endpoint: string, body?: unknown): Promise {
+ const response = await fetch(`${this.serverUrl}${endpoint}`, {
+ method: "POST",
+ headers: this.getHeaders(),
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ return response.json();
+ }
+
+ private async get(endpoint: string): Promise {
+ const headers = this.getHeaders();
+ const response = await fetch(`${this.serverUrl}${endpoint}`, { headers });
+ return response.json();
+ }
+
+ private async put(endpoint: string, body?: unknown): Promise {
+ const response = await fetch(`${this.serverUrl}${endpoint}`, {
+ method: "PUT",
+ headers: this.getHeaders(),
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ return response.json();
+ }
+
+ private async httpDelete(endpoint: string): Promise {
+ const response = await fetch(`${this.serverUrl}${endpoint}`, {
+ method: "DELETE",
+ headers: this.getHeaders(),
+ });
+ return response.json();
+ }
+
+ // Basic operations
+ async ping(): Promise {
+ const result = await this.get<{ status: string }>("/api/health");
+ return result.status === "ok" ? "pong" : "error";
+ }
+
+ async openExternalLink(url: string): Promise<{ success: boolean; error?: string }> {
+ // Open in new tab
+ window.open(url, "_blank", "noopener,noreferrer");
+ return { success: true };
+ }
+
+ // File picker - uses server-side file browser dialog
+ async openDirectory(): Promise {
+ const fileBrowser = getGlobalFileBrowser();
+
+ if (!fileBrowser) {
+ console.error("File browser not initialized");
+ return { canceled: true, filePaths: [] };
+ }
+
+ const path = await fileBrowser();
+
+ if (!path) {
+ return { canceled: true, filePaths: [] };
+ }
+
+ // Validate with server
+ const result = await this.post<{
+ success: boolean;
+ path?: string;
+ error?: string;
+ }>("/api/fs/validate-path", { filePath: path });
+
+ if (result.success && result.path) {
+ return { canceled: false, filePaths: [result.path] };
+ }
+
+ console.error("Invalid directory:", result.error);
+ return { canceled: true, filePaths: [] };
+ }
+
+ async openFile(options?: object): Promise {
+ const fileBrowser = getGlobalFileBrowser();
+
+ if (!fileBrowser) {
+ console.error("File browser not initialized");
+ return { canceled: true, filePaths: [] };
+ }
+
+ // For now, use the same directory browser (could be enhanced for file selection)
+ const path = await fileBrowser();
+
+ if (!path) {
+ return { canceled: true, filePaths: [] };
+ }
+
+ const result = await this.post<{ success: boolean; exists: boolean }>(
+ "/api/fs/exists",
+ { filePath: path }
+ );
+
+ if (result.success && result.exists) {
+ return { canceled: false, filePaths: [path] };
+ }
+
+ console.error("File not found");
+ return { canceled: true, filePaths: [] };
+ }
+
+ // File system operations
+ async readFile(filePath: string): Promise {
+ return this.post("/api/fs/read", { filePath });
+ }
+
+ async writeFile(filePath: string, content: string): Promise {
+ return this.post("/api/fs/write", { filePath, content });
+ }
+
+ async mkdir(dirPath: string): Promise {
+ return this.post("/api/fs/mkdir", { dirPath });
+ }
+
+ async readdir(dirPath: string): Promise {
+ return this.post("/api/fs/readdir", { dirPath });
+ }
+
+ async exists(filePath: string): Promise {
+ const result = await this.post<{ success: boolean; exists: boolean }>(
+ "/api/fs/exists",
+ { filePath }
+ );
+ return result.exists;
+ }
+
+ async stat(filePath: string): Promise {
+ return this.post("/api/fs/stat", { filePath });
+ }
+
+ async deleteFile(filePath: string): Promise {
+ return this.post("/api/fs/delete", { filePath });
+ }
+
+ async trashItem(filePath: string): Promise {
+ // In web mode, trash is just delete
+ return this.deleteFile(filePath);
+ }
+
+ async getPath(name: string): Promise {
+ // Server provides data directory
+ if (name === "userData") {
+ const result = await this.get<{ dataDir: string }>("/api/health/detailed");
+ return result.dataDir || "/data";
+ }
+ return `/data/${name}`;
+ }
+
+ async saveImageToTemp(
+ data: string,
+ filename: string,
+ mimeType: string,
+ projectPath?: string
+ ): Promise {
+ return this.post("/api/fs/save-image", { data, filename, mimeType, projectPath });
+ }
+
+ // CLI checks - server-side
+ async checkClaudeCli(): Promise<{
+ success: boolean;
+ status?: string;
+ method?: string;
+ version?: string;
+ path?: string;
+ recommendation?: string;
+ installCommands?: {
+ macos?: string;
+ windows?: string;
+ linux?: string;
+ npm?: string;
+ };
+ error?: string;
+ }> {
+ return this.get("/api/setup/claude-status");
+ }
+
+ async checkCodexCli(): Promise<{
+ success: boolean;
+ status?: string;
+ method?: string;
+ version?: string;
+ path?: string;
+ hasApiKey?: boolean;
+ recommendation?: string;
+ installCommands?: {
+ macos?: string;
+ windows?: string;
+ linux?: string;
+ npm?: string;
+ };
+ error?: string;
+ }> {
+ return this.get("/api/setup/codex-status");
+ }
+
+ // Model API
+ model = {
+ getAvailable: async (): Promise<{
+ success: boolean;
+ models?: ModelDefinition[];
+ error?: string;
+ }> => {
+ return this.get("/api/models/available");
+ },
+ checkProviders: async (): Promise<{
+ success: boolean;
+ providers?: Record;
+ error?: string;
+ }> => {
+ return this.get("/api/models/providers");
+ },
+ };
+
+ async testOpenAIConnection(apiKey?: string): Promise<{
+ success: boolean;
+ message?: string;
+ error?: string;
+ }> {
+ return this.post("/api/setup/test-openai", { apiKey });
+ }
+
+ // Setup API
+ setup = {
+ getClaudeStatus: (): Promise<{
+ success: boolean;
+ status?: string;
+ installed?: boolean;
+ method?: string;
+ version?: string;
+ path?: string;
+ auth?: {
+ authenticated: boolean;
+ method: string;
+ hasCredentialsFile?: boolean;
+ hasToken?: boolean;
+ hasStoredOAuthToken?: boolean;
+ hasStoredApiKey?: boolean;
+ hasEnvApiKey?: boolean;
+ hasEnvOAuthToken?: boolean;
+ hasCliAuth?: boolean;
+ hasRecentActivity?: boolean;
+ };
+ error?: string;
+ }> => this.get("/api/setup/claude-status"),
+
+ getCodexStatus: (): Promise<{
+ success: boolean;
+ status?: string;
+ method?: string;
+ version?: string;
+ path?: string;
+ auth?: {
+ authenticated: boolean;
+ method: string;
+ hasAuthFile: boolean;
+ hasEnvKey: boolean;
+ hasStoredApiKey?: boolean;
+ hasEnvApiKey?: boolean;
+ };
+ error?: string;
+ }> => this.get("/api/setup/codex-status"),
+
+ installClaude: (): Promise<{
+ success: boolean;
+ message?: string;
+ error?: string;
+ }> => this.post("/api/setup/install-claude"),
+
+ installCodex: (): Promise<{
+ success: boolean;
+ message?: string;
+ error?: string;
+ }> => this.post("/api/setup/install-codex"),
+
+ authClaude: (): Promise<{
+ success: boolean;
+ token?: string;
+ requiresManualAuth?: boolean;
+ terminalOpened?: boolean;
+ command?: string;
+ error?: string;
+ message?: string;
+ output?: string;
+ }> => this.post("/api/setup/auth-claude"),
+
+ authCodex: (apiKey?: string): Promise<{
+ success: boolean;
+ requiresManualAuth?: boolean;
+ command?: string;
+ error?: string;
+ }> => this.post("/api/setup/auth-codex", { apiKey }),
+
+ storeApiKey: (provider: string, apiKey: string): Promise<{
+ success: boolean;
+ error?: string;
+ }> => this.post("/api/setup/store-api-key", { provider, apiKey }),
+
+ getApiKeys: (): Promise<{
+ success: boolean;
+ hasAnthropicKey: boolean;
+ hasOpenAIKey: boolean;
+ hasGoogleKey: boolean;
+ }> => this.get("/api/setup/api-keys"),
+
+ configureCodexMcp: (projectPath: string): Promise<{
+ success: boolean;
+ configPath?: string;
+ error?: string;
+ }> => this.post("/api/setup/configure-codex-mcp", { projectPath }),
+
+ getPlatform: (): Promise<{
+ success: boolean;
+ platform: string;
+ arch: string;
+ homeDir: string;
+ isWindows: boolean;
+ isMac: boolean;
+ isLinux: boolean;
+ }> => this.get("/api/setup/platform"),
+
+ onInstallProgress: (callback: (progress: unknown) => void) => {
+ return this.subscribeToEvent("agent:stream", callback);
+ },
+
+ onAuthProgress: (callback: (progress: unknown) => void) => {
+ return this.subscribeToEvent("agent:stream", callback);
+ },
+ };
+
+ // Features API
+ features: FeaturesAPI = {
+ getAll: (projectPath: string) =>
+ this.post("/api/features/list", { projectPath }),
+ get: (projectPath: string, featureId: string) =>
+ this.post("/api/features/get", { projectPath, featureId }),
+ create: (projectPath: string, feature: Feature) =>
+ this.post("/api/features/create", { projectPath, feature }),
+ update: (projectPath: string, featureId: string, updates: Partial) =>
+ this.post("/api/features/update", { projectPath, featureId, updates }),
+ delete: (projectPath: string, featureId: string) =>
+ this.post("/api/features/delete", { projectPath, featureId }),
+ getAgentOutput: (projectPath: string, featureId: string) =>
+ this.post("/api/features/agent-output", { projectPath, featureId }),
+ };
+
+ // Auto Mode API
+ autoMode: AutoModeAPI = {
+ start: (projectPath: string, maxConcurrency?: number) =>
+ this.post("/api/auto-mode/start", { projectPath, maxConcurrency }),
+ stop: (projectPath: string) =>
+ this.post("/api/auto-mode/stop", { projectPath }),
+ stopFeature: (featureId: string) =>
+ this.post("/api/auto-mode/stop-feature", { featureId }),
+ status: (projectPath?: string) =>
+ this.post("/api/auto-mode/status", { projectPath }),
+ runFeature: (projectPath: string, featureId: string, useWorktrees?: boolean) =>
+ this.post("/api/auto-mode/run-feature", { projectPath, featureId, useWorktrees }),
+ verifyFeature: (projectPath: string, featureId: string) =>
+ this.post("/api/auto-mode/verify-feature", { projectPath, featureId }),
+ resumeFeature: (projectPath: string, featureId: string) =>
+ this.post("/api/auto-mode/resume-feature", { projectPath, featureId }),
+ contextExists: (projectPath: string, featureId: string) =>
+ this.post("/api/auto-mode/context-exists", { projectPath, featureId }),
+ analyzeProject: (projectPath: string) =>
+ this.post("/api/auto-mode/analyze-project", { projectPath }),
+ followUpFeature: (
+ projectPath: string,
+ featureId: string,
+ prompt: string,
+ imagePaths?: string[]
+ ) =>
+ this.post("/api/auto-mode/follow-up-feature", {
+ projectPath,
+ featureId,
+ prompt,
+ imagePaths,
+ }),
+ commitFeature: (projectPath: string, featureId: string) =>
+ this.post("/api/auto-mode/commit-feature", { projectPath, featureId }),
+ onEvent: (callback: (event: AutoModeEvent) => void) => {
+ return this.subscribeToEvent("auto-mode:event", callback as EventCallback);
+ },
+ };
+
+ // Worktree API
+ worktree: WorktreeAPI = {
+ revertFeature: (projectPath: string, featureId: string) =>
+ this.post("/api/worktree/revert", { projectPath, featureId }),
+ mergeFeature: (projectPath: string, featureId: string, options?: object) =>
+ this.post("/api/worktree/merge", { projectPath, featureId, options }),
+ getInfo: (projectPath: string, featureId: string) =>
+ this.post("/api/worktree/info", { projectPath, featureId }),
+ getStatus: (projectPath: string, featureId: string) =>
+ this.post("/api/worktree/status", { projectPath, featureId }),
+ list: (projectPath: string) =>
+ this.post("/api/worktree/list", { projectPath }),
+ getDiffs: (projectPath: string, featureId: string) =>
+ this.post("/api/worktree/diffs", { projectPath, featureId }),
+ getFileDiff: (projectPath: string, featureId: string, filePath: string) =>
+ this.post("/api/worktree/file-diff", { projectPath, featureId, filePath }),
+ };
+
+ // Git API
+ git: GitAPI = {
+ getDiffs: (projectPath: string) =>
+ this.post("/api/git/diffs", { projectPath }),
+ getFileDiff: (projectPath: string, filePath: string) =>
+ this.post("/api/git/file-diff", { projectPath, filePath }),
+ };
+
+ // Suggestions API
+ suggestions: SuggestionsAPI = {
+ generate: (projectPath: string, suggestionType?: SuggestionType) =>
+ this.post("/api/suggestions/generate", { projectPath, suggestionType }),
+ stop: () => this.post("/api/suggestions/stop"),
+ status: () => this.get("/api/suggestions/status"),
+ onEvent: (callback: (event: SuggestionsEvent) => void) => {
+ return this.subscribeToEvent("suggestions:event", callback as EventCallback);
+ },
+ };
+
+ // Spec Regeneration API
+ specRegeneration: SpecRegenerationAPI = {
+ create: (projectPath: string, projectOverview: string, generateFeatures?: boolean) =>
+ this.post("/api/spec-regeneration/create", {
+ projectPath,
+ projectOverview,
+ generateFeatures,
+ }),
+ generate: (projectPath: string, projectDefinition: string) =>
+ this.post("/api/spec-regeneration/generate", { projectPath, projectDefinition }),
+ generateFeatures: (projectPath: string) =>
+ this.post("/api/spec-regeneration/generate-features", { projectPath }),
+ stop: () => this.post("/api/spec-regeneration/stop"),
+ status: () => this.get("/api/spec-regeneration/status"),
+ onEvent: (callback: (event: SpecRegenerationEvent) => void) => {
+ return this.subscribeToEvent(
+ "spec-regeneration:event",
+ callback as EventCallback
+ );
+ },
+ };
+
+ // Running Agents API
+ runningAgents = {
+ getAll: (): Promise<{
+ success: boolean;
+ runningAgents?: Array<{
+ featureId: string;
+ projectPath: string;
+ projectName: string;
+ isAutoMode: boolean;
+ }>;
+ totalCount?: number;
+ autoLoopRunning?: boolean;
+ error?: string;
+ }> => 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
+let httpApiClientInstance: HttpApiClient | null = null;
+
+export function getHttpApiClient(): HttpApiClient {
+ if (!httpApiClientInstance) {
+ httpApiClientInstance = new HttpApiClient();
+ }
+ return httpApiClientInstance;
+}
diff --git a/apps/app/src/store/app-store.ts b/apps/app/src/store/app-store.ts
index ba8a1130..81d50617 100644
--- a/apps/app/src/store/app-store.ts
+++ b/apps/app/src/store/app-store.ts
@@ -9,7 +9,6 @@ export type ViewMode =
| "board"
| "agent"
| "settings"
- | "tools"
| "interview"
| "context"
| "profiles"
@@ -113,7 +112,6 @@ export interface KeyboardShortcuts {
agent: string;
spec: string;
context: string;
- tools: string;
settings: string;
profiles: string;
@@ -139,7 +137,6 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
agent: "A",
spec: "D",
context: "C",
- tools: "T",
settings: "S",
profiles: "M",
@@ -354,6 +351,7 @@ export interface AutoModeActivity {
tool?: string;
passes?: boolean;
phase?: "planning" | "action" | "verification";
+ errorType?: "authentication" | "execution";
}
export interface AppActions {
@@ -638,6 +636,7 @@ export const useAppStore = create()(
name: trashed.name,
path: trashed.path,
lastOpened: new Date().toISOString(),
+ theme: trashed.theme, // Preserve theme from trashed project
};
set({
@@ -1138,26 +1137,32 @@ export const useAppStore = create()(
{
name: "automaker-storage",
partialize: (state) => ({
+ // Project management
projects: state.projects,
currentProject: state.currentProject,
trashedProjects: state.trashedProjects,
projectHistory: state.projectHistory,
projectHistoryIndex: state.projectHistoryIndex,
+ // Features - cached locally for faster hydration (authoritative source is server)
+ features: state.features,
+ // UI state
currentView: state.currentView,
theme: state.theme,
sidebarOpen: state.sidebarOpen,
- apiKeys: state.apiKeys,
- chatSessions: state.chatSessions,
chatHistoryOpen: state.chatHistoryOpen,
+ kanbanCardDetailLevel: state.kanbanCardDetailLevel,
+ // Settings
+ apiKeys: state.apiKeys,
maxConcurrency: state.maxConcurrency,
autoModeByProject: state.autoModeByProject,
- kanbanCardDetailLevel: state.kanbanCardDetailLevel,
defaultSkipTests: state.defaultSkipTests,
useWorktrees: state.useWorktrees,
showProfilesOnly: state.showProfilesOnly,
keyboardShortcuts: state.keyboardShortcuts,
muteDoneSound: state.muteDoneSound,
+ // Profiles and sessions
aiProfiles: state.aiProfiles,
+ chatSessions: state.chatSessions,
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
}),
}
diff --git a/apps/app/src/store/setup-store.ts b/apps/app/src/store/setup-store.ts
index 8609d339..15714d28 100644
--- a/apps/app/src/store/setup-store.ts
+++ b/apps/app/src/store/setup-store.ts
@@ -10,10 +10,20 @@ export interface CliStatus {
error?: string;
}
+// Claude Auth Method - all possible authentication sources
+export type ClaudeAuthMethod =
+ | "oauth_token_env" // CLAUDE_CODE_OAUTH_TOKEN environment variable
+ | "oauth_token" // Stored OAuth token from claude login
+ | "api_key_env" // ANTHROPIC_API_KEY environment variable
+ | "api_key" // Manually stored API key
+ | "credentials_file" // Generic credentials file detection
+ | "cli_authenticated" // Claude CLI is installed and has active sessions/activity
+ | "none";
+
// Claude Auth Status
export interface ClaudeAuthStatus {
authenticated: boolean;
- method: "oauth_token_env" | "oauth_token" | "api_key" | "api_key_env" | "none";
+ method: ClaudeAuthMethod;
hasCredentialsFile?: boolean;
oauthTokenValid?: boolean;
apiKeyValid?: boolean;
@@ -22,12 +32,23 @@ export interface ClaudeAuthStatus {
error?: string;
}
+// Codex Auth Method - all possible authentication sources
+export type CodexAuthMethod =
+ | "subscription" // Codex/OpenAI Plus or Team subscription
+ | "cli_verified" // CLI logged in with OpenAI account
+ | "cli_tokens" // CLI with stored access tokens
+ | "api_key" // Manually stored API key
+ | "env" // OPENAI_API_KEY environment variable
+ | "none";
+
// Codex Auth Status
export interface CodexAuthStatus {
authenticated: boolean;
- method: "api_key" | "env" | "cli_verified" | "cli_tokens" | "none";
+ method: CodexAuthMethod;
apiKeyValid?: boolean;
mcpConfigured?: boolean;
+ hasSubscription?: boolean;
+ cliLoggedIn?: boolean;
error?: string;
}
diff --git a/apps/app/src/types/electron.d.ts b/apps/app/src/types/electron.d.ts
index a816218c..a640fe02 100644
--- a/apps/app/src/types/electron.d.ts
+++ b/apps/app/src/types/electron.d.ts
@@ -192,6 +192,7 @@ export type AutoModeEvent =
| {
type: "auto_mode_error";
error: string;
+ errorType?: "authentication" | "execution";
featureId?: string;
projectId?: string;
projectPath?: string;
diff --git a/apps/marketing/public/index.html b/apps/marketing/public/index.html
index b15b92f9..3f9a6336 100644
--- a/apps/marketing/public/index.html
+++ b/apps/marketing/public/index.html
@@ -298,6 +298,65 @@
.feature-card:nth-child(4) { animation-delay: 0.4s; }
.feature-card:nth-child(5) { animation-delay: 0.5s; }
.feature-card:nth-child(6) { animation-delay: 0.6s; }
+
+ /* Download Buttons */
+ .download-section {
+ margin-top: 2.5rem;
+ }
+
+ .download-label {
+ color: var(--text-muted);
+ font-size: 0.9rem;
+ margin-bottom: 1rem;
+ }
+
+ .download-buttons {
+ display: flex;
+ gap: 1rem;
+ justify-content: center;
+ flex-wrap: wrap;
+ }
+
+ .btn-download {
+ padding: 0.75rem 1.5rem;
+ border-radius: 0.5rem;
+ text-decoration: none;
+ font-weight: 600;
+ transition: all 0.3s;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ background: rgba(30, 41, 59, 0.8);
+ color: var(--text);
+ border: 1px solid rgba(148, 163, 184, 0.2);
+ font-size: 0.9rem;
+ }
+
+ .btn-download:hover {
+ background: rgba(99, 102, 241, 0.2);
+ border-color: var(--primary);
+ transform: translateY(-2px);
+ }
+
+ .btn-download svg {
+ width: 20px;
+ height: 20px;
+ }
+
+ .download-subtitle {
+ color: var(--text-muted);
+ font-size: 0.9rem;
+ margin-top: 1rem;
+ }
+
+ .download-subtitle a {
+ color: var(--primary);
+ text-decoration: none;
+ }
+
+ .download-subtitle a:hover {
+ text-decoration: underline;
+ }
@@ -307,6 +366,7 @@
@@ -321,6 +381,27 @@
View on GitHub
Get Started
+
+