mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
feat: add three viewing modes for app specification (#566)
* feat: add three viewing modes for app specification Introduces View, Edit, and Source modes for the spec page: - View: Clean read-only display with cards, badges, and accordions - Edit: Structured form-based editor for all spec fields - Source: Raw XML editor for advanced users Also adds @automaker/spec-parser shared package for XML parsing between server and client. * fix: address PR review feedback - Replace array index keys with stable UUIDs in array-field-editor, features-section, and roadmap-section components - Replace regex-based XML parsing with fast-xml-parser for robustness - Simplify renderContent logic in spec-view by removing dead code paths * fix: convert git+ssh URLs to https in package-lock.json * fix: address PR review feedback for spec visualiser - Remove unused RefreshCw import from spec-view.tsx - Add explicit parsedSpec check in renderContent for robustness - Hide save button in view mode since it's read-only - Remove GripVertical drag handles since drag-and-drop is not implemented - Rename Map imports to MapIcon to avoid shadowing global Map - Escape tagName in xml-utils.ts RegExp functions for safety - Add aria-label attributes for accessibility on mode tabs * fix: address additional PR review feedback - Fix Textarea controlled/uncontrolled warning with default value - Preserve IDs in useEffect sync to avoid unnecessary remounts - Consolidate lucide-react imports - Add JSDoc note about tag attributes limitation in xml-utils.ts - Remove redundant disabled prop from SpecModeTabs
This commit is contained in:
committed by
GitHub
parent
af95dae73a
commit
c4652190eb
@@ -40,6 +40,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@automaker/dependency-resolver": "1.0.0",
|
||||
"@automaker/spec-parser": "1.0.0",
|
||||
"@automaker/types": "1.0.0",
|
||||
"@codemirror/lang-xml": "6.1.0",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { xml } from '@codemirror/lang-xml';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { Extension } from '@codemirror/state';
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||
import { tags as t } from '@lezer/highlight';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface XmlSyntaxEditorProps {
|
||||
@@ -14,52 +11,19 @@ interface XmlSyntaxEditorProps {
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
// Syntax highlighting that uses CSS variables from the app's theme system
|
||||
// This automatically adapts to any theme (dark, light, dracula, nord, etc.)
|
||||
const syntaxColors = HighlightStyle.define([
|
||||
// XML tags - use primary color
|
||||
{ tag: t.tagName, color: 'var(--primary)' },
|
||||
{ tag: t.angleBracket, color: 'var(--muted-foreground)' },
|
||||
|
||||
// Attributes
|
||||
{ tag: t.attributeName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
|
||||
{ tag: t.attributeValue, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
|
||||
|
||||
// Strings and content
|
||||
{ tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
|
||||
{ tag: t.content, color: 'var(--foreground)' },
|
||||
|
||||
// Comments
|
||||
{ tag: t.comment, color: 'var(--muted-foreground)', fontStyle: 'italic' },
|
||||
|
||||
// Special
|
||||
{ tag: t.processingInstruction, color: 'var(--muted-foreground)' },
|
||||
{ tag: t.documentMeta, color: 'var(--muted-foreground)' },
|
||||
]);
|
||||
|
||||
// Editor theme using CSS variables
|
||||
// Simple editor theme - inherits text color from parent
|
||||
const editorTheme = EditorView.theme({
|
||||
'&': {
|
||||
height: '100%',
|
||||
fontSize: '0.875rem',
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--foreground)',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
},
|
||||
'.cm-content': {
|
||||
padding: '1rem',
|
||||
minHeight: '100%',
|
||||
caretColor: 'var(--primary)',
|
||||
},
|
||||
'.cm-cursor, .cm-dropCursor': {
|
||||
borderLeftColor: 'var(--primary)',
|
||||
},
|
||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
|
||||
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'transparent',
|
||||
@@ -73,15 +37,8 @@ const editorTheme = EditorView.theme({
|
||||
'.cm-gutters': {
|
||||
display: 'none',
|
||||
},
|
||||
'.cm-placeholder': {
|
||||
color: 'var(--muted-foreground)',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
});
|
||||
|
||||
// Combine all extensions
|
||||
const extensions: Extension[] = [xml(), syntaxHighlighting(syntaxColors), editorTheme];
|
||||
|
||||
export function XmlSyntaxEditor({
|
||||
value,
|
||||
onChange,
|
||||
@@ -94,16 +51,16 @@ export function XmlSyntaxEditor({
|
||||
<CodeMirror
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
extensions={extensions}
|
||||
extensions={[xml(), editorTheme]}
|
||||
theme="none"
|
||||
placeholder={placeholder}
|
||||
className="h-full [&_.cm-editor]:h-full"
|
||||
className="h-full [&_.cm-editor]:h-full [&_.cm-content]:text-foreground"
|
||||
basicSetup={{
|
||||
lineNumbers: false,
|
||||
foldGutter: false,
|
||||
highlightActiveLine: false,
|
||||
highlightSelectionMatches: true,
|
||||
autocompletion: true,
|
||||
highlightSelectionMatches: false,
|
||||
autocompletion: false,
|
||||
bracketMatching: true,
|
||||
indentOnInput: true,
|
||||
}}
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
// Extracted hooks
|
||||
import { useSpecLoading, useSpecSave, useSpecGeneration } from './spec-view/hooks';
|
||||
import { useSpecLoading, useSpecSave, useSpecGeneration, useSpecParser } from './spec-view/hooks';
|
||||
|
||||
// Extracted components
|
||||
import { SpecHeader, SpecEditor, SpecEmptyState } from './spec-view/components';
|
||||
import {
|
||||
SpecHeader,
|
||||
SpecEditor,
|
||||
SpecEmptyState,
|
||||
SpecViewMode,
|
||||
SpecEditMode,
|
||||
SpecModeTabs,
|
||||
} from './spec-view/components';
|
||||
|
||||
// Extracted dialogs
|
||||
import { CreateSpecDialog, RegenerateSpecDialog } from './spec-view/dialogs';
|
||||
|
||||
// Types
|
||||
import type { SpecViewMode as SpecViewModeType } from './spec-view/types';
|
||||
|
||||
export function SpecView() {
|
||||
const { currentProject, appSpec } = useAppStore();
|
||||
|
||||
// View mode state - default to 'view'
|
||||
const [mode, setMode] = useState<SpecViewModeType>('view');
|
||||
|
||||
// Actions panel state (for tablet/mobile)
|
||||
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
||||
|
||||
@@ -21,7 +34,10 @@ export function SpecView() {
|
||||
const { isLoading, specExists, isGenerationRunning, loadSpec } = useSpecLoading();
|
||||
|
||||
// Save state
|
||||
const { isSaving, hasChanges, saveSpec, handleChange, setHasChanges } = useSpecSave();
|
||||
const { isSaving, hasChanges, saveSpec, handleChange } = useSpecSave();
|
||||
|
||||
// Parse the spec XML
|
||||
const { isValid: isParseValid, parsedSpec, errors: parseErrors } = useSpecParser(appSpec);
|
||||
|
||||
// Generation state and handlers
|
||||
const {
|
||||
@@ -70,8 +86,17 @@ export function SpecView() {
|
||||
handleSync,
|
||||
} = useSpecGeneration({ loadSpec });
|
||||
|
||||
// Reset hasChanges when spec is reloaded
|
||||
// (This is needed because loadSpec updates appSpec in the store)
|
||||
// Handle mode change - if parse is invalid, force source mode
|
||||
const handleModeChange = useCallback(
|
||||
(newMode: SpecViewModeType) => {
|
||||
if ((newMode === 'view' || newMode === 'edit') && !isParseValid) {
|
||||
// Can't switch to view/edit if parse is invalid
|
||||
return;
|
||||
}
|
||||
setMode(newMode);
|
||||
},
|
||||
[isParseValid]
|
||||
);
|
||||
|
||||
// No project selected
|
||||
if (!currentProject) {
|
||||
@@ -126,6 +151,28 @@ export function SpecView() {
|
||||
);
|
||||
}
|
||||
|
||||
// Render content based on mode
|
||||
const renderContent = () => {
|
||||
// If the XML is invalid or spec is not parsed, we can only show the source editor.
|
||||
// The tabs for other modes are disabled, but this is an extra safeguard.
|
||||
if (!isParseValid || !parsedSpec) {
|
||||
return <SpecEditor value={appSpec} onChange={handleChange} />;
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case 'view':
|
||||
return <SpecViewMode spec={parsedSpec} />;
|
||||
case 'edit':
|
||||
return <SpecEditMode spec={parsedSpec} onChange={handleChange} />;
|
||||
case 'source':
|
||||
default:
|
||||
return <SpecEditor value={appSpec} onChange={handleChange} />;
|
||||
}
|
||||
};
|
||||
|
||||
const isProcessing =
|
||||
isRegenerating || isGenerationRunning || isCreating || isGeneratingFeatures || isSyncing;
|
||||
|
||||
// Main view - spec exists
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="spec-view">
|
||||
@@ -145,9 +192,33 @@ export function SpecView() {
|
||||
onSaveClick={saveSpec}
|
||||
showActionsPanel={showActionsPanel}
|
||||
onToggleActionsPanel={() => setShowActionsPanel(!showActionsPanel)}
|
||||
showSaveButton={mode !== 'view'}
|
||||
/>
|
||||
|
||||
<SpecEditor value={appSpec} onChange={handleChange} />
|
||||
{/* Mode tabs and content container */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Mode tabs bar - inside the content area, centered */}
|
||||
{!isProcessing && (
|
||||
<div className="flex items-center justify-center px-4 py-2 border-b border-border bg-muted/30 relative">
|
||||
<SpecModeTabs mode={mode} onModeChange={handleModeChange} isParseValid={isParseValid} />
|
||||
{/* Show parse error indicator - positioned to the right */}
|
||||
{!isParseValid && parseErrors.length > 0 && (
|
||||
<span className="absolute right-4 text-xs text-destructive">
|
||||
XML has errors - fix in Source mode
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show parse error banner if in source mode with errors */}
|
||||
{!isParseValid && parseErrors.length > 0 && mode === 'source' && (
|
||||
<div className="px-4 py-2 bg-destructive/10 border-b border-destructive/20 text-sm text-destructive">
|
||||
<span className="font-medium">XML Parse Errors:</span> {parseErrors.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
||||
<RegenerateSpecDialog
|
||||
open={showRegenerateDialog}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -23,6 +23,8 @@ interface SpecHeaderProps {
|
||||
onSaveClick: () => void;
|
||||
showActionsPanel: boolean;
|
||||
onToggleActionsPanel: () => void;
|
||||
// Mode-related props for save button visibility
|
||||
showSaveButton: boolean;
|
||||
}
|
||||
|
||||
export function SpecHeader({
|
||||
@@ -41,6 +43,7 @@ export function SpecHeader({
|
||||
onSaveClick,
|
||||
showActionsPanel,
|
||||
onToggleActionsPanel,
|
||||
showSaveButton,
|
||||
}: SpecHeaderProps) {
|
||||
const isProcessing = isRegenerating || isCreating || isGeneratingFeatures || isSyncing;
|
||||
const phaseLabel = PHASE_LABELS[currentPhase] || currentPhase;
|
||||
@@ -133,15 +136,17 @@ export function SpecHeader({
|
||||
<ListPlus className="w-4 h-4 mr-2" />
|
||||
Generate Features
|
||||
</Button>
|
||||
<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>
|
||||
{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 */}
|
||||
@@ -212,15 +217,17 @@ export function SpecHeader({
|
||||
<ListPlus className="w-4 h-4 mr-2" />
|
||||
Generate Features
|
||||
</Button>
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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