mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
Merge pull request #97 from AutoMaker-Org/ui-tweaks
feat: implement completed features management in BoardView and Kanban…
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
167
apps/app/src/components/layout/project-setup-dialog.tsx
Normal file
167
apps/app/src/components/layout/project-setup-dialog.tsx
Normal 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'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'll analyze your project'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'll analyze your project'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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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">
|
||||
"{deleteCompletedFeature?.description?.slice(0, 100)}
|
||||
{(deleteCompletedFeature?.description?.length ?? 0) > 100
|
||||
? "..."
|
||||
: ""}
|
||||
"
|
||||
</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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
93
apps/app/src/config/model-config.ts
Normal file
93
apps/app/src/config/model-config.ts
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
8
apps/app/src/types/electron.d.ts
vendored
8
apps/app/src/types/electron.d.ts
vendored
@@ -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;
|
||||
}>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
291
apps/server/src/lib/sdk-options.ts
Normal file
291
apps/server/src/lib/sdk-options.ts
Normal 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 }),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user