chore: update dependencies and improve project structure

- Added `morgan` for enhanced request logging in the server.
- Updated `package-lock.json` to include new dependencies and their types.
- Refactored the `NewProjectModal` component for improved readability and structure.
- Enhanced the `FileBrowserDialog` to support initial path selection and improved error handling.
- Updated various components to ensure consistent formatting and better user experience.
- Introduced XML format specification for app specifications to maintain consistency across the application.
This commit is contained in:
Cody Seibert
2025-12-14 10:59:52 -05:00
parent ebc4f1422a
commit 9bb843f82f
20 changed files with 1667 additions and 654 deletions

View File

@@ -1,7 +1,14 @@
"use client";
import { useState, useEffect } from "react";
import { FolderOpen, Folder, ChevronRight, Home, ArrowLeft, HardDrive } from "lucide-react";
import {
FolderOpen,
Folder,
ChevronRight,
Home,
ArrowLeft,
HardDrive,
} from "lucide-react";
import {
Dialog,
DialogContent,
@@ -24,6 +31,7 @@ interface BrowseResult {
directories: DirectoryEntry[];
drives?: string[];
error?: string;
warning?: string;
}
interface FileBrowserDialogProps {
@@ -32,6 +40,7 @@ interface FileBrowserDialogProps {
onSelect: (path: string) => void;
title?: string;
description?: string;
initialPath?: string;
}
export function FileBrowserDialog({
@@ -40,6 +49,7 @@ export function FileBrowserDialog({
onSelect,
title = "Select Project Directory",
description = "Navigate to your project folder",
initialPath,
}: FileBrowserDialogProps) {
const [currentPath, setCurrentPath] = useState<string>("");
const [parentPath, setParentPath] = useState<string | null>(null);
@@ -47,14 +57,17 @@ export function FileBrowserDialog({
const [drives, setDrives] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [warning, setWarning] = useState("");
const browseDirectory = async (dirPath?: string) => {
setLoading(true);
setError("");
setWarning("");
try {
// Get server URL from environment or default
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
const serverUrl =
process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
const response = await fetch(`${serverUrl}/api/fs/browse`, {
method: "POST",
@@ -69,23 +82,37 @@ export function FileBrowserDialog({
setParentPath(result.parentPath);
setDirectories(result.directories);
setDrives(result.drives || []);
setWarning(result.warning || "");
} else {
setError(result.error || "Failed to browse directory");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load directories");
setError(
err instanceof Error ? err.message : "Failed to load directories"
);
} finally {
setLoading(false);
}
};
// Load home directory on mount
// Reset current path when dialog closes
useEffect(() => {
if (open && !currentPath) {
browseDirectory();
if (!open) {
setCurrentPath("");
setParentPath(null);
setDirectories([]);
setError("");
setWarning("");
}
}, [open]);
// Load initial path or home directory when dialog opens
useEffect(() => {
if (open && !currentPath) {
browseDirectory(initialPath);
}
}, [open, initialPath]);
const handleSelectDirectory = (dir: DirectoryEntry) => {
browseDirectory(dir.path);
};
@@ -135,7 +162,9 @@ export function FileBrowserDialog({
{drives.map((drive) => (
<Button
key={drive}
variant={currentPath.startsWith(drive) ? "default" : "outline"}
variant={
currentPath.startsWith(drive) ? "default" : "outline"
}
size="sm"
onClick={() => handleSelectDrive(drive)}
className="h-7 px-3 text-xs"
@@ -178,7 +207,9 @@ export function FileBrowserDialog({
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-lg">
{loading && (
<div className="flex items-center justify-center h-full p-8">
<div className="text-sm text-muted-foreground">Loading directories...</div>
<div className="text-sm text-muted-foreground">
Loading directories...
</div>
</div>
)}
@@ -188,9 +219,17 @@ export function FileBrowserDialog({
</div>
)}
{!loading && !error && directories.length === 0 && (
{warning && (
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg mb-2">
<div className="text-sm text-yellow-500">{warning}</div>
</div>
)}
{!loading && !error && !warning && directories.length === 0 && (
<div className="flex items-center justify-center h-full p-8">
<div className="text-sm text-muted-foreground">No subdirectories found</div>
<div className="text-sm text-muted-foreground">
No subdirectories found
</div>
</div>
)}
@@ -212,7 +251,8 @@ export function FileBrowserDialog({
</div>
<div className="text-xs text-muted-foreground">
Click on a folder to navigate. Select the current folder or navigate to a subfolder.
Click on a folder to navigate. Select the current folder or navigate
to a subfolder.
</div>
</div>

View File

@@ -34,6 +34,10 @@ import {
Sparkles,
Loader2,
Terminal,
Rocket,
Zap,
CheckCircle2,
ArrowRight,
} from "lucide-react";
import {
DropdownMenu,
@@ -78,6 +82,7 @@ import { themeOptions } from "@/config/theme-options";
import { Checkbox } from "@/components/ui/checkbox";
import type { SpecRegenerationEvent } from "@/types/electron";
import { DeleteProjectDialog } from "@/components/views/settings-view/components/delete-project-dialog";
import { NewProjectModal } from "@/components/new-project-modal";
import {
DndContext,
DragEndEvent,
@@ -92,6 +97,8 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { getHttpApiClient } from "@/lib/http-api-client";
import type { StarterTemplate } from "@/lib/templates";
interface NavSection {
label?: string;
@@ -205,6 +212,8 @@ export function Sidebar() {
setPreviewTheme,
theme: globalTheme,
moveProjectToTrash,
specCreatingForProject,
setSpecCreatingForProject,
} = useAppStore();
// Get customizable keyboard shortcuts
@@ -224,17 +233,26 @@ export function Sidebar() {
// State for running agents count
const [runningAgentsCount, setRunningAgentsCount] = useState(0);
// State for new project modal
const [showNewProjectModal, setShowNewProjectModal] = useState(false);
const [isCreatingProject, setIsCreatingProject] = useState(false);
// State for new project onboarding dialog
const [showOnboardingDialog, setShowOnboardingDialog] = useState(false);
const [newProjectName, setNewProjectName] = useState("");
const [newProjectPath, setNewProjectPath] = useState("");
// State for new project setup dialog
const [showSetupDialog, setShowSetupDialog] = useState(false);
const [setupProjectPath, setSetupProjectPath] = useState("");
const [projectOverview, setProjectOverview] = useState("");
const [isCreatingSpec, setIsCreatingSpec] = useState(false);
const [creatingSpecProjectPath, setCreatingSpecProjectPath] = useState<
string | null
>(null);
const [generateFeatures, setGenerateFeatures] = useState(true);
const [showSpecIndicator, setShowSpecIndicator] = useState(true);
// Derive isCreatingSpec from store state
const isCreatingSpec = specCreatingForProject !== null;
const creatingSpecProjectPath = specCreatingForProject;
// Ref for project search input
const projectSearchInputRef = useRef<HTMLInputElement>(null);
@@ -324,22 +342,39 @@ export function Sidebar() {
const unsubscribe = api.specRegeneration.onEvent(
(event: SpecRegenerationEvent) => {
console.log("[Sidebar] Spec regeneration event:", event.type);
console.log(
"[Sidebar] Spec regeneration event:",
event.type,
"for project:",
event.projectPath
);
// Only handle events for the project we're currently setting up
if (
event.projectPath !== creatingSpecProjectPath &&
event.projectPath !== setupProjectPath
) {
console.log(
"[Sidebar] Ignoring event - not for project being set up"
);
return;
}
if (event.type === "spec_regeneration_complete") {
setIsCreatingSpec(false);
setCreatingSpecProjectPath(null);
setSpecCreatingForProject(null);
setShowSetupDialog(false);
setProjectOverview("");
setSetupProjectPath("");
// Clear onboarding state if we came from onboarding
setNewProjectName("");
setNewProjectPath("");
toast.success("App specification created", {
description: "Your project is now set up and ready to go!",
});
// Navigate to spec view to show the new spec
setCurrentView("spec");
} else if (event.type === "spec_regeneration_error") {
setIsCreatingSpec(false);
setCreatingSpecProjectPath(null);
setSpecCreatingForProject(null);
toast.error("Failed to create specification", {
description: event.error,
});
@@ -350,7 +385,12 @@ export function Sidebar() {
return () => {
unsubscribe();
};
}, [setCurrentView]);
}, [
setCurrentView,
creatingSpecProjectPath,
setupProjectPath,
setSpecCreatingForProject,
]);
// Fetch running agents count function - used for initial load and event-driven updates
const fetchRunningAgentsCount = useCallback(async () => {
@@ -399,8 +439,8 @@ export function Sidebar() {
const handleCreateInitialSpec = useCallback(async () => {
if (!setupProjectPath || !projectOverview.trim()) return;
setIsCreatingSpec(true);
setCreatingSpecProjectPath(setupProjectPath);
// Set store state immediately so the loader shows up right away
setSpecCreatingForProject(setupProjectPath);
setShowSpecIndicator(true);
setShowSetupDialog(false);
@@ -408,8 +448,7 @@ export function Sidebar() {
const api = getElectronAPI();
if (!api.specRegeneration) {
toast.error("Spec regeneration not available");
setIsCreatingSpec(false);
setCreatingSpecProjectPath(null);
setSpecCreatingForProject(null);
return;
}
const result = await api.specRegeneration.create(
@@ -420,8 +459,7 @@ export function Sidebar() {
if (!result.success) {
console.error("[Sidebar] Failed to start spec creation:", result.error);
setIsCreatingSpec(false);
setCreatingSpecProjectPath(null);
setSpecCreatingForProject(null);
toast.error("Failed to create specification", {
description: result.error,
});
@@ -429,24 +467,345 @@ export function Sidebar() {
// If successful, we'll wait for the events to update the state
} catch (error) {
console.error("[Sidebar] Failed to create spec:", error);
setIsCreatingSpec(false);
setCreatingSpecProjectPath(null);
setSpecCreatingForProject(null);
toast.error("Failed to create specification", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
}, [setupProjectPath, projectOverview]);
}, [setupProjectPath, projectOverview, setSpecCreatingForProject]);
// Handle skipping setup
const handleSkipSetup = useCallback(() => {
setShowSetupDialog(false);
setProjectOverview("");
setSetupProjectPath("");
// Clear onboarding state if we came from onboarding
if (newProjectPath) {
setNewProjectName("");
setNewProjectPath("");
}
toast.info("Setup skipped", {
description: "You can set up your app_spec.txt later from the Spec view.",
});
}, [newProjectPath]);
// Handle onboarding dialog - generate spec
const handleOnboardingGenerateSpec = useCallback(() => {
setShowOnboardingDialog(false);
// Navigate to the setup dialog flow
setSetupProjectPath(newProjectPath);
setProjectOverview("");
setShowSetupDialog(true);
}, [newProjectPath]);
// Handle onboarding dialog - skip
const handleOnboardingSkip = useCallback(() => {
setShowOnboardingDialog(false);
setNewProjectName("");
setNewProjectPath("");
toast.info(
"You can generate your app_spec.txt anytime from the Spec view",
{
description: "Your project is ready to use!",
}
);
}, []);
/**
* Create a blank project with just .automaker directory structure
*/
const handleCreateBlankProject = useCallback(
async (projectName: string, parentDir: string) => {
setIsCreatingProject(true);
try {
const api = getElectronAPI();
const projectPath = `${parentDir}/${projectName}`;
// Create project directory
const mkdirResult = await api.mkdir(projectPath);
if (!mkdirResult.success) {
toast.error("Failed to create project directory", {
description: mkdirResult.error || "Unknown error occurred",
});
return;
}
// Initialize .automaker directory with all necessary files
const initResult = await initializeProject(projectPath);
if (!initResult.success) {
toast.error("Failed to initialize project", {
description: initResult.error || "Unknown error occurred",
});
return;
}
// Update the app_spec.txt with the project name
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>
<project_name>${projectName}</project_name>
<overview>
Describe your project here. This file will be analyzed by an AI agent
to understand your project structure and tech stack.
</overview>
<technology_stack>
<!-- The AI agent will fill this in after analyzing your project -->
</technology_stack>
<core_capabilities>
<!-- List core features and capabilities -->
</core_capabilities>
<implemented_features>
<!-- The AI agent will populate this based on code analysis -->
</implemented_features>
</project_specification>`
);
const trashedProject = trashedProjects.find(
(p) => p.path === projectPath
);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
const project = upsertAndSetCurrentProject(
projectPath,
projectName,
effectiveTheme
);
setShowNewProjectModal(false);
// Show onboarding dialog for new project
setNewProjectName(projectName);
setNewProjectPath(projectPath);
setShowOnboardingDialog(true);
toast.success("Project created", {
description: `Created ${projectName} with .automaker directory`,
});
} catch (error) {
console.error("[Sidebar] Failed to create project:", error);
toast.error("Failed to create project", {
description: error instanceof Error ? error.message : "Unknown error",
});
} finally {
setIsCreatingProject(false);
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
);
/**
* Create a project from a GitHub starter template
*/
const handleCreateFromTemplate = useCallback(
async (
template: StarterTemplate,
projectName: string,
parentDir: string
) => {
setIsCreatingProject(true);
try {
const httpClient = getHttpApiClient();
const api = getElectronAPI();
// Clone the template repository
const cloneResult = await httpClient.templates.clone(
template.repoUrl,
projectName,
parentDir
);
if (!cloneResult.success || !cloneResult.projectPath) {
toast.error("Failed to clone template", {
description: cloneResult.error || "Unknown error occurred",
});
return;
}
const projectPath = cloneResult.projectPath;
// Initialize .automaker directory with all necessary files
const initResult = await initializeProject(projectPath);
if (!initResult.success) {
toast.error("Failed to initialize project", {
description: initResult.error || "Unknown error occurred",
});
return;
}
// Update the app_spec.txt with template-specific info
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>
<project_name>${projectName}</project_name>
<overview>
This project was created from the "${template.name}" starter template.
${template.description}
</overview>
<technology_stack>
${template.techStack
.map((tech) => `<technology>${tech}</technology>`)
.join("\n ")}
</technology_stack>
<core_capabilities>
${template.features
.map((feature) => `<capability>${feature}</capability>`)
.join("\n ")}
</core_capabilities>
<implemented_features>
<!-- The AI agent will populate this based on code analysis -->
</implemented_features>
</project_specification>`
);
const trashedProject = trashedProjects.find(
(p) => p.path === projectPath
);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
const project = upsertAndSetCurrentProject(
projectPath,
projectName,
effectiveTheme
);
setShowNewProjectModal(false);
// Show onboarding dialog for new project
setNewProjectName(projectName);
setNewProjectPath(projectPath);
setShowOnboardingDialog(true);
toast.success("Project created from template", {
description: `Created ${projectName} from ${template.name}`,
});
} catch (error) {
console.error(
"[Sidebar] Failed to create project from template:",
error
);
toast.error("Failed to create project", {
description: error instanceof Error ? error.message : "Unknown error",
});
} finally {
setIsCreatingProject(false);
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
);
/**
* Create a project from a custom GitHub URL
*/
const handleCreateFromCustomUrl = useCallback(
async (repoUrl: string, projectName: string, parentDir: string) => {
setIsCreatingProject(true);
try {
const httpClient = getHttpApiClient();
const api = getElectronAPI();
// Clone the repository
const cloneResult = await httpClient.templates.clone(
repoUrl,
projectName,
parentDir
);
if (!cloneResult.success || !cloneResult.projectPath) {
toast.error("Failed to clone repository", {
description: cloneResult.error || "Unknown error occurred",
});
return;
}
const projectPath = cloneResult.projectPath;
// Initialize .automaker directory with all necessary files
const initResult = await initializeProject(projectPath);
if (!initResult.success) {
toast.error("Failed to initialize project", {
description: initResult.error || "Unknown error occurred",
});
return;
}
// Update the app_spec.txt with basic info
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>
<project_name>${projectName}</project_name>
<overview>
This project was cloned from ${repoUrl}.
The AI agent will analyze the project structure.
</overview>
<technology_stack>
<!-- The AI agent will fill this in after analyzing your project -->
</technology_stack>
<core_capabilities>
<!-- List core features and capabilities -->
</core_capabilities>
<implemented_features>
<!-- The AI agent will populate this based on code analysis -->
</implemented_features>
</project_specification>`
);
const trashedProject = trashedProjects.find(
(p) => p.path === projectPath
);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
const project = upsertAndSetCurrentProject(
projectPath,
projectName,
effectiveTheme
);
setShowNewProjectModal(false);
// Show onboarding dialog for new project
setNewProjectName(projectName);
setNewProjectPath(projectPath);
setShowOnboardingDialog(true);
toast.success("Project created from repository", {
description: `Created ${projectName} from ${repoUrl}`,
});
} catch (error) {
console.error("[Sidebar] Failed to create project from URL:", error);
toast.error("Failed to create project", {
description: error instanceof Error ? error.message : "Unknown error",
});
} finally {
setIsCreatingProject(false);
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
);
/**
* Opens the system folder selection dialog and initializes the selected project.
* Used by both the 'O' keyboard shortcut and the folder icon button.
@@ -840,7 +1199,7 @@ export function Sidebar() {
<img
src="/logo.png"
alt="A"
className="h-[1.3em] w-auto inline-block align-middle group-hover:rotate-12 transition-transform"
className="h-[1.8em] w-auto inline-block align-middle group-hover:rotate-12 transition-transform"
/>
<span className="-ml-0.5">
uto<span className="text-brand-500">maker</span>
@@ -868,7 +1227,7 @@ export function Sidebar() {
{sidebarOpen && (
<div className="flex items-center gap-2 titlebar-no-drag px-2 mt-3">
<button
onClick={() => setCurrentView("welcome")}
onClick={() => setShowNewProjectModal(true)}
className="group flex items-center justify-center flex-1 px-3 py-2.5 rounded-lg relative overflow-hidden transition-all text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50 border border-sidebar-border"
title="New Project"
data-testid="new-project-button"
@@ -1586,27 +1945,112 @@ export function Sidebar() {
</DialogContent>
</Dialog>
{/* Spec Creation Indicator - Bottom Right Toast */}
{isCreatingSpec &&
showSpecIndicator &&
currentProject?.path === creatingSpecProjectPath && (
<div className="fixed bottom-4 right-4 z-50 flex items-center gap-3 bg-card border border-border rounded-lg shadow-lg p-4 max-w-sm">
<Loader2 className="w-5 h-5 animate-spin text-primary flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Creating App Specification</p>
<p className="text-xs text-muted-foreground truncate">
Working on your project...
{/* New Project Onboarding Dialog */}
<Dialog
open={showOnboardingDialog}
onOpenChange={(open) => {
if (!open) {
handleOnboardingSkip();
}
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-brand-500/10 border border-brand-500/20">
<Rocket className="w-6 h-6 text-brand-500" />
</div>
<div>
<DialogTitle className="text-2xl">
Welcome to {newProjectName}!
</DialogTitle>
<DialogDescription className="text-muted-foreground mt-1">
Your new project is ready. Let&apos;s get you started.
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="space-y-6 py-6">
{/* Main explanation */}
<div className="space-y-3">
<p className="text-sm text-foreground leading-relaxed">
Would you like to auto-generate your{" "}
<strong>app_spec.txt</strong>? This file helps describe your
project and is used to pre-populate your backlog with features
to work on.
</p>
</div>
{/* Benefits list */}
<div className="space-y-3 rounded-lg bg-muted/50 border border-border p-4">
<div className="flex items-start gap-3">
<CheckCircle2 className="w-5 h-5 text-brand-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-foreground">
Pre-populate your backlog
</p>
<p className="text-xs text-muted-foreground mt-1">
Automatically generate features based on your project
specification
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Zap className="w-5 h-5 text-brand-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-foreground">
Better AI assistance
</p>
<p className="text-xs text-muted-foreground mt-1">
Help AI agents understand your project structure and tech
stack
</p>
</div>
</div>
<div className="flex items-start gap-3">
<FileText className="w-5 h-5 text-brand-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-foreground">
Project documentation
</p>
<p className="text-xs text-muted-foreground mt-1">
Keep a clear record of your project&apos;s capabilities and
features
</p>
</div>
</div>
</div>
{/* Info box */}
<div className="rounded-lg bg-blue-500/10 border border-blue-500/20 p-3">
<p className="text-xs text-blue-400 leading-relaxed">
<strong className="text-blue-300">Tip:</strong> You can always
generate or edit your app_spec.txt later from the Spec Editor in
the sidebar.
</p>
</div>
<button
onClick={() => setShowSpecIndicator(false)}
className="p-1 hover:bg-muted rounded-md transition-colors flex-shrink-0"
aria-label="Dismiss notification"
>
<X className="w-4 h-4 text-muted-foreground" />
</button>
</div>
)}
<DialogFooter className="gap-2">
<Button
variant="ghost"
onClick={handleOnboardingSkip}
className="text-muted-foreground hover:text-foreground"
>
Skip for now
</Button>
<Button
onClick={handleOnboardingGenerateSpec}
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0"
>
<Sparkles className="w-4 h-4 mr-2" />
Generate App Spec
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Project Confirmation Dialog */}
<DeleteProjectDialog
@@ -1615,6 +2059,16 @@ export function Sidebar() {
project={currentProject}
onConfirm={moveProjectToTrash}
/>
{/* New Project Modal */}
<NewProjectModal
open={showNewProjectModal}
onOpenChange={setShowNewProjectModal}
onCreateBlankProject={handleCreateBlankProject}
onCreateFromTemplate={handleCreateFromTemplate}
onCreateFromCustomUrl={handleCreateFromCustomUrl}
isCreating={isCreatingProject}
/>
</aside>
);
}

View File

@@ -41,7 +41,10 @@ interface ValidationErrors {
interface NewProjectModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreateBlankProject: (projectName: string, parentDir: string) => Promise<void>;
onCreateBlankProject: (
projectName: string,
parentDir: string
) => Promise<void>;
onCreateFromTemplate: (
template: StarterTemplate,
projectName: string,
@@ -67,7 +70,8 @@ export function NewProjectModal({
const [projectName, setProjectName] = useState("");
const [workspaceDir, setWorkspaceDir] = useState<string>("");
const [isLoadingWorkspace, setIsLoadingWorkspace] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<StarterTemplate | null>(null);
const [selectedTemplate, setSelectedTemplate] =
useState<StarterTemplate | null>(null);
const [useCustomUrl, setUseCustomUrl] = useState(false);
const [customUrl, setCustomUrl] = useState("");
const [errors, setErrors] = useState<ValidationErrors>({});
@@ -78,7 +82,8 @@ export function NewProjectModal({
if (open) {
setIsLoadingWorkspace(true);
const httpClient = getHttpApiClient();
httpClient.workspace.getConfig()
httpClient.workspace
.getConfig()
.then((result) => {
if (result.success && result.workspaceDir) {
setWorkspaceDir(result.workspaceDir);
@@ -113,7 +118,10 @@ export function NewProjectModal({
}, [projectName, errors.projectName]);
useEffect(() => {
if ((selectedTemplate || (useCustomUrl && customUrl)) && errors.templateSelection) {
if (
(selectedTemplate || (useCustomUrl && customUrl)) &&
errors.templateSelection
) {
setErrors((prev) => ({ ...prev, templateSelection: false }));
}
}, [selectedTemplate, useCustomUrl, customUrl, errors.templateSelection]);
@@ -187,7 +195,9 @@ export function NewProjectModal({
const handleBrowseDirectory = async () => {
const selectedPath = await openFileBrowser({
title: "Select Base Project Directory",
description: "Choose the parent directory where your project will be created",
description:
"Choose the parent directory where your project will be created",
initialPath: workspaceDir || undefined,
});
if (selectedPath) {
setWorkspaceDir(selectedPath);
@@ -199,9 +209,16 @@ export function NewProjectModal({
};
// Use platform-specific path separator
const pathSep = typeof window !== 'undefined' && (window as any).electronAPI ?
(navigator.platform.indexOf('Win') !== -1 ? '\\' : '/') : '/';
const projectPath = workspaceDir && projectName ? `${workspaceDir}${pathSep}${projectName}` : "";
const pathSep =
typeof window !== "undefined" && (window as any).electronAPI
? navigator.platform.indexOf("Win") !== -1
? "\\"
: "/"
: "/";
const projectPath =
workspaceDir && projectName
? `${workspaceDir}${pathSep}${projectName}`
: "";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -210,7 +227,9 @@ export function NewProjectModal({
data-testid="new-project-modal"
>
<DialogHeader className="pb-2">
<DialogTitle className="text-foreground">Create New Project</DialogTitle>
<DialogTitle className="text-foreground">
Create New Project
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Start with a blank project or choose from a starter template.
</DialogDescription>
@@ -219,8 +238,15 @@ export function NewProjectModal({
{/* Project Name Input - Always visible at top */}
<div className="space-y-3 pb-4 border-b border-border">
<div className="space-y-2">
<Label htmlFor="project-name" className={cn("text-foreground", errors.projectName && "text-red-500")}>
Project Name {errors.projectName && <span className="text-red-500">*</span>}
<Label
htmlFor="project-name"
className={cn(
"text-foreground",
errors.projectName && "text-red-500"
)}
>
Project Name{" "}
{errors.projectName && <span className="text-red-500">*</span>}
</Label>
<Input
id="project-name"
@@ -242,16 +268,23 @@ export function NewProjectModal({
</div>
{/* Workspace Directory Display */}
<div className={cn(
"flex items-center gap-2 text-sm",
errors.workspaceDir ? "text-red-500" : "text-muted-foreground"
)}>
<div
className={cn(
"flex items-center gap-2 text-sm",
errors.workspaceDir ? "text-red-500" : "text-muted-foreground"
)}
>
<Folder className="w-4 h-4 shrink-0" />
<span className="flex-1 min-w-0">
{isLoadingWorkspace ? (
"Loading workspace..."
) : workspaceDir ? (
<>Will be created at: <code className="text-xs bg-muted px-1.5 py-0.5 rounded truncate">{projectPath || "..."}</code></>
<>
Will be created at:{" "}
<code className="text-xs bg-muted px-1.5 py-0.5 rounded truncate">
{projectPath || workspaceDir}
</code>
</>
) : (
<span className="text-red-500">No workspace configured</span>
)}
@@ -302,14 +335,18 @@ export function NewProjectModal({
<div className="space-y-4">
{/* Error message for template selection */}
{errors.templateSelection && (
<p className="text-sm text-red-500">Please select a template or enter a custom GitHub URL</p>
<p className="text-sm text-red-500">
Please select a template or enter a custom GitHub URL
</p>
)}
{/* Preset Templates */}
<div className={cn(
"space-y-3 rounded-lg p-1 -m-1",
errors.templateSelection && "ring-2 ring-red-500/50"
)}>
<div
className={cn(
"space-y-3 rounded-lg p-1 -m-1",
errors.templateSelection && "ring-2 ring-red-500/50"
)}
>
{starterTemplates.map((template) => (
<div
key={template.id}
@@ -328,9 +365,10 @@ export function NewProjectModal({
<h4 className="font-medium text-foreground">
{template.name}
</h4>
{selectedTemplate?.id === template.id && !useCustomUrl && (
<Check className="w-4 h-4 text-brand-500" />
)}
{selectedTemplate?.id === template.id &&
!useCustomUrl && (
<Check className="w-4 h-4 text-brand-500" />
)}
</div>
<p className="text-sm text-muted-foreground mb-3">
{template.description}
@@ -391,15 +429,22 @@ export function NewProjectModal({
>
<div className="flex items-center gap-2 mb-2">
<Link className="w-4 h-4 text-muted-foreground" />
<h4 className="font-medium text-foreground">Custom GitHub URL</h4>
{useCustomUrl && <Check className="w-4 h-4 text-brand-500" />}
<h4 className="font-medium text-foreground">
Custom GitHub URL
</h4>
{useCustomUrl && (
<Check className="w-4 h-4 text-brand-500" />
)}
</div>
<p className="text-sm text-muted-foreground mb-3">
Clone any public GitHub repository as a starting point.
</p>
{useCustomUrl && (
<div onClick={(e) => e.stopPropagation()} className="space-y-1">
<div
onClick={(e) => e.stopPropagation()}
className="space-y-1"
>
<Input
placeholder="https://github.com/username/repository"
value={customUrl}
@@ -413,7 +458,9 @@ export function NewProjectModal({
data-testid="custom-url-input"
/>
{errors.customUrl && (
<p className="text-xs text-red-500">GitHub URL is required</p>
<p className="text-xs text-red-500">
GitHub URL is required
</p>
)}
</div>
)}

View File

@@ -345,6 +345,7 @@ export function AnalysisView() {
const techStack = detectTechStack();
// Generate the spec content
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
const specContent = `<project_specification>
<project_name>${projectName}</project_name>

View File

@@ -28,6 +28,7 @@ import {
} from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { cn, modelSupportsThinking } from "@/lib/utils";
import type { SpecRegenerationEvent } from "@/types/electron";
import {
Card,
CardDescription,
@@ -179,6 +180,8 @@ export function BoardView() {
kanbanCardDetailLevel,
setKanbanCardDetailLevel,
boardBackgroundByProject,
specCreatingForProject,
setSpecCreatingForProject,
} = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
@@ -233,6 +236,9 @@ export function BoardView() {
const [searchQuery, setSearchQuery] = useState("");
// Validation state for add feature form
const [descriptionError, setDescriptionError] = useState(false);
// Derive spec creation state from store - check if current project is the one being created
const isCreatingSpec = specCreatingForProject === currentProject?.path;
const creatingSpecProjectPath = specCreatingForProject;
// Make current project available globally for modal
useEffect(() => {
@@ -264,6 +270,37 @@ export function BoardView() {
};
}, []);
// Subscribe to spec regeneration events to clear state on completion
useEffect(() => {
const api = getElectronAPI();
if (!api.specRegeneration) return;
const unsubscribe = api.specRegeneration.onEvent((event) => {
console.log(
"[BoardView] Spec regeneration event:",
event.type,
"for project:",
event.projectPath
);
// Only handle completion/error events for the project being created
// The creating state is set by sidebar when user initiates the action
if (event.projectPath !== specCreatingForProject) {
return;
}
if (event.type === "spec_regeneration_complete") {
setSpecCreatingForProject(null);
} else if (event.type === "spec_regeneration_error") {
setSpecCreatingForProject(null);
}
});
return () => {
unsubscribe();
};
}, [specCreatingForProject, setSpecCreatingForProject]);
// Track previous project to detect switches
const prevProjectPathRef = useRef<string | null>(null);
const isSwitchingProjectRef = useRef<boolean>(false);
@@ -1791,34 +1828,50 @@ export function BoardView() {
<div className="flex-1 flex flex-col overflow-hidden">
{/* Search Bar Row */}
<div className="px-4 pt-4 pb-2 flex items-center justify-between">
<div className="relative max-w-md flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<Input
ref={searchInputRef}
type="text"
placeholder="Search features by keyword..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-12 border-border"
data-testid="kanban-search-input"
/>
{searchQuery ? (
<button
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-sm hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
data-testid="kanban-search-clear"
aria-label="Clear search"
>
<X className="w-4 h-4" />
</button>
) : (
<span
className="absolute right-2 top-1/2 -translate-y-1/2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70"
data-testid="kanban-search-hotkey"
>
/
</span>
)}
<div className="relative max-w-md flex-1 flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<Input
ref={searchInputRef}
type="text"
placeholder="Search features by keyword..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-12 border-border"
data-testid="kanban-search-input"
/>
{searchQuery ? (
<button
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-sm hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
data-testid="kanban-search-clear"
aria-label="Clear search"
>
<X className="w-4 h-4" />
</button>
) : (
<span
className="absolute right-2 top-1/2 -translate-y-1/2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70"
data-testid="kanban-search-hotkey"
>
/
</span>
)}
</div>
{/* Spec Creation Loading Badge */}
{isCreatingSpec &&
currentProject?.path === creatingSpecProjectPath && (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-brand-500/10 border border-brand-500/20 shrink-0"
title="Creating App Specification"
data-testid="spec-creation-badge"
>
<Loader2 className="w-3 h-3 animate-spin text-brand-500 shrink-0" />
<span className="text-xs font-medium text-brand-500 whitespace-nowrap">
Creating spec
</span>
</div>
)}
</div>
{/* Board Background & Detail Level Controls */}

View File

@@ -248,6 +248,7 @@ export function InterviewView() {
.toLowerCase()
.replace(/[^a-z0-9-]/g, "");
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
return `<project_specification>
<project_name>${projectName || "my-project"}</project_name>

View File

@@ -14,7 +14,17 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Save, RefreshCw, FileText, Sparkles, Loader2, FilePlus2, AlertCircle, ListPlus, CheckCircle2 } 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";
@@ -43,14 +53,14 @@ export function SpecView() {
const [projectOverview, setProjectOverview] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [generateFeatures, setGenerateFeatures] = useState(true);
// Generate features only state
const [isGeneratingFeatures, setIsGeneratingFeatures] = useState(false);
// Logs state (kept for internal tracking, but UI removed)
const [logs, setLogs] = useState<string>("");
const logsRef = useRef<string>("");
// Phase tracking and status
const [currentPhase, setCurrentPhase] = useState<string>("");
const [errorMessage, setErrorMessage] = useState<string>("");
@@ -107,28 +117,33 @@ export function SpecView() {
if (status.success && status.isRunning) {
// Something is running - restore state using backend's authoritative phase
console.log("[SpecView] Spec generation is running - restoring state", { phase: status.currentPhase });
console.log(
"[SpecView] Spec generation is running - restoring state",
{ phase: status.currentPhase }
);
if (!stateRestoredRef.current) {
setIsCreating(true);
setIsRegenerating(true);
stateRestoredRef.current = true;
}
// Use the backend's currentPhase directly - single source of truth
if (status.currentPhase) {
setCurrentPhase(status.currentPhase);
} else {
setCurrentPhase("in progress");
}
// Add resume message to logs if needed
if (!logsRef.current) {
const resumeMessage = "[Status] Resumed monitoring existing spec generation process...\n";
const resumeMessage =
"[Status] Resumed monitoring existing spec generation process...\n";
logsRef.current = resumeMessage;
setLogs(resumeMessage);
} else if (!logsRef.current.includes("Resumed monitoring")) {
const resumeMessage = "\n[Status] Resumed monitoring existing spec generation process...\n";
const resumeMessage =
"\n[Status] Resumed monitoring existing spec generation process...\n";
logsRef.current = logsRef.current + resumeMessage;
setLogs(logsRef.current);
}
@@ -154,7 +169,11 @@ export function SpecView() {
// Sync state when tab becomes visible (user returns to spec editor)
useEffect(() => {
const handleVisibilityChange = async () => {
if (!document.hidden && currentProject && (isCreating || isRegenerating || isGeneratingFeatures)) {
if (
!document.hidden &&
currentProject &&
(isCreating || isRegenerating || isGeneratingFeatures)
) {
// Tab became visible and we think we're still generating - verify status from backend
try {
const api = getElectronAPI();
@@ -162,10 +181,12 @@ export function SpecView() {
const status = await api.specRegeneration.status();
console.log("[SpecView] Visibility change - status check:", status);
if (!status.isRunning) {
// Backend says not running - clear state
console.log("[SpecView] Visibility change: Backend indicates generation complete - clearing state");
console.log(
"[SpecView] Visibility change: Backend indicates generation complete - clearing state"
);
setIsCreating(false);
setIsRegenerating(false);
setIsGeneratingFeatures(false);
@@ -177,7 +198,10 @@ export function SpecView() {
setCurrentPhase(status.currentPhase);
}
} catch (error) {
console.error("[SpecView] Failed to check status on visibility change:", error);
console.error(
"[SpecView] Failed to check status on visibility change:",
error
);
}
}
};
@@ -186,11 +210,21 @@ export function SpecView() {
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [currentProject, isCreating, isRegenerating, isGeneratingFeatures, loadSpec]);
}, [
currentProject,
isCreating,
isRegenerating,
isGeneratingFeatures,
loadSpec,
]);
// Periodic status check to ensure state stays in sync (only when we think we're running)
useEffect(() => {
if (!currentProject || (!isCreating && !isRegenerating && !isGeneratingFeatures)) return;
if (
!currentProject ||
(!isCreating && !isRegenerating && !isGeneratingFeatures)
)
return;
const intervalId = setInterval(async () => {
try {
@@ -198,21 +232,26 @@ export function SpecView() {
if (!api.specRegeneration) return;
const status = await api.specRegeneration.status();
if (!status.isRunning) {
// Backend says not running - clear state
console.log("[SpecView] Periodic check: Backend indicates generation complete - clearing state");
console.log(
"[SpecView] Periodic check: Backend indicates generation complete - clearing state"
);
setIsCreating(false);
setIsRegenerating(false);
setIsGeneratingFeatures(false);
setCurrentPhase("");
stateRestoredRef.current = false;
loadSpec();
} else if (status.currentPhase && status.currentPhase !== currentPhase) {
} else if (
status.currentPhase &&
status.currentPhase !== currentPhase
) {
// Still running but phase changed - update from backend
console.log("[SpecView] Periodic check: Phase updated from backend", {
old: currentPhase,
new: status.currentPhase
console.log("[SpecView] Periodic check: Phase updated from backend", {
old: currentPhase,
new: status.currentPhase,
});
setCurrentPhase(status.currentPhase);
}
@@ -224,173 +263,214 @@ export function SpecView() {
return () => {
clearInterval(intervalId);
};
}, [currentProject, isCreating, isRegenerating, isGeneratingFeatures, currentPhase, loadSpec]);
}, [
currentProject,
isCreating,
isRegenerating,
isGeneratingFeatures,
currentPhase,
loadSpec,
]);
// Subscribe to spec regeneration events
useEffect(() => {
const api = getElectronAPI();
if (!api.specRegeneration) return;
const unsubscribe = api.specRegeneration.onEvent((event: SpecRegenerationEvent) => {
console.log("[SpecView] Regeneration event:", event.type);
const unsubscribe = api.specRegeneration.onEvent(
(event: SpecRegenerationEvent) => {
console.log(
"[SpecView] Regeneration event:",
event.type,
"for project:",
event.projectPath
);
if (event.type === "spec_regeneration_progress") {
// Extract phase from content if present
const phaseMatch = event.content.match(/\[Phase:\s*([^\]]+)\]/);
if (phaseMatch) {
const phase = phaseMatch[1];
setCurrentPhase(phase);
console.log(`[SpecView] Phase updated: ${phase}`);
// If phase is "complete", clear running state immediately
if (phase === "complete") {
console.log("[SpecView] Phase is complete - clearing state");
// Only handle events for the current project
if (event.projectPath !== currentProject?.path) {
console.log("[SpecView] Ignoring event - not for current project");
return;
}
if (event.type === "spec_regeneration_progress") {
// Extract phase from content if present
const phaseMatch = event.content.match(/\[Phase:\s*([^\]]+)\]/);
if (phaseMatch) {
const phase = phaseMatch[1];
setCurrentPhase(phase);
console.log(`[SpecView] Phase updated: ${phase}`);
// If phase is "complete", clear running state immediately
if (phase === "complete") {
console.log("[SpecView] Phase is complete - clearing state");
setIsCreating(false);
setIsRegenerating(false);
stateRestoredRef.current = false;
// Small delay to ensure spec file is written
setTimeout(() => {
loadSpec();
}, SPEC_FILE_WRITE_DELAY);
}
}
// Check for completion indicators in content
if (
event.content.includes("All tasks completed") ||
event.content.includes("✓ All tasks completed")
) {
// This indicates everything is done - clear state immediately
console.log(
"[SpecView] Detected completion in progress message - clearing state"
);
setIsCreating(false);
setIsRegenerating(false);
setCurrentPhase("");
stateRestoredRef.current = false;
// Small delay to ensure spec file is written
setTimeout(() => {
loadSpec();
}, SPEC_FILE_WRITE_DELAY);
}
}
// Check for completion indicators in content
if (event.content.includes("All tasks completed") ||
event.content.includes("✓ All tasks completed")) {
// This indicates everything is done - clear state immediately
console.log("[SpecView] Detected completion in progress message - clearing state");
setIsCreating(false);
setIsRegenerating(false);
setCurrentPhase("");
stateRestoredRef.current = false;
setTimeout(() => {
loadSpec();
}, SPEC_FILE_WRITE_DELAY);
}
// Append progress to logs
const newLog = logsRef.current + event.content;
logsRef.current = newLog;
setLogs(newLog);
console.log("[SpecView] Progress:", event.content.substring(0, 100));
// Append progress to logs
const newLog = logsRef.current + event.content;
logsRef.current = newLog;
setLogs(newLog);
console.log("[SpecView] Progress:", event.content.substring(0, 100));
// Clear error message when we get new progress
if (errorMessage) {
setErrorMessage("");
}
} else if (event.type === "spec_regeneration_tool") {
// Check if this is a feature creation tool
const isFeatureTool = event.tool === "mcp__automaker-tools__UpdateFeatureStatus" ||
event.tool === "UpdateFeatureStatus" ||
event.tool?.includes("Feature");
if (isFeatureTool) {
// Ensure we're in feature generation phase
if (currentPhase !== "feature_generation") {
setCurrentPhase("feature_generation");
// Clear error message when we get new progress
if (errorMessage) {
setErrorMessage("");
}
} else if (event.type === "spec_regeneration_tool") {
// Check if this is a feature creation tool
const isFeatureTool =
event.tool === "mcp__automaker-tools__UpdateFeatureStatus" ||
event.tool === "UpdateFeatureStatus" ||
event.tool?.includes("Feature");
if (isFeatureTool) {
// Ensure we're in feature generation phase
if (currentPhase !== "feature_generation") {
setCurrentPhase("feature_generation");
setIsCreating(true);
setIsRegenerating(true);
console.log(
"[SpecView] Detected feature creation tool - setting phase to feature_generation"
);
}
}
// Log tool usage with details
const toolInput = event.input
? ` (${JSON.stringify(event.input).substring(0, 100)}...)`
: "";
const toolLog = `\n[Tool] ${event.tool}${toolInput}\n`;
const newLog = logsRef.current + toolLog;
logsRef.current = newLog;
setLogs(newLog);
console.log("[SpecView] Tool:", event.tool, event.input);
} else if (event.type === "spec_regeneration_complete") {
// Add completion message to logs first
const completionLog =
logsRef.current + `\n[Complete] ${event.message}\n`;
logsRef.current = completionLog;
setLogs(completionLog);
// --- Completion Detection Logic ---
// 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 === "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) &&
!isIntermediateCompletion;
if (shouldComplete) {
// Fully complete - clear all states immediately
console.log(
"[SpecView] Final completion detected - clearing state",
{
isFinalCompletionMessage,
hasCompletePhase,
message: event.message,
}
);
setIsRegenerating(false);
setIsCreating(false);
setIsGeneratingFeatures(false);
setCurrentPhase("");
setShowRegenerateDialog(false);
setShowCreateDialog(false);
setProjectDefinition("");
setProjectOverview("");
setErrorMessage("");
stateRestoredRef.current = false;
// 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: <CheckCircle2 className="w-4 h-4" />,
}
);
} else if (isIntermediateCompletion) {
// Intermediate completion - keep state active for feature generation
setIsCreating(true);
setIsRegenerating(true);
console.log("[SpecView] Detected feature creation tool - setting phase to feature_generation");
setCurrentPhase("feature_generation");
console.log(
"[SpecView] Intermediate completion, continuing with feature generation"
);
}
}
// Log tool usage with details
const toolInput = event.input ? ` (${JSON.stringify(event.input).substring(0, 100)}...)` : "";
const toolLog = `\n[Tool] ${event.tool}${toolInput}\n`;
const newLog = logsRef.current + toolLog;
logsRef.current = newLog;
setLogs(newLog);
console.log("[SpecView] Tool:", event.tool, event.input);
} else if (event.type === "spec_regeneration_complete") {
// Add completion message to logs first
const completionLog = logsRef.current + `\n[Complete] ${event.message}\n`;
logsRef.current = completionLog;
setLogs(completionLog);
// --- Completion Detection Logic ---
// 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 === "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) && !isIntermediateCompletion;
if (shouldComplete) {
// Fully complete - clear all states immediately
console.log("[SpecView] Final completion detected - clearing state", {
isFinalCompletionMessage,
hasCompletePhase,
message: event.message
});
console.log("[SpecView] Spec generation event:", event.message);
} else if (event.type === "spec_regeneration_error") {
setIsRegenerating(false);
setIsCreating(false);
setIsGeneratingFeatures(false);
setCurrentPhase("");
setShowRegenerateDialog(false);
setShowCreateDialog(false);
setProjectDefinition("");
setProjectOverview("");
setErrorMessage("");
stateRestoredRef.current = false;
// 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: <CheckCircle2 className="w-4 h-4" />,
}
);
} else if (isIntermediateCompletion) {
// Intermediate completion - keep state active for feature generation
setIsCreating(true);
setIsRegenerating(true);
setCurrentPhase("feature_generation");
console.log("[SpecView] Intermediate completion, continuing with feature generation");
setCurrentPhase("error");
setErrorMessage(event.error);
stateRestoredRef.current = false; // Reset restoration flag
// Add error to logs
const errorLog = logsRef.current + `\n\n[ERROR] ${event.error}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
console.error("[SpecView] Regeneration error:", event.error);
}
console.log("[SpecView] Spec generation event:", event.message);
} else if (event.type === "spec_regeneration_error") {
setIsRegenerating(false);
setIsCreating(false);
setIsGeneratingFeatures(false);
setCurrentPhase("error");
setErrorMessage(event.error);
stateRestoredRef.current = false; // Reset restoration flag
// Add error to logs
const errorLog = logsRef.current + `\n\n[ERROR] ${event.error}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
console.error("[SpecView] Regeneration error:", event.error);
}
});
);
return () => {
unsubscribe();
@@ -476,7 +556,10 @@ export function SpecView() {
// Reset logs when starting new generation
logsRef.current = "";
setLogs("");
console.log("[SpecView] Starting spec creation, generateFeatures:", generateFeatures);
console.log(
"[SpecView] Starting spec creation, generateFeatures:",
generateFeatures
);
try {
const api = getElectronAPI();
if (!api.specRegeneration) {
@@ -537,7 +620,10 @@ export function SpecView() {
if (!result.success) {
const errorMsg = result.error || "Unknown error";
console.error("[SpecView] Failed to start feature generation:", errorMsg);
console.error(
"[SpecView] Failed to start feature generation:",
errorMsg
);
setIsGeneratingFeatures(false);
setCurrentPhase("error");
setErrorMessage(errorMsg);
@@ -606,18 +692,31 @@ export function SpecView() {
</div>
<div className="flex flex-col gap-1 min-w-0">
<span className="text-sm font-semibold text-primary leading-tight tracking-tight">
{isCreating ? "Generating Specification" : "Regenerating Specification"}
{isCreating
? "Generating Specification"
: "Regenerating Specification"}
</span>
{currentPhase && (
<span className="text-xs text-muted-foreground/90 leading-tight font-medium">
{currentPhase === "initialization" && "Initializing..."}
{currentPhase === "setup" && "Setting up tools..."}
{currentPhase === "analysis" && "Analyzing project structure..."}
{currentPhase === "spec_complete" && "Spec created! Generating features..."}
{currentPhase === "feature_generation" && "Creating features from roadmap..."}
{currentPhase === "analysis" &&
"Analyzing project structure..."}
{currentPhase === "spec_complete" &&
"Spec created! Generating features..."}
{currentPhase === "feature_generation" &&
"Creating features from roadmap..."}
{currentPhase === "complete" && "Complete!"}
{currentPhase === "error" && "Error occurred"}
{!["initialization", "setup", "analysis", "spec_complete", "feature_generation", "complete", "error"].includes(currentPhase) && currentPhase}
{![
"initialization",
"setup",
"analysis",
"spec_complete",
"feature_generation",
"complete",
"error",
].includes(currentPhase) && currentPhase}
</span>
)}
</div>
@@ -653,12 +752,23 @@ export function SpecView() {
<span className="text-sm font-semibold text-primary text-center tracking-tight">
{currentPhase === "initialization" && "Initializing..."}
{currentPhase === "setup" && "Setting up tools..."}
{currentPhase === "analysis" && "Analyzing project structure..."}
{currentPhase === "spec_complete" && "Spec created! Generating features..."}
{currentPhase === "feature_generation" && "Creating features from roadmap..."}
{currentPhase === "analysis" &&
"Analyzing project structure..."}
{currentPhase === "spec_complete" &&
"Spec created! Generating features..."}
{currentPhase === "feature_generation" &&
"Creating features from roadmap..."}
{currentPhase === "complete" && "Complete!"}
{currentPhase === "error" && "Error occurred"}
{!["initialization", "setup", "analysis", "spec_complete", "feature_generation", "complete", "error"].includes(currentPhase) && currentPhase}
{![
"initialization",
"setup",
"analysis",
"spec_complete",
"feature_generation",
"complete",
"error",
].includes(currentPhase) && currentPhase}
</span>
</div>
)}
@@ -682,10 +792,7 @@ export function SpecView() {
)}
{!isCreating && (
<div className="flex gap-2 justify-center">
<Button
size="lg"
onClick={() => setShowCreateDialog(true)}
>
<Button size="lg" onClick={() => setShowCreateDialog(true)}>
<FilePlus2 className="w-5 h-5 mr-2" />
Create app_spec
</Button>
@@ -695,8 +802,8 @@ export function SpecView() {
</div>
{/* Create Dialog */}
<Dialog
open={showCreateDialog}
<Dialog
open={showCreateDialog}
onOpenChange={(open) => {
if (!open && !isCreating) {
setShowCreateDialog(false);
@@ -707,20 +814,20 @@ export function SpecView() {
<DialogHeader>
<DialogTitle>Create App Specification</DialogTitle>
<DialogDescription className="text-muted-foreground">
We didn&apos;t find an app_spec.txt file. Let us help you generate your app_spec.txt
to help describe your project for our system. We&apos;ll analyze your project&apos;s
tech stack and create a comprehensive specification.
We didn&apos;t find an app_spec.txt file. Let us help you
generate your app_spec.txt to help describe your project for our
system. We&apos;ll analyze your project&apos;s tech stack and
create a comprehensive specification.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">
Project Overview
</label>
<label className="text-sm font-medium">Project Overview</label>
<p className="text-xs text-muted-foreground">
Describe what your project does and what features you want to build.
Be as detailed as you want - this will help us create a better specification.
Describe what your project does and what features you want to
build. Be as detailed as you want - this will help us create a
better specification.
</p>
<textarea
className="w-full h-48 p-3 rounded-md border border-border bg-background font-mono text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
@@ -736,19 +843,23 @@ export function SpecView() {
<Checkbox
id="generate-features"
checked={generateFeatures}
onCheckedChange={(checked) => setGenerateFeatures(checked === true)}
onCheckedChange={(checked) =>
setGenerateFeatures(checked === true)
}
disabled={isCreating}
/>
<div className="space-y-1">
<label
htmlFor="generate-features"
className={`text-sm font-medium ${isCreating ? "" : "cursor-pointer"}`}
className={`text-sm font-medium ${
isCreating ? "" : "cursor-pointer"
}`}
>
Generate feature list
</label>
<p className="text-xs text-muted-foreground">
Automatically create features in the features folder from the
implementation roadmap after the spec is generated.
Automatically create features in the features folder from
the implementation roadmap after the spec is generated.
</p>
</div>
</div>
@@ -812,18 +923,33 @@ export function SpecView() {
</div>
<div className="flex flex-col gap-1 min-w-0">
<span className="text-sm font-semibold text-primary leading-tight tracking-tight">
{isGeneratingFeatures ? "Generating Features" : isCreating ? "Generating Specification" : "Regenerating Specification"}
{isGeneratingFeatures
? "Generating Features"
: isCreating
? "Generating Specification"
: "Regenerating Specification"}
</span>
{currentPhase && (
<span className="text-xs text-muted-foreground/90 leading-tight font-medium">
{currentPhase === "initialization" && "Initializing..."}
{currentPhase === "setup" && "Setting up tools..."}
{currentPhase === "analysis" && "Analyzing project structure..."}
{currentPhase === "spec_complete" && "Spec created! Generating features..."}
{currentPhase === "feature_generation" && "Creating features from roadmap..."}
{currentPhase === "analysis" &&
"Analyzing project structure..."}
{currentPhase === "spec_complete" &&
"Spec created! Generating features..."}
{currentPhase === "feature_generation" &&
"Creating features from roadmap..."}
{currentPhase === "complete" && "Complete!"}
{currentPhase === "error" && "Error occurred"}
{!["initialization", "setup", "analysis", "spec_complete", "feature_generation", "complete", "error"].includes(currentPhase) && currentPhase}
{![
"initialization",
"setup",
"analysis",
"spec_complete",
"feature_generation",
"complete",
"error",
].includes(currentPhase) && currentPhase}
</span>
)}
</div>
@@ -833,8 +959,12 @@ export function SpecView() {
<div className="flex items-center gap-3 px-6 py-3.5 rounded-xl bg-gradient-to-r from-destructive/15 to-destructive/5 border border-destructive/30 shadow-lg backdrop-blur-md">
<AlertCircle className="w-5 h-5 text-destructive flex-shrink-0" />
<div className="flex flex-col gap-1 min-w-0">
<span className="text-sm font-semibold text-destructive leading-tight tracking-tight">Error</span>
<span className="text-xs text-destructive/90 leading-tight font-medium">{errorMessage}</span>
<span className="text-sm font-semibold text-destructive leading-tight tracking-tight">
Error
</span>
<span className="text-xs text-destructive/90 leading-tight font-medium">
{errorMessage}
</span>
</div>
</div>
)}
@@ -856,7 +986,13 @@ export function SpecView() {
<Button
size="sm"
onClick={saveSpec}
disabled={!hasChanges || isSaving || isCreating || isRegenerating || isGeneratingFeatures}
disabled={
!hasChanges ||
isSaving ||
isCreating ||
isRegenerating ||
isGeneratingFeatures
}
data-testid="save-spec"
>
<Save className="w-4 h-4 mr-2" />
@@ -879,8 +1015,8 @@ export function SpecView() {
</div>
{/* Regenerate Dialog */}
<Dialog
open={showRegenerateDialog}
<Dialog
open={showRegenerateDialog}
onOpenChange={(open) => {
if (!open && !isRegenerating) {
setShowRegenerateDialog(false);
@@ -891,9 +1027,10 @@ export function SpecView() {
<DialogHeader>
<DialogTitle>Regenerate App Specification</DialogTitle>
<DialogDescription className="text-muted-foreground">
We will regenerate your app spec based on a short project definition and the
current tech stack found in your project. The agent will analyze your codebase
to understand your existing technologies and create a comprehensive specification.
We will regenerate your app spec based on a short project
definition and the current tech stack found in your project. The
agent will analyze your codebase to understand your existing
technologies and create a comprehensive specification.
</DialogDescription>
</DialogHeader>
@@ -903,8 +1040,9 @@ export function SpecView() {
Describe your project
</label>
<p className="text-xs text-muted-foreground">
Provide a clear description of what your app should do. Be as detailed as you
want - the more context you provide, the more comprehensive the spec will be.
Provide a clear description of what your app should do. Be as
detailed as you want - the more context you provide, the more
comprehensive the spec will be.
</p>
<textarea
className="w-full h-40 p-3 rounded-md border border-border bg-background font-mono text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
@@ -945,9 +1083,17 @@ export function SpecView() {
</Button>
<HotkeyButton
onClick={handleRegenerate}
disabled={!projectDefinition.trim() || isRegenerating || isGeneratingFeatures}
disabled={
!projectDefinition.trim() ||
isRegenerating ||
isGeneratingFeatures
}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={showRegenerateDialog && !isRegenerating && !isGeneratingFeatures}
hotkeyActive={
showRegenerateDialog &&
!isRegenerating &&
!isGeneratingFeatures
}
>
{isRegenerating ? (
<>
@@ -965,7 +1111,6 @@ export function SpecView() {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -255,6 +255,7 @@ export function WelcomeView() {
}
// Update the app_spec.txt with the project name
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>
@@ -352,6 +353,7 @@ export function WelcomeView() {
}
// Update the app_spec.txt with template-specific info
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>
@@ -456,6 +458,7 @@ export function WelcomeView() {
}
// Update the app_spec.txt with basic info
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>

View File

@@ -1,11 +1,18 @@
"use client";
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
import {
createContext,
useContext,
useState,
useCallback,
type ReactNode,
} from "react";
import { FileBrowserDialog } from "@/components/dialogs/file-browser-dialog";
interface FileBrowserOptions {
title?: string;
description?: string;
initialPath?: string;
}
interface FileBrowserContextValue {
@@ -16,36 +23,47 @@ const FileBrowserContext = createContext<FileBrowserContextValue | null>(null);
export function FileBrowserProvider({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const [resolver, setResolver] = useState<((value: string | null) => void) | null>(null);
const [resolver, setResolver] = useState<
((value: string | null) => void) | null
>(null);
const [dialogOptions, setDialogOptions] = useState<FileBrowserOptions>({});
const openFileBrowser = useCallback((options?: FileBrowserOptions): Promise<string | null> => {
return new Promise((resolve) => {
setDialogOptions(options || {});
setIsOpen(true);
setResolver(() => resolve);
});
}, []);
const openFileBrowser = useCallback(
(options?: FileBrowserOptions): Promise<string | null> => {
return new Promise((resolve) => {
setDialogOptions(options || {});
setIsOpen(true);
setResolver(() => resolve);
});
},
[]
);
const handleSelect = useCallback((path: string) => {
if (resolver) {
resolver(path);
setResolver(null);
}
setIsOpen(false);
setDialogOptions({});
}, [resolver]);
const handleOpenChange = useCallback((open: boolean) => {
if (!open && resolver) {
resolver(null);
setResolver(null);
}
setIsOpen(open);
if (!open) {
const handleSelect = useCallback(
(path: string) => {
if (resolver) {
resolver(path);
setResolver(null);
}
setIsOpen(false);
setDialogOptions({});
}
}, [resolver]);
},
[resolver]
);
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open && resolver) {
resolver(null);
setResolver(null);
}
setIsOpen(open);
if (!open) {
setDialogOptions({});
}
},
[resolver]
);
return (
<FileBrowserContext.Provider value={{ openFileBrowser }}>
@@ -56,6 +74,7 @@ export function FileBrowserProvider({ children }: { children: ReactNode }) {
onSelect={handleSelect}
title={dialogOptions.title}
description={dialogOptions.description}
initialPath={dialogOptions.initialPath}
/>
</FileBrowserContext.Provider>
);
@@ -70,9 +89,13 @@ export function useFileBrowser() {
}
// Global reference for non-React code (like HttpApiClient)
let globalFileBrowserFn: ((options?: FileBrowserOptions) => Promise<string | null>) | null = null;
let globalFileBrowserFn:
| ((options?: FileBrowserOptions) => Promise<string | null>)
| null = null;
export function setGlobalFileBrowser(fn: (options?: FileBrowserOptions) => Promise<string | null>) {
export function setGlobalFileBrowser(
fn: (options?: FileBrowserOptions) => Promise<string | null>
) {
globalFileBrowserFn = fn;
}

View File

@@ -133,10 +133,15 @@ export interface SuggestionsAPI {
// Spec Regeneration types
export type SpecRegenerationEvent =
| { type: "spec_regeneration_progress"; content: string }
| { type: "spec_regeneration_tool"; tool: string; input: unknown }
| { type: "spec_regeneration_complete"; message: string }
| { type: "spec_regeneration_error"; error: string };
| { type: "spec_regeneration_progress"; content: string; projectPath: string }
| {
type: "spec_regeneration_tool";
tool: string;
input: unknown;
projectPath: string;
}
| { type: "spec_regeneration_complete"; message: string; projectPath: string }
| { type: "spec_regeneration_error"; error: string; projectPath: string };
export interface SpecRegenerationAPI {
create: (
@@ -1923,6 +1928,7 @@ async function simulateSpecCreation(
emitSpecRegenerationEvent({
type: "spec_regeneration_progress",
content: "[Phase: initialization] Starting project analysis...\n",
projectPath: projectPath,
});
await new Promise((resolve) => {
@@ -1935,6 +1941,7 @@ async function simulateSpecCreation(
type: "spec_regeneration_tool",
tool: "Glob",
input: { pattern: "**/*.{json,ts,tsx}" },
projectPath: projectPath,
});
await new Promise((resolve) => {
@@ -1946,6 +1953,7 @@ async function simulateSpecCreation(
emitSpecRegenerationEvent({
type: "spec_regeneration_progress",
content: "[Phase: analysis] Detecting tech stack...\n",
projectPath: projectPath,
});
await new Promise((resolve) => {
@@ -1989,6 +1997,7 @@ async function simulateSpecCreation(
emitSpecRegenerationEvent({
type: "spec_regeneration_complete",
message: "All tasks completed!",
projectPath: projectPath,
});
mockSpecRegenerationRunning = false;
@@ -2004,6 +2013,7 @@ async function simulateSpecRegeneration(
emitSpecRegenerationEvent({
type: "spec_regeneration_progress",
content: "[Phase: initialization] Starting spec regeneration...\n",
projectPath: projectPath,
});
await new Promise((resolve) => {
@@ -2015,6 +2025,7 @@ async function simulateSpecRegeneration(
emitSpecRegenerationEvent({
type: "spec_regeneration_progress",
content: "[Phase: analysis] Analyzing codebase...\n",
projectPath: projectPath,
});
await new Promise((resolve) => {
@@ -2049,6 +2060,7 @@ async function simulateSpecRegeneration(
emitSpecRegenerationEvent({
type: "spec_regeneration_complete",
message: "All tasks completed!",
projectPath: projectPath,
});
mockSpecRegenerationRunning = false;
@@ -2062,6 +2074,7 @@ async function simulateFeatureGeneration(projectPath: string) {
type: "spec_regeneration_progress",
content:
"[Phase: initialization] Starting feature generation from existing app_spec.txt...\n",
projectPath: projectPath,
});
await new Promise((resolve) => {
@@ -2072,6 +2085,7 @@ async function simulateFeatureGeneration(projectPath: string) {
emitSpecRegenerationEvent({
type: "spec_regeneration_progress",
content: "[Phase: feature_generation] Reading implementation roadmap...\n",
projectPath: projectPath,
});
await new Promise((resolve) => {
@@ -2083,6 +2097,7 @@ async function simulateFeatureGeneration(projectPath: string) {
emitSpecRegenerationEvent({
type: "spec_regeneration_progress",
content: "[Phase: feature_generation] Creating features from roadmap...\n",
projectPath: projectPath,
});
await new Promise((resolve) => {
@@ -2094,11 +2109,13 @@ async function simulateFeatureGeneration(projectPath: string) {
emitSpecRegenerationEvent({
type: "spec_regeneration_progress",
content: "[Phase: complete] All tasks completed!\n",
projectPath: projectPath,
});
emitSpecRegenerationEvent({
type: "spec_regeneration_complete",
message: "All tasks completed!",
projectPath: projectPath,
});
mockSpecRegenerationRunning = false;

View File

@@ -428,6 +428,10 @@ export interface AppState {
// Terminal state
terminalState: TerminalState;
// Spec Creation State (per-project, keyed by project path)
// Tracks which project is currently having its spec generated
specCreatingForProject: string | null;
}
// Default background settings for board backgrounds
@@ -630,6 +634,10 @@ export interface AppActions {
direction?: "horizontal" | "vertical"
) => void;
// Spec Creation actions
setSpecCreatingForProject: (projectPath: string | null) => void;
isSpecCreatingForProject: (projectPath: string) => boolean;
// Reset
reset: () => void;
}
@@ -713,6 +721,7 @@ const initialState: AppState = {
activeSessionId: null,
defaultFontSize: 14,
},
specCreatingForProject: null,
};
export const useAppStore = create<AppState & AppActions>()(
@@ -2080,6 +2089,15 @@ export const useAppStore = create<AppState & AppActions>()(
});
},
// Spec Creation actions
setSpecCreatingForProject: (projectPath) => {
set({ specCreatingForProject: projectPath });
},
isSpecCreatingForProject: (projectPath) => {
return get().specCreatingForProject === projectPath;
},
// Reset
reset: () => set(initialState),
}),

View File

@@ -243,19 +243,23 @@ export type SpecRegenerationEvent =
| {
type: "spec_regeneration_progress";
content: string;
projectPath: string;
}
| {
type: "spec_regeneration_tool";
tool: string;
input: unknown;
projectPath: string;
}
| {
type: "spec_regeneration_complete";
message: string;
projectPath: string;
}
| {
type: "spec_regeneration_error";
error: string;
projectPath: string;
};
export interface SpecRegenerationAPI {