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