feat: enhance BottomDock and ContextPanel with new functionality

- Added click-outside detection to BottomDock for improved user experience when interacting with the panel.
- Introduced new dialog components in ContextPanel for creating, renaming, and deleting markdown files, enhancing file management capabilities.
- Implemented file saving and description editing features, allowing users to manage file content more effectively.
- Updated the SpecPanel to support spec regeneration with user-defined project descriptions and feature generation options.

These changes significantly improve the usability and functionality of the application, making it easier for users to manage their projects and specifications.
This commit is contained in:
webdevcody
2026-01-09 23:30:58 -05:00
parent d9299b4680
commit 5543b46f64
3 changed files with 1168 additions and 145 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useSyncExternalStore } from 'react';
import { useState, useCallback, useSyncExternalStore, useRef, useEffect } from 'react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
@@ -108,6 +108,25 @@ export function BottomDock({ className }: BottomDockProps) {
const autoModeState = currentProject ? getAutoModeState(currentProject.id) : null;
const runningAgentsCount = autoModeState?.runningTasks?.length ?? 0;
// Ref for click-outside detection
const dockRef = useRef<HTMLDivElement>(null);
// Handle click outside to close the panel
useEffect(() => {
if (!isExpanded) return;
const handleClickOutside = (event: MouseEvent) => {
if (dockRef.current && !dockRef.current.contains(event.target as Node)) {
setIsExpanded(false);
setIsMaximized(false);
}
};
// Use mousedown for more responsive feel
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isExpanded]);
const handleTabClick = useCallback(
(tab: DockTab) => {
if (activeTab === tab) {
@@ -214,6 +233,7 @@ export function BottomDock({ className }: BottomDockProps) {
return (
<div
ref={dockRef}
className={cn(
'bg-background/95 backdrop-blur-sm',
'transition-all duration-300 ease-in-out flex',
@@ -371,6 +391,7 @@ export function BottomDock({ className }: BottomDockProps) {
return (
<div
ref={dockRef}
className={cn(
'fixed left-0 right-0 bottom-0 border-t border-border bg-background/95 backdrop-blur-sm z-30',
'transition-all duration-300 ease-in-out flex flex-col',

View File

@@ -1,5 +1,18 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { FolderOpen, FileText, Image, Loader2, Upload, FilePlus } from 'lucide-react';
import {
FolderOpen,
FileText,
Image,
Loader2,
Upload,
FilePlus,
Save,
Trash2,
Pencil,
Eye,
MoreVertical,
ArrowLeft,
} from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
import { useAppStore } from '@/store/app-store';
@@ -7,6 +20,25 @@ import { cn } from '@/lib/utils';
import { sanitizeFilename } from '@/lib/image-utils';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card } from '@/components/ui/card';
import { Markdown } from '@/components/ui/markdown';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface ContextFile {
name: string;
@@ -25,11 +57,30 @@ export function ContextPanel() {
const [loading, setLoading] = useState(true);
const [selectedFile, setSelectedFile] = useState<ContextFile | null>(null);
const [fileContent, setFileContent] = useState<string>('');
const [originalContent, setOriginalContent] = useState<string>('');
const [isDropHovering, setIsDropHovering] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isPreviewMode, setIsPreviewMode] = useState(false);
const [generatingDescriptions, setGeneratingDescriptions] = useState<Set<string>>(new Set());
const fileInputRef = useRef<HTMLInputElement>(null);
// Dialog states
const [isCreateMarkdownOpen, setIsCreateMarkdownOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [isEditDescriptionOpen, setIsEditDescriptionOpen] = useState(false);
// Dialog form values
const [newMarkdownName, setNewMarkdownName] = useState('');
const [newMarkdownDescription, setNewMarkdownDescription] = useState('');
const [newMarkdownContent, setNewMarkdownContent] = useState('');
const [renameFileName, setRenameFileName] = useState('');
const [editDescriptionValue, setEditDescriptionValue] = useState('');
const [editDescriptionFileName, setEditDescriptionFileName] = useState('');
const hasChanges = fileContent !== originalContent;
// Helper functions
const isImageFile = (filename: string): boolean => {
const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp'];
@@ -37,6 +88,11 @@ export function ContextPanel() {
return imageExtensions.includes(ext);
};
const isMarkdownFile = (filename: string): boolean => {
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
return ext === '.md' || ext === '.markdown';
};
const getContextPath = useCallback(() => {
if (!currentProject) return null;
return `${currentProject.path}/.automaker/context`;
@@ -116,24 +172,38 @@ export function ContextPanel() {
}, [loadContextFiles]);
const handleSelectFile = useCallback(async (file: ContextFile) => {
if (file.type === 'image') {
setSelectedFile(file);
setFileContent('');
return;
}
try {
const api = getElectronAPI();
const result = await api.readFile(file.path);
if (result.success && result.content) {
if (result.success && result.content !== undefined) {
setSelectedFile(file);
setFileContent(result.content);
setOriginalContent(result.content);
setIsPreviewMode(isMarkdownFile(file.name));
}
} catch (error) {
console.error('Error reading file:', error);
}
}, []);
// Save file content
const handleSaveFile = useCallback(async () => {
if (!selectedFile || !hasChanges) return;
setIsSaving(true);
try {
const api = getElectronAPI();
await api.writeFile(selectedFile.path, fileContent);
setOriginalContent(fileContent);
toast.success('File saved');
} catch (error) {
console.error('Failed to save file:', error);
toast.error('Failed to save file');
} finally {
setIsSaving(false);
}
}, [selectedFile, fileContent, hasChanges]);
// Generate description for a file
const generateDescription = async (
filePath: string,
@@ -299,6 +369,175 @@ export function ContextPanel() {
}
};
// Create markdown file
const handleCreateMarkdown = async () => {
const contextPath = getContextPath();
if (!contextPath || !newMarkdownName.trim()) return;
try {
const api = getElectronAPI();
let filename = newMarkdownName.trim();
if (!filename.includes('.')) {
filename += '.md';
}
const filePath = `${contextPath}/${filename}`;
await api.writeFile(filePath, newMarkdownContent);
if (newMarkdownDescription.trim()) {
const metadata = await loadMetadata();
metadata.files[filename] = { description: newMarkdownDescription.trim() };
await saveMetadata(metadata);
}
await loadContextFiles();
setIsCreateMarkdownOpen(false);
setNewMarkdownName('');
setNewMarkdownDescription('');
setNewMarkdownContent('');
toast.success('Markdown file created');
} catch (error) {
console.error('Failed to create markdown:', error);
toast.error('Failed to create file');
}
};
// Delete selected file
const handleDeleteFile = async () => {
if (!selectedFile) return;
try {
const api = getElectronAPI();
await api.deleteFile(selectedFile.path);
const metadata = await loadMetadata();
delete metadata.files[selectedFile.name];
await saveMetadata(metadata);
setIsDeleteDialogOpen(false);
setSelectedFile(null);
setFileContent('');
setOriginalContent('');
await loadContextFiles();
toast.success('File deleted');
} catch (error) {
console.error('Failed to delete file:', error);
toast.error('Failed to delete file');
}
};
// Rename selected file
const handleRenameFile = async () => {
const contextPath = getContextPath();
if (!selectedFile || !contextPath || !renameFileName.trim()) return;
const newName = renameFileName.trim();
if (newName === selectedFile.name) {
setIsRenameDialogOpen(false);
return;
}
try {
const api = getElectronAPI();
const newPath = `${contextPath}/${newName}`;
const exists = await api.exists(newPath);
if (exists) {
toast.error('A file with this name already exists');
return;
}
const result = await api.readFile(selectedFile.path);
if (!result.success || result.content === undefined) {
toast.error('Failed to read file for rename');
return;
}
await api.writeFile(newPath, result.content);
await api.deleteFile(selectedFile.path);
const metadata = await loadMetadata();
if (metadata.files[selectedFile.name]) {
metadata.files[newName] = metadata.files[selectedFile.name];
delete metadata.files[selectedFile.name];
await saveMetadata(metadata);
}
setIsRenameDialogOpen(false);
setRenameFileName('');
await loadContextFiles();
const renamedFile: ContextFile = {
name: newName,
type: isImageFile(newName) ? 'image' : 'text',
path: newPath,
description: metadata.files[newName]?.description,
};
setSelectedFile(renamedFile);
toast.success('File renamed');
} catch (error) {
console.error('Failed to rename file:', error);
toast.error('Failed to rename file');
}
};
// Save edited description
const handleSaveDescription = async () => {
if (!editDescriptionFileName) return;
try {
const metadata = await loadMetadata();
metadata.files[editDescriptionFileName] = { description: editDescriptionValue.trim() };
await saveMetadata(metadata);
if (selectedFile?.name === editDescriptionFileName) {
setSelectedFile({ ...selectedFile, description: editDescriptionValue.trim() });
}
await loadContextFiles();
setIsEditDescriptionOpen(false);
setEditDescriptionValue('');
setEditDescriptionFileName('');
toast.success('Description saved');
} catch (error) {
console.error('Failed to save description:', error);
toast.error('Failed to save description');
}
};
// Delete file from list (dropdown action)
const handleDeleteFromList = async (file: ContextFile) => {
try {
const api = getElectronAPI();
await api.deleteFile(file.path);
const metadata = await loadMetadata();
delete metadata.files[file.name];
await saveMetadata(metadata);
if (selectedFile?.path === file.path) {
setSelectedFile(null);
setFileContent('');
setOriginalContent('');
}
await loadContextFiles();
toast.success('File deleted');
} catch (error) {
console.error('Failed to delete file:', error);
toast.error('Failed to delete file');
}
};
// Go back to file list
const handleBack = useCallback(() => {
setSelectedFile(null);
setFileContent('');
setOriginalContent('');
setIsPreviewMode(false);
}, []);
if (loading) {
return (
<div className="flex items-center justify-center h-full">
@@ -309,7 +548,10 @@ export function ContextPanel() {
return (
<div
className={cn('h-full flex relative', isDropHovering && 'ring-2 ring-primary ring-inset')}
className={cn(
'h-full flex flex-col relative',
isDropHovering && 'ring-2 ring-primary ring-inset'
)}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@@ -343,99 +585,394 @@ export function ContextPanel() {
</div>
)}
{/* File List */}
<div className="w-48 border-r border-border/50 flex flex-col">
<div className="flex items-center justify-between px-2 py-2 border-b border-border/50">
<span className="text-xs font-medium">Files</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={handleImportClick}
disabled={isUploading}
title="Import file"
>
<FilePlus className="h-3 w-3" />
</Button>
</div>
<div className="flex-1 overflow-auto">
<div className="p-1 space-y-0.5">
{files.length === 0 ? (
<div className="text-center py-4 px-2">
<Upload className="h-6 w-6 mx-auto text-muted-foreground/50 mb-1" />
<p className="text-[10px] text-muted-foreground">
No context files.
<br />
Drop files here or click +
</p>
</div>
) : (
files.map((file) => {
const isGenerating = generatingDescriptions.has(file.name);
return (
<button
key={file.name}
onClick={() => handleSelectFile(file)}
className={cn(
'w-full flex items-center gap-1.5 px-2 py-1.5 rounded text-left',
'text-xs transition-colors',
selectedFile?.name === file.name
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
)}
>
{file.type === 'image' ? (
<Image className="h-3 w-3 shrink-0" />
) : (
<FileText className="h-3 w-3 shrink-0" />
)}
<div className="flex-1 min-w-0">
<span className="truncate block">{file.name}</span>
{isGenerating && (
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
<Loader2 className="h-2 w-2 animate-spin" />
Generating...
</span>
)}
</div>
</button>
);
})
)}
{/* Single View: Either File List OR File Content */}
{!selectedFile ? (
/* File List View */
<>
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
<span className="text-xs font-medium">Context Files</span>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setIsCreateMarkdownOpen(true)}
title="Create markdown"
>
<FilePlus className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleImportClick}
disabled={isUploading}
title="Import file"
>
<Upload className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
</div>
{/* Content Preview */}
<div className="flex-1 flex flex-col">
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
<span className="text-xs font-medium">{selectedFile?.name || 'Select a file'}</span>
</div>
<div className="flex-1 overflow-auto">
<div className="p-3">
{selectedFile ? (
selectedFile.type === 'image' ? (
<div className="flex-1 overflow-auto">
{files.length === 0 ? (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<Image className="h-12 w-12 mx-auto text-muted-foreground/50 mb-2" />
<p className="text-xs text-muted-foreground">
Image preview not available in panel
<FolderOpen className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
<p className="text-xs text-muted-foreground">No context files</p>
<p className="text-[10px] text-muted-foreground mt-1">
Drop files here or click + to add
</p>
</div>
) : (
<pre className="text-xs font-mono whitespace-pre-wrap text-muted-foreground">
{fileContent || 'No content'}
</pre>
)
</div>
) : (
<div className="text-center py-8">
<FolderOpen className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
<p className="text-xs text-muted-foreground">Select a file to preview</p>
<p className="text-[10px] text-muted-foreground mt-1">Or drop files to add them</p>
<div className="p-2 space-y-1">
{files.map((file) => {
const isGenerating = generatingDescriptions.has(file.name);
return (
<div
key={file.name}
className={cn(
'group flex items-center gap-2 px-3 py-2 rounded-md cursor-pointer',
'text-sm transition-colors',
'hover:bg-accent/50'
)}
onClick={() => handleSelectFile(file)}
>
{file.type === 'image' ? (
<Image className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="flex-1 min-w-0">
<span className="truncate block font-medium">{file.name}</span>
{isGenerating ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Generating description...
</span>
) : file.description ? (
<span className="text-xs text-muted-foreground line-clamp-1">
{file.description}
</span>
) : null}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-accent rounded transition-opacity"
>
<MoreVertical className="w-4 h-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setRenameFileName(file.name);
setSelectedFile(file);
setIsRenameDialogOpen(true);
}}
>
<Pencil className="w-3 h-3 mr-2" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setEditDescriptionFileName(file.name);
setEditDescriptionValue(file.description || '');
setIsEditDescriptionOpen(true);
}}
>
<FileText className="w-3 h-3 mr-2" />
Edit Description
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleDeleteFromList(file);
}}
className="text-red-500 focus:text-red-500"
>
<Trash2 className="w-3 h-3 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
</>
) : (
/* File Content View */
<>
<div className="flex items-center justify-between px-2 py-2 border-b border-border/50">
<div className="flex items-center gap-2 min-w-0">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={handleBack}
title="Back to files"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<span className="text-xs font-medium truncate">{selectedFile.name}</span>
{hasChanges && <span className="text-[10px] text-amber-500 shrink-0">Unsaved</span>}
</div>
<div className="flex items-center gap-1">
{selectedFile.type === 'text' && isMarkdownFile(selectedFile.name) && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setIsPreviewMode(!isPreviewMode)}
title={isPreviewMode ? 'Edit' : 'Preview'}
>
{isPreviewMode ? (
<Pencil className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</Button>
)}
{selectedFile.type === 'text' && hasChanges && (
<Button
variant="ghost"
size="sm"
className="h-6 text-xs px-2"
onClick={handleSaveFile}
disabled={isSaving}
>
{isSaving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<Save className="h-3.5 w-3.5 mr-1" />
Save
</>
)}
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-red-500 hover:text-red-400"
onClick={() => setIsDeleteDialogOpen(true)}
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* Description section */}
<div className="px-2 pt-2">
<div className="bg-muted/30 rounded p-2 text-xs">
<div className="flex items-start justify-between gap-1">
<div className="flex-1 min-w-0">
<span className="text-[10px] font-medium text-muted-foreground uppercase">
Description
</span>
{generatingDescriptions.has(selectedFile.name) ? (
<div className="flex items-center gap-1 text-muted-foreground mt-0.5">
<Loader2 className="w-3 h-3 animate-spin" />
<span>Generating...</span>
</div>
) : selectedFile.description ? (
<p className="text-xs mt-0.5">{selectedFile.description}</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5 italic">No description</p>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 shrink-0"
onClick={() => {
setEditDescriptionFileName(selectedFile.name);
setEditDescriptionValue(selectedFile.description || '');
setIsEditDescriptionOpen(true);
}}
>
<Pencil className="w-3 h-3" />
</Button>
</div>
</div>
</div>
{/* Content area */}
<div className="flex-1 overflow-auto p-2">
{selectedFile.type === 'image' ? (
<div className="h-full flex items-center justify-center bg-muted/20 rounded">
<img
src={fileContent}
alt={selectedFile.name}
className="max-w-full max-h-full object-contain"
/>
</div>
) : isPreviewMode && isMarkdownFile(selectedFile.name) ? (
<Card className="h-full overflow-auto p-3">
<Markdown>{fileContent}</Markdown>
</Card>
) : (
<textarea
value={fileContent}
onChange={(e) => setFileContent(e.target.value)}
className={cn(
'w-full h-full p-2 font-mono text-xs bg-muted/30 rounded resize-none',
'focus:outline-none focus:ring-1 focus:ring-ring'
)}
placeholder="Enter content..."
spellCheck={false}
/>
)}
</div>
</>
)}
{/* Create Markdown Dialog */}
<Dialog open={isCreateMarkdownOpen} onOpenChange={setIsCreateMarkdownOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Create Markdown File</DialogTitle>
<DialogDescription>Create a new markdown file for AI context.</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="space-y-1">
<Label htmlFor="md-filename" className="text-xs">
File Name
</Label>
<Input
id="md-filename"
value={newMarkdownName}
onChange={(e) => setNewMarkdownName(e.target.value)}
placeholder="context-file.md"
className="h-8 text-sm"
/>
</div>
<div className="space-y-1">
<Label htmlFor="md-description" className="text-xs">
Description
</Label>
<Input
id="md-description"
value={newMarkdownDescription}
onChange={(e) => setNewMarkdownDescription(e.target.value)}
placeholder="e.g., Coding style guidelines"
className="h-8 text-sm"
/>
</div>
<div className="space-y-1">
<Label htmlFor="md-content" className="text-xs">
Content
</Label>
<Textarea
id="md-content"
value={newMarkdownContent}
onChange={(e) => setNewMarkdownContent(e.target.value)}
placeholder="Enter markdown content..."
className="h-32 text-sm font-mono resize-none"
/>
</div>
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => setIsCreateMarkdownOpen(false)}>
Cancel
</Button>
<Button size="sm" onClick={handleCreateMarkdown} disabled={!newMarkdownName.trim()}>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Delete File</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{selectedFile?.name}"? This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => setIsDeleteDialogOpen(false)}>
Cancel
</Button>
<Button variant="destructive" size="sm" onClick={handleDeleteFile}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Rename Dialog */}
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Rename File</DialogTitle>
<DialogDescription>Enter a new name for "{selectedFile?.name}".</DialogDescription>
</DialogHeader>
<div className="py-2">
<Input
value={renameFileName}
onChange={(e) => setRenameFileName(e.target.value)}
placeholder="Enter new filename"
className="h-8 text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter' && renameFileName.trim()) {
handleRenameFile();
}
}}
/>
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => setIsRenameDialogOpen(false)}>
Cancel
</Button>
<Button
size="sm"
onClick={handleRenameFile}
disabled={!renameFileName.trim() || renameFileName === selectedFile?.name}
>
Rename
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Description Dialog */}
<Dialog open={isEditDescriptionOpen} onOpenChange={setIsEditDescriptionOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Edit Description</DialogTitle>
<DialogDescription>
Update the description for "{editDescriptionFileName}".
</DialogDescription>
</DialogHeader>
<div className="py-2">
<Textarea
value={editDescriptionValue}
onChange={(e) => setEditDescriptionValue(e.target.value)}
placeholder="Enter description..."
className="h-24 text-sm resize-none"
/>
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => setIsEditDescriptionOpen(false)}>
Cancel
</Button>
<Button size="sm" onClick={handleSaveDescription}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,66 +1,275 @@
import { useState, useEffect, useCallback } from 'react';
import { FileText, Loader2, Save } from 'lucide-react';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
FileText,
Loader2,
Save,
Sparkles,
RefreshCw,
FilePlus2,
Clock,
AlertCircle,
} from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { SpecRegenerationEvent } from '@/types/electron';
// Feature count options
type FeatureCount = 20 | 50 | 100;
const FEATURE_COUNT_OPTIONS: { value: FeatureCount; label: string; warning?: string }[] = [
{ value: 20, label: '20' },
{ value: 50, label: '50', warning: 'May take up to 5 minutes' },
{ value: 100, label: '100', warning: 'May take up to 5 minutes' },
];
const PHASE_LABELS: Record<string, string> = {
initialization: 'Initializing...',
setup: 'Setting up tools...',
analysis: 'Analyzing project...',
spec_complete: 'Spec created! Generating features...',
feature_generation: 'Creating features...',
complete: 'Complete!',
error: 'Error occurred',
};
const SPEC_FILE_WRITE_DELAY = 500;
export function SpecPanel() {
const { currentProject } = useAppStore();
const { currentProject, appSpec, setAppSpec } = useAppStore();
const [specContent, setSpecContent] = useState('');
const [originalContent, setOriginalContent] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [specExists, setSpecExists] = useState(false);
// Generation state
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false);
const [projectOverview, setProjectOverview] = useState('');
const [projectDefinition, setProjectDefinition] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [generateFeatures, setGenerateFeatures] = useState(true);
const [analyzeProject, setAnalyzeProject] = useState(true);
const [featureCount, setFeatureCount] = useState<FeatureCount>(50);
const [currentPhase, setCurrentPhase] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const hasChanges = specContent !== originalContent;
// Load spec from file
const loadSpec = useCallback(async () => {
if (!currentProject?.path) return;
setLoading(true);
try {
const api = getElectronAPI();
if (api.spec?.read) {
const result = await api.spec.read(currentProject.path);
if (result.success && result.content !== undefined) {
setSpecContent(result.content);
setOriginalContent(result.content);
// Check if generation is running
if (api.specRegeneration) {
const status = await api.specRegeneration.status();
if (status.success && status.isRunning) {
setIsGenerating(true);
if (status.currentPhase) {
setCurrentPhase(status.currentPhase);
}
setLoading(false);
return;
}
}
// Read the spec file using the correct API
const specPath = `${currentProject.path}/.automaker/app_spec.txt`;
const result = await api.readFile(specPath);
if (result.success && result.content) {
setSpecContent(result.content);
setOriginalContent(result.content);
setAppSpec(result.content);
setSpecExists(true);
} else {
setSpecContent('');
setOriginalContent('');
setSpecExists(false);
}
} catch (error) {
console.error('Error loading spec:', error);
setSpecExists(false);
} finally {
setLoading(false);
}
}, [currentProject?.path]);
}, [currentProject?.path, setAppSpec]);
useEffect(() => {
loadSpec();
}, [loadSpec]);
// Sync with store
useEffect(() => {
if (appSpec && appSpec !== specContent && !hasChanges) {
setSpecContent(appSpec);
setOriginalContent(appSpec);
setSpecExists(true);
}
}, [appSpec, specContent, hasChanges]);
// Subscribe to spec regeneration events
useEffect(() => {
if (!currentProject?.path) return;
const api = getElectronAPI();
if (!api.specRegeneration) return;
const unsubscribe = api.specRegeneration.onEvent((event: SpecRegenerationEvent) => {
if (event.projectPath !== currentProject.path) return;
if (event.type === 'spec_regeneration_progress') {
setIsGenerating(true);
const phaseMatch = event.content.match(/\[Phase:\s*([^\]]+)\]/);
if (phaseMatch) {
setCurrentPhase(phaseMatch[1]);
}
if (event.content.includes('All tasks completed')) {
setIsGenerating(false);
setCurrentPhase('');
setTimeout(() => loadSpec(), SPEC_FILE_WRITE_DELAY);
}
} else if (event.type === 'spec_regeneration_complete') {
const isFinal =
event.message?.includes('All tasks completed') ||
event.message === 'Spec regeneration complete!' ||
event.message === 'Initial spec creation complete!';
if (isFinal) {
setIsGenerating(false);
setCurrentPhase('');
setShowCreateDialog(false);
setShowRegenerateDialog(false);
setProjectOverview('');
setProjectDefinition('');
setErrorMessage('');
setTimeout(() => loadSpec(), SPEC_FILE_WRITE_DELAY);
toast.success('Spec Generation Complete', {
description: 'Your app specification has been saved.',
});
}
} else if (event.type === 'spec_regeneration_error') {
setIsGenerating(false);
setCurrentPhase('error');
setErrorMessage(event.error);
}
});
return () => unsubscribe();
}, [currentProject?.path, loadSpec]);
// Save spec
const handleSave = useCallback(async () => {
if (!currentProject?.path || !hasChanges) return;
setSaving(true);
try {
const api = getElectronAPI();
if (api.spec?.write) {
const result = await api.spec.write(currentProject.path, specContent);
if (result.success) {
setOriginalContent(specContent);
toast.success('Spec saved');
} else {
toast.error('Failed to save spec');
}
}
const specPath = `${currentProject.path}/.automaker/app_spec.txt`;
await api.writeFile(specPath, specContent);
setOriginalContent(specContent);
setAppSpec(specContent);
toast.success('Spec saved');
} catch (error) {
toast.error('Failed to save spec');
} finally {
setSaving(false);
}
}, [currentProject?.path, specContent, hasChanges]);
}, [currentProject?.path, specContent, hasChanges, setAppSpec]);
// Create spec
const handleCreateSpec = useCallback(async () => {
if (!currentProject?.path || !projectOverview.trim()) return;
setIsGenerating(true);
setShowCreateDialog(false);
setCurrentPhase('initialization');
setErrorMessage('');
try {
const api = getElectronAPI();
if (!api.specRegeneration) {
setIsGenerating(false);
toast.error('Spec generation not available');
return;
}
const result = await api.specRegeneration.create(
currentProject.path,
projectOverview.trim(),
generateFeatures,
analyzeProject,
generateFeatures ? featureCount : undefined
);
if (!result.success) {
setIsGenerating(false);
setCurrentPhase('error');
setErrorMessage(result.error || 'Failed to create spec');
}
} catch (error) {
setIsGenerating(false);
setCurrentPhase('error');
setErrorMessage(error instanceof Error ? error.message : 'Failed to create spec');
}
}, [currentProject?.path, projectOverview, generateFeatures, analyzeProject, featureCount]);
// Regenerate spec
const handleRegenerate = useCallback(async () => {
if (!currentProject?.path || !projectDefinition.trim()) return;
setIsGenerating(true);
setShowRegenerateDialog(false);
setCurrentPhase('initialization');
setErrorMessage('');
try {
const api = getElectronAPI();
if (!api.specRegeneration) {
setIsGenerating(false);
toast.error('Spec generation not available');
return;
}
const result = await api.specRegeneration.generate(
currentProject.path,
projectDefinition.trim(),
generateFeatures,
analyzeProject,
generateFeatures ? featureCount : undefined
);
if (!result.success) {
setIsGenerating(false);
setCurrentPhase('error');
setErrorMessage(result.error || 'Failed to regenerate spec');
}
} catch (error) {
setIsGenerating(false);
setCurrentPhase('error');
setErrorMessage(error instanceof Error ? error.message : 'Failed to regenerate spec');
}
}, [currentProject?.path, projectDefinition, generateFeatures, analyzeProject, featureCount]);
const selectedOption = FEATURE_COUNT_OPTIONS.find((o) => o.value === featureCount);
const phaseLabel = PHASE_LABELS[currentPhase] || currentPhase;
if (loading) {
return (
@@ -70,6 +279,161 @@ export function SpecPanel() {
);
}
// Generation in progress view
if (isGenerating) {
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
<div className="flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
<span className="text-xs font-medium">Generating Spec...</span>
</div>
</div>
<div className="flex-1 flex items-center justify-center p-4">
<div className="text-center">
<div className="mb-4">
<div className="p-3 rounded-full bg-primary/10 inline-block">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
</div>
<p className="text-sm font-medium mb-2">
{currentPhase === 'feature_generation'
? 'Creating Features...'
: 'Generating Specification'}
</p>
{currentPhase && <p className="text-xs text-muted-foreground">{phaseLabel}</p>}
{errorMessage && (
<div className="mt-3 p-2 rounded bg-destructive/10 border border-destructive/20">
<p className="text-xs text-destructive">{errorMessage}</p>
</div>
)}
</div>
</div>
</div>
);
}
// Empty state - no spec exists
if (!specExists) {
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
<div className="flex items-center gap-2">
<FileText className="h-3.5 w-3.5" />
<span className="text-xs font-medium">App Specification</span>
</div>
</div>
<div className="flex-1 flex items-center justify-center p-4">
<div className="text-center">
<FilePlus2 className="h-10 w-10 mx-auto text-muted-foreground/50 mb-3" />
<p className="text-sm font-medium mb-1">No Spec Found</p>
<p className="text-xs text-muted-foreground mb-4">
Create an app specification to help AI understand your project.
</p>
<Button size="sm" onClick={() => setShowCreateDialog(true)}>
<Sparkles className="h-3.5 w-3.5 mr-1.5" />
Create Spec
</Button>
</div>
</div>
{/* Create Spec Dialog */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Create App Specification</DialogTitle>
<DialogDescription>
Describe your project and we'll generate a comprehensive specification.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="text-sm font-medium">Project Overview</label>
<Textarea
value={projectOverview}
onChange={(e) => setProjectOverview(e.target.value)}
placeholder="Describe what your project does and what features you want to build..."
className="h-32 resize-none font-mono text-sm"
autoFocus
/>
</div>
<div className="flex items-start space-x-3">
<Checkbox
id="analyze-project"
checked={analyzeProject}
onCheckedChange={(checked) => setAnalyzeProject(checked === true)}
/>
<div className="space-y-0.5">
<label htmlFor="analyze-project" className="text-sm font-medium cursor-pointer">
Analyze current project
</label>
<p className="text-xs text-muted-foreground">
Research your codebase to understand the tech stack.
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<Checkbox
id="generate-features"
checked={generateFeatures}
onCheckedChange={(checked) => setGenerateFeatures(checked === true)}
/>
<div className="space-y-0.5">
<label htmlFor="generate-features" className="text-sm font-medium cursor-pointer">
Generate feature list
</label>
<p className="text-xs text-muted-foreground">
Automatically create features from the spec.
</p>
</div>
</div>
{generateFeatures && (
<div className="space-y-2 pl-6">
<label className="text-sm font-medium">Number of Features</label>
<div className="flex gap-2">
{FEATURE_COUNT_OPTIONS.map((option) => (
<Button
key={option.value}
type="button"
variant={featureCount === option.value ? 'default' : 'outline'}
size="sm"
onClick={() => setFeatureCount(option.value)}
className="flex-1"
>
{option.label}
</Button>
))}
</div>
{selectedOption?.warning && (
<p className="text-xs text-amber-500 flex items-center gap-1">
<Clock className="w-3 h-3" />
{selectedOption.warning}
</p>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setShowCreateDialog(false)}>
Cancel
</Button>
<Button onClick={handleCreateSpec} disabled={!projectOverview.trim()}>
<Sparkles className="w-4 h-4 mr-2" />
Generate Spec
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// Main view - spec exists
return (
<div className="h-full flex flex-col">
{/* Header */}
@@ -77,47 +441,148 @@ export function SpecPanel() {
<div className="flex items-center gap-2">
<FileText className="h-3.5 w-3.5" />
<span className="text-xs font-medium">App Specification</span>
{hasChanges && <span className="text-[10px] text-amber-500">Unsaved changes</span>}
{hasChanges && <span className="text-[10px] text-amber-500">Unsaved</span>}
</div>
{hasChanges && (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={handleSave}
disabled={saving}
size="icon"
className="h-6 w-6"
onClick={() => setShowRegenerateDialog(true)}
title="Regenerate spec"
>
{saving ? (
<Loader2 className="h-3 w-3 animate-spin mr-1" />
) : (
<Save className="h-3 w-3 mr-1" />
)}
Save
<RefreshCw className="h-3 w-3" />
</Button>
)}
{hasChanges && (
<Button
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={handleSave}
disabled={saving}
>
{saving ? (
<Loader2 className="h-3 w-3 animate-spin mr-1" />
) : (
<Save className="h-3 w-3 mr-1" />
)}
Save
</Button>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 p-2 overflow-hidden">
{specContent ? (
<Textarea
value={specContent}
onChange={(e) => setSpecContent(e.target.value)}
className={cn(
'h-full w-full resize-none font-mono text-xs',
'bg-muted/30 border-0 focus-visible:ring-1'
)}
placeholder="Enter your app specification..."
/>
) : (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<FileText className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
<p className="text-xs text-muted-foreground">No spec file found</p>
</div>
</div>
)}
<Textarea
value={specContent}
onChange={(e) => setSpecContent(e.target.value)}
className={cn(
'h-full w-full resize-none font-mono text-xs',
'bg-muted/30 border-0 focus-visible:ring-1'
)}
placeholder="Enter your app specification..."
/>
</div>
{/* Regenerate Spec Dialog */}
<Dialog open={showRegenerateDialog} onOpenChange={setShowRegenerateDialog}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Regenerate App Specification</DialogTitle>
<DialogDescription>
We'll regenerate your spec based on your project description.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="text-sm font-medium">Describe your project</label>
<Textarea
value={projectDefinition}
onChange={(e) => setProjectDefinition(e.target.value)}
placeholder="Describe what your app should do..."
className="h-32 resize-none font-mono text-sm"
autoFocus
/>
</div>
<div className="flex items-start space-x-3">
<Checkbox
id="regen-analyze-project"
checked={analyzeProject}
onCheckedChange={(checked) => setAnalyzeProject(checked === true)}
/>
<div className="space-y-0.5">
<label
htmlFor="regen-analyze-project"
className="text-sm font-medium cursor-pointer"
>
Analyze current project
</label>
<p className="text-xs text-muted-foreground">
Research your codebase to understand the tech stack.
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<Checkbox
id="regen-generate-features"
checked={generateFeatures}
onCheckedChange={(checked) => setGenerateFeatures(checked === true)}
/>
<div className="space-y-0.5">
<label
htmlFor="regen-generate-features"
className="text-sm font-medium cursor-pointer"
>
Generate feature list
</label>
<p className="text-xs text-muted-foreground">
Automatically create features from the spec.
</p>
</div>
</div>
{generateFeatures && (
<div className="space-y-2 pl-6">
<label className="text-sm font-medium">Number of Features</label>
<div className="flex gap-2">
{FEATURE_COUNT_OPTIONS.map((option) => (
<Button
key={option.value}
type="button"
variant={featureCount === option.value ? 'default' : 'outline'}
size="sm"
onClick={() => setFeatureCount(option.value)}
className="flex-1"
>
{option.label}
</Button>
))}
</div>
{selectedOption?.warning && (
<p className="text-xs text-amber-500 flex items-center gap-1">
<Clock className="w-3 h-3" />
{selectedOption.warning}
</p>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setShowRegenerateDialog(false)}>
Cancel
</Button>
<Button onClick={handleRegenerate} disabled={!projectDefinition.trim()}>
<Sparkles className="w-4 h-4 mr-2" />
Regenerate Spec
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}