mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-03-17 10:53:09 +00:00
feat: add scaffold router and project template selection step
Add a new scaffold system that lets users choose a project template (blank or agentic starter) during project creation. This inserts a template selection step between folder selection and spec method choice. Backend: - New server/routers/scaffold.py with SSE streaming endpoint for running hardcoded scaffold commands (npx create-agentic-app) - Path validation, security checks, and cross-platform npx resolution - Registered scaffold_router in server/main.py and routers/__init__.py Frontend (NewProjectModal.tsx): - New "template" step with Blank Project and Agentic Starter cards - Real-time scaffold output streaming with auto-scroll log viewer - Success, error, and retry states with proper back-navigation - Updated step flow: name → folder → template → method → chat/complete Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,14 +4,15 @@
|
||||
* Multi-step modal for creating new projects:
|
||||
* 1. Enter project name
|
||||
* 2. Select project folder
|
||||
* 3. Choose spec method (Claude or manual)
|
||||
* 4a. If Claude: Show SpecCreationChat
|
||||
* 4b. If manual: Create project and close
|
||||
* 3. Choose project template (blank or agentic starter)
|
||||
* 4. Choose spec method (Claude or manual)
|
||||
* 5a. If Claude: Show SpecCreationChat
|
||||
* 5b. If manual: Create project and close
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Bot, FileEdit, ArrowRight, ArrowLeft, Loader2, CheckCircle2, Folder } from 'lucide-react'
|
||||
import { Bot, FileEdit, ArrowRight, ArrowLeft, Loader2, CheckCircle2, Folder, Zap, FileCode2, AlertCircle, RotateCcw } from 'lucide-react'
|
||||
import { useCreateProject } from '../hooks/useProjects'
|
||||
import { SpecCreationChat } from './SpecCreationChat'
|
||||
import { FolderBrowser } from './FolderBrowser'
|
||||
@@ -32,8 +33,9 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
|
||||
type InitializerStatus = 'idle' | 'starting' | 'error'
|
||||
type ScaffoldStatus = 'idle' | 'running' | 'success' | 'error'
|
||||
|
||||
type Step = 'name' | 'folder' | 'method' | 'chat' | 'complete'
|
||||
type Step = 'name' | 'folder' | 'template' | 'method' | 'chat' | 'complete'
|
||||
type SpecMethod = 'claude' | 'manual'
|
||||
|
||||
interface NewProjectModalProps {
|
||||
@@ -57,6 +59,10 @@ export function NewProjectModal({
|
||||
const [initializerStatus, setInitializerStatus] = useState<InitializerStatus>('idle')
|
||||
const [initializerError, setInitializerError] = useState<string | null>(null)
|
||||
const [yoloModeSelected, setYoloModeSelected] = useState(false)
|
||||
const [scaffoldStatus, setScaffoldStatus] = useState<ScaffoldStatus>('idle')
|
||||
const [scaffoldOutput, setScaffoldOutput] = useState<string[]>([])
|
||||
const [scaffoldError, setScaffoldError] = useState<string | null>(null)
|
||||
const scaffoldLogRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Suppress unused variable warning - specMethod may be used in future
|
||||
void _specMethod
|
||||
@@ -91,13 +97,84 @@ export function NewProjectModal({
|
||||
|
||||
const handleFolderSelect = (path: string) => {
|
||||
setProjectPath(path)
|
||||
changeStep('method')
|
||||
changeStep('template')
|
||||
}
|
||||
|
||||
const handleFolderCancel = () => {
|
||||
changeStep('name')
|
||||
}
|
||||
|
||||
const handleTemplateSelect = async (choice: 'blank' | 'agentic-starter') => {
|
||||
if (choice === 'blank') {
|
||||
changeStep('method')
|
||||
return
|
||||
}
|
||||
|
||||
if (!projectPath) return
|
||||
|
||||
setScaffoldStatus('running')
|
||||
setScaffoldOutput([])
|
||||
setScaffoldError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/scaffold/run', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ template: 'agentic-starter', target_path: projectPath }),
|
||||
})
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
setScaffoldStatus('error')
|
||||
setScaffoldError(`Server error: ${res.status}`)
|
||||
return
|
||||
}
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
try {
|
||||
const event = JSON.parse(line.slice(6))
|
||||
if (event.type === 'output') {
|
||||
setScaffoldOutput(prev => {
|
||||
const next = [...prev, event.line]
|
||||
return next.length > 100 ? next.slice(-100) : next
|
||||
})
|
||||
// Auto-scroll
|
||||
setTimeout(() => scaffoldLogRef.current?.scrollTo(0, scaffoldLogRef.current.scrollHeight), 0)
|
||||
} else if (event.type === 'complete') {
|
||||
if (event.success) {
|
||||
setScaffoldStatus('success')
|
||||
setTimeout(() => changeStep('method'), 1200)
|
||||
} else {
|
||||
setScaffoldStatus('error')
|
||||
setScaffoldError(`Scaffold exited with code ${event.exit_code}`)
|
||||
}
|
||||
} else if (event.type === 'error') {
|
||||
setScaffoldStatus('error')
|
||||
setScaffoldError(event.message)
|
||||
}
|
||||
} catch {
|
||||
// skip malformed SSE lines
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setScaffoldStatus('error')
|
||||
setScaffoldError(err instanceof Error ? err.message : 'Failed to run scaffold')
|
||||
}
|
||||
}
|
||||
|
||||
const handleMethodSelect = async (method: SpecMethod) => {
|
||||
setSpecMethod(method)
|
||||
|
||||
@@ -188,13 +265,21 @@ export function NewProjectModal({
|
||||
setInitializerStatus('idle')
|
||||
setInitializerError(null)
|
||||
setYoloModeSelected(false)
|
||||
setScaffoldStatus('idle')
|
||||
setScaffoldOutput([])
|
||||
setScaffoldError(null)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (step === 'method') {
|
||||
changeStep('folder')
|
||||
changeStep('template')
|
||||
setSpecMethod(null)
|
||||
} else if (step === 'template') {
|
||||
changeStep('folder')
|
||||
setScaffoldStatus('idle')
|
||||
setScaffoldOutput([])
|
||||
setScaffoldError(null)
|
||||
} else if (step === 'folder') {
|
||||
changeStep('name')
|
||||
setProjectPath(null)
|
||||
@@ -255,6 +340,7 @@ export function NewProjectModal({
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{step === 'name' && 'Create New Project'}
|
||||
{step === 'template' && 'Choose Project Template'}
|
||||
{step === 'method' && 'Choose Setup Method'}
|
||||
{step === 'complete' && 'Project Created!'}
|
||||
</DialogTitle>
|
||||
@@ -294,7 +380,127 @@ export function NewProjectModal({
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Step 2: Spec Method */}
|
||||
{/* Step 2: Project Template */}
|
||||
{step === 'template' && (
|
||||
<div className="space-y-4">
|
||||
{scaffoldStatus === 'idle' && (
|
||||
<>
|
||||
<DialogDescription>
|
||||
Start with a blank project or use a pre-configured template.
|
||||
</DialogDescription>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Card
|
||||
className="cursor-pointer hover:border-primary transition-colors"
|
||||
onClick={() => handleTemplateSelect('blank')}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2 bg-secondary rounded-lg">
|
||||
<FileCode2 size={24} className="text-secondary-foreground" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="font-semibold">Blank Project</span>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Start from scratch. AutoForge will scaffold your app based on the spec you define.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="cursor-pointer hover:border-primary transition-colors"
|
||||
onClick={() => handleTemplateSelect('agentic-starter')}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<Zap size={24} className="text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">Agentic Starter</span>
|
||||
<Badge variant="secondary">Next.js</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Pre-configured Next.js app with BetterAuth, Drizzle ORM, Postgres, and AI capabilities.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="sm:justify-start">
|
||||
<Button variant="ghost" onClick={handleBack}>
|
||||
<ArrowLeft size={16} />
|
||||
Back
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{scaffoldStatus === 'running' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 size={16} className="animate-spin text-primary" />
|
||||
<span className="font-medium">Setting up Agentic Starter...</span>
|
||||
</div>
|
||||
<div
|
||||
ref={scaffoldLogRef}
|
||||
className="bg-muted rounded-lg p-3 max-h-60 overflow-y-auto font-mono text-xs leading-relaxed"
|
||||
>
|
||||
{scaffoldOutput.map((line, i) => (
|
||||
<div key={i} className="whitespace-pre-wrap break-all">{line}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scaffoldStatus === 'success' && (
|
||||
<div className="text-center py-6">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-primary/10 rounded-full mb-3">
|
||||
<CheckCircle2 size={24} className="text-primary" />
|
||||
</div>
|
||||
<p className="font-medium">Template ready!</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Proceeding to setup method...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scaffoldStatus === 'error' && (
|
||||
<div className="space-y-3">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle size={16} />
|
||||
<AlertDescription>
|
||||
{scaffoldError || 'An unknown error occurred'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{scaffoldOutput.length > 0 && (
|
||||
<div className="bg-muted rounded-lg p-3 max-h-40 overflow-y-auto font-mono text-xs leading-relaxed">
|
||||
{scaffoldOutput.slice(-10).map((line, i) => (
|
||||
<div key={i} className="whitespace-pre-wrap break-all">{line}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="sm:justify-start gap-2">
|
||||
<Button variant="ghost" onClick={handleBack}>
|
||||
<ArrowLeft size={16} />
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => handleTemplateSelect('agentic-starter')}>
|
||||
<RotateCcw size={16} />
|
||||
Retry
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Spec Method */}
|
||||
{step === 'method' && (
|
||||
<div className="space-y-4">
|
||||
<DialogDescription>
|
||||
|
||||
Reference in New Issue
Block a user