feat: refactor spec-view to folder pattern and add feature count selector

Closes #151

- Refactor spec-view.tsx from 1,230 lines to ~170 lines following folder-pattern.md
- Create unified CreateSpecDialog with all features from both dialogs:
  - featureCount selector (20/50/100) - was missing in spec-view
  - analyzeProject checkbox - was missing in sidebar
- Extract components: spec-header, spec-editor, spec-empty-state
- Extract hooks: use-spec-loading, use-spec-save, use-spec-generation
- Extract dialogs: create-spec-dialog, regenerate-spec-dialog
- Update sidebar to use new CreateSpecDialog with analyzeProject state
- Delete deprecated project-setup-dialog.tsx

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kacper
2025-12-18 00:14:44 +01:00
parent 3eac848d4f
commit e78bfc80ec
15 changed files with 1510 additions and 1231 deletions

View File

@@ -81,10 +81,8 @@ import { themeOptions } from "@/config/theme-options";
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 {
ProjectSetupDialog,
type FeatureCount,
} from "@/components/layout/project-setup-dialog";
import { CreateSpecDialog } from "@/components/views/spec-view/dialogs";
import type { FeatureCount } from "@/components/views/spec-view/types";
import {
DndContext,
DragEndEvent,
@@ -290,6 +288,7 @@ export function Sidebar() {
const [setupProjectPath, setSetupProjectPath] = useState("");
const [projectOverview, setProjectOverview] = useState("");
const [generateFeatures, setGenerateFeatures] = useState(true);
const [analyzeProject, setAnalyzeProject] = useState(true);
const [featureCount, setFeatureCount] = useState<FeatureCount>(50);
const [showSpecIndicator, setShowSpecIndicator] = useState(true);
@@ -496,7 +495,7 @@ export function Sidebar() {
setupProjectPath,
projectOverview.trim(),
generateFeatures,
undefined, // analyzeProject - use default
analyzeProject,
generateFeatures ? featureCount : undefined // only pass maxFeatures if generating features
);
@@ -525,6 +524,7 @@ export function Sidebar() {
setupProjectPath,
projectOverview,
generateFeatures,
analyzeProject,
featureCount,
setSpecCreatingForProject,
]);
@@ -2276,18 +2276,23 @@ export function Sidebar() {
</Dialog>
{/* New Project Setup Dialog */}
<ProjectSetupDialog
<CreateSpecDialog
open={showSetupDialog}
onOpenChange={setShowSetupDialog}
projectOverview={projectOverview}
onProjectOverviewChange={setProjectOverview}
generateFeatures={generateFeatures}
onGenerateFeaturesChange={setGenerateFeatures}
analyzeProject={analyzeProject}
onAnalyzeProjectChange={setAnalyzeProject}
featureCount={featureCount}
onFeatureCountChange={setFeatureCount}
onCreateSpec={handleCreateInitialSpec}
onSkip={handleSkipSetup}
isCreatingSpec={isCreatingSpec}
showSkipButton={true}
title="Set Up Your Project"
description="We didn'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'll analyze your project's tech stack and create a comprehensive specification."
/>
{/* New Project Onboarding Dialog */}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
export { SpecHeader } from "./spec-header";
export { SpecEditor } from "./spec-editor";
export { SpecEmptyState } from "./spec-empty-state";

View File

@@ -0,0 +1,22 @@
import { Card } from "@/components/ui/card";
import { XmlSyntaxEditor } from "@/components/ui/xml-syntax-editor";
interface SpecEditorProps {
value: string;
onChange: (value: string) => void;
}
export function SpecEditor({ value, onChange }: SpecEditorProps) {
return (
<div className="flex-1 p-4 overflow-hidden">
<Card className="h-full">
<XmlSyntaxEditor
value={value}
onChange={onChange}
placeholder="Write your app specification here..."
data-testid="spec-editor"
/>
</Card>
</div>
);
}

View File

@@ -0,0 +1,127 @@
import { Button } from "@/components/ui/button";
import {
FileText,
FilePlus2,
Loader2,
} from "lucide-react";
import { PHASE_LABELS } from "../constants";
interface SpecEmptyStateProps {
projectPath: string;
isCreating: boolean;
isRegenerating: boolean;
currentPhase: string;
errorMessage: string;
onCreateClick: () => void;
}
export function SpecEmptyState({
projectPath,
isCreating,
isRegenerating,
currentPhase,
errorMessage,
onCreateClick,
}: SpecEmptyStateProps) {
const isProcessing = isCreating || isRegenerating;
const phaseLabel = PHASE_LABELS[currentPhase] || currentPhase;
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="spec-view-empty"
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-3">
<FileText className="w-5 h-5 text-muted-foreground" />
<div>
<h1 className="text-xl font-bold">App Specification</h1>
<p className="text-sm text-muted-foreground">
{projectPath}/.automaker/app_spec.txt
</p>
</div>
</div>
{isProcessing && (
<div className="flex items-center gap-3 px-6 py-3.5 rounded-xl bg-linear-to-r from-primary/15 to-primary/5 border border-primary/30 shadow-lg backdrop-blur-md">
<div className="relative">
<Loader2 className="w-5 h-5 animate-spin text-primary shrink-0" />
<div className="absolute inset-0 w-5 h-5 animate-ping text-primary/20" />
</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"}
</span>
{currentPhase && (
<span className="text-xs text-muted-foreground/90 leading-tight font-medium">
{phaseLabel}
</span>
)}
</div>
</div>
)}
{errorMessage && (
<div className="flex items-center gap-2 text-destructive">
<span className="text-sm font-medium">Error: {errorMessage}</span>
</div>
)}
</div>
{/* Empty State Content */}
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-center max-w-md">
<div className="mb-6 flex justify-center">
<div className="p-4 rounded-full bg-primary/10">
{isCreating ? (
<Loader2 className="w-12 h-12 text-primary animate-spin" />
) : (
<FilePlus2 className="w-12 h-12 text-primary" />
)}
</div>
</div>
<h2 className="text-2xl font-semibold mb-4">
{isCreating ? (
<>
<div className="mb-4">
<span>Generating App Specification</span>
</div>
{currentPhase && (
<div className="px-6 py-3.5 rounded-xl bg-linear-to-r from-primary/15 to-primary/5 border border-primary/30 shadow-lg backdrop-blur-md inline-flex items-center justify-center">
<span className="text-sm font-semibold text-primary text-center tracking-tight">
{phaseLabel}
</span>
</div>
)}
</>
) : (
"No App Specification Found"
)}
</h2>
<p className="text-muted-foreground mb-6">
{isCreating
? currentPhase === "feature_generation"
? "The app specification has been created! Now generating features from the implementation roadmap..."
: "We're analyzing your project and generating a comprehensive specification. This may take a few moments..."
: "Create an app specification to help our system understand your project. We'll analyze your codebase and generate a comprehensive spec based on your description."}
</p>
{errorMessage && (
<div className="mb-4 p-3 rounded-md bg-destructive/10 border border-destructive/20">
<p className="text-sm text-destructive font-medium">Error:</p>
<p className="text-sm text-destructive">{errorMessage}</p>
</div>
)}
{!isCreating && (
<div className="flex gap-2 justify-center">
<Button size="lg" onClick={onCreateClick}>
<FilePlus2 className="w-5 h-5 mr-2" />
Create app_spec
</Button>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
import { Button } from "@/components/ui/button";
import {
Save,
Sparkles,
Loader2,
FileText,
AlertCircle,
} from "lucide-react";
import { PHASE_LABELS } from "../constants";
interface SpecHeaderProps {
projectPath: string;
isRegenerating: boolean;
isCreating: boolean;
isGeneratingFeatures: boolean;
isSaving: boolean;
hasChanges: boolean;
currentPhase: string;
errorMessage: string;
onRegenerateClick: () => void;
onSaveClick: () => void;
}
export function SpecHeader({
projectPath,
isRegenerating,
isCreating,
isGeneratingFeatures,
isSaving,
hasChanges,
currentPhase,
errorMessage,
onRegenerateClick,
onSaveClick,
}: SpecHeaderProps) {
const isProcessing = isRegenerating || isCreating || isGeneratingFeatures;
const phaseLabel = PHASE_LABELS[currentPhase] || currentPhase;
return (
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-3">
<FileText className="w-5 h-5 text-muted-foreground" />
<div>
<h1 className="text-xl font-bold">App Specification</h1>
<p className="text-sm text-muted-foreground">
{projectPath}/.automaker/app_spec.txt
</p>
</div>
</div>
<div className="flex items-center gap-3">
{isProcessing && (
<div className="flex items-center gap-3 px-6 py-3.5 rounded-xl bg-linear-to-r from-primary/15 to-primary/5 border border-primary/30 shadow-lg backdrop-blur-md">
<div className="relative">
<Loader2 className="w-5 h-5 animate-spin text-primary shrink-0" />
<div className="absolute inset-0 w-5 h-5 animate-ping text-primary/20" />
</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"}
</span>
{currentPhase && (
<span className="text-xs text-muted-foreground/90 leading-tight font-medium">
{phaseLabel}
</span>
)}
</div>
</div>
)}
{errorMessage && (
<div className="flex items-center gap-3 px-6 py-3.5 rounded-xl bg-linear-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 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>
</div>
</div>
)}
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={onRegenerateClick}
disabled={isProcessing}
data-testid="regenerate-spec"
>
{isRegenerating ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Sparkles className="w-4 h-4 mr-2" />
)}
{isRegenerating ? "Regenerating..." : "Regenerate"}
</Button>
<Button
size="sm"
onClick={onSaveClick}
disabled={!hasChanges || isSaving || isProcessing}
data-testid="save-spec"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? "Saving..." : hasChanges ? "Save Changes" : "Saved"}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import type { FeatureCount } from "./types";
// Delay before reloading spec file to ensure it's written to disk
export const SPEC_FILE_WRITE_DELAY = 500;
// Interval for polling backend status during generation
export const STATUS_CHECK_INTERVAL_MS = 2000;
// Feature count options with labels and warnings
export const FEATURE_COUNT_OPTIONS: {
value: FeatureCount;
label: string;
warning?: string;
}[] = [
{ value: 20, label: "20" },
{ value: 50, label: "50", warning: "May take up to 5 minutes" },
{ value: 100, label: "100", warning: "May take up to 5 minutes" },
];
// Phase display labels for UI
export const PHASE_LABELS: Record<string, string> = {
initialization: "Initializing...",
setup: "Setting up tools...",
analysis: "Analyzing project structure...",
spec_complete: "Spec created! Generating features...",
feature_generation: "Creating features from roadmap...",
complete: "Complete!",
error: "Error occurred",
};

View File

@@ -1,5 +1,4 @@
import { Sparkles, Clock } from "lucide-react";
import { Sparkles, Clock, Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
@@ -9,66 +8,49 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import { FEATURE_COUNT_OPTIONS } from "../constants";
import type { CreateSpecDialogProps, FeatureCount } from "../types";
// Feature count options
export type FeatureCount = 20 | 50 | 100;
const FEATURE_COUNT_OPTIONS: {
value: FeatureCount;
label: string;
warning?: string;
}[] = [
{ value: 20, label: "20" },
{ value: 50, label: "50", warning: "May take up to 5 minutes" },
{ value: 100, label: "100", warning: "May take up to 5 minutes" },
];
interface ProjectSetupDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectOverview: string;
onProjectOverviewChange: (value: string) => void;
generateFeatures: boolean;
onGenerateFeaturesChange: (value: boolean) => void;
featureCount: FeatureCount;
onFeatureCountChange: (value: FeatureCount) => void;
onCreateSpec: () => void;
onSkip: () => void;
isCreatingSpec: boolean;
}
export function ProjectSetupDialog({
export function CreateSpecDialog({
open,
onOpenChange,
projectOverview,
onProjectOverviewChange,
generateFeatures,
onGenerateFeaturesChange,
analyzeProject,
onAnalyzeProjectChange,
featureCount,
onFeatureCountChange,
onCreateSpec,
onSkip,
isCreatingSpec,
}: ProjectSetupDialogProps) {
showSkipButton = false,
title = "Create App Specification",
description = "We didn'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'll analyze your project's tech stack and create a comprehensive specification.",
}: CreateSpecDialogProps) {
const selectedOption = FEATURE_COUNT_OPTIONS.find(
(o) => o.value === featureCount
);
return (
<Dialog
open={open}
onOpenChange={(open) => {
onOpenChange(open);
if (!open && !isCreatingSpec) {
if (!open && !isCreatingSpec && onSkip) {
onSkip();
}
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Set Up Your Project</DialogTitle>
<DialogTitle>{title}</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.
{description}
</DialogDescription>
</DialogHeader>
@@ -86,21 +68,52 @@ export function ProjectSetupDialog({
onChange={(e) => onProjectOverviewChange(e.target.value)}
placeholder="e.g., A project management tool that allows teams to track tasks, manage sprints, and visualize progress through kanban boards. It should support user authentication, real-time updates, and file attachments..."
autoFocus
disabled={isCreatingSpec}
/>
</div>
<div className="flex items-start space-x-3 pt-2">
<Checkbox
id="sidebar-generate-features"
id="create-analyze-project"
checked={analyzeProject}
onCheckedChange={(checked) =>
onAnalyzeProjectChange(checked === true)
}
disabled={isCreatingSpec}
/>
<div className="space-y-1">
<label
htmlFor="create-analyze-project"
className={`text-sm font-medium ${
isCreatingSpec ? "" : "cursor-pointer"
}`}
>
Analyze current project for additional context
</label>
<p className="text-xs text-muted-foreground">
If checked, the agent will research your existing codebase to
understand the tech stack. If unchecked, defaults to TanStack
Start, Drizzle ORM, PostgreSQL, shadcn/ui, Tailwind CSS, and
React.
</p>
</div>
</div>
<div className="flex items-start space-x-3 pt-2">
<Checkbox
id="create-generate-features"
checked={generateFeatures}
onCheckedChange={(checked) =>
onGenerateFeaturesChange(checked === true)
}
disabled={isCreatingSpec}
/>
<div className="space-y-1">
<label
htmlFor="sidebar-generate-features"
className="text-sm font-medium cursor-pointer"
htmlFor="create-generate-features"
className={`text-sm font-medium ${
isCreatingSpec ? "" : "cursor-pointer"
}`}
>
Generate feature list
</label>
@@ -124,7 +137,10 @@ export function ProjectSetupDialog({
featureCount === option.value ? "default" : "outline"
}
size="sm"
onClick={() => onFeatureCountChange(option.value)}
onClick={() =>
onFeatureCountChange(option.value as FeatureCount)
}
disabled={isCreatingSpec}
className={cn(
"flex-1 transition-all",
featureCount === option.value
@@ -137,14 +153,10 @@ export function ProjectSetupDialog({
</Button>
))}
</div>
{FEATURE_COUNT_OPTIONS.find((o) => o.value === featureCount)
?.warning && (
{selectedOption?.warning && (
<p className="text-xs text-amber-500 flex items-center gap-1">
<Clock className="w-3 h-3" />
{
FEATURE_COUNT_OPTIONS.find((o) => o.value === featureCount)
?.warning
}
{selectedOption.warning}
</p>
)}
</div>
@@ -152,13 +164,37 @@ export function ProjectSetupDialog({
</div>
<DialogFooter>
<Button variant="ghost" onClick={onSkip}>
Skip for now
</Button>
<Button onClick={onCreateSpec} disabled={!projectOverview.trim()}>
<Sparkles className="w-4 h-4 mr-2" />
Generate Spec
</Button>
{showSkipButton && onSkip ? (
<Button variant="ghost" onClick={onSkip} disabled={isCreatingSpec}>
Skip for now
</Button>
) : (
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isCreatingSpec}
>
Cancel
</Button>
)}
<HotkeyButton
onClick={onCreateSpec}
disabled={!projectOverview.trim() || isCreatingSpec}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={open && !isCreatingSpec}
>
{isCreatingSpec ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Generating...
</>
) : (
<>
<Sparkles className="w-4 h-4 mr-2" />
Generate Spec
</>
)}
</HotkeyButton>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -0,0 +1,2 @@
export { CreateSpecDialog } from "./create-spec-dialog";
export { RegenerateSpecDialog } from "./regenerate-spec-dialog";

View File

@@ -0,0 +1,197 @@
import { Sparkles, Clock, Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import { FEATURE_COUNT_OPTIONS } from "../constants";
import type { RegenerateSpecDialogProps, FeatureCount } from "../types";
export function RegenerateSpecDialog({
open,
onOpenChange,
projectDefinition,
onProjectDefinitionChange,
generateFeatures,
onGenerateFeaturesChange,
analyzeProject,
onAnalyzeProjectChange,
featureCount,
onFeatureCountChange,
onRegenerate,
isRegenerating,
isGeneratingFeatures = false,
}: RegenerateSpecDialogProps) {
const selectedOption = FEATURE_COUNT_OPTIONS.find(
(o) => o.value === featureCount
);
const isDisabled = isRegenerating || isGeneratingFeatures;
return (
<Dialog
open={open}
onOpenChange={(open) => {
if (!open && !isRegenerating) {
onOpenChange(false);
}
}}
>
<DialogContent className="max-w-2xl">
<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.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">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.
</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"
value={projectDefinition}
onChange={(e) => onProjectDefinitionChange(e.target.value)}
placeholder="e.g., A task management app where users can create projects, add tasks with due dates, assign tasks to team members, track progress with a kanban board, and receive notifications for upcoming deadlines..."
disabled={isDisabled}
/>
</div>
<div className="flex items-start space-x-3 pt-2">
<Checkbox
id="regenerate-analyze-project"
checked={analyzeProject}
onCheckedChange={(checked) =>
onAnalyzeProjectChange(checked === true)
}
disabled={isDisabled}
/>
<div className="space-y-1">
<label
htmlFor="regenerate-analyze-project"
className={`text-sm font-medium ${
isDisabled ? "" : "cursor-pointer"
}`}
>
Analyze current project for additional context
</label>
<p className="text-xs text-muted-foreground">
If checked, the agent will research your existing codebase to
understand the tech stack. If unchecked, defaults to TanStack
Start, Drizzle ORM, PostgreSQL, shadcn/ui, Tailwind CSS, and
React.
</p>
</div>
</div>
<div className="flex items-start space-x-3 pt-2">
<Checkbox
id="regenerate-generate-features"
checked={generateFeatures}
onCheckedChange={(checked) =>
onGenerateFeaturesChange(checked === true)
}
disabled={isDisabled}
/>
<div className="space-y-1">
<label
htmlFor="regenerate-generate-features"
className={`text-sm font-medium ${
isDisabled ? "" : "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 regenerated.
</p>
</div>
</div>
{/* Feature Count Selection - only shown when generateFeatures is enabled */}
{generateFeatures && (
<div className="space-y-2 pt-2 pl-7">
<label className="text-sm font-medium">Number of Features</label>
<div className="flex gap-2">
{FEATURE_COUNT_OPTIONS.map((option) => (
<Button
key={option.value}
type="button"
variant={
featureCount === option.value ? "default" : "outline"
}
size="sm"
onClick={() =>
onFeatureCountChange(option.value as FeatureCount)
}
disabled={isDisabled}
className={cn(
"flex-1 transition-all",
featureCount === option.value
? "bg-primary hover:bg-primary/90 text-primary-foreground"
: "bg-muted/30 hover:bg-muted/50 border-border"
)}
data-testid={`regenerate-feature-count-${option.value}`}
>
{option.label}
</Button>
))}
</div>
{selectedOption?.warning && (
<p className="text-xs text-amber-500 flex items-center gap-1">
<Clock className="w-3 h-3" />
{selectedOption.warning}
</p>
)}
</div>
)}
</div>
<DialogFooter>
<div className="flex gap-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isDisabled}
>
Cancel
</Button>
<HotkeyButton
onClick={onRegenerate}
disabled={!projectDefinition.trim() || isDisabled}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={open && !isDisabled}
>
{isRegenerating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Regenerating...
</>
) : (
<>
<Sparkles className="w-4 h-4 mr-2" />
Regenerate Spec
</>
)}
</HotkeyButton>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,3 @@
export { useSpecLoading } from "./use-spec-loading";
export { useSpecSave } from "./use-spec-save";
export { useSpecGeneration } from "./use-spec-generation";

View File

@@ -0,0 +1,662 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { toast } from "sonner";
import { CheckCircle2 } from "lucide-react";
import { createElement } from "react";
import { SPEC_FILE_WRITE_DELAY, STATUS_CHECK_INTERVAL_MS } from "../constants";
import type { FeatureCount } from "../types";
import type { SpecRegenerationEvent } from "@/types/electron";
interface UseSpecGenerationOptions {
loadSpec: () => Promise<void>;
}
export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
const { currentProject } = useAppStore();
// Dialog visibility state
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false);
// Create spec state
const [projectOverview, setProjectOverview] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [generateFeatures, setGenerateFeatures] = useState(true);
const [analyzeProjectOnCreate, setAnalyzeProjectOnCreate] = useState(true);
const [featureCountOnCreate, setFeatureCountOnCreate] =
useState<FeatureCount>(50);
// Regenerate spec state
const [projectDefinition, setProjectDefinition] = useState("");
const [isRegenerating, setIsRegenerating] = useState(false);
const [generateFeaturesOnRegenerate, setGenerateFeaturesOnRegenerate] =
useState(true);
const [analyzeProjectOnRegenerate, setAnalyzeProjectOnRegenerate] =
useState(true);
const [featureCountOnRegenerate, setFeatureCountOnRegenerate] =
useState<FeatureCount>(50);
// Generate features only state
const [isGeneratingFeatures, setIsGeneratingFeatures] = useState(false);
// Logs state (kept for internal tracking)
const [logs, setLogs] = useState<string>("");
const logsRef = useRef<string>("");
// Phase tracking and status
const [currentPhase, setCurrentPhase] = useState<string>("");
const [errorMessage, setErrorMessage] = useState<string>("");
const statusCheckRef = useRef<boolean>(false);
const stateRestoredRef = useRef<boolean>(false);
const pendingStatusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Reset all state when project changes
useEffect(() => {
setIsCreating(false);
setIsRegenerating(false);
setIsGeneratingFeatures(false);
setCurrentPhase("");
setErrorMessage("");
setLogs("");
logsRef.current = "";
stateRestoredRef.current = false;
statusCheckRef.current = false;
if (pendingStatusTimeoutRef.current) {
clearTimeout(pendingStatusTimeoutRef.current);
pendingStatusTimeoutRef.current = null;
}
}, [currentProject?.path]);
// Check if spec regeneration is running when component mounts or project changes
useEffect(() => {
const checkStatus = async () => {
if (!currentProject || statusCheckRef.current) return;
statusCheckRef.current = true;
try {
const api = getElectronAPI();
if (!api.specRegeneration) {
statusCheckRef.current = false;
return;
}
const status = await api.specRegeneration.status();
console.log(
"[useSpecGeneration] Status check on mount:",
status,
"for project:",
currentProject.path
);
if (status.success && status.isRunning) {
console.log(
"[useSpecGeneration] Spec generation is running globally. Tentatively showing loader."
);
setIsCreating(true);
setIsRegenerating(true);
if (status.currentPhase) {
setCurrentPhase(status.currentPhase);
} else {
setCurrentPhase("initialization");
}
if (pendingStatusTimeoutRef.current) {
clearTimeout(pendingStatusTimeoutRef.current);
}
pendingStatusTimeoutRef.current = setTimeout(() => {
console.log(
"[useSpecGeneration] No events received for current project - clearing tentative state"
);
setIsCreating(false);
setIsRegenerating(false);
setCurrentPhase("");
pendingStatusTimeoutRef.current = null;
}, 3000);
} else if (status.success && !status.isRunning) {
setIsCreating(false);
setIsRegenerating(false);
setCurrentPhase("");
stateRestoredRef.current = false;
}
} catch (error) {
console.error("[useSpecGeneration] Failed to check status:", error);
} finally {
statusCheckRef.current = false;
}
};
stateRestoredRef.current = false;
checkStatus();
}, [currentProject]);
// Sync state when tab becomes visible
useEffect(() => {
const handleVisibilityChange = async () => {
if (
!document.hidden &&
currentProject &&
(isCreating || isRegenerating || isGeneratingFeatures)
) {
try {
const api = getElectronAPI();
if (!api.specRegeneration) return;
const status = await api.specRegeneration.status();
console.log(
"[useSpecGeneration] Visibility change - status check:",
status
);
if (!status.isRunning) {
console.log(
"[useSpecGeneration] Visibility change: Backend indicates generation complete - clearing state"
);
setIsCreating(false);
setIsRegenerating(false);
setIsGeneratingFeatures(false);
setCurrentPhase("");
stateRestoredRef.current = false;
loadSpec();
} else if (status.currentPhase) {
setCurrentPhase(status.currentPhase);
}
} catch (error) {
console.error(
"[useSpecGeneration] Failed to check status on visibility change:",
error
);
}
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [
currentProject,
isCreating,
isRegenerating,
isGeneratingFeatures,
loadSpec,
]);
// Periodic status check
useEffect(() => {
if (
!currentProject ||
(!isCreating && !isRegenerating && !isGeneratingFeatures)
)
return;
const intervalId = setInterval(async () => {
try {
const api = getElectronAPI();
if (!api.specRegeneration) return;
const status = await api.specRegeneration.status();
if (!status.isRunning) {
console.log(
"[useSpecGeneration] 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
) {
console.log(
"[useSpecGeneration] Periodic check: Phase updated from backend",
{
old: currentPhase,
new: status.currentPhase,
}
);
setCurrentPhase(status.currentPhase);
}
} catch (error) {
console.error(
"[useSpecGeneration] Periodic status check error:",
error
);
}
}, STATUS_CHECK_INTERVAL_MS);
return () => {
clearInterval(intervalId);
};
}, [
currentProject,
isCreating,
isRegenerating,
isGeneratingFeatures,
currentPhase,
loadSpec,
]);
// Subscribe to spec regeneration events
useEffect(() => {
if (!currentProject) return;
const api = getElectronAPI();
if (!api.specRegeneration) return;
const unsubscribe = api.specRegeneration.onEvent(
(event: SpecRegenerationEvent) => {
console.log(
"[useSpecGeneration] Regeneration event:",
event.type,
"for project:",
event.projectPath,
"current project:",
currentProject?.path
);
if (event.projectPath !== currentProject?.path) {
console.log(
"[useSpecGeneration] Ignoring event - not for current project"
);
return;
}
if (pendingStatusTimeoutRef.current) {
clearTimeout(pendingStatusTimeoutRef.current);
pendingStatusTimeoutRef.current = null;
console.log(
"[useSpecGeneration] Event confirmed this is for current project - clearing timeout"
);
}
if (event.type === "spec_regeneration_progress") {
setIsCreating(true);
setIsRegenerating(true);
const phaseMatch = event.content.match(/\[Phase:\s*([^\]]+)\]/);
if (phaseMatch) {
const phase = phaseMatch[1];
setCurrentPhase(phase);
console.log(`[useSpecGeneration] Phase updated: ${phase}`);
if (phase === "complete") {
console.log(
"[useSpecGeneration] Phase is complete - clearing state"
);
setIsCreating(false);
setIsRegenerating(false);
stateRestoredRef.current = false;
setTimeout(() => {
loadSpec();
}, SPEC_FILE_WRITE_DELAY);
}
}
if (
event.content.includes("All tasks completed") ||
event.content.includes("✓ All tasks completed")
) {
console.log(
"[useSpecGeneration] Detected completion in progress message - clearing state"
);
setIsCreating(false);
setIsRegenerating(false);
setCurrentPhase("");
stateRestoredRef.current = false;
setTimeout(() => {
loadSpec();
}, SPEC_FILE_WRITE_DELAY);
}
const newLog = logsRef.current + event.content;
logsRef.current = newLog;
setLogs(newLog);
console.log(
"[useSpecGeneration] Progress:",
event.content.substring(0, 100)
);
if (errorMessage) {
setErrorMessage("");
}
} else if (event.type === "spec_regeneration_tool") {
const isFeatureTool =
event.tool === "mcp__automaker-tools__UpdateFeatureStatus" ||
event.tool === "UpdateFeatureStatus" ||
event.tool?.includes("Feature");
if (isFeatureTool) {
if (currentPhase !== "feature_generation") {
setCurrentPhase("feature_generation");
setIsCreating(true);
setIsRegenerating(true);
console.log(
"[useSpecGeneration] Detected feature creation tool - setting phase to feature_generation"
);
}
}
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("[useSpecGeneration] Tool:", event.tool, event.input);
} else if (event.type === "spec_regeneration_complete") {
const completionLog =
logsRef.current + `\n[Complete] ${event.message}\n`;
logsRef.current = completionLog;
setLogs(completionLog);
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]");
const isIntermediateCompletion =
event.message?.includes("Features are being generated") ||
event.message?.includes("features are being generated");
const shouldComplete =
(isFinalCompletionMessage || hasCompletePhase) &&
!isIntermediateCompletion;
if (shouldComplete) {
console.log(
"[useSpecGeneration] 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;
setTimeout(() => {
loadSpec();
}, SPEC_FILE_WRITE_DELAY);
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: createElement(CheckCircle2, { className: "w-4 h-4" }),
}
);
} else if (isIntermediateCompletion) {
setIsCreating(true);
setIsRegenerating(true);
setCurrentPhase("feature_generation");
console.log(
"[useSpecGeneration] Intermediate completion, continuing with feature generation"
);
}
console.log(
"[useSpecGeneration] 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;
const errorLog = logsRef.current + `\n\n[ERROR] ${event.error}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
console.error("[useSpecGeneration] Regeneration error:", event.error);
}
}
);
return () => {
unsubscribe();
};
}, [currentProject?.path, loadSpec, errorMessage, currentPhase]);
// Handler functions
const handleCreateSpec = useCallback(async () => {
if (!currentProject || !projectOverview.trim()) return;
setIsCreating(true);
setShowCreateDialog(false);
setCurrentPhase("initialization");
setErrorMessage("");
logsRef.current = "";
setLogs("");
console.log(
"[useSpecGeneration] Starting spec creation, generateFeatures:",
generateFeatures
);
try {
const api = getElectronAPI();
if (!api.specRegeneration) {
console.error("[useSpecGeneration] Spec regeneration not available");
setIsCreating(false);
return;
}
const result = await api.specRegeneration.create(
currentProject.path,
projectOverview.trim(),
generateFeatures,
analyzeProjectOnCreate,
generateFeatures ? featureCountOnCreate : undefined
);
if (!result.success) {
const errorMsg = result.error || "Unknown error";
console.error(
"[useSpecGeneration] Failed to start spec creation:",
errorMsg
);
setIsCreating(false);
setCurrentPhase("error");
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to start spec creation: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error("[useSpecGeneration] Failed to create spec:", errorMsg);
setIsCreating(false);
setCurrentPhase("error");
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to create spec: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
}
}, [
currentProject,
projectOverview,
generateFeatures,
analyzeProjectOnCreate,
featureCountOnCreate,
]);
const handleRegenerate = useCallback(async () => {
if (!currentProject || !projectDefinition.trim()) return;
setIsRegenerating(true);
setShowRegenerateDialog(false);
setCurrentPhase("initialization");
setErrorMessage("");
logsRef.current = "";
setLogs("");
console.log(
"[useSpecGeneration] Starting spec regeneration, generateFeatures:",
generateFeaturesOnRegenerate
);
try {
const api = getElectronAPI();
if (!api.specRegeneration) {
console.error("[useSpecGeneration] Spec regeneration not available");
setIsRegenerating(false);
return;
}
const result = await api.specRegeneration.generate(
currentProject.path,
projectDefinition.trim(),
generateFeaturesOnRegenerate,
analyzeProjectOnRegenerate,
generateFeaturesOnRegenerate ? featureCountOnRegenerate : undefined
);
if (!result.success) {
const errorMsg = result.error || "Unknown error";
console.error(
"[useSpecGeneration] Failed to start regeneration:",
errorMsg
);
setIsRegenerating(false);
setCurrentPhase("error");
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to start regeneration: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error("[useSpecGeneration] Failed to regenerate spec:", errorMsg);
setIsRegenerating(false);
setCurrentPhase("error");
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to regenerate spec: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
}
}, [
currentProject,
projectDefinition,
generateFeaturesOnRegenerate,
analyzeProjectOnRegenerate,
featureCountOnRegenerate,
]);
const handleGenerateFeatures = useCallback(async () => {
if (!currentProject) return;
setIsGeneratingFeatures(true);
setShowRegenerateDialog(false);
setCurrentPhase("initialization");
setErrorMessage("");
logsRef.current = "";
setLogs("");
console.log(
"[useSpecGeneration] Starting feature generation from existing spec"
);
try {
const api = getElectronAPI();
if (!api.specRegeneration) {
console.error("[useSpecGeneration] Spec regeneration not available");
setIsGeneratingFeatures(false);
return;
}
const result = await api.specRegeneration.generateFeatures(
currentProject.path
);
if (!result.success) {
const errorMsg = result.error || "Unknown error";
console.error(
"[useSpecGeneration] Failed to start feature generation:",
errorMsg
);
setIsGeneratingFeatures(false);
setCurrentPhase("error");
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to start feature generation: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(
"[useSpecGeneration] Failed to generate features:",
errorMsg
);
setIsGeneratingFeatures(false);
setCurrentPhase("error");
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to generate features: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
}
}, [currentProject]);
return {
// Dialog state
showCreateDialog,
setShowCreateDialog,
showRegenerateDialog,
setShowRegenerateDialog,
// Create state
projectOverview,
setProjectOverview,
isCreating,
generateFeatures,
setGenerateFeatures,
analyzeProjectOnCreate,
setAnalyzeProjectOnCreate,
featureCountOnCreate,
setFeatureCountOnCreate,
// Regenerate state
projectDefinition,
setProjectDefinition,
isRegenerating,
generateFeaturesOnRegenerate,
setGenerateFeaturesOnRegenerate,
analyzeProjectOnRegenerate,
setAnalyzeProjectOnRegenerate,
featureCountOnRegenerate,
setFeatureCountOnRegenerate,
// Feature generation state
isGeneratingFeatures,
// Status state
currentPhase,
errorMessage,
logs,
// Handlers
handleCreateSpec,
handleRegenerate,
handleGenerateFeatures,
};
}

View File

@@ -0,0 +1,46 @@
import { useEffect, useState, useCallback } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
export function useSpecLoading() {
const { currentProject, setAppSpec } = useAppStore();
const [isLoading, setIsLoading] = useState(true);
const [specExists, setSpecExists] = useState(true);
const loadSpec = useCallback(async () => {
if (!currentProject) return;
setIsLoading(true);
try {
const api = getElectronAPI();
const result = await api.readFile(
`${currentProject.path}/.automaker/app_spec.txt`
);
if (result.success && result.content) {
setAppSpec(result.content);
setSpecExists(true);
} else {
// File doesn't exist
setAppSpec("");
setSpecExists(false);
}
} catch (error) {
console.error("Failed to load spec:", error);
setSpecExists(false);
} finally {
setIsLoading(false);
}
}, [currentProject, setAppSpec]);
useEffect(() => {
loadSpec();
}, [loadSpec]);
return {
isLoading,
specExists,
setSpecExists,
loadSpec,
};
}

View File

@@ -0,0 +1,40 @@
import { useState } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
export function useSpecSave() {
const { currentProject, appSpec, setAppSpec } = useAppStore();
const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const saveSpec = async () => {
if (!currentProject) return;
setIsSaving(true);
try {
const api = getElectronAPI();
await api.writeFile(
`${currentProject.path}/.automaker/app_spec.txt`,
appSpec
);
setHasChanges(false);
} catch (error) {
console.error("Failed to save spec:", error);
} finally {
setIsSaving(false);
}
};
const handleChange = (value: string) => {
setAppSpec(value);
setHasChanges(true);
};
return {
isSaving,
hasChanges,
setHasChanges,
saveSpec,
handleChange,
};
}

View File

@@ -0,0 +1,50 @@
// Feature count options for spec generation
export type FeatureCount = 20 | 50 | 100;
// Generation phases for UI display
export type GenerationPhase =
| "initialization"
| "setup"
| "analysis"
| "spec_complete"
| "feature_generation"
| "complete"
| "error";
// Props for the unified create spec dialog
export interface CreateSpecDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectOverview: string;
onProjectOverviewChange: (value: string) => void;
generateFeatures: boolean;
onGenerateFeaturesChange: (value: boolean) => void;
analyzeProject: boolean;
onAnalyzeProjectChange: (value: boolean) => void;
featureCount: FeatureCount;
onFeatureCountChange: (value: FeatureCount) => void;
onCreateSpec: () => void;
onSkip?: () => void;
isCreatingSpec: boolean;
showSkipButton?: boolean;
title?: string;
description?: string;
}
// Props for the regenerate spec dialog
export interface RegenerateSpecDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectDefinition: string;
onProjectDefinitionChange: (value: string) => void;
generateFeatures: boolean;
onGenerateFeaturesChange: (value: boolean) => void;
analyzeProject: boolean;
onAnalyzeProjectChange: (value: boolean) => void;
featureCount: FeatureCount;
onFeatureCountChange: (value: FeatureCount) => void;
onRegenerate: () => void;
onGenerateFeaturesOnly?: () => void;
isRegenerating: boolean;
isGeneratingFeatures?: boolean;
}