Update app_spec.txt and coding_prompt.md for improved clarity and consistency

- Updated references to `app_spec.txt` and `feature_list.json` in app_spec.txt to include the correct path.
- Enhanced coding_prompt.md by incorporating testing utilities for better test management and readability.
- Added new utility functions in tests/utils.ts to streamline test interactions.

This commit aims to improve documentation accuracy and maintainability of testing practices.
This commit is contained in:
Cody Seibert
2025-12-09 00:45:34 -05:00
parent adad2262c2
commit 2822cdfc32
32 changed files with 1324 additions and 4395 deletions

View File

@@ -0,0 +1,310 @@
"use client";
import React, { useState, useRef, useCallback } from "react";
import { cn } from "@/lib/utils";
import { ImageIcon, X, Upload } from "lucide-react";
export interface FeatureImage {
id: string;
data: string; // base64 encoded
mimeType: string;
filename: string;
size: number;
}
interface FeatureImageUploadProps {
images: FeatureImage[];
onImagesChange: (images: FeatureImage[]) => void;
maxFiles?: number;
maxFileSize?: number; // in bytes, default 10MB
className?: string;
disabled?: boolean;
}
const ACCEPTED_IMAGE_TYPES = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
];
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
export function FeatureImageUpload({
images,
onImagesChange,
maxFiles = 5,
maxFileSize = DEFAULT_MAX_FILE_SIZE,
className,
disabled = false,
}: FeatureImageUploadProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
resolve(reader.result);
} else {
reject(new Error("Failed to read file as base64"));
}
};
reader.onerror = () => reject(new Error("Failed to read file"));
reader.readAsDataURL(file);
});
};
const processFiles = useCallback(
async (files: FileList) => {
if (disabled || isProcessing) return;
setIsProcessing(true);
const newImages: FeatureImage[] = [];
const errors: string[] = [];
for (const file of Array.from(files)) {
// Validate file type
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
errors.push(
`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`
);
continue;
}
// Validate file size
if (file.size > maxFileSize) {
const maxSizeMB = maxFileSize / (1024 * 1024);
errors.push(
`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`
);
continue;
}
// Check if we've reached max files
if (newImages.length + images.length >= maxFiles) {
errors.push(`Maximum ${maxFiles} images allowed.`);
break;
}
try {
const base64 = await fileToBase64(file);
const imageAttachment: FeatureImage = {
id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
data: base64,
mimeType: file.type,
filename: file.name,
size: file.size,
};
newImages.push(imageAttachment);
} catch (error) {
errors.push(`${file.name}: Failed to process image.`);
}
}
if (errors.length > 0) {
console.warn("Image upload errors:", errors);
}
if (newImages.length > 0) {
onImagesChange([...images, ...newImages]);
}
setIsProcessing(false);
},
[disabled, isProcessing, images, maxFiles, maxFileSize, onImagesChange]
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
if (disabled) return;
const files = e.dataTransfer.files;
if (files.length > 0) {
processFiles(files);
}
},
[disabled, processFiles]
);
const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) {
setIsDragOver(true);
}
},
[disabled]
);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
}, []);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
processFiles(files);
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
},
[processFiles]
);
const handleBrowseClick = useCallback(() => {
if (!disabled && fileInputRef.current) {
fileInputRef.current.click();
}
}, [disabled]);
const removeImage = useCallback(
(imageId: string) => {
onImagesChange(images.filter((img) => img.id !== imageId));
},
[images, onImagesChange]
);
const clearAllImages = useCallback(() => {
onImagesChange([]);
}, [onImagesChange]);
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
};
return (
<div className={cn("relative", className)}>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
accept={ACCEPTED_IMAGE_TYPES.join(",")}
onChange={handleFileSelect}
className="hidden"
disabled={disabled}
data-testid="feature-image-input"
/>
{/* Drop zone */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={handleBrowseClick}
className={cn(
"relative rounded-lg border-2 border-dashed transition-all duration-200 cursor-pointer",
{
"border-blue-400 bg-blue-50 dark:bg-blue-950/20":
isDragOver && !disabled,
"border-muted-foreground/25": !isDragOver && !disabled,
"border-muted-foreground/10 opacity-50 cursor-not-allowed": disabled,
"hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-950/10":
!disabled && !isDragOver,
}
)}
data-testid="feature-image-dropzone"
>
<div className="flex flex-col items-center justify-center p-4 text-center">
<div
className={cn(
"rounded-full p-2 mb-2",
isDragOver && !disabled
? "bg-blue-100 dark:bg-blue-900/30"
: "bg-muted"
)}
>
{isProcessing ? (
<Upload className="h-5 w-5 animate-spin text-muted-foreground" />
) : (
<ImageIcon className="h-5 w-5 text-muted-foreground" />
)}
</div>
<p className="text-sm text-muted-foreground">
{isDragOver && !disabled
? "Drop images here"
: "Click or drag images here"}
</p>
<p className="text-xs text-muted-foreground mt-1">
Up to {maxFiles} images, max{" "}
{Math.round(maxFileSize / (1024 * 1024))}MB each
</p>
</div>
</div>
{/* Image previews */}
{images.length > 0 && (
<div className="mt-3 space-y-2" data-testid="feature-image-previews">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
{images.length} image{images.length > 1 ? "s" : ""} selected
</p>
<button
type="button"
onClick={clearAllImages}
className="text-xs text-muted-foreground hover:text-foreground"
disabled={disabled}
>
Clear all
</button>
</div>
<div className="flex flex-wrap gap-2">
{images.map((image) => (
<div
key={image.id}
className="relative group rounded-md border border-muted bg-muted/50 overflow-hidden"
data-testid={`feature-image-preview-${image.id}`}
>
{/* Image thumbnail */}
<div className="w-16 h-16 flex items-center justify-center">
<img
src={image.data}
alt={image.filename}
className="max-w-full max-h-full object-contain"
/>
</div>
{/* Remove button */}
{!disabled && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeImage(image.id);
}}
className="absolute top-0.5 right-0.5 p-0.5 rounded-full bg-destructive text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity"
data-testid={`remove-image-${image.id}`}
>
<X className="h-3 w-3" />
</button>
)}
{/* Filename tooltip on hover */}
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-[10px] text-white truncate">
{image.filename}
</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input min-h-[80px] w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm resize-none",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -106,6 +106,11 @@ export function AgentOutputModal({
if (!api?.autoMode) return;
const unsubscribe = api.autoMode.onEvent((event) => {
// Filter events for this specific feature only
if (event.featureId !== featureId) {
return;
}
let newContent = "";
if (event.type === "auto_mode_progress") {

View File

@@ -79,6 +79,7 @@ export function BoardView() {
const [showActivityLog, setShowActivityLog] = useState(false);
const [showOutputModal, setShowOutputModal] = useState(false);
const [outputFeature, setOutputFeature] = useState<Feature | null>(null);
const [featuresWithContext, setFeaturesWithContext] = useState<Set<string>>(new Set());
// Make current project available globally for modal
useEffect(() => {
@@ -185,6 +186,32 @@ export function BoardView() {
loadFeatures();
}, [loadFeatures]);
// Check which features have context files
useEffect(() => {
const checkAllContexts = async () => {
const inProgressFeatures = features.filter((f) => f.status === "in_progress");
const contextChecks = await Promise.all(
inProgressFeatures.map(async (f) => ({
id: f.id,
hasContext: await checkContextExists(f.id),
}))
);
const newSet = new Set<string>();
contextChecks.forEach(({ id, hasContext }) => {
if (hasContext) {
newSet.add(id);
}
});
setFeaturesWithContext(newSet);
};
if (features.length > 0 && !isLoading) {
checkAllContexts();
}
}, [features, isLoading]);
// Save features to file
const saveFeatures = useCallback(async () => {
if (!currentProject) return;
@@ -360,6 +387,59 @@ export function BoardView() {
}
};
const handleResumeFeature = async (feature: Feature) => {
if (!currentProject) return;
console.log("[Board] Resuming feature:", { id: feature.id, description: feature.description });
try {
const api = getElectronAPI();
if (!api?.autoMode) {
console.error("Auto mode API not available");
return;
}
// Call the API to resume this specific feature by ID with context
const result = await api.autoMode.resumeFeature(
currentProject.path,
feature.id
);
if (result.success) {
console.log("[Board] Feature resume started successfully");
// The feature status will be updated by the auto mode service
// and the UI will reload features when resume completes
} else {
console.error("[Board] Failed to resume feature:", result.error);
await loadFeatures();
}
} catch (error) {
console.error("[Board] Error resuming feature:", error);
await loadFeatures();
}
};
const checkContextExists = async (featureId: string): Promise<boolean> => {
if (!currentProject) return false;
try {
const api = getElectronAPI();
if (!api?.autoMode?.contextExists) {
return false;
}
const result = await api.autoMode.contextExists(
currentProject.path,
featureId
);
return result.success && result.exists === true;
} catch (error) {
console.error("[Board] Error checking context:", error);
return false;
}
};
const getColumnFeatures = (columnId: ColumnId) => {
return features.filter((f) => f.status === columnId);
};
@@ -504,6 +584,8 @@ export function BoardView() {
onDelete={() => handleDeleteFeature(feature.id)}
onViewOutput={() => handleViewOutput(feature)}
onVerify={() => handleVerifyFeature(feature)}
onResume={() => handleResumeFeature(feature)}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
/>
))}

View File

@@ -295,11 +295,14 @@ export function InterviewView() {
await api.mkdir(fullProjectPath);
// Write app_spec.txt with generated content
await api.writeFile(`${fullProjectPath}/app_spec.txt`, generatedSpec);
await api.writeFile(
`${fullProjectPath}/.automaker/app_spec.txt`,
generatedSpec
);
// Create initial .automaker/feature_list.json
await api.writeFile(
`${fullProjectPath}/feature_list.json`,
`${fullProjectPath}/.automaker/feature_list.json`,
JSON.stringify(
[
{

View File

@@ -12,7 +12,7 @@ import {
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Feature } from "@/store/app-store";
import { GripVertical, Edit, CheckCircle2, Circle, Loader2, Trash2, Eye, PlayCircle } from "lucide-react";
import { GripVertical, Edit, CheckCircle2, Circle, Loader2, Trash2, Eye, PlayCircle, RotateCcw } from "lucide-react";
interface KanbanCardProps {
feature: Feature;
@@ -20,10 +20,12 @@ interface KanbanCardProps {
onDelete: () => void;
onViewOutput?: () => void;
onVerify?: () => void;
onResume?: () => void;
hasContext?: boolean;
isCurrentAutoTask?: boolean;
}
export function KanbanCard({ feature, onEdit, onDelete, onViewOutput, onVerify, isCurrentAutoTask }: KanbanCardProps) {
export function KanbanCard({ feature, onEdit, onDelete, onViewOutput, onVerify, onResume, hasContext, isCurrentAutoTask }: KanbanCardProps) {
// Disable dragging if the feature is in progress or verified
const isDraggable = feature.status === "backlog";
const {
@@ -127,7 +129,21 @@ export function KanbanCard({ feature, onEdit, onDelete, onViewOutput, onVerify,
)}
{!isCurrentAutoTask && feature.status === "in_progress" && (
<>
{onVerify && (
{hasContext && onResume ? (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs bg-blue-600 hover:bg-blue-700"
onClick={(e) => {
e.stopPropagation();
onResume();
}}
data-testid={`resume-feature-${feature.id}`}
>
<RotateCcw className="w-3 h-3 mr-1" />
Resume
</Button>
) : onVerify ? (
<Button
variant="default"
size="sm"
@@ -139,9 +155,9 @@ export function KanbanCard({ feature, onEdit, onDelete, onViewOutput, onVerify,
data-testid={`verify-feature-${feature.id}`}
>
<PlayCircle className="w-3 h-3 mr-1" />
Verify
Implement
</Button>
)}
) : null}
{onViewOutput && (
<Button
variant="ghost"

View File

@@ -12,10 +12,25 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { FolderOpen, Plus, Cpu, Folder, Clock, Sparkles, MessageSquare, ChevronDown } from "lucide-react";
import {
FolderOpen,
Plus,
Cpu,
Folder,
Clock,
Sparkles,
MessageSquare,
ChevronDown,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
@@ -24,7 +39,8 @@ import {
} from "@/components/ui/dropdown-menu";
export function WelcomeView() {
const { projects, addProject, setCurrentProject, setCurrentView } = useAppStore();
const { projects, addProject, setCurrentProject, setCurrentView } =
useAppStore();
const [showNewProjectDialog, setShowNewProjectDialog] = useState(false);
const [newProjectName, setNewProjectName] = useState("");
const [newProjectPath, setNewProjectPath] = useState("");
@@ -101,13 +117,17 @@ export function WelcomeView() {
);
await api.writeFile(
`${projectPath}/feature_list.json`,
`${projectPath}/.automaker/feature_list.json`,
JSON.stringify(
[
{
category: "Core",
description: "First feature to implement",
steps: ["Step 1: Define requirements", "Step 2: Implement", "Step 3: Test"],
steps: [
"Step 1: Define requirements",
"Step 2: Implement",
"Step 3: Test",
],
passes: false,
},
],
@@ -151,8 +171,12 @@ export function WelcomeView() {
<Cpu className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Welcome to Automaker</h1>
<p className="text-sm text-zinc-400">Your autonomous AI development studio</p>
<h1 className="text-2xl font-bold text-white">
Welcome to Automaker
</h1>
<p className="text-sm text-zinc-400">
Your autonomous AI development studio
</p>
</div>
</div>
</div>
@@ -174,9 +198,12 @@ export function WelcomeView() {
<Plus className="w-6 h-6 text-white" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-white mb-1">New Project</h3>
<h3 className="text-lg font-semibold text-white mb-1">
New Project
</h3>
<p className="text-sm text-zinc-400">
Create a new project from scratch with AI-powered development
Create a new project from scratch with AI-powered
development
</p>
</div>
</div>
@@ -223,7 +250,9 @@ export function WelcomeView() {
<FolderOpen className="w-6 h-6 text-zinc-400 group-hover:text-white transition-colors" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-white mb-1">Open Project</h3>
<h3 className="text-lg font-semibold text-white mb-1">
Open Project
</h3>
<p className="text-sm text-zinc-400">
Open an existing project folder to continue working
</p>
@@ -246,7 +275,9 @@ export function WelcomeView() {
<div>
<div className="flex items-center gap-2 mb-4">
<Clock className="w-5 h-5 text-zinc-400" />
<h2 className="text-lg font-semibold text-white">Recent Projects</h2>
<h2 className="text-lg font-semibold text-white">
Recent Projects
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{recentProjects.map((project) => (
@@ -266,10 +297,14 @@ export function WelcomeView() {
<p className="font-medium text-white truncate group-hover:text-brand-500 transition-colors">
{project.name}
</p>
<p className="text-xs text-zinc-500 truncate mt-0.5">{project.path}</p>
<p className="text-xs text-zinc-500 truncate mt-0.5">
{project.path}
</p>
{project.lastOpened && (
<p className="text-xs text-zinc-600 mt-1">
{new Date(project.lastOpened).toLocaleDateString()}
{new Date(
project.lastOpened
).toLocaleDateString()}
</p>
)}
</div>
@@ -287,7 +322,9 @@ export function WelcomeView() {
<div className="w-16 h-16 rounded-2xl bg-zinc-900/50 border border-white/10 flex items-center justify-center mb-4">
<Sparkles className="w-8 h-8 text-zinc-600" />
</div>
<h3 className="text-lg font-semibold text-white mb-2">No projects yet</h3>
<h3 className="text-lg font-semibold text-white mb-2">
No projects yet
</h3>
<p className="text-sm text-zinc-400 max-w-md">
Get started by creating a new project or opening an existing one
</p>
@@ -297,7 +334,10 @@ export function WelcomeView() {
</div>
{/* New Project Dialog */}
<Dialog open={showNewProjectDialog} onOpenChange={setShowNewProjectDialog}>
<Dialog
open={showNewProjectDialog}
onOpenChange={setShowNewProjectDialog}
>
<DialogContent
className="bg-zinc-900 border-white/10"
data-testid="new-project-dialog"