Merge pull request #97 from AutoMaker-Org/ui-tweaks

feat: implement completed features management in BoardView and Kanban…
This commit is contained in:
Web Dev Cody
2025-12-15 10:06:04 -05:00
committed by GitHub
23 changed files with 1427 additions and 471 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -1,172 +0,0 @@
import {
query,
Options,
SDKAssistantMessage,
} from "@anthropic-ai/claude-agent-sdk";
import { NextRequest, NextResponse } from "next/server";
import path from "path";
const systemPrompt = `You are an AI assistant helping users build software. You are part of the Automaker application,
which is designed to help developers plan, design, and implement software projects autonomously.
Your role is to:
- Help users define their project requirements and specifications
- Ask clarifying questions to better understand their needs
- Suggest technical approaches and architectures
- Guide them through the development process
- Be conversational and helpful
- Write, edit, and modify code files as requested
- Execute commands and tests
- Search and analyze the codebase
When discussing projects, help users think through:
- Core functionality and features
- Technical stack choices
- Data models and architecture
- User experience considerations
- Testing strategies
You have full access to the codebase and can:
- Read files to understand existing code
- Write new files
- Edit existing files
- Run bash commands
- Search for code patterns
- Execute tests and builds`;
export async function POST(request: NextRequest) {
try {
const { messages, workingDirectory } = await request.json();
console.log(
"[API] CLAUDE_CODE_OAUTH_TOKEN present:",
!!process.env.CLAUDE_CODE_OAUTH_TOKEN
);
if (!process.env.CLAUDE_CODE_OAUTH_TOKEN) {
return NextResponse.json(
{ error: "CLAUDE_CODE_OAUTH_TOKEN not configured" },
{ status: 500 }
);
}
// Get the last user message
const lastMessage = messages[messages.length - 1];
// Determine working directory - default to parent of app directory
const cwd = workingDirectory || path.resolve(process.cwd(), "..");
console.log("[API] Working directory:", cwd);
// Create query with options that enable code modification
const options: Options = {
// model: "claude-sonnet-4-20250514",
model: "claude-opus-4-5-20251101",
systemPrompt,
maxTurns: 20,
cwd,
// Enable all core tools for code modification
allowedTools: [
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
"WebSearch",
"WebFetch",
],
// Auto-accept file edits within the working directory
permissionMode: "acceptEdits",
// Enable sandbox for safer bash execution
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
};
// Convert message history to SDK format to preserve conversation context
// Include both user and assistant messages for full context
const sessionId = `api-session-${Date.now()}`;
const conversationMessages = messages.map(
(msg: { role: string; content: string }) => {
if (msg.role === "user") {
return {
type: "user" as const,
message: {
role: "user" as const,
content: msg.content,
},
parent_tool_use_id: null,
session_id: sessionId,
};
} else {
// Assistant message
return {
type: "assistant" as const,
message: {
role: "assistant" as const,
content: [
{
type: "text" as const,
text: msg.content,
},
],
},
session_id: sessionId,
};
}
}
);
// Execute query with full conversation context
const queryResult = query({
prompt:
conversationMessages.length > 0
? conversationMessages
: lastMessage.content,
options,
});
let responseText = "";
const toolUses: Array<{ name: string; input: unknown }> = [];
// Collect the response from the async generator
for await (const msg of queryResult) {
if (msg.type === "assistant") {
const assistantMsg = msg as SDKAssistantMessage;
if (assistantMsg.message.content) {
for (const block of assistantMsg.message.content) {
if (block.type === "text") {
responseText += block.text;
} else if (block.type === "tool_use") {
// Track tool usage for transparency
toolUses.push({
name: block.name,
input: block.input,
});
}
}
}
} else if (msg.type === "result") {
if (msg.subtype === "success") {
if (msg.result) {
responseText = msg.result;
}
}
}
}
return NextResponse.json({
content: responseText || "Sorry, I couldn't generate a response.",
toolUses: toolUses.length > 0 ? toolUses : undefined,
});
} catch (error: unknown) {
console.error("Claude API error:", error);
const errorMessage =
error instanceof Error
? error.message
: "Failed to get response from Claude";
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}

View File

@@ -0,0 +1,167 @@
"use client";
import { Sparkles, Clock } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
// Feature count options
export 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" },
];
interface ProjectSetupDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectOverview: string;
onProjectOverviewChange: (value: string) => void;
generateFeatures: boolean;
onGenerateFeaturesChange: (value: boolean) => void;
featureCount: FeatureCount;
onFeatureCountChange: (value: FeatureCount) => void;
onCreateSpec: () => void;
onSkip: () => void;
isCreatingSpec: boolean;
}
export function ProjectSetupDialog({
open,
onOpenChange,
projectOverview,
onProjectOverviewChange,
generateFeatures,
onGenerateFeaturesChange,
featureCount,
onFeatureCountChange,
onCreateSpec,
onSkip,
isCreatingSpec,
}: ProjectSetupDialogProps) {
return (
<Dialog
open={open}
onOpenChange={(open) => {
onOpenChange(open);
if (!open && !isCreatingSpec) {
onSkip();
}
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Set Up Your Project</DialogTitle>
<DialogDescription className="text-muted-foreground">
We didn&apos;t find an app_spec.txt file. Let us help you generate
your app_spec.txt to help describe your project for our system.
We&apos;ll analyze your project&apos;s tech stack and create a
comprehensive specification.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Project Overview</label>
<p className="text-xs text-muted-foreground">
Describe what your project does and what features you want to
build. Be as detailed as you want - this will help us create a
better specification.
</p>
<textarea
className="w-full h-48 p-3 rounded-md border border-border bg-background font-mono text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
value={projectOverview}
onChange={(e) => onProjectOverviewChange(e.target.value)}
placeholder="e.g., A project management tool that allows teams to track tasks, manage sprints, and visualize progress through kanban boards. It should support user authentication, real-time updates, and file attachments..."
autoFocus
/>
</div>
<div className="flex items-start space-x-3 pt-2">
<Checkbox
id="sidebar-generate-features"
checked={generateFeatures}
onCheckedChange={(checked) =>
onGenerateFeaturesChange(checked === true)
}
/>
<div className="space-y-1">
<label
htmlFor="sidebar-generate-features"
className="text-sm font-medium cursor-pointer"
>
Generate feature list
</label>
<p className="text-xs text-muted-foreground">
Automatically create features in the features folder from the
implementation roadmap after the spec is generated.
</p>
</div>
</div>
{/* Feature Count Selection - only shown when generateFeatures is enabled */}
{generateFeatures && (
<div className="space-y-2 pt-2 pl-7">
<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={() => onFeatureCountChange(option.value)}
className={cn(
"flex-1 transition-all",
featureCount === option.value
? "bg-primary hover:bg-primary/90 text-primary-foreground"
: "bg-muted/30 hover:bg-muted/50 border-border"
)}
data-testid={`feature-count-${option.value}`}
>
{option.label}
</Button>
))}
</div>
{FEATURE_COUNT_OPTIONS.find((o) => o.value === featureCount)
?.warning && (
<p className="text-xs text-amber-500 flex items-center gap-1">
<Clock className="w-3 h-3" />
{
FEATURE_COUNT_OPTIONS.find((o) => o.value === featureCount)
?.warning
}
</p>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={onSkip}>
Skip for now
</Button>
<Button onClick={onCreateSpec} disabled={!projectOverview.trim()}>
<Sparkles className="w-4 h-4 mr-2" />
Generate Spec
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -79,10 +79,13 @@ import {
} from "@/lib/project-init";
import { toast } from "sonner";
import { themeOptions } from "@/config/theme-options";
import { Checkbox } from "@/components/ui/checkbox";
import type { SpecRegenerationEvent } from "@/types/electron";
import { DeleteProjectDialog } from "@/components/views/settings-view/components/delete-project-dialog";
import { NewProjectModal } from "@/components/new-project-modal";
import {
ProjectSetupDialog,
type FeatureCount,
} from "@/components/layout/project-setup-dialog";
import {
DndContext,
DragEndEvent,
@@ -149,7 +152,8 @@ function SortableProjectItem({
"flex items-center gap-2 px-2.5 py-2 rounded-lg cursor-pointer transition-all duration-200",
"text-muted-foreground hover:text-foreground hover:bg-accent/80",
isDragging && "bg-accent shadow-lg scale-[1.02]",
isHighlighted && "bg-brand-500/10 text-foreground ring-1 ring-brand-500/20"
isHighlighted &&
"bg-brand-500/10 text-foreground ring-1 ring-brand-500/20"
)}
data-testid={`project-option-${project.id}`}
>
@@ -170,7 +174,9 @@ function SortableProjectItem({
onClick={() => onSelect(project)}
>
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate text-sm font-medium">{project.name}</span>
<span className="flex-1 truncate text-sm font-medium">
{project.name}
</span>
{currentProjectId === project.id && (
<Check className="h-4 w-4 text-brand-500 shrink-0" />
)}
@@ -258,6 +264,7 @@ export function Sidebar() {
const [setupProjectPath, setSetupProjectPath] = useState("");
const [projectOverview, setProjectOverview] = useState("");
const [generateFeatures, setGenerateFeatures] = useState(true);
const [featureCount, setFeatureCount] = useState<FeatureCount>(50);
const [showSpecIndicator, setShowSpecIndicator] = useState(true);
// Derive isCreatingSpec from store state
@@ -463,7 +470,9 @@ export function Sidebar() {
const result = await api.specRegeneration.create(
setupProjectPath,
projectOverview.trim(),
generateFeatures
generateFeatures,
undefined, // analyzeProject - use default
generateFeatures ? featureCount : undefined // only pass maxFeatures if generating features
);
if (!result.success) {
@@ -472,6 +481,12 @@ export function Sidebar() {
toast.error("Failed to create specification", {
description: result.error,
});
} else {
// Show processing toast to inform user
toast.info("Generating app specification...", {
description:
"This may take a minute. You'll be notified when complete.",
});
}
// If successful, we'll wait for the events to update the state
} catch (error) {
@@ -481,7 +496,13 @@ export function Sidebar() {
description: error instanceof Error ? error.message : "Unknown error",
});
}
}, [setupProjectPath, projectOverview, setSpecCreatingForProject]);
}, [
setupProjectPath,
projectOverview,
generateFeatures,
featureCount,
setSpecCreatingForProject,
]);
// Handle skipping setup
const handleSkipSetup = useCallback(() => {
@@ -1248,16 +1269,55 @@ export function Sidebar() {
className="size-8 group-hover:rotate-12 transition-transform duration-300 ease-out"
>
<defs>
<linearGradient id="bg-collapsed" x1="0" y1="0" x2="256" y2="256" gradientUnits="userSpaceOnUse">
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
<linearGradient
id="bg-collapsed"
x1="0"
y1="0"
x2="256"
y2="256"
gradientUnits="userSpaceOnUse"
>
<stop
offset="0%"
style={{ stopColor: "var(--brand-400)" }}
/>
<stop
offset="100%"
style={{ stopColor: "var(--brand-600)" }}
/>
</linearGradient>
<filter id="iconShadow-collapsed" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="4" stdDeviation="4" floodColor="#000000" floodOpacity="0.25" />
<filter
id="iconShadow-collapsed"
x="-20%"
y="-20%"
width="140%"
height="140%"
>
<feDropShadow
dx="0"
dy="4"
stdDeviation="4"
floodColor="#000000"
floodOpacity="0.25"
/>
</filter>
</defs>
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-collapsed)" />
<g fill="none" stroke="#FFFFFF" strokeWidth="20" strokeLinecap="round" strokeLinejoin="round" filter="url(#iconShadow-collapsed)">
<rect
x="16"
y="16"
width="224"
height="224"
rx="56"
fill="url(#bg-collapsed)"
/>
<g
fill="none"
stroke="#FFFFFF"
strokeWidth="20"
strokeLinecap="round"
strokeLinejoin="round"
filter="url(#iconShadow-collapsed)"
>
<path d="M92 92 L52 128 L92 164" />
<path d="M144 72 L116 184" />
<path d="M164 92 L204 128 L164 164" />
@@ -1265,12 +1325,7 @@ export function Sidebar() {
</svg>
</div>
) : (
<div
className={cn(
"flex items-center gap-1",
"hidden lg:flex"
)}
>
<div className={cn("flex items-center gap-1", "hidden lg:flex")}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
@@ -1279,16 +1334,55 @@ export function Sidebar() {
className="h-[36.8px] w-[36.8px] group-hover:rotate-12 transition-transform duration-300 ease-out"
>
<defs>
<linearGradient id="bg-expanded" x1="0" y1="0" x2="256" y2="256" gradientUnits="userSpaceOnUse">
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
<linearGradient
id="bg-expanded"
x1="0"
y1="0"
x2="256"
y2="256"
gradientUnits="userSpaceOnUse"
>
<stop
offset="0%"
style={{ stopColor: "var(--brand-400)" }}
/>
<stop
offset="100%"
style={{ stopColor: "var(--brand-600)" }}
/>
</linearGradient>
<filter id="iconShadow-expanded" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="4" stdDeviation="4" floodColor="#000000" floodOpacity="0.25" />
<filter
id="iconShadow-expanded"
x="-20%"
y="-20%"
width="140%"
height="140%"
>
<feDropShadow
dx="0"
dy="4"
stdDeviation="4"
floodColor="#000000"
floodOpacity="0.25"
/>
</filter>
</defs>
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-expanded)" />
<g fill="none" stroke="#FFFFFF" strokeWidth="20" strokeLinecap="round" strokeLinejoin="round" filter="url(#iconShadow-expanded)">
<rect
x="16"
y="16"
width="224"
height="224"
rx="56"
fill="url(#bg-expanded)"
/>
<g
fill="none"
stroke="#FFFFFF"
strokeWidth="20"
strokeLinecap="round"
strokeLinejoin="round"
filter="url(#iconShadow-expanded)"
>
<path d="M92 92 L52 128 L92 164" />
<path d="M144 72 L116 184" />
<path d="M164 92 L204 128 L164 164" />
@@ -1371,7 +1465,7 @@ export function Sidebar() {
onClick={() => setShowTrashDialog(true)}
className={cn(
"group flex items-center justify-center px-3 h-[42px] rounded-xl",
"relative overflow-hidden",
"relative",
"text-muted-foreground hover:text-destructive",
// Subtle background that turns red on hover
"bg-accent/20 hover:bg-destructive/15",
@@ -1385,7 +1479,7 @@ export function Sidebar() {
>
<Recycle className="size-4 shrink-0 transition-transform duration-200 group-hover:rotate-12" />
{trashedProjects.length > 0 && (
<span className="absolute -top-1.5 -right-1.5 flex items-center justify-center min-w-4 h-4 px-1 text-[9px] font-bold rounded-full bg-destructive text-destructive-foreground shadow-sm">
<span className="absolute -top-1.5 -right-1.5 z-10 flex items-center justify-center min-w-4 h-4 px-1 text-[9px] font-bold rounded-full bg-red-500 text-white shadow-md ring-1 ring-red-600/50">
{trashedProjects.length > 9 ? "9+" : trashedProjects.length}
</span>
)}
@@ -1413,7 +1507,8 @@ export function Sidebar() {
"text-foreground titlebar-no-drag min-w-0",
"transition-all duration-200 ease-out",
"hover:scale-[1.01] active:scale-[0.99]",
isProjectPickerOpen && "from-brand-500/10 to-brand-600/5 border-brand-500/30 ring-2 ring-brand-500/20 shadow-lg shadow-brand-500/5"
isProjectPickerOpen &&
"from-brand-500/10 to-brand-600/5 border-brand-500/30 ring-2 ring-brand-500/20 shadow-lg shadow-brand-500/5"
)}
data-testid="project-selector"
>
@@ -1430,10 +1525,12 @@ export function Sidebar() {
>
{formatShortcut(shortcuts.projectPicker, true)}
</span>
<ChevronDown className={cn(
"h-4 w-4 text-muted-foreground shrink-0 transition-transform duration-200",
isProjectPickerOpen && "rotate-180"
)} />
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground shrink-0 transition-transform duration-200",
isProjectPickerOpen && "rotate-180"
)}
/>
</div>
</button>
</DropdownMenuTrigger>
@@ -1499,7 +1596,11 @@ export function Sidebar() {
{/* Keyboard hint */}
<div className="px-2 pt-2 mt-1.5 border-t border-border/50">
<p className="text-[10px] text-muted-foreground text-center tracking-wide">
<span className="text-foreground/60">arrow</span> navigate <span className="mx-1 text-foreground/30">|</span> <span className="text-foreground/60">enter</span> select <span className="mx-1 text-foreground/30">|</span> <span className="text-foreground/60">esc</span> close
<span className="text-foreground/60">arrow</span> navigate{" "}
<span className="mx-1 text-foreground/30">|</span>{" "}
<span className="text-foreground/60">enter</span> select{" "}
<span className="mx-1 text-foreground/30">|</span>{" "}
<span className="text-foreground/60">esc</span> close
</p>
</div>
</DropdownMenuContent>
@@ -1531,7 +1632,10 @@ export function Sidebar() {
<MoreVertical className="w-4 h-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 bg-popover/95 backdrop-blur-xl">
<DropdownMenuContent
align="end"
className="w-56 bg-popover/95 backdrop-blur-xl"
>
{/* Project Theme Submenu */}
<DropdownMenuSub>
<DropdownMenuSubTrigger data-testid="project-theme-trigger">
@@ -1809,13 +1913,15 @@ export function Sidebar() {
</div>
{/* Bottom Section - Running Agents / Bug Report / Settings */}
<div className={cn(
"shrink-0",
// Top border with gradient fade
"border-t border-border/40",
// Elevated background for visual separation
"bg-gradient-to-t from-background/10 via-sidebar/50 to-transparent"
)}>
<div
className={cn(
"shrink-0",
// Top border with gradient fade
"border-t border-border/40",
// Elevated background for visual separation
"bg-gradient-to-t from-background/10 via-sidebar/50 to-transparent"
)}
>
{/* Course Promo Badge */}
<CoursePromoBadge sidebarOpen={sidebarOpen} />
{/* Wiki Link */}
@@ -1865,14 +1971,16 @@ export function Sidebar() {
Wiki
</span>
{!sidebarOpen && (
<span className={cn(
"absolute left-full ml-3 px-2.5 py-1.5 rounded-lg",
"bg-popover text-popover-foreground text-xs font-medium",
"border border-border shadow-lg",
"opacity-0 group-hover:opacity-100",
"transition-all duration-200 whitespace-nowrap z-50",
"translate-x-1 group-hover:translate-x-0"
)}>
<span
className={cn(
"absolute left-full ml-3 px-2.5 py-1.5 rounded-lg",
"bg-popover text-popover-foreground text-xs font-medium",
"border border-border shadow-lg",
"opacity-0 group-hover:opacity-100",
"transition-all duration-200 whitespace-nowrap z-50",
"translate-x-1 group-hover:translate-x-0"
)}
>
Wiki
</span>
)}
@@ -1957,14 +2065,16 @@ export function Sidebar() {
</span>
)}
{!sidebarOpen && (
<span className={cn(
"absolute left-full ml-3 px-2.5 py-1.5 rounded-lg",
"bg-popover text-popover-foreground text-xs font-medium",
"border border-border shadow-lg",
"opacity-0 group-hover:opacity-100",
"transition-all duration-200 whitespace-nowrap z-50",
"translate-x-1 group-hover:translate-x-0"
)}>
<span
className={cn(
"absolute left-full ml-3 px-2.5 py-1.5 rounded-lg",
"bg-popover text-popover-foreground text-xs font-medium",
"border border-border shadow-lg",
"opacity-0 group-hover:opacity-100",
"transition-all duration-200 whitespace-nowrap z-50",
"translate-x-1 group-hover:translate-x-0"
)}
>
Running Agents
{runningAgentsCount > 0 && (
<span className="ml-2 px-1.5 py-0.5 bg-brand-500 text-white rounded-full text-[10px] font-semibold">
@@ -2035,14 +2145,16 @@ export function Sidebar() {
</span>
)}
{!sidebarOpen && (
<span className={cn(
"absolute left-full ml-3 px-2.5 py-1.5 rounded-lg",
"bg-popover text-popover-foreground text-xs font-medium",
"border border-border shadow-lg",
"opacity-0 group-hover:opacity-100",
"transition-all duration-200 whitespace-nowrap z-50",
"translate-x-1 group-hover:translate-x-0"
)}>
<span
className={cn(
"absolute left-full ml-3 px-2.5 py-1.5 rounded-lg",
"bg-popover text-popover-foreground text-xs font-medium",
"border border-border shadow-lg",
"opacity-0 group-hover:opacity-100",
"transition-all duration-200 whitespace-nowrap z-50",
"translate-x-1 group-hover:translate-x-0"
)}
>
Settings
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
{formatShortcut(shortcuts.settings, true)}
@@ -2141,80 +2253,19 @@ export function Sidebar() {
</Dialog>
{/* New Project Setup Dialog */}
<Dialog
<ProjectSetupDialog
open={showSetupDialog}
onOpenChange={(open) => {
if (!open && !isCreatingSpec) {
handleSkipSetup();
}
}}
>
<DialogContent className="max-w-2xl bg-popover/95 backdrop-blur-xl">
<DialogHeader>
<DialogTitle>Set Up Your Project</DialogTitle>
<DialogDescription className="text-muted-foreground">
We didn&apos;t find an app_spec.txt file. Let us help you generate
your app_spec.txt to help describe your project for our system.
We&apos;ll analyze your project&apos;s tech stack and create a
comprehensive specification.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Project Overview</label>
<p className="text-xs text-muted-foreground">
Describe what your project does and what features you want to
build. Be as detailed as you want - this will help us create a
better specification.
</p>
<textarea
className="w-full h-48 p-3 rounded-lg border border-border bg-background/50 font-mono text-sm resize-none focus:outline-none focus:ring-2 focus:ring-brand-500/30 focus:border-brand-500/50 transition-all"
value={projectOverview}
onChange={(e) => setProjectOverview(e.target.value)}
placeholder="e.g., A project management tool that allows teams to track tasks, manage sprints, and visualize progress through kanban boards. It should support user authentication, real-time updates, and file attachments..."
autoFocus
/>
</div>
<div className="flex items-start space-x-3 pt-2">
<Checkbox
id="sidebar-generate-features"
checked={generateFeatures}
onCheckedChange={(checked) =>
setGenerateFeatures(checked === true)
}
/>
<div className="space-y-1">
<label
htmlFor="sidebar-generate-features"
className="text-sm font-medium cursor-pointer"
>
Generate feature list
</label>
<p className="text-xs text-muted-foreground">
Automatically create features in the features folder from the
implementation roadmap after the spec is generated.
</p>
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={handleSkipSetup}>
Skip for now
</Button>
<Button
onClick={handleCreateInitialSpec}
disabled={!projectOverview.trim()}
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0"
>
<Sparkles className="w-4 h-4 mr-2" />
Generate Spec
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
onOpenChange={setShowSetupDialog}
projectOverview={projectOverview}
onProjectOverviewChange={setProjectOverview}
generateFeatures={generateFeatures}
onGenerateFeaturesChange={setGenerateFeatures}
featureCount={featureCount}
onFeatureCountChange={setFeatureCount}
onCreateSpec={handleCreateInitialSpec}
onSkip={handleSkipSetup}
isCreatingSpec={isCreatingSpec}
/>
{/* New Project Onboarding Dialog */}
<Dialog

View File

@@ -43,14 +43,16 @@ const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName

View File

@@ -90,6 +90,8 @@ import {
Maximize2,
Shuffle,
ImageIcon,
Archive,
ArchiveRestore,
} from "lucide-react";
import { toast } from "sonner";
import { Slider } from "@/components/ui/slider";
@@ -112,9 +114,21 @@ type ColumnId = Feature["status"];
const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
{ id: "backlog", title: "Backlog", colorClass: "bg-[var(--status-backlog)]" },
{ id: "in_progress", title: "In Progress", colorClass: "bg-[var(--status-in-progress)]" },
{ id: "waiting_approval", title: "Waiting Approval", colorClass: "bg-[var(--status-waiting)]" },
{ id: "verified", title: "Verified", colorClass: "bg-[var(--status-success)]" },
{
id: "in_progress",
title: "In Progress",
colorClass: "bg-[var(--status-in-progress)]",
},
{
id: "waiting_approval",
title: "Waiting Approval",
colorClass: "bg-[var(--status-waiting)]",
},
{
id: "verified",
title: "Verified",
colorClass: "bg-[var(--status-success)]",
},
];
type ModelOption = {
@@ -208,6 +222,9 @@ export function BoardView() {
useState(false);
const [showBoardBackgroundModal, setShowBoardBackgroundModal] =
useState(false);
const [showCompletedModal, setShowCompletedModal] = useState(false);
const [deleteCompletedFeature, setDeleteCompletedFeature] =
useState<Feature | null>(null);
const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
const [showFollowUpDialog, setShowFollowUpDialog] = useState(false);
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
@@ -270,7 +287,7 @@ export function BoardView() {
};
}, []);
// Subscribe to spec regeneration events to clear state on completion
// Subscribe to spec regeneration events to clear creating state on completion
useEffect(() => {
const api = getElectronAPI();
if (!api.specRegeneration) return;
@@ -490,6 +507,30 @@ export function BoardView() {
}
}, [currentProject, setFeatures]);
// Subscribe to spec regeneration complete events to refresh kanban board
useEffect(() => {
const api = getElectronAPI();
if (!api.specRegeneration) return;
const unsubscribe = api.specRegeneration.onEvent((event) => {
// Refresh the kanban board when spec regeneration completes for the current project
if (
event.type === "spec_regeneration_complete" &&
currentProject &&
event.projectPath === currentProject.path
) {
console.log(
"[BoardView] Spec regeneration complete, refreshing features"
);
loadFeatures();
}
});
return () => {
unsubscribe();
};
}, [currentProject, loadFeatures]);
// Load persisted categories from file
const loadCategories = useCallback(async () => {
if (!currentProject) return;
@@ -844,34 +885,12 @@ export function BoardView() {
// Same column, nothing to do
if (targetStatus === draggedFeature.status) return;
// Check concurrency limit before moving to in_progress (only for backlog -> in_progress and if running agent)
if (
targetStatus === "in_progress" &&
draggedFeature.status === "backlog" &&
!autoMode.canStartNewTask
) {
console.log("[Board] Cannot start new task - at max concurrency limit");
toast.error("Concurrency limit reached", {
description: `You can only have ${autoMode.maxConcurrency} task${
autoMode.maxConcurrency > 1 ? "s" : ""
} running at a time. Wait for a task to complete or increase the limit.`,
});
return;
}
// Handle different drag scenarios
if (draggedFeature.status === "backlog") {
// From backlog
if (targetStatus === "in_progress") {
// Update with startedAt timestamp
const updates = {
status: targetStatus,
startedAt: new Date().toISOString(),
};
updateFeature(featureId, updates);
persistFeatureUpdate(featureId, updates);
console.log("[Board] Feature moved to in_progress, starting agent...");
await handleRunFeature(draggedFeature);
// Use helper function to handle concurrency check and start implementation
await handleStartImplementation(draggedFeature);
} else {
moveFeature(featureId, targetStatus);
persistFeatureUpdate(featureId, { status: targetStatus });
@@ -1144,6 +1163,28 @@ export function BoardView() {
}
};
// Helper function to start implementing a feature (from backlog to in_progress)
const handleStartImplementation = async (feature: Feature) => {
if (!autoMode.canStartNewTask) {
toast.error("Concurrency limit reached", {
description: `You can only have ${autoMode.maxConcurrency} task${
autoMode.maxConcurrency > 1 ? "s" : ""
} running at a time. Wait for a task to complete or increase the limit.`,
});
return false;
}
const updates = {
status: "in_progress" as const,
startedAt: new Date().toISOString(),
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
console.log("[Board] Feature moved to in_progress, starting agent...");
await handleRunFeature(feature);
return true;
};
const handleVerifyFeature = async (feature: Feature) => {
if (!currentProject) return;
@@ -1497,6 +1538,47 @@ export function BoardView() {
}
};
// Complete a verified feature (move to completed/archived)
const handleCompleteFeature = (feature: Feature) => {
console.log("[Board] Completing feature:", {
id: feature.id,
description: feature.description,
});
const updates = {
status: "completed" as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.success("Feature completed", {
description: `Archived: ${feature.description.slice(0, 50)}${
feature.description.length > 50 ? "..." : ""
}`,
});
};
// Unarchive a completed feature (move back to verified)
const handleUnarchiveFeature = (feature: Feature) => {
console.log("[Board] Unarchiving feature:", {
id: feature.id,
description: feature.description,
});
const updates = {
status: "verified" as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.success("Feature restored", {
description: `Moved back to verified: ${feature.description.slice(
0,
50
)}${feature.description.length > 50 ? "..." : ""}`,
});
};
const checkContextExists = async (featureId: string): Promise<boolean> => {
if (!currentProject) return false;
@@ -1518,6 +1600,11 @@ export function BoardView() {
}
};
// Memoize completed features for the archive modal
const completedFeatures = useMemo(() => {
return features.filter((f) => f.status === "completed");
}, [features]);
// Memoize column features to prevent unnecessary re-renders
const columnFeaturesMap = useMemo(() => {
const map: Record<ColumnId, Feature[]> = {
@@ -1525,6 +1612,7 @@ export function BoardView() {
in_progress: [],
waiting_approval: [],
verified: [],
completed: [], // Completed features are shown in the archive modal, not as a column
};
// Filter features by search query (case-insensitive)
@@ -1903,6 +1991,31 @@ export function BoardView() {
</TooltipContent>
</Tooltip>
{/* Completed/Archived Features Button */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => setShowCompletedModal(true)}
className="h-8 px-2 relative"
data-testid="completed-features-button"
>
<Archive className="w-4 h-4" />
{completedFeatures.length > 0 && (
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center">
{completedFeatures.length > 99
? "99+"
: completedFeatures.length}
</span>
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Completed Features ({completedFeatures.length})</p>
</TooltipContent>
</Tooltip>
{/* Kanban Card Detail Level Toggle */}
<div
className="flex items-center rounded-lg bg-secondary border border-border"
@@ -2067,7 +2180,7 @@ export function BoardView() {
data-testid="start-next-button"
>
<FastForward className="w-3 h-3 mr-1" />
Pull Top
Make
</HotkeyButton>
)}
</div>
@@ -2107,6 +2220,12 @@ export function BoardView() {
onCommit={() => handleCommitFeature(feature)}
onRevert={() => handleRevertFeature(feature)}
onMerge={() => handleMergeFeature(feature)}
onComplete={() =>
handleCompleteFeature(feature)
}
onImplement={() =>
handleStartImplementation(feature)
}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(
feature.id
@@ -2131,10 +2250,12 @@ export function BoardView() {
})}
</div>
<DragOverlay dropAnimation={{
duration: 200,
easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
}}>
<DragOverlay
dropAnimation={{
duration: 200,
easing: "cubic-bezier(0.18, 0.67, 0.6, 1.22)",
}}
>
{activeFeature && (
<Card className="w-72 rotate-2 shadow-2xl shadow-black/25 border-primary/50 bg-card/95 backdrop-blur-sm transition-transform">
<CardHeader className="p-3">
@@ -2160,6 +2281,136 @@ export function BoardView() {
onOpenChange={setShowBoardBackgroundModal}
/>
{/* Completed Features Modal */}
<Dialog open={showCompletedModal} onOpenChange={setShowCompletedModal}>
<DialogContent className="max-w-5xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Archive className="w-5 h-5 text-brand-500" />
Completed Features
</DialogTitle>
<DialogDescription>
{completedFeatures.length === 0
? "No completed features yet. Features you complete will appear here."
: `${completedFeatures.length} completed feature${
completedFeatures.length === 1 ? "" : "s"
}`}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4">
{completedFeatures.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Archive className="w-12 h-12 mb-4 opacity-50" />
<p className="text-lg font-medium">No completed features</p>
<p className="text-sm">
Complete features from the Verified column to archive them
here.
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{completedFeatures.map((feature) => (
<Card
key={feature.id}
className="flex flex-col"
data-testid={`completed-card-${feature.id}`}
>
<CardHeader className="p-3 pb-2 flex-1">
<CardTitle className="text-sm leading-tight line-clamp-3">
{feature.description || feature.summary || feature.id}
</CardTitle>
<CardDescription className="text-xs mt-1 truncate">
{feature.category || "Uncategorized"}
</CardDescription>
</CardHeader>
<div className="p-3 pt-0 flex gap-2">
<Button
variant="secondary"
size="sm"
className="flex-1 h-7 text-xs"
onClick={() => handleUnarchiveFeature(feature)}
data-testid={`unarchive-${feature.id}`}
>
<ArchiveRestore className="w-3 h-3 mr-1" />
Restore
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
onClick={() => setDeleteCompletedFeature(feature)}
data-testid={`delete-completed-${feature.id}`}
title="Delete"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</Card>
))}
</div>
)}
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setShowCompletedModal(false)}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Completed Feature Confirmation Dialog */}
<Dialog
open={!!deleteCompletedFeature}
onOpenChange={(open) => !open && setDeleteCompletedFeature(null)}
>
<DialogContent data-testid="delete-completed-confirmation-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<Trash2 className="w-5 h-5" />
Delete Feature
</DialogTitle>
<DialogDescription>
Are you sure you want to permanently delete this feature?
<span className="block mt-2 font-medium text-foreground">
&quot;{deleteCompletedFeature?.description?.slice(0, 100)}
{(deleteCompletedFeature?.description?.length ?? 0) > 100
? "..."
: ""}
&quot;
</span>
<span className="block mt-2 text-destructive font-medium">
This action cannot be undone.
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setDeleteCompletedFeature(null)}
data-testid="cancel-delete-completed-button"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={async () => {
if (deleteCompletedFeature) {
await handleDeleteFeature(deleteCompletedFeature.id);
setDeleteCompletedFeature(null);
}
}}
data-testid="confirm-delete-completed-button"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add Feature Dialog */}
<Dialog
open={showAddDialog}

View File

@@ -57,6 +57,8 @@ import {
ChevronDown,
ChevronUp,
Brain,
Wand2,
Archive,
} from "lucide-react";
import { CountUpTimer } from "@/components/ui/count-up-timer";
import { getElectronAPI } from "@/lib/electron";
@@ -103,6 +105,8 @@ interface KanbanCardProps {
onCommit?: () => void;
onRevert?: () => void;
onMerge?: () => void;
onImplement?: () => void;
onComplete?: () => void;
hasContext?: boolean;
isCurrentAutoTask?: boolean;
shortcutKey?: string;
@@ -128,6 +132,8 @@ export const KanbanCard = memo(function KanbanCard({
onCommit,
onRevert,
onMerge,
onImplement,
onComplete,
hasContext,
isCurrentAutoTask,
shortcutKey,
@@ -436,7 +442,82 @@ export const KanbanCard = memo(function KanbanCard({
)}
</div>
)}
{!isCurrentAutoTask && (
{!isCurrentAutoTask && feature.status === "backlog" && (
<div className="absolute top-2 right-2">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick(e);
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`delete-backlog-${feature.id}`}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
{!isCurrentAutoTask &&
(feature.status === "waiting_approval" ||
feature.status === "verified") && (
<div className="absolute top-2 right-2 flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`edit-${
feature.status === "waiting_approval" ? "waiting" : "verified"
}-${feature.id}`}
title="Edit"
>
<Edit className="w-4 h-4" />
</Button>
{onViewOutput && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`logs-${
feature.status === "waiting_approval"
? "waiting"
: "verified"
}-${feature.id}`}
title="Logs"
>
<FileText className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick(e);
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`delete-${
feature.status === "waiting_approval" ? "waiting" : "verified"
}-${feature.id}`}
title="Delete"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
{!isCurrentAutoTask && feature.status === "in_progress" && (
<div className="absolute top-2 right-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -463,7 +544,7 @@ export const KanbanCard = memo(function KanbanCard({
<Edit className="w-3 h-3 mr-2" />
Edit
</DropdownMenuItem>
{onViewOutput && feature.status !== "backlog" && (
{onViewOutput && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
@@ -647,7 +728,8 @@ export const KanbanCard = memo(function KanbanCard({
"break-words hyphens-auto line-clamp-2 leading-relaxed",
todo.status === "completed" &&
"text-muted-foreground/60 line-through",
todo.status === "in_progress" && "text-[var(--status-warning)]",
todo.status === "in_progress" &&
"text-[var(--status-warning)]",
todo.status === "pending" &&
"text-muted-foreground/80"
)}
@@ -833,11 +915,12 @@ export const KanbanCard = memo(function KanbanCard({
)}
{!isCurrentAutoTask && feature.status === "verified" && (
<>
{hasContext && onViewOutput && (
{/* Logs button - styled like Refine */}
{onViewOutput && (
<Button
variant="ghost"
variant="secondary"
size="sm"
className="h-7 text-[11px] px-2"
className="flex-1 h-7 text-xs min-w-0"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
@@ -845,7 +928,25 @@ export const KanbanCard = memo(function KanbanCard({
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-output-verified-${feature.id}`}
>
<FileText className="w-3 h-3" />
<FileText className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Logs</span>
</Button>
)}
{/* Complete button */}
{onComplete && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs min-w-0 bg-brand-500 hover:bg-brand-600"
onClick={(e) => {
e.stopPropagation();
onComplete();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`complete-${feature.id}`}
>
<Archive className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Complete</span>
</Button>
)}
</>
@@ -876,6 +977,7 @@ export const KanbanCard = memo(function KanbanCard({
</Tooltip>
</TooltipProvider>
)}
{/* Refine prompt button */}
{onFollowUp && (
<Button
variant="secondary"
@@ -888,8 +990,8 @@ export const KanbanCard = memo(function KanbanCard({
onPointerDown={(e) => e.stopPropagation()}
data-testid={`follow-up-${feature.id}`}
>
<MessageSquare className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Follow-up</span>
<Wand2 className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Refine</span>
</Button>
)}
{hasWorktree && onMerge && (
@@ -927,6 +1029,40 @@ export const KanbanCard = memo(function KanbanCard({
)}
</>
)}
{!isCurrentAutoTask && feature.status === "backlog" && (
<>
<Button
variant="secondary"
size="sm"
className="flex-1 h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`edit-backlog-${feature.id}`}
>
<Edit className="w-3 h-3 mr-1" />
Edit
</Button>
{onImplement && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
onImplement();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`make-${feature.id}`}
>
<PlayCircle className="w-3 h-3 mr-1" />
Make
</Button>
)}
</>
)}
</div>
</CardContent>

View File

@@ -0,0 +1,93 @@
/**
* Model Configuration - Centralized model settings for the app
*
* Models can be overridden via environment variables:
* - AUTOMAKER_MODEL_CHAT: Model for chat interactions
* - AUTOMAKER_MODEL_DEFAULT: Fallback model for all operations
*/
/**
* Claude model aliases for convenience
*/
export const CLAUDE_MODEL_MAP: Record<string, string> = {
haiku: "claude-haiku-4-5",
sonnet: "claude-sonnet-4-20250514",
opus: "claude-opus-4-5-20251101",
} as const;
/**
* Default models per use case
*/
export const DEFAULT_MODELS = {
chat: "claude-opus-4-5-20251101",
default: "claude-opus-4-5-20251101",
} as const;
/**
* Resolve a model alias to a full model string
*/
export function resolveModelString(
modelKey?: string,
defaultModel: string = DEFAULT_MODELS.default
): string {
if (!modelKey) {
return defaultModel;
}
// Full Claude model string - pass through
if (modelKey.includes("claude-")) {
return modelKey;
}
// Check alias map
const resolved = CLAUDE_MODEL_MAP[modelKey];
if (resolved) {
return resolved;
}
// Unknown key - use default
return defaultModel;
}
/**
* Get the model for chat operations
*
* Priority:
* 1. Explicit model parameter
* 2. AUTOMAKER_MODEL_CHAT environment variable
* 3. AUTOMAKER_MODEL_DEFAULT environment variable
* 4. Default chat model
*/
export function getChatModel(explicitModel?: string): string {
if (explicitModel) {
return resolveModelString(explicitModel);
}
const envModel =
process.env.AUTOMAKER_MODEL_CHAT || process.env.AUTOMAKER_MODEL_DEFAULT;
if (envModel) {
return resolveModelString(envModel);
}
return DEFAULT_MODELS.chat;
}
/**
* Default allowed tools for chat interactions
*/
export const CHAT_TOOLS = [
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
"WebSearch",
"WebFetch",
] as const;
/**
* Default max turns for chat
*/
export const CHAT_MAX_TURNS = 1000;

View File

@@ -148,15 +148,17 @@ export interface SpecRegenerationAPI {
projectPath: string,
projectOverview: string,
generateFeatures?: boolean,
analyzeProject?: boolean
analyzeProject?: boolean,
maxFeatures?: number
) => Promise<{ success: boolean; error?: string }>;
generate: (
projectPath: string,
projectDefinition: string,
generateFeatures?: boolean,
analyzeProject?: boolean
analyzeProject?: boolean,
maxFeatures?: number
) => Promise<{ success: boolean; error?: string }>;
generateFeatures: (projectPath: string) => Promise<{
generateFeatures: (projectPath: string, maxFeatures?: number) => Promise<{
success: boolean;
error?: string;
}>;
@@ -1836,7 +1838,9 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
create: async (
projectPath: string,
projectOverview: string,
generateFeatures = true
generateFeatures = true,
_analyzeProject?: boolean,
maxFeatures?: number
) => {
if (mockSpecRegenerationRunning) {
return { success: false, error: "Spec creation is already running" };
@@ -1844,7 +1848,7 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
mockSpecRegenerationRunning = true;
console.log(
`[Mock] Creating initial spec for: ${projectPath}, generateFeatures: ${generateFeatures}`
`[Mock] Creating initial spec for: ${projectPath}, generateFeatures: ${generateFeatures}, maxFeatures: ${maxFeatures}`
);
// Simulate async spec creation
@@ -1856,7 +1860,9 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
generate: async (
projectPath: string,
projectDefinition: string,
generateFeatures = false
generateFeatures = false,
_analyzeProject?: boolean,
maxFeatures?: number
) => {
if (mockSpecRegenerationRunning) {
return {
@@ -1867,7 +1873,7 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
mockSpecRegenerationRunning = true;
console.log(
`[Mock] Regenerating spec for: ${projectPath}, generateFeatures: ${generateFeatures}`
`[Mock] Regenerating spec for: ${projectPath}, generateFeatures: ${generateFeatures}, maxFeatures: ${maxFeatures}`
);
// Simulate async spec regeneration
@@ -1880,7 +1886,7 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
return { success: true };
},
generateFeatures: async (projectPath: string) => {
generateFeatures: async (projectPath: string, maxFeatures?: number) => {
if (mockSpecRegenerationRunning) {
return {
success: false,
@@ -1890,7 +1896,7 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
mockSpecRegenerationRunning = true;
console.log(
`[Mock] Generating features from existing spec for: ${projectPath}`
`[Mock] Generating features from existing spec for: ${projectPath}, maxFeatures: ${maxFeatures}`
);
// Simulate async feature generation

View File

@@ -582,28 +582,32 @@ export class HttpApiClient implements ElectronAPI {
projectPath: string,
projectOverview: string,
generateFeatures?: boolean,
analyzeProject?: boolean
analyzeProject?: boolean,
maxFeatures?: number
) =>
this.post("/api/spec-regeneration/create", {
projectPath,
projectOverview,
generateFeatures,
analyzeProject,
maxFeatures,
}),
generate: (
projectPath: string,
projectDefinition: string,
generateFeatures?: boolean,
analyzeProject?: boolean
analyzeProject?: boolean,
maxFeatures?: number
) =>
this.post("/api/spec-regeneration/generate", {
projectPath,
projectDefinition,
generateFeatures,
analyzeProject,
maxFeatures,
}),
generateFeatures: (projectPath: string) =>
this.post("/api/spec-regeneration/generate-features", { projectPath }),
generateFeatures: (projectPath: string, maxFeatures?: number) =>
this.post("/api/spec-regeneration/generate-features", { projectPath, maxFeatures }),
stop: () => this.post("/api/spec-regeneration/stop"),
status: () => this.get("/api/spec-regeneration/status"),
onEvent: (callback: (event: SpecRegenerationEvent) => void) => {

View File

@@ -277,7 +277,12 @@ export interface Feature {
category: string;
description: string;
steps: string[];
status: "backlog" | "in_progress" | "waiting_approval" | "verified";
status:
| "backlog"
| "in_progress"
| "waiting_approval"
| "verified"
| "completed";
images?: FeatureImage[];
imagePaths?: FeatureImagePath[]; // Paths to temp files for agent context
startedAt?: string; // ISO timestamp for when the card moved to in_progress

View File

@@ -267,7 +267,8 @@ export interface SpecRegenerationAPI {
projectPath: string,
projectOverview: string,
generateFeatures?: boolean,
analyzeProject?: boolean
analyzeProject?: boolean,
maxFeatures?: number
) => Promise<{
success: boolean;
error?: string;
@@ -277,13 +278,14 @@ export interface SpecRegenerationAPI {
projectPath: string,
projectDefinition: string,
generateFeatures?: boolean,
analyzeProject?: boolean
analyzeProject?: boolean,
maxFeatures?: number
) => Promise<{
success: boolean;
error?: string;
}>;
generateFeatures: (projectPath: string) => Promise<{
generateFeatures: (projectPath: string, maxFeatures?: number) => Promise<{
success: boolean;
error?: string;
}>;

View File

@@ -48,7 +48,9 @@ export function resolveModelString(
// Look up Claude model alias
const resolved = CLAUDE_MODEL_MAP[modelKey];
if (resolved) {
console.log(`[ModelResolver] Resolved model alias: "${modelKey}" -> "${resolved}"`);
console.log(
`[ModelResolver] Resolved model alias: "${modelKey}" -> "${resolved}"`
);
return resolved;
}
@@ -73,8 +75,5 @@ export function getEffectiveModel(
sessionModel?: string,
defaultModel?: string
): string {
return resolveModelString(
explicitModel || sessionModel,
defaultModel
);
return resolveModelString(explicitModel || sessionModel, defaultModel);
}

View File

@@ -0,0 +1,291 @@
/**
* SDK Options Factory - Centralized configuration for Claude Agent SDK
*
* Provides presets for common use cases:
* - Spec generation: Long-running analysis with read-only tools
* - Feature generation: Quick JSON generation from specs
* - Feature building: Autonomous feature implementation with full tool access
* - Suggestions: Analysis with read-only tools
* - Chat: Full tool access for interactive coding
*
* Uses model-resolver for consistent model handling across the application.
*/
import type { Options } from "@anthropic-ai/claude-agent-sdk";
import {
resolveModelString,
DEFAULT_MODELS,
CLAUDE_MODEL_MAP,
} from "./model-resolver.js";
/**
* Tool presets for different use cases
*/
export const TOOL_PRESETS = {
/** Read-only tools for analysis */
readOnly: ["Read", "Glob", "Grep"] as const,
/** Tools for spec generation that needs to read the codebase */
specGeneration: ["Read", "Glob", "Grep"] as const,
/** Full tool access for feature implementation */
fullAccess: [
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
"WebSearch",
"WebFetch",
] as const,
/** Tools for chat/interactive mode */
chat: [
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
"WebSearch",
"WebFetch",
] as const,
} as const;
/**
* Max turns presets for different use cases
*/
export const MAX_TURNS = {
/** Quick operations that shouldn't need many iterations */
quick: 5,
/** Standard operations */
standard: 20,
/** Long-running operations like full spec generation */
extended: 50,
/** Very long operations that may require extensive exploration */
maximum: 1000,
} as const;
/**
* Model presets for different use cases
*
* These can be overridden via environment variables:
* - AUTOMAKER_MODEL_SPEC: Model for spec generation
* - AUTOMAKER_MODEL_FEATURES: Model for feature generation
* - AUTOMAKER_MODEL_SUGGESTIONS: Model for suggestions
* - AUTOMAKER_MODEL_CHAT: Model for chat
* - AUTOMAKER_MODEL_DEFAULT: Fallback model for all operations
*/
export function getModelForUseCase(
useCase: "spec" | "features" | "suggestions" | "chat" | "auto" | "default",
explicitModel?: string
): string {
// Explicit model takes precedence
if (explicitModel) {
return resolveModelString(explicitModel);
}
// Check environment variable override for this use case
const envVarMap: Record<string, string | undefined> = {
spec: process.env.AUTOMAKER_MODEL_SPEC,
features: process.env.AUTOMAKER_MODEL_FEATURES,
suggestions: process.env.AUTOMAKER_MODEL_SUGGESTIONS,
chat: process.env.AUTOMAKER_MODEL_CHAT,
auto: process.env.AUTOMAKER_MODEL_AUTO,
default: process.env.AUTOMAKER_MODEL_DEFAULT,
};
const envModel = envVarMap[useCase] || envVarMap.default;
if (envModel) {
return resolveModelString(envModel);
}
const defaultModels: Record<string, string> = {
spec: CLAUDE_MODEL_MAP["haiku"], // used to generate app specs
features: CLAUDE_MODEL_MAP["haiku"], // used to generate features from app specs
suggestions: CLAUDE_MODEL_MAP["haiku"], // used for suggestions
chat: CLAUDE_MODEL_MAP["haiku"], // used for chat
auto: CLAUDE_MODEL_MAP["opus"], // used to implement kanban cards
default: CLAUDE_MODEL_MAP["opus"],
};
return resolveModelString(defaultModels[useCase] || DEFAULT_MODELS.claude);
}
/**
* Base options that apply to all SDK calls
*/
function getBaseOptions(): Partial<Options> {
return {
permissionMode: "acceptEdits",
};
}
/**
* Options configuration for creating SDK options
*/
export interface CreateSdkOptionsConfig {
/** Working directory for the agent */
cwd: string;
/** Optional explicit model override */
model?: string;
/** Optional session model (used as fallback if explicit model not provided) */
sessionModel?: string;
/** Optional system prompt */
systemPrompt?: string;
/** Optional abort controller for cancellation */
abortController?: AbortController;
}
/**
* Create SDK options for spec generation
*
* Configuration:
* - Uses read-only tools for codebase analysis
* - Extended turns for thorough exploration
* - Opus model by default (can be overridden)
*/
export function createSpecGenerationOptions(
config: CreateSdkOptionsConfig
): Options {
return {
...getBaseOptions(),
model: getModelForUseCase("spec", config.model),
maxTurns: MAX_TURNS.maximum,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.specGeneration],
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }),
};
}
/**
* Create SDK options for feature generation from specs
*
* Configuration:
* - Uses read-only tools (just needs to read the spec)
* - Quick turns since it's mostly JSON generation
* - Sonnet model by default for speed
*/
export function createFeatureGenerationOptions(
config: CreateSdkOptionsConfig
): Options {
return {
...getBaseOptions(),
model: getModelForUseCase("features", config.model),
maxTurns: MAX_TURNS.quick,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.readOnly],
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }),
};
}
/**
* Create SDK options for generating suggestions
*
* Configuration:
* - Uses read-only tools for analysis
* - Quick turns for focused suggestions
* - Opus model by default for thorough analysis
*/
export function createSuggestionsOptions(
config: CreateSdkOptionsConfig
): Options {
return {
...getBaseOptions(),
model: getModelForUseCase("suggestions", config.model),
maxTurns: MAX_TURNS.quick,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.readOnly],
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }),
};
}
/**
* Create SDK options for chat/interactive mode
*
* Configuration:
* - Full tool access for code modification
* - Standard turns for interactive sessions
* - Model priority: explicit model > session model > chat default
* - Sandbox enabled for bash safety
*/
export function createChatOptions(config: CreateSdkOptionsConfig): Options {
// Model priority: explicit model > session model > chat default
const effectiveModel = config.model || config.sessionModel;
return {
...getBaseOptions(),
model: getModelForUseCase("chat", effectiveModel),
maxTurns: MAX_TURNS.standard,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.chat],
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }),
};
}
/**
* Create SDK options for autonomous feature building/implementation
*
* Configuration:
* - Full tool access for code modification and implementation
* - Extended turns for thorough feature implementation
* - Uses default model (can be overridden)
* - Sandbox enabled for bash safety
*/
export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
return {
...getBaseOptions(),
model: getModelForUseCase("auto", config.model),
maxTurns: MAX_TURNS.maximum,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.fullAccess],
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }),
};
}
/**
* Create custom SDK options with explicit configuration
*
* Use this when the preset options don't fit your use case.
*/
export function createCustomOptions(
config: CreateSdkOptionsConfig & {
maxTurns?: number;
allowedTools?: readonly string[];
sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean };
}
): Options {
return {
...getBaseOptions(),
model: getModelForUseCase("default", config.model),
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
cwd: config.cwd,
allowedTools: config.allowedTools
? [...config.allowedTools]
: [...TOOL_PRESETS.readOnly],
...(config.sandbox && { sandbox: config.sandbox }),
...(config.systemPrompt && { systemPrompt: config.systemPrompt }),
...(config.abortController && { abortController: config.abortController }),
};
}

View File

@@ -2,23 +2,29 @@
* Generate features from existing app_spec.txt
*/
import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
import { query } from "@anthropic-ai/claude-agent-sdk";
import path from "path";
import fs from "fs/promises";
import type { EventEmitter } from "../../lib/events.js";
import { createLogger } from "../../lib/logger.js";
import { createFeatureGenerationOptions } from "../../lib/sdk-options.js";
import { logAuthStatus } from "./common.js";
import { parseAndCreateFeatures } from "./parse-and-create-features.js";
const logger = createLogger("SpecRegeneration");
const DEFAULT_MAX_FEATURES = 50;
export async function generateFeaturesFromSpec(
projectPath: string,
events: EventEmitter,
abortController: AbortController
abortController: AbortController,
maxFeatures?: number
): Promise<void> {
const featureCount = maxFeatures ?? DEFAULT_MAX_FEATURES;
logger.debug("========== generateFeaturesFromSpec() started ==========");
logger.debug("projectPath:", projectPath);
logger.debug("maxFeatures:", featureCount);
// Read existing spec
const specPath = path.join(projectPath, ".automaker", "app_spec.txt");
@@ -29,6 +35,10 @@ export async function generateFeaturesFromSpec(
try {
spec = await fs.readFile(specPath, "utf-8");
logger.info(`Spec loaded successfully (${spec.length} chars)`);
logger.info(`Spec preview (first 500 chars): ${spec.substring(0, 500)}`);
logger.info(
`Spec preview (last 500 chars): ${spec.substring(spec.length - 500)}`
);
} catch (readError) {
logger.error("❌ Failed to read spec file:", readError);
events.emit("spec-regeneration:event", {
@@ -66,9 +76,16 @@ Format as JSON:
]
}
Generate 5-15 features that build on each other logically.`;
Generate ${featureCount} features that build on each other logically.
logger.debug("Prompt length:", `${prompt.length} chars`);
IMPORTANT: Do not ask for clarification. The specification is provided above. Generate the JSON immediately.`;
logger.info("========== PROMPT BEING SENT ==========");
logger.info(`Prompt length: ${prompt.length} chars`);
logger.info(
`Prompt preview (first 1000 chars):\n${prompt.substring(0, 1000)}`
);
logger.info("========== END PROMPT PREVIEW ==========");
events.emit("spec-regeneration:event", {
type: "spec_regeneration_progress",
@@ -76,14 +93,10 @@ Generate 5-15 features that build on each other logically.`;
projectPath: projectPath,
});
const options: Options = {
model: "claude-sonnet-4-20250514",
maxTurns: 5,
const options = createFeatureGenerationOptions({
cwd: projectPath,
allowedTools: ["Read", "Glob"],
permissionMode: "acceptEdits",
abortController,
};
});
logger.debug("SDK Options:", JSON.stringify(options, null, 2));
logger.info("Calling Claude Agent SDK query() for features...");
@@ -120,7 +133,7 @@ Generate 5-15 features that build on each other logically.`;
if (msg.type === "assistant" && msg.message.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
responseText = block.text;
responseText += block.text;
logger.debug(
`Feature text block received (${block.text.length} chars)`
);
@@ -147,6 +160,9 @@ Generate 5-15 features that build on each other logically.`;
logger.info(`Feature stream complete. Total messages: ${messageCount}`);
logger.info(`Feature response length: ${responseText.length} chars`);
logger.info("========== FULL RESPONSE TEXT ==========");
logger.info(responseText);
logger.info("========== END RESPONSE TEXT ==========");
await parseAndCreateFeatures(projectPath, responseText, events);

View File

@@ -2,12 +2,13 @@
* Generate app_spec.txt from project overview
*/
import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
import { query } from "@anthropic-ai/claude-agent-sdk";
import path from "path";
import fs from "fs/promises";
import type { EventEmitter } from "../../lib/events.js";
import { getAppSpecFormatInstruction } from "../../lib/app-spec-format.js";
import { createLogger } from "../../lib/logger.js";
import { createSpecGenerationOptions } from "../../lib/sdk-options.js";
import { logAuthStatus } from "./common.js";
import { generateFeaturesFromSpec } from "./generate-features-from-spec.js";
@@ -19,13 +20,16 @@ export async function generateSpec(
events: EventEmitter,
abortController: AbortController,
generateFeatures?: boolean,
analyzeProject?: boolean
analyzeProject?: boolean,
maxFeatures?: number
): Promise<void> {
logger.debug("========== generateSpec() started ==========");
logger.debug("projectPath:", projectPath);
logger.debug("projectOverview length:", `${projectOverview.length} chars`);
logger.debug("generateFeatures:", generateFeatures);
logger.debug("analyzeProject:", analyzeProject);
logger.info("========== generateSpec() started ==========");
logger.info("projectPath:", projectPath);
logger.info("projectOverview length:", `${projectOverview.length} chars`);
logger.info("projectOverview preview:", projectOverview.substring(0, 300));
logger.info("generateFeatures:", generateFeatures);
logger.info("analyzeProject:", analyzeProject);
logger.info("maxFeatures:", maxFeatures);
// Build the prompt based on whether we should analyze the project
let analysisInstructions = "";
@@ -63,21 +67,20 @@ ${analysisInstructions}
${getAppSpecFormatInstruction()}`;
logger.debug("Prompt length:", `${prompt.length} chars`);
logger.info("========== PROMPT BEING SENT ==========");
logger.info(`Prompt length: ${prompt.length} chars`);
logger.info(`Prompt preview (first 500 chars):\n${prompt.substring(0, 500)}`);
logger.info("========== END PROMPT PREVIEW ==========");
events.emit("spec-regeneration:event", {
type: "spec_progress",
content: "Starting spec generation...\n",
});
const options: Options = {
model: "claude-opus-4-5-20251101",
maxTurns: 10,
const options = createSpecGenerationOptions({
cwd: projectPath,
allowedTools: ["Read", "Glob", "Grep"],
permissionMode: "acceptEdits",
abortController,
};
});
logger.debug("SDK Options:", JSON.stringify(options, null, 2));
logger.info("Calling Claude Agent SDK query()...");
@@ -98,45 +101,96 @@ ${getAppSpecFormatInstruction()}`;
let responseText = "";
let messageCount = 0;
logger.debug("Starting to iterate over stream...");
logger.info("Starting to iterate over stream...");
try {
for await (const msg of stream) {
messageCount++;
logger.debug(
`Stream message #${messageCount}:`,
JSON.stringify(
{ type: msg.type, subtype: (msg as any).subtype },
null,
2
)
logger.info(
`Stream message #${messageCount}: type=${msg.type}, subtype=${
(msg as any).subtype
}`
);
if (msg.type === "assistant" && msg.message.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
responseText += block.text;
logger.debug(`Text block received (${block.text.length} chars)`);
events.emit("spec-regeneration:event", {
type: "spec_regeneration_progress",
content: block.text,
projectPath: projectPath,
});
} else if (block.type === "tool_use") {
logger.debug("Tool use:", block.name);
events.emit("spec-regeneration:event", {
type: "spec_tool",
tool: block.name,
input: block.input,
});
if (msg.type === "assistant") {
// Log the full message structure to debug
logger.info(`Assistant msg keys: ${Object.keys(msg).join(", ")}`);
const msgAny = msg as any;
if (msgAny.message) {
logger.info(
`msg.message keys: ${Object.keys(msgAny.message).join(", ")}`
);
if (msgAny.message.content) {
logger.info(
`msg.message.content length: ${msgAny.message.content.length}`
);
for (const block of msgAny.message.content) {
logger.info(
`Block keys: ${Object.keys(block).join(", ")}, type: ${
block.type
}`
);
if (block.type === "text") {
responseText += block.text;
logger.info(
`Text block received (${block.text.length} chars), total now: ${responseText.length} chars`
);
logger.info(`Text preview: ${block.text.substring(0, 200)}...`);
events.emit("spec-regeneration:event", {
type: "spec_regeneration_progress",
content: block.text,
projectPath: projectPath,
});
} else if (block.type === "tool_use") {
logger.info("Tool use:", block.name);
events.emit("spec-regeneration:event", {
type: "spec_tool",
tool: block.name,
input: block.input,
});
}
}
} else {
logger.warn("msg.message.content is falsy");
}
} else {
logger.warn("msg.message is falsy");
// Log full message to see structure
logger.info(
`Full assistant msg: ${JSON.stringify(msg).substring(0, 1000)}`
);
}
} else if (msg.type === "result" && (msg as any).subtype === "success") {
logger.debug("Received success result");
responseText = (msg as any).result || responseText;
logger.info("Received success result");
logger.info(`Result value: "${(msg as any).result}"`);
logger.info(
`Current responseText length before result: ${responseText.length}`
);
// Only use result if it has content, otherwise keep accumulated text
if ((msg as any).result && (msg as any).result.length > 0) {
logger.info("Using result value as responseText");
responseText = (msg as any).result;
} else {
logger.info("Result is empty, keeping accumulated responseText");
}
} else if (msg.type === "result") {
// Handle all result types
const subtype = (msg as any).subtype;
logger.info(`Result message: subtype=${subtype}`);
if (subtype === "error_max_turns") {
logger.error(
"❌ Hit max turns limit! Claude used too many tool calls."
);
logger.info(`responseText so far: ${responseText.length} chars`);
}
} else if ((msg as { type: string }).type === "error") {
logger.error("❌ Received error message from stream:");
logger.error("Error message:", JSON.stringify(msg, null, 2));
} else if (msg.type === "user") {
// Log user messages (tool results)
logger.info(
`User message (tool result): ${JSON.stringify(msg).substring(0, 500)}`
);
}
}
} catch (streamError) {
@@ -147,16 +201,31 @@ ${getAppSpecFormatInstruction()}`;
logger.info(`Stream iteration complete. Total messages: ${messageCount}`);
logger.info(`Response text length: ${responseText.length} chars`);
logger.info("========== FINAL RESPONSE TEXT ==========");
logger.info(responseText || "(empty)");
logger.info("========== END RESPONSE TEXT ==========");
if (!responseText || responseText.trim().length === 0) {
logger.error("❌ WARNING: responseText is empty! Nothing to save.");
}
// Save spec
const specDir = path.join(projectPath, ".automaker");
const specPath = path.join(specDir, "app_spec.txt");
logger.info("Saving spec to:", specPath);
logger.info(`Content to save (${responseText.length} chars)`);
await fs.mkdir(specDir, { recursive: true });
await fs.writeFile(specPath, responseText);
// Verify the file was written
const savedContent = await fs.readFile(specPath, "utf-8");
logger.info(`Verified saved file: ${savedContent.length} chars`);
if (savedContent.length === 0) {
logger.error("❌ File was saved but is empty!");
}
logger.info("Spec saved successfully");
// Emit spec completion event
@@ -185,7 +254,8 @@ ${getAppSpecFormatInstruction()}`;
await generateFeaturesFromSpec(
projectPath,
events,
featureAbortController
featureAbortController,
maxFeatures
);
// Final completion will be emitted by generateFeaturesFromSpec -> parseAndCreateFeatures
} catch (featureError) {

View File

@@ -14,23 +14,32 @@ export async function parseAndCreateFeatures(
content: string,
events: EventEmitter
): Promise<void> {
logger.debug("========== parseAndCreateFeatures() started ==========");
logger.debug("Content length:", `${content.length} chars`);
logger.info("========== parseAndCreateFeatures() started ==========");
logger.info(`Content length: ${content.length} chars`);
logger.info("========== CONTENT RECEIVED FOR PARSING ==========");
logger.info(content);
logger.info("========== END CONTENT ==========");
try {
// Extract JSON from response
logger.debug("Extracting JSON from response...");
logger.info("Extracting JSON from response...");
logger.info(`Looking for pattern: /{[\\s\\S]*"features"[\\s\\S]*}/`);
const jsonMatch = content.match(/\{[\s\S]*"features"[\s\S]*\}/);
if (!jsonMatch) {
logger.error("❌ No valid JSON found in response");
logger.error("Content preview:", content.substring(0, 500));
logger.error("Full content received:");
logger.error(content);
throw new Error("No valid JSON found in response");
}
logger.debug(`JSON match found (${jsonMatch[0].length} chars)`);
logger.info(`JSON match found (${jsonMatch[0].length} chars)`);
logger.info("========== MATCHED JSON ==========");
logger.info(jsonMatch[0]);
logger.info("========== END MATCHED JSON ==========");
const parsed = JSON.parse(jsonMatch[0]);
logger.info(`Parsed ${parsed.features?.length || 0} features`);
logger.info("Parsed features:", JSON.stringify(parsed.features, null, 2));
const featuresDir = path.join(projectPath, ".automaker", "features");
await fs.mkdir(featuresDir, { recursive: true });

View File

@@ -22,12 +22,13 @@ export function createCreateHandler(events: EventEmitter) {
logger.debug("Request body:", JSON.stringify(req.body, null, 2));
try {
const { projectPath, projectOverview, generateFeatures, analyzeProject } =
const { projectPath, projectOverview, generateFeatures, analyzeProject, maxFeatures } =
req.body as {
projectPath: string;
projectOverview: string;
generateFeatures?: boolean;
analyzeProject?: boolean;
maxFeatures?: number;
};
logger.debug("Parsed params:");
@@ -38,6 +39,7 @@ export function createCreateHandler(events: EventEmitter) {
);
logger.debug(" generateFeatures:", generateFeatures);
logger.debug(" analyzeProject:", analyzeProject);
logger.debug(" maxFeatures:", maxFeatures);
if (!projectPath || !projectOverview) {
logger.error("Missing required parameters");
@@ -68,7 +70,8 @@ export function createCreateHandler(events: EventEmitter) {
events,
abortController,
generateFeatures,
analyzeProject
analyzeProject,
maxFeatures
)
.catch((error) => {
logError(error, "Generation failed with error");

View File

@@ -22,9 +22,13 @@ export function createGenerateFeaturesHandler(events: EventEmitter) {
logger.debug("Request body:", JSON.stringify(req.body, null, 2));
try {
const { projectPath } = req.body as { projectPath: string };
const { projectPath, maxFeatures } = req.body as {
projectPath: string;
maxFeatures?: number;
};
logger.debug("projectPath:", projectPath);
logger.debug("maxFeatures:", maxFeatures);
if (!projectPath) {
logger.error("Missing projectPath parameter");
@@ -45,7 +49,12 @@ export function createGenerateFeaturesHandler(events: EventEmitter) {
setRunningState(true, abortController);
logger.info("Starting background feature generation task...");
generateFeaturesFromSpec(projectPath, events, abortController)
generateFeaturesFromSpec(
projectPath,
events,
abortController,
maxFeatures
)
.catch((error) => {
logError(error, "Feature generation failed with error");
events.emit("spec-regeneration:event", {

View File

@@ -27,11 +27,13 @@ export function createGenerateHandler(events: EventEmitter) {
projectDefinition,
generateFeatures,
analyzeProject,
maxFeatures,
} = req.body as {
projectPath: string;
projectDefinition: string;
generateFeatures?: boolean;
analyzeProject?: boolean;
maxFeatures?: number;
};
logger.debug("Parsed params:");
@@ -42,6 +44,7 @@ export function createGenerateHandler(events: EventEmitter) {
);
logger.debug(" generateFeatures:", generateFeatures);
logger.debug(" analyzeProject:", analyzeProject);
logger.debug(" maxFeatures:", maxFeatures);
if (!projectPath || !projectDefinition) {
logger.error("Missing required parameters");
@@ -71,7 +74,8 @@ export function createGenerateHandler(events: EventEmitter) {
events,
abortController,
generateFeatures,
analyzeProject
analyzeProject,
maxFeatures
)
.catch((error) => {
logError(error, "Generation failed with error");

View File

@@ -2,9 +2,10 @@
* Business logic for generating suggestions
*/
import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
import { query } from "@anthropic-ai/claude-agent-sdk";
import type { EventEmitter } from "../../lib/events.js";
import { createLogger } from "../../lib/logger.js";
import { createSuggestionsOptions } from "../../lib/sdk-options.js";
const logger = createLogger("Suggestions");
@@ -54,14 +55,10 @@ Format your response as JSON:
content: `Starting ${suggestionType} analysis...\n`,
});
const options: Options = {
model: "claude-opus-4-5-20251101",
maxTurns: 5,
const options = createSuggestionsOptions({
cwd: projectPath,
allowedTools: ["Read", "Glob", "Grep"],
permissionMode: "acceptEdits",
abortController,
};
});
const stream = query({ prompt, options });
let responseText = "";

View File

@@ -11,7 +11,7 @@ import { ProviderFactory } from "../providers/provider-factory.js";
import type { ExecuteOptions } from "../providers/types.js";
import { readImageAsBase64 } from "../lib/image-handler.js";
import { buildPromptWithImages } from "../lib/prompt-builder.js";
import { getEffectiveModel } from "../lib/model-resolver.js";
import { createChatOptions } from "../lib/sdk-options.js";
import { isAbortError } from "../lib/error-handler.js";
interface Message {
@@ -176,8 +176,19 @@ export class AgentService {
await this.saveSession(sessionId, session.messages);
try {
// Use session model, parameter model, or default
const effectiveModel = getEffectiveModel(model, session.model);
// Build SDK options using centralized configuration
const sdkOptions = createChatOptions({
cwd: workingDirectory || session.workingDirectory,
model: model,
sessionModel: session.model,
systemPrompt: this.getSystemPrompt(),
abortController: session.abortController!,
});
// Extract model, maxTurns, and allowedTools from SDK options
const effectiveModel = sdkOptions.model!;
const maxTurns = sdkOptions.maxTurns;
const allowedTools = sdkOptions.allowedTools as string[] | undefined;
// Get provider for this model
const provider = ProviderFactory.getProviderForModel(effectiveModel);
@@ -192,17 +203,8 @@ export class AgentService {
model: effectiveModel,
cwd: workingDirectory || session.workingDirectory,
systemPrompt: this.getSystemPrompt(),
maxTurns: 20,
allowedTools: [
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
"WebSearch",
"WebFetch",
],
maxTurns: maxTurns,
allowedTools: allowedTools,
abortController: session.abortController!,
conversationHistory:
conversationHistory.length > 0 ? conversationHistory : undefined,

View File

@@ -9,16 +9,16 @@
* - Verification and merge workflows
*/
import { AbortError } from "@anthropic-ai/claude-agent-sdk";
import { ProviderFactory } from "../providers/provider-factory.js";
import type { ExecuteOptions } from "../providers/types.js";
import { exec } from "child_process";
import { promisify } from "util";
import path from "path";
import fs from "fs/promises";
import type { EventEmitter, EventType } from "../lib/events.js";
import type { EventEmitter } from "../lib/events.js";
import { buildPromptWithImages } from "../lib/prompt-builder.js";
import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js";
import { createAutoModeOptions } from "../lib/sdk-options.js";
import { isAbortError, classifyError } from "../lib/error-handler.js";
const execAsync = promisify(exec);
@@ -1085,7 +1085,18 @@ When done, summarize what you implemented and any notes for the developer.`;
imagePaths?: string[],
model?: string
): Promise<void> {
const finalModel = resolveModelString(model, DEFAULT_MODELS.claude);
// Build SDK options using centralized configuration for feature implementation
const sdkOptions = createAutoModeOptions({
cwd: workDir,
model: model,
abortController,
});
// Extract model, maxTurns, and allowedTools from SDK options
const finalModel = sdkOptions.model!;
const maxTurns = sdkOptions.maxTurns;
const allowedTools = sdkOptions.allowedTools as string[] | undefined;
console.log(
`[AutoMode] runAgent called for feature ${featureId} with model: ${finalModel}`
);
@@ -1108,9 +1119,9 @@ When done, summarize what you implemented and any notes for the developer.`;
const options: ExecuteOptions = {
prompt: promptContent,
model: finalModel,
maxTurns: 50,
maxTurns: maxTurns,
cwd: workDir,
allowedTools: ["Read", "Write", "Edit", "Glob", "Grep", "Bash"],
allowedTools: allowedTools,
abortController,
};