mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 20:43:36 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user