Merge branch 'v0.13.0rc' into feat/react-query

Merged latest changes from v0.13.0rc into feat/react-query while preserving
React Query migration. Key merge decisions:

- Kept React Query hooks for data fetching (useRunningAgents, useStopFeature, etc.)
- Added backlog plan handling to running-agents-view stop functionality
- Imported both SkeletonPulse and Spinner for CLI status components
- Used Spinner for refresh buttons across all settings sections
- Preserved isBacklogPlan check in agent-output-modal TaskProgressPanel
- Added handleOpenInIntegratedTerminal to worktree actions while keeping React Query mutations
This commit is contained in:
Shirone
2026-01-19 13:28:43 +01:00
387 changed files with 28102 additions and 6881 deletions

View File

@@ -0,0 +1,106 @@
import { Plus, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card } from '@/components/ui/card';
import { useRef, useState, useEffect } from 'react';
interface ArrayFieldEditorProps {
values: string[];
onChange: (values: string[]) => void;
placeholder?: string;
addLabel?: string;
emptyMessage?: string;
}
interface ItemWithId {
id: string;
value: string;
}
function generateId(): string {
return crypto.randomUUID();
}
export function ArrayFieldEditor({
values,
onChange,
placeholder = 'Enter value...',
addLabel = 'Add Item',
emptyMessage = 'No items added yet.',
}: ArrayFieldEditorProps) {
// Track items with stable IDs
const [items, setItems] = useState<ItemWithId[]>(() =>
values.map((value) => ({ id: generateId(), value }))
);
// Track if we're making an internal change to avoid sync loops
const isInternalChange = useRef(false);
// Sync external values to internal items when values change externally
useEffect(() => {
if (isInternalChange.current) {
isInternalChange.current = false;
return;
}
// External change - rebuild items with new IDs
setItems(values.map((value) => ({ id: generateId(), value })));
}, [values]);
const handleAdd = () => {
const newItems = [...items, { id: generateId(), value: '' }];
setItems(newItems);
isInternalChange.current = true;
onChange(newItems.map((item) => item.value));
};
const handleRemove = (id: string) => {
const newItems = items.filter((item) => item.id !== id);
setItems(newItems);
isInternalChange.current = true;
onChange(newItems.map((item) => item.value));
};
const handleChange = (id: string, value: string) => {
const newItems = items.map((item) => (item.id === id ? { ...item, value } : item));
setItems(newItems);
isInternalChange.current = true;
onChange(newItems.map((item) => item.value));
};
return (
<div className="space-y-2">
{items.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">{emptyMessage}</p>
) : (
<div className="space-y-2">
{items.map((item) => (
<Card key={item.id} className="p-2">
<div className="flex items-center gap-2">
<Input
value={item.value}
onChange={(e) => handleChange(item.id, e.target.value)}
placeholder={placeholder}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemove(item.id)}
className="shrink-0 text-muted-foreground hover:text-destructive"
>
<X className="w-4 h-4" />
</Button>
</div>
</Card>
))}
</div>
)}
<Button type="button" variant="outline" size="sm" onClick={handleAdd} className="gap-1">
<Plus className="w-4 h-4" />
{addLabel}
</Button>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Lightbulb } from 'lucide-react';
import { ArrayFieldEditor } from './array-field-editor';
interface CapabilitiesSectionProps {
capabilities: string[];
onChange: (capabilities: string[]) => void;
}
export function CapabilitiesSection({ capabilities, onChange }: CapabilitiesSectionProps) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Lightbulb className="w-5 h-5 text-primary" />
Core Capabilities
</CardTitle>
</CardHeader>
<CardContent>
<ArrayFieldEditor
values={capabilities}
onChange={onChange}
placeholder="e.g., User authentication, Data visualization..."
addLabel="Add Capability"
emptyMessage="No capabilities defined. Add your core features."
/>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,261 @@
import { Plus, X, ChevronDown, ChevronUp, FolderOpen } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { ListChecks } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import type { SpecOutput } from '@automaker/spec-parser';
type Feature = SpecOutput['implemented_features'][number];
interface FeaturesSectionProps {
features: Feature[];
onChange: (features: Feature[]) => void;
}
interface FeatureWithId extends Feature {
_id: string;
_locationIds?: string[];
}
function generateId(): string {
return crypto.randomUUID();
}
function featureToInternal(feature: Feature): FeatureWithId {
return {
...feature,
_id: generateId(),
_locationIds: feature.file_locations?.map(() => generateId()),
};
}
function internalToFeature(internal: FeatureWithId): Feature {
const { _id, _locationIds, ...feature } = internal;
return feature;
}
interface FeatureCardProps {
feature: FeatureWithId;
index: number;
onChange: (feature: FeatureWithId) => void;
onRemove: () => void;
}
function FeatureCard({ feature, index, onChange, onRemove }: FeatureCardProps) {
const [isOpen, setIsOpen] = useState(false);
const handleNameChange = (name: string) => {
onChange({ ...feature, name });
};
const handleDescriptionChange = (description: string) => {
onChange({ ...feature, description });
};
const handleAddLocation = () => {
const locations = feature.file_locations || [];
const locationIds = feature._locationIds || [];
onChange({
...feature,
file_locations: [...locations, ''],
_locationIds: [...locationIds, generateId()],
});
};
const handleRemoveLocation = (locId: string) => {
const locationIds = feature._locationIds || [];
const idx = locationIds.indexOf(locId);
if (idx === -1) return;
const newLocations = feature.file_locations?.filter((_, i) => i !== idx);
const newLocationIds = locationIds.filter((id) => id !== locId);
onChange({
...feature,
file_locations: newLocations && newLocations.length > 0 ? newLocations : undefined,
_locationIds: newLocationIds.length > 0 ? newLocationIds : undefined,
});
};
const handleLocationChange = (locId: string, value: string) => {
const locationIds = feature._locationIds || [];
const idx = locationIds.indexOf(locId);
if (idx === -1) return;
const locations = [...(feature.file_locations || [])];
locations[idx] = value;
onChange({ ...feature, file_locations: locations });
};
return (
<Card className="border-border">
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className="flex items-center gap-2 p-3">
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="p-1 h-auto">
{isOpen ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</Button>
</CollapsibleTrigger>
<div className="flex-1 min-w-0">
<Input
value={feature.name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Feature name..."
className="font-medium"
/>
</div>
<Badge variant="outline" className="shrink-0">
#{index + 1}
</Badge>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onRemove}
className="shrink-0 text-muted-foreground hover:text-destructive"
>
<X className="w-4 h-4" />
</Button>
</div>
<CollapsibleContent>
<div className="px-3 pb-3 space-y-4 border-t border-border pt-3 ml-10">
<div className="space-y-2">
<Label>Description</Label>
<Textarea
value={feature.description}
onChange={(e) => handleDescriptionChange(e.target.value)}
placeholder="Describe what this feature does..."
rows={3}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-1">
<FolderOpen className="w-4 h-4" />
File Locations
</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddLocation}
className="gap-1 h-7"
>
<Plus className="w-3 h-3" />
Add
</Button>
</div>
{(feature.file_locations || []).length === 0 ? (
<p className="text-sm text-muted-foreground">No file locations specified.</p>
) : (
<div className="space-y-2">
{(feature.file_locations || []).map((location, idx) => {
const locId = feature._locationIds?.[idx] || `fallback-${idx}`;
return (
<div key={locId} className="flex items-center gap-2">
<Input
value={location}
onChange={(e) => handleLocationChange(locId, e.target.value)}
placeholder="e.g., src/components/feature.tsx"
className="flex-1 font-mono text-sm"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveLocation(locId)}
className="shrink-0 text-muted-foreground hover:text-destructive h-8 w-8"
>
<X className="w-3 h-3" />
</Button>
</div>
);
})}
</div>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
</Card>
);
}
export function FeaturesSection({ features, onChange }: FeaturesSectionProps) {
// Track features with stable IDs
const [items, setItems] = useState<FeatureWithId[]>(() => features.map(featureToInternal));
// Track if we're making an internal change to avoid sync loops
const isInternalChange = useRef(false);
// Sync external features to internal items when features change externally
useEffect(() => {
if (isInternalChange.current) {
isInternalChange.current = false;
return;
}
setItems(features.map(featureToInternal));
}, [features]);
const handleAdd = () => {
const newItems = [...items, featureToInternal({ name: '', description: '' })];
setItems(newItems);
isInternalChange.current = true;
onChange(newItems.map(internalToFeature));
};
const handleRemove = (id: string) => {
const newItems = items.filter((item) => item._id !== id);
setItems(newItems);
isInternalChange.current = true;
onChange(newItems.map(internalToFeature));
};
const handleFeatureChange = (id: string, feature: FeatureWithId) => {
const newItems = items.map((item) => (item._id === id ? feature : item));
setItems(newItems);
isInternalChange.current = true;
onChange(newItems.map(internalToFeature));
};
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<ListChecks className="w-5 h-5 text-primary" />
Implemented Features
<Badge variant="outline" className="ml-2">
{items.length}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{items.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
No features added yet. Click below to add implemented features.
</p>
) : (
<div className="space-y-2">
{items.map((feature, index) => (
<FeatureCard
key={feature._id}
feature={feature}
index={index}
onChange={(f) => handleFeatureChange(feature._id, f)}
onRemove={() => handleRemove(feature._id)}
/>
))}
</div>
)}
<Button type="button" variant="outline" size="sm" onClick={handleAdd} className="gap-1">
<Plus className="w-4 h-4" />
Add Feature
</Button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,7 @@
export { ArrayFieldEditor } from './array-field-editor';
export { ProjectInfoSection } from './project-info-section';
export { TechStackSection } from './tech-stack-section';
export { CapabilitiesSection } from './capabilities-section';
export { FeaturesSection } from './features-section';
export { RoadmapSection } from './roadmap-section';
export { RequirementsSection, GuidelinesSection } from './optional-sections';

View File

@@ -0,0 +1,59 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollText, Wrench } from 'lucide-react';
import { ArrayFieldEditor } from './array-field-editor';
interface RequirementsSectionProps {
requirements: string[];
onChange: (requirements: string[]) => void;
}
export function RequirementsSection({ requirements, onChange }: RequirementsSectionProps) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<ScrollText className="w-5 h-5 text-primary" />
Additional Requirements
<span className="text-sm font-normal text-muted-foreground">(Optional)</span>
</CardTitle>
</CardHeader>
<CardContent>
<ArrayFieldEditor
values={requirements}
onChange={onChange}
placeholder="e.g., Node.js >= 18, Docker required..."
addLabel="Add Requirement"
emptyMessage="No additional requirements specified."
/>
</CardContent>
</Card>
);
}
interface GuidelinesSectionProps {
guidelines: string[];
onChange: (guidelines: string[]) => void;
}
export function GuidelinesSection({ guidelines, onChange }: GuidelinesSectionProps) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Wrench className="w-5 h-5 text-primary" />
Development Guidelines
<span className="text-sm font-normal text-muted-foreground">(Optional)</span>
</CardTitle>
</CardHeader>
<CardContent>
<ArrayFieldEditor
values={guidelines}
onChange={onChange}
placeholder="e.g., Follow TypeScript strict mode, use ESLint..."
addLabel="Add Guideline"
emptyMessage="No development guidelines specified."
/>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,51 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { FileText } from 'lucide-react';
interface ProjectInfoSectionProps {
projectName: string;
overview: string;
onProjectNameChange: (value: string) => void;
onOverviewChange: (value: string) => void;
}
export function ProjectInfoSection({
projectName,
overview,
onProjectNameChange,
onOverviewChange,
}: ProjectInfoSectionProps) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<FileText className="w-5 h-5 text-primary" />
Project Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="project-name">Project Name</Label>
<Input
id="project-name"
value={projectName}
onChange={(e) => onProjectNameChange(e.target.value)}
placeholder="Enter project name..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="overview">Overview</Label>
<Textarea
id="overview"
value={overview}
onChange={(e) => onOverviewChange(e.target.value)}
placeholder="Describe what this project does, its purpose, and key goals..."
rows={5}
/>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,195 @@
import { Plus, X, Map as MapIcon } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { SpecOutput } from '@automaker/spec-parser';
type RoadmapPhase = NonNullable<SpecOutput['implementation_roadmap']>[number];
type PhaseStatus = 'completed' | 'in_progress' | 'pending';
interface PhaseWithId extends RoadmapPhase {
_id: string;
}
function generateId(): string {
return crypto.randomUUID();
}
function phaseToInternal(phase: RoadmapPhase): PhaseWithId {
return { ...phase, _id: generateId() };
}
function internalToPhase(internal: PhaseWithId): RoadmapPhase {
const { _id, ...phase } = internal;
return phase;
}
interface RoadmapSectionProps {
phases: RoadmapPhase[];
onChange: (phases: RoadmapPhase[]) => void;
}
interface PhaseCardProps {
phase: PhaseWithId;
onChange: (phase: PhaseWithId) => void;
onRemove: () => void;
}
function PhaseCard({ phase, onChange, onRemove }: PhaseCardProps) {
const handlePhaseNameChange = (name: string) => {
onChange({ ...phase, phase: name });
};
const handleStatusChange = (status: PhaseStatus) => {
onChange({ ...phase, status });
};
const handleDescriptionChange = (description: string) => {
onChange({ ...phase, description });
};
return (
<Card className="border-border">
<div className="p-3 space-y-3">
<div className="flex items-start gap-2">
<div className="flex-1 space-y-3">
<div className="flex flex-col sm:flex-row gap-2">
<div className="flex-1">
<Label className="sr-only">Phase Name</Label>
<Input
value={phase.phase}
onChange={(e) => handlePhaseNameChange(e.target.value)}
placeholder="Phase name..."
/>
</div>
<div className="w-full sm:w-40">
<Label className="sr-only">Status</Label>
<Select value={phase.status} onValueChange={handleStatusChange}>
<SelectTrigger>
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="sr-only">Description</Label>
<Textarea
value={phase.description ?? ''}
onChange={(e) => handleDescriptionChange(e.target.value)}
placeholder="Describe what this phase involves..."
rows={2}
/>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onRemove}
className="shrink-0 text-muted-foreground hover:text-destructive"
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
</Card>
);
}
export function RoadmapSection({ phases, onChange }: RoadmapSectionProps) {
// Track phases with stable IDs
const [items, setItems] = useState<PhaseWithId[]>(() => phases.map(phaseToInternal));
// Track if we're making an internal change to avoid sync loops
const isInternalChange = useRef(false);
// Sync external phases to internal items when phases change externally
// Preserve existing IDs where possible to avoid unnecessary remounts
useEffect(() => {
if (isInternalChange.current) {
isInternalChange.current = false;
return;
}
setItems((currentItems) => {
return phases.map((phase, index) => {
// Try to find existing item by index (positional matching)
const existingItem = currentItems[index];
if (existingItem) {
// Reuse the existing ID, update the phase data
return { ...phase, _id: existingItem._id };
}
// New phase - generate new ID
return phaseToInternal(phase);
});
});
}, [phases]);
const handleAdd = () => {
const newItems = [...items, phaseToInternal({ phase: '', status: 'pending', description: '' })];
setItems(newItems);
isInternalChange.current = true;
onChange(newItems.map(internalToPhase));
};
const handleRemove = (id: string) => {
const newItems = items.filter((item) => item._id !== id);
setItems(newItems);
isInternalChange.current = true;
onChange(newItems.map(internalToPhase));
};
const handlePhaseChange = (id: string, phase: PhaseWithId) => {
const newItems = items.map((item) => (item._id === id ? phase : item));
setItems(newItems);
isInternalChange.current = true;
onChange(newItems.map(internalToPhase));
};
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<MapIcon className="w-5 h-5 text-primary" />
Implementation Roadmap
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{items.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
No roadmap phases defined. Add phases to track implementation progress.
</p>
) : (
<div className="space-y-2">
{items.map((phase) => (
<PhaseCard
key={phase._id}
phase={phase}
onChange={(p) => handlePhaseChange(phase._id, p)}
onRemove={() => handleRemove(phase._id)}
/>
))}
</div>
)}
<Button type="button" variant="outline" size="sm" onClick={handleAdd} className="gap-1">
<Plus className="w-4 h-4" />
Add Phase
</Button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,30 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Cpu } from 'lucide-react';
import { ArrayFieldEditor } from './array-field-editor';
interface TechStackSectionProps {
technologies: string[];
onChange: (technologies: string[]) => void;
}
export function TechStackSection({ technologies, onChange }: TechStackSectionProps) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Cpu className="w-5 h-5 text-primary" />
Technology Stack
</CardTitle>
</CardHeader>
<CardContent>
<ArrayFieldEditor
values={technologies}
onChange={onChange}
placeholder="e.g., React, TypeScript, Node.js..."
addLabel="Add Technology"
emptyMessage="No technologies added. Add your tech stack."
/>
</CardContent>
</Card>
);
}

View File

@@ -1,3 +1,6 @@
export { SpecHeader } from './spec-header';
export { SpecEditor } from './spec-editor';
export { SpecEmptyState } from './spec-empty-state';
export { SpecModeTabs } from './spec-mode-tabs';
export { SpecViewMode } from './spec-view-mode';
export { SpecEditMode } from './spec-edit-mode';

View File

@@ -0,0 +1,118 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import type { SpecOutput } from '@automaker/spec-parser';
import { specToXml } from '@automaker/spec-parser';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
ProjectInfoSection,
TechStackSection,
CapabilitiesSection,
FeaturesSection,
RoadmapSection,
RequirementsSection,
GuidelinesSection,
} from './edit-mode';
interface SpecEditModeProps {
spec: SpecOutput;
onChange: (xmlContent: string) => void;
}
export function SpecEditMode({ spec, onChange }: SpecEditModeProps) {
// Local state for form editing
const [formData, setFormData] = useState<SpecOutput>(spec);
// Track the last spec we synced FROM to detect external changes
const lastExternalSpecRef = useRef<string>(JSON.stringify(spec));
// Flag to prevent re-syncing when we caused the change
const isInternalChangeRef = useRef(false);
// Reset form only when spec changes externally (e.g., after save, sync, or regenerate)
useEffect(() => {
const specJson = JSON.stringify(spec);
// If we caused this change (internal), just update the ref and skip reset
if (isInternalChangeRef.current) {
lastExternalSpecRef.current = specJson;
isInternalChangeRef.current = false;
return;
}
// External change - reset form data
if (specJson !== lastExternalSpecRef.current) {
lastExternalSpecRef.current = specJson;
setFormData(spec);
}
}, [spec]);
// Update a field and notify parent
const updateField = useCallback(
<K extends keyof SpecOutput>(field: K, value: SpecOutput[K]) => {
setFormData((prev) => {
const newData = { ...prev, [field]: value };
// Mark as internal change before notifying parent
isInternalChangeRef.current = true;
const xmlContent = specToXml(newData);
onChange(xmlContent);
return newData;
});
},
[onChange]
);
return (
<ScrollArea className="flex-1">
<div className="p-4 space-y-6 max-w-4xl mx-auto">
{/* Project Information */}
<ProjectInfoSection
projectName={formData.project_name}
overview={formData.overview}
onProjectNameChange={(value) => updateField('project_name', value)}
onOverviewChange={(value) => updateField('overview', value)}
/>
{/* Technology Stack */}
<TechStackSection
technologies={formData.technology_stack}
onChange={(value) => updateField('technology_stack', value)}
/>
{/* Core Capabilities */}
<CapabilitiesSection
capabilities={formData.core_capabilities}
onChange={(value) => updateField('core_capabilities', value)}
/>
{/* Implemented Features */}
<FeaturesSection
features={formData.implemented_features}
onChange={(value) => updateField('implemented_features', value)}
/>
{/* Additional Requirements (Optional) */}
<RequirementsSection
requirements={formData.additional_requirements || []}
onChange={(value) =>
updateField('additional_requirements', value.length > 0 ? value : undefined)
}
/>
{/* Development Guidelines (Optional) */}
<GuidelinesSection
guidelines={formData.development_guidelines || []}
onChange={(value) =>
updateField('development_guidelines', value.length > 0 ? value : undefined)
}
/>
{/* Implementation Roadmap (Optional) */}
<RoadmapSection
phases={formData.implementation_roadmap || []}
onChange={(value) =>
updateField('implementation_roadmap', value.length > 0 ? value : undefined)
}
/>
</div>
</ScrollArea>
);
}

View File

@@ -8,8 +8,8 @@ interface SpecEditorProps {
export function SpecEditor({ value, onChange }: SpecEditorProps) {
return (
<div className="flex-1 p-4 overflow-hidden">
<Card className="h-full">
<div className="flex-1 p-4 overflow-hidden min-h-0">
<Card className="h-full overflow-hidden">
<XmlSyntaxEditor
value={value}
onChange={onChange}

View File

@@ -1,5 +1,6 @@
import { Button } from '@/components/ui/button';
import { FileText, FilePlus2, Loader2 } from 'lucide-react';
import { FileText, FilePlus2 } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { PHASE_LABELS } from '../constants';
interface SpecEmptyStateProps {
@@ -36,7 +37,7 @@ export function SpecEmptyState({
{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" />
<Spinner size="md" className="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">
@@ -64,7 +65,7 @@ export function SpecEmptyState({
<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" />
<Spinner size="xl" className="w-12 h-12" />
) : (
<FilePlus2 className="w-12 h-12 text-primary" />
)}

View File

@@ -1,5 +1,10 @@
import { Button } from '@/components/ui/button';
import { Save, Sparkles, Loader2, FileText, AlertCircle } from 'lucide-react';
import {
HeaderActionsPanel,
HeaderActionsPanelTrigger,
} from '@/components/ui/header-actions-panel';
import { Save, Sparkles, FileText, AlertCircle, ListPlus, RefreshCcw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { PHASE_LABELS } from '../constants';
interface SpecHeaderProps {
@@ -7,12 +12,19 @@ interface SpecHeaderProps {
isRegenerating: boolean;
isCreating: boolean;
isGeneratingFeatures: boolean;
isSyncing: boolean;
isSaving: boolean;
hasChanges: boolean;
currentPhase: string;
errorMessage: string;
onRegenerateClick: () => void;
onGenerateFeaturesClick: () => void;
onSyncClick: () => void;
onSaveClick: () => void;
showActionsPanel: boolean;
onToggleActionsPanel: () => void;
// Mode-related props for save button visibility
showSaveButton: boolean;
}
export function SpecHeader({
@@ -20,87 +32,205 @@ export function SpecHeader({
isRegenerating,
isCreating,
isGeneratingFeatures,
isSyncing,
isSaving,
hasChanges,
currentPhase,
errorMessage,
onRegenerateClick,
onGenerateFeaturesClick,
onSyncClick,
onSaveClick,
showActionsPanel,
onToggleActionsPanel,
showSaveButton,
}: SpecHeaderProps) {
const isProcessing = isRegenerating || isCreating || isGeneratingFeatures;
const isProcessing = isRegenerating || isCreating || isGeneratingFeatures || isSyncing;
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 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">
{/* Status indicators - always visible */}
{isProcessing && (
<div className="hidden lg: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">
<Spinner size="md" className="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">
{isSyncing
? 'Syncing Specification'
: 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>
)}
{/* Mobile processing indicator */}
{isProcessing && (
<div className="lg:hidden flex items-center gap-2 px-3 py-2 rounded-lg bg-primary/10 border border-primary/20">
<Spinner size="sm" />
<span className="text-xs font-medium text-primary">Processing...</span>
</div>
)}
{errorMessage && (
<div className="hidden lg: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>
)}
{/* Mobile error indicator */}
{errorMessage && (
<div className="lg:hidden flex items-center gap-2 px-3 py-2 rounded-lg bg-destructive/10 border border-destructive/20">
<AlertCircle className="w-4 h-4 text-destructive" />
<span className="text-xs font-medium text-destructive">Error</span>
</div>
)}
{/* Desktop: show actions inline - hidden when processing since status card shows progress */}
{!isProcessing && (
<div className="hidden lg:flex gap-2">
<Button size="sm" variant="outline" onClick={onSyncClick} data-testid="sync-spec">
<RefreshCcw className="w-4 h-4 mr-2" />
Sync
</Button>
<Button
size="sm"
variant="outline"
onClick={onRegenerateClick}
data-testid="regenerate-spec"
>
<Sparkles className="w-4 h-4 mr-2" />
Regenerate
</Button>
<Button
size="sm"
variant="outline"
onClick={onGenerateFeaturesClick}
data-testid="generate-features"
>
<ListPlus className="w-4 h-4 mr-2" />
Generate Features
</Button>
{showSaveButton && (
<Button
size="sm"
onClick={onSaveClick}
disabled={!hasChanges || isSaving}
data-testid="save-spec"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'}
</Button>
)}
</div>
)}
{/* Tablet/Mobile: show trigger for actions panel */}
<HeaderActionsPanelTrigger isOpen={showActionsPanel} onToggle={onToggleActionsPanel} />
</div>
</div>
<div className="flex items-center gap-3">
{/* Actions Panel (tablet/mobile) */}
<HeaderActionsPanel
isOpen={showActionsPanel}
onClose={onToggleActionsPanel}
title="Specification Actions"
>
{/* Status messages in panel */}
{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'}
<div className="flex items-center gap-3 p-3 rounded-lg bg-primary/10 border border-primary/20">
<Spinner size="sm" className="shrink-0" />
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-sm font-medium text-primary">
{isSyncing
? 'Syncing Specification'
: isGeneratingFeatures
? 'Generating Features'
: isCreating
? 'Generating Specification'
: 'Regenerating Specification'}
</span>
{currentPhase && (
<span className="text-xs text-muted-foreground/90 leading-tight font-medium">
{phaseLabel}
</span>
)}
{currentPhase && <span className="text-xs text-muted-foreground">{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 className="flex items-center gap-3 p-3 rounded-lg bg-destructive/10 border border-destructive/20">
<AlertCircle className="w-4 h-4 text-destructive shrink-0" />
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-sm font-medium text-destructive">Error</span>
<span className="text-xs text-destructive/80">{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" />
) : (
{/* Hide action buttons when processing - status card shows progress */}
{!isProcessing && (
<>
<Button
variant="outline"
className="w-full justify-start"
onClick={onSyncClick}
data-testid="sync-spec-mobile"
>
<RefreshCcw className="w-4 h-4 mr-2" />
Sync
</Button>
<Button
variant="outline"
className="w-full justify-start"
onClick={onRegenerateClick}
data-testid="regenerate-spec-mobile"
>
<Sparkles className="w-4 h-4 mr-2" />
Regenerate
</Button>
<Button
variant="outline"
className="w-full justify-start"
onClick={onGenerateFeaturesClick}
data-testid="generate-features-mobile"
>
<ListPlus className="w-4 h-4 mr-2" />
Generate Features
</Button>
{showSaveButton && (
<Button
className="w-full justify-start"
onClick={onSaveClick}
disabled={!hasChanges || isSaving}
data-testid="save-spec-mobile"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'}
</Button>
)}
{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>
</>
)}
</HeaderActionsPanel>
</>
);
}

View File

@@ -0,0 +1,55 @@
import { Eye, Edit3, Code } from 'lucide-react';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { SpecViewMode } from '../types';
interface SpecModeTabsProps {
mode: SpecViewMode;
onModeChange: (mode: SpecViewMode) => void;
isParseValid: boolean;
disabled?: boolean;
}
export function SpecModeTabs({
mode,
onModeChange,
isParseValid,
disabled = false,
}: SpecModeTabsProps) {
const handleValueChange = (value: string) => {
onModeChange(value as SpecViewMode);
};
return (
<Tabs value={mode} onValueChange={handleValueChange}>
<TabsList>
<TabsTrigger
value="view"
disabled={disabled || !isParseValid}
title={!isParseValid ? 'Fix XML errors to enable View mode' : 'View formatted spec'}
aria-label="View"
>
<Eye className="w-4 h-4" />
<span className="hidden sm:inline">View</span>
</TabsTrigger>
<TabsTrigger
value="edit"
disabled={disabled || !isParseValid}
title={!isParseValid ? 'Fix XML errors to enable Edit mode' : 'Edit spec with form'}
aria-label="Edit"
>
<Edit3 className="w-4 h-4" />
<span className="hidden sm:inline">Edit</span>
</TabsTrigger>
<TabsTrigger
value="source"
disabled={disabled}
title="Edit raw XML source"
aria-label="Source"
>
<Code className="w-4 h-4" />
<span className="hidden sm:inline">Source</span>
</TabsTrigger>
</TabsList>
</Tabs>
);
}

View File

@@ -0,0 +1,223 @@
import type { SpecOutput } from '@automaker/spec-parser';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import {
CheckCircle2,
Circle,
Clock,
Cpu,
FileCode2,
FolderOpen,
Lightbulb,
ListChecks,
Map as MapIcon,
ScrollText,
Wrench,
} from 'lucide-react';
interface SpecViewModeProps {
spec: SpecOutput;
}
function StatusBadge({ status }: { status: 'completed' | 'in_progress' | 'pending' }) {
const variants = {
completed: { variant: 'success' as const, icon: CheckCircle2, label: 'Completed' },
in_progress: { variant: 'warning' as const, icon: Clock, label: 'In Progress' },
pending: { variant: 'muted' as const, icon: Circle, label: 'Pending' },
};
const { variant, icon: Icon, label } = variants[status];
return (
<Badge variant={variant} className="gap-1">
<Icon className="w-3 h-3" />
{label}
</Badge>
);
}
export function SpecViewMode({ spec }: SpecViewModeProps) {
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-6 max-w-4xl mx-auto">
{/* Project Header */}
<div className="space-y-3">
<h1 className="text-3xl font-bold tracking-tight">{spec.project_name}</h1>
<p className="text-muted-foreground text-lg leading-relaxed">{spec.overview}</p>
</div>
{/* Technology Stack */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Cpu className="w-5 h-5 text-primary" />
Technology Stack
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{spec.technology_stack.map((tech, index) => (
<Badge key={index} variant="secondary" className="text-sm">
{tech}
</Badge>
))}
</div>
</CardContent>
</Card>
{/* Core Capabilities */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Lightbulb className="w-5 h-5 text-primary" />
Core Capabilities
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{spec.core_capabilities.map((capability, index) => (
<li key={index} className="flex items-start gap-2">
<CheckCircle2 className="w-4 h-4 text-green-500 mt-1 shrink-0" />
<span>{capability}</span>
</li>
))}
</ul>
</CardContent>
</Card>
{/* Implemented Features */}
{spec.implemented_features.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<ListChecks className="w-5 h-5 text-primary" />
Implemented Features
<Badge variant="outline" className="ml-2">
{spec.implemented_features.length}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<Accordion type="multiple" className="w-full">
{spec.implemented_features.map((feature, index) => (
<AccordionItem key={index} value={`feature-${index}`}>
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-2 text-left">
<FileCode2 className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="font-medium">{feature.name}</span>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-3 pl-6">
<p className="text-muted-foreground">{feature.description}</p>
{feature.file_locations && feature.file_locations.length > 0 && (
<div className="space-y-1">
<p className="text-sm font-medium flex items-center gap-1">
<FolderOpen className="w-4 h-4" />
File Locations:
</p>
<ul className="text-sm text-muted-foreground space-y-1">
{feature.file_locations.map((loc, locIndex) => (
<li
key={locIndex}
className="font-mono text-xs bg-muted px-2 py-1 rounded"
>
{loc}
</li>
))}
</ul>
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</CardContent>
</Card>
)}
{/* Additional Requirements */}
{spec.additional_requirements && spec.additional_requirements.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<ScrollText className="w-5 h-5 text-primary" />
Additional Requirements
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{spec.additional_requirements.map((req, index) => (
<li key={index} className="flex items-start gap-2">
<Circle className="w-2 h-2 text-muted-foreground mt-2 shrink-0 fill-current" />
<span>{req}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* Development Guidelines */}
{spec.development_guidelines && spec.development_guidelines.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Wrench className="w-5 h-5 text-primary" />
Development Guidelines
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{spec.development_guidelines.map((guideline, index) => (
<li key={index} className="flex items-start gap-2">
<Circle className="w-2 h-2 text-muted-foreground mt-2 shrink-0 fill-current" />
<span>{guideline}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* Implementation Roadmap */}
{spec.implementation_roadmap && spec.implementation_roadmap.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<MapIcon className="w-5 h-5 text-primary" />
Implementation Roadmap
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{spec.implementation_roadmap.map((phase, index) => (
<div
key={index}
className="flex flex-col sm:flex-row sm:items-start gap-2 sm:gap-4 p-3 rounded-lg bg-muted/50"
>
<div className="flex items-center gap-2 sm:w-48 shrink-0">
<StatusBadge status={phase.status} />
</div>
<div className="flex-1">
<p className="font-medium">{phase.phase}</p>
<p className="text-sm text-muted-foreground">{phase.description}</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
</ScrollArea>
);
}

View File

@@ -24,6 +24,7 @@ export const PHASE_LABELS: Record<string, string> = {
analysis: 'Analyzing project structure...',
spec_complete: 'Spec created! Generating features...',
feature_generation: 'Creating features from roadmap...',
working: 'Working...',
complete: 'Complete!',
error: 'Error occurred',
};

View File

@@ -1,4 +1,5 @@
import { Sparkles, Clock, Loader2 } from 'lucide-react';
import { Sparkles, Clock } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import {
Dialog,
DialogContent,
@@ -163,7 +164,7 @@ export function CreateSpecDialog({
>
{isCreatingSpec ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Generating...
</>
) : (

View File

@@ -1,4 +1,5 @@
import { Sparkles, Clock, Loader2 } from 'lucide-react';
import { Sparkles, Clock } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import {
Dialog,
DialogContent,
@@ -158,7 +159,7 @@ export function RegenerateSpecDialog({
>
{isRegenerating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Regenerating...
</>
) : (

View File

@@ -1,3 +1,5 @@
export { useSpecLoading } from './use-spec-loading';
export { useSpecSave } from './use-spec-save';
export { useSpecGeneration } from './use-spec-generation';
export { useSpecParser } from './use-spec-parser';
export type { UseSpecParserResult } from './use-spec-parser';

View File

@@ -45,6 +45,9 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
// Generate features only state
const [isGeneratingFeatures, setIsGeneratingFeatures] = useState(false);
// Sync state
const [isSyncing, setIsSyncing] = useState(false);
// Logs state (kept for internal tracking)
const [logs, setLogs] = useState<string>('');
const logsRef = useRef<string>('');
@@ -61,6 +64,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
setIsCreating(false);
setIsRegenerating(false);
setIsGeneratingFeatures(false);
setIsSyncing(false);
setCurrentPhase('');
setErrorMessage('');
setLogs('');
@@ -141,7 +145,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
if (
!document.hidden &&
currentProject &&
(isCreating || isRegenerating || isGeneratingFeatures)
(isCreating || isRegenerating || isGeneratingFeatures || isSyncing)
) {
try {
const api = getElectronAPI();
@@ -157,6 +161,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
setIsCreating(false);
setIsRegenerating(false);
setIsGeneratingFeatures(false);
setIsSyncing(false);
setCurrentPhase('');
stateRestoredRef.current = false;
loadSpec();
@@ -173,11 +178,12 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [currentProject, isCreating, isRegenerating, isGeneratingFeatures, loadSpec]);
}, [currentProject, isCreating, isRegenerating, isGeneratingFeatures, isSyncing, loadSpec]);
// Periodic status check
useEffect(() => {
if (!currentProject || (!isCreating && !isRegenerating && !isGeneratingFeatures)) return;
if (!currentProject || (!isCreating && !isRegenerating && !isGeneratingFeatures && !isSyncing))
return;
const intervalId = setInterval(async () => {
try {
@@ -193,6 +199,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
setIsCreating(false);
setIsRegenerating(false);
setIsGeneratingFeatures(false);
setIsSyncing(false);
setCurrentPhase('');
stateRestoredRef.current = false;
loadSpec();
@@ -211,7 +218,15 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
return () => {
clearInterval(intervalId);
};
}, [currentProject, isCreating, isRegenerating, isGeneratingFeatures, currentPhase, loadSpec]);
}, [
currentProject,
isCreating,
isRegenerating,
isGeneratingFeatures,
isSyncing,
currentPhase,
loadSpec,
]);
// Subscribe to spec regeneration events
useEffect(() => {
@@ -323,7 +338,8 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
event.message === 'All tasks completed!' ||
event.message === 'All tasks completed' ||
event.message === 'Spec regeneration complete!' ||
event.message === 'Initial spec creation complete!';
event.message === 'Initial spec creation complete!' ||
event.message?.includes('Spec sync complete');
const hasCompletePhase = logsRef.current.includes('[Phase: complete]');
@@ -343,6 +359,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
setIsRegenerating(false);
setIsCreating(false);
setIsGeneratingFeatures(false);
setIsSyncing(false);
setCurrentPhase('');
setShowRegenerateDialog(false);
setShowCreateDialog(false);
@@ -355,18 +372,23 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
loadSpec();
}, SPEC_FILE_WRITE_DELAY);
const isSyncComplete = event.message?.includes('sync');
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',
isSyncComplete
? 'Spec Sync Complete'
: 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.',
description: isSyncComplete
? 'Your spec has been updated with the latest changes.'
: isFeatureGeneration
? 'Features have been created from the app specification.'
: 'Your app specification has been saved.',
icon: createElement(CheckCircle2, { className: 'w-4 h-4' }),
}
);
@@ -384,6 +406,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
setIsRegenerating(false);
setIsCreating(false);
setIsGeneratingFeatures(false);
setIsSyncing(false);
setCurrentPhase('error');
setErrorMessage(event.error);
stateRestoredRef.current = false;
@@ -508,6 +531,46 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
});
}, [currentProject, generateFeaturesMutation]);
const handleSync = useCallback(async () => {
if (!currentProject) return;
setIsSyncing(true);
setCurrentPhase('sync');
setErrorMessage('');
logsRef.current = '';
setLogs('');
logger.debug('[useSpecGeneration] Starting spec sync');
try {
const api = getElectronAPI();
if (!api.specRegeneration) {
logger.error('[useSpecGeneration] Spec regeneration not available');
setIsSyncing(false);
return;
}
const result = await api.specRegeneration.sync(currentProject.path);
if (!result.success) {
const errorMsg = result.error || 'Unknown error';
logger.error('[useSpecGeneration] Failed to start spec sync:', errorMsg);
setIsSyncing(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to start spec sync: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error('[useSpecGeneration] Failed to sync spec:', errorMsg);
setIsSyncing(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to sync spec: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
}
}, [currentProject]);
return {
// Dialog state
showCreateDialog,
@@ -540,6 +603,9 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
// Feature generation state
isGeneratingFeatures,
// Sync state
isSyncing,
// Status state
currentPhase,
errorMessage,
@@ -548,6 +614,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
// Handlers
handleCreateSpec,
handleRegenerate,
handleSync,
handleGenerateFeatures,
};
}

View File

@@ -0,0 +1,61 @@
import { useMemo } from 'react';
import {
xmlToSpec,
isValidSpecXml,
type ParseResult,
type SpecOutput,
} from '@automaker/spec-parser';
/**
* Result of the spec parsing hook.
*/
export interface UseSpecParserResult {
/** Whether the XML is valid */
isValid: boolean;
/** The parsed spec object, or null if parsing failed */
parsedSpec: SpecOutput | null;
/** Parsing errors, if any */
errors: string[];
/** The full parse result */
parseResult: ParseResult | null;
}
/**
* Hook to parse XML spec content into a SpecOutput object.
* Memoizes the parsing result to avoid unnecessary re-parsing.
*
* @param xmlContent - The raw XML content from app_spec.txt
* @returns Parsed spec data with validation status
*/
export function useSpecParser(xmlContent: string): UseSpecParserResult {
return useMemo(() => {
if (!xmlContent || !xmlContent.trim()) {
return {
isValid: false,
parsedSpec: null,
errors: ['No spec content provided'],
parseResult: null,
};
}
// Quick structure check first
if (!isValidSpecXml(xmlContent)) {
return {
isValid: false,
parsedSpec: null,
errors: ['Invalid XML structure - missing required elements'],
parseResult: null,
};
}
// Full parse
const parseResult = xmlToSpec(xmlContent);
return {
isValid: parseResult.success,
parsedSpec: parseResult.spec,
errors: parseResult.errors,
parseResult,
};
}, [xmlContent]);
}

View File

@@ -1,3 +1,6 @@
// Spec view mode - determines how the spec is displayed/edited
export type SpecViewMode = 'view' | 'edit' | 'source';
// Feature count options for spec generation
export type FeatureCount = 20 | 50 | 100;