diff --git a/apps/ui/package.json b/apps/ui/package.json index 72755463..f0053d53 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -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", diff --git a/apps/ui/src/components/ui/xml-syntax-editor.tsx b/apps/ui/src/components/ui/xml-syntax-editor.tsx index 8929d4a8..6f9aac33 100644 --- a/apps/ui/src/components/ui/xml-syntax-editor.tsx +++ b/apps/ui/src/components/ui/xml-syntax-editor.tsx @@ -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({ ('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 ; + } + + switch (mode) { + case 'view': + return ; + case 'edit': + return ; + case 'source': + default: + return ; + } + }; + + const isProcessing = + isRegenerating || isGenerationRunning || isCreating || isGeneratingFeatures || isSyncing; + // Main view - spec exists return (
@@ -145,9 +192,33 @@ export function SpecView() { onSaveClick={saveSpec} showActionsPanel={showActionsPanel} onToggleActionsPanel={() => setShowActionsPanel(!showActionsPanel)} + showSaveButton={mode !== 'view'} /> - + {/* Mode tabs and content container */} +
+ {/* Mode tabs bar - inside the content area, centered */} + {!isProcessing && ( +
+ + {/* Show parse error indicator - positioned to the right */} + {!isParseValid && parseErrors.length > 0 && ( + + XML has errors - fix in Source mode + + )} +
+ )} + + {/* Show parse error banner if in source mode with errors */} + {!isParseValid && parseErrors.length > 0 && mode === 'source' && ( +
+ XML Parse Errors: {parseErrors.join(', ')} +
+ )} + + {renderContent()} +
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(() => + 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 ( +
+ {items.length === 0 ? ( +

{emptyMessage}

+ ) : ( +
+ {items.map((item) => ( + +
+ handleChange(item.id, e.target.value)} + placeholder={placeholder} + className="flex-1" + /> + +
+
+ ))} +
+ )} + +
+ ); +} diff --git a/apps/ui/src/components/views/spec-view/components/edit-mode/capabilities-section.tsx b/apps/ui/src/components/views/spec-view/components/edit-mode/capabilities-section.tsx new file mode 100644 index 00000000..cfec2d78 --- /dev/null +++ b/apps/ui/src/components/views/spec-view/components/edit-mode/capabilities-section.tsx @@ -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 ( + + + + + Core Capabilities + + + + + + + ); +} diff --git a/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx b/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx new file mode 100644 index 00000000..1cdbac2f --- /dev/null +++ b/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx @@ -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 ( + + +
+ + + +
+ handleNameChange(e.target.value)} + placeholder="Feature name..." + className="font-medium" + /> +
+ + #{index + 1} + + +
+ +
+
+ +