feat: AI-assisted flow builder with 4-stage wizard

Implements the complete AI flow builder feature using a guided 4-stage
wizard (Foundation → Scaffold → Branch Detail → Review & Assemble).
AI assists at bounded points using Claude Haiku for cost-efficient
structured JSON generation (~$0.01-0.03/flow).

Backend: new models (ai_conversations, ai_usage), Alembic migration,
quota enforcement with billing anchor, Anthropic API integration with
prompt caching, tree validation, conversation CRUD with 24h TTL,
APScheduler cleanup job, 5 API endpoints, Pydantic schemas.

Frontend: TypeScript types, API client, Zustand store for wizard state,
7 components (modal, step indicator, foundation form, branch selector,
branch detail view, tree preview, quota display), MyTreesPage integration
with "Build with AI" button (hidden when AI not configured).

Tests: 14 validator unit tests + 11 endpoint integration tests with
mocked Anthropic (zero real API spend). All 25 tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-20 08:07:08 -05:00
parent aef40078d0
commit 44432413c2
35 changed files with 3662 additions and 5 deletions

View File

@@ -0,0 +1,135 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Modal } from '@/components/common/Modal'
import { useAIFlowBuilderStore } from '@/store/aiFlowBuilderStore'
import { treesApi } from '@/api/trees'
import { toast } from '@/lib/toast'
import { WizardStepIndicator } from './WizardStepIndicator'
import { FoundationForm } from './FoundationForm'
import { BranchSelector } from './BranchSelector'
import { BranchDetailView } from './BranchDetailView'
import { TreePreviewCard } from './TreePreviewCard'
import { GeneratingAnimation } from './GeneratingAnimation'
interface AIFlowBuilderModalProps {
isOpen: boolean
onClose: () => void
}
export function AIFlowBuilderModal({ isOpen, onClose }: AIFlowBuilderModalProps) {
const navigate = useNavigate()
const {
phase,
metadata,
assembledTree,
loadQuota,
scaffold,
reset,
} = useAIFlowBuilderStore()
// Load quota when modal opens
useEffect(() => {
if (isOpen) {
loadQuota()
}
}, [isOpen, loadQuota])
// Auto-trigger scaffold after conversation starts
useEffect(() => {
if (phase === 'scaffolding' && !useAIFlowBuilderStore.getState().suggestedBranches.length) {
scaffold()
}
}, [phase, scaffold])
const handleClose = () => {
reset()
onClose()
}
const handleOpenInEditor = async () => {
if (!assembledTree) return
try {
const tree = await treesApi.create({
name: assembledTree.suggested_name,
description: assembledTree.suggested_description,
tree_structure: assembledTree.tree_structure,
tree_type: metadata.flow_type,
})
handleClose()
const editorPath =
metadata.flow_type === 'procedural'
? `/flows/${tree.id}/edit`
: `/trees/${tree.id}/edit`
navigate(editorPath)
} catch {
toast.error('Failed to create flow. Please try again.')
}
}
const getTitle = () => {
switch (phase) {
case 'foundation':
return 'Build with AI'
case 'scaffolding':
case 'generating':
return 'AI Scaffold'
case 'detailing':
return 'Branch Detail'
case 'reviewing':
return 'Review & Assemble'
case 'error':
return 'AI Flow Builder'
default:
return 'Build with AI'
}
}
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title={getTitle()}
size="lg"
footer={
<WizardStepIndicator phase={phase} />
}
>
{phase === 'foundation' && <FoundationForm />}
{phase === 'scaffolding' && <BranchSelector />}
{phase === 'generating' && <GeneratingAnimation />}
{phase === 'detailing' && <BranchDetailView />}
{phase === 'reviewing' && (
<TreePreviewCard onOpenInEditor={handleOpenInEditor} />
)}
{phase === 'error' && <ErrorView />}
</Modal>
)
}
function ErrorView() {
const { error, reset, setPhase } = useAIFlowBuilderStore()
return (
<div className="flex flex-col items-center gap-4 py-8">
<div className="rounded-lg border border-red-400/20 bg-red-400/5 px-4 py-3 text-sm text-red-400">
{error || 'An unexpected error occurred.'}
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => setPhase('foundation')}
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
Go Back
</button>
<button
type="button"
onClick={reset}
className="rounded-lg bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
>
Start Over
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,208 @@
import { useState } from 'react'
import { Check, RefreshCw, SkipForward, ChevronRight, ChevronLeft } from 'lucide-react'
import { useAIFlowBuilderStore } from '@/store/aiFlowBuilderStore'
import { GeneratingAnimation } from './GeneratingAnimation'
import { cn } from '@/lib/utils'
export function BranchDetailView() {
const {
selectedBranches,
generateBranchDetail,
assemble,
isLoading,
error,
phase,
setError,
} = useAIFlowBuilderStore()
const [viewingIndex, setViewingIndex] = useState(0)
const currentBranch = selectedBranches[viewingIndex]
const allBranchesHaveDetail = selectedBranches.every((b) => b.steps)
const branchesWithDetail = selectedBranches.filter((b) => b.steps).length
const handleGenerate = async (branchName: string) => {
setError(null)
await generateBranchDetail(branchName)
}
const handleAssemble = async () => {
await assemble()
}
if (phase === 'generating' && isLoading) {
return <GeneratingAnimation />
}
return (
<div className="space-y-4">
{/* Branch tabs */}
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{selectedBranches.map((branch, i) => (
<button
key={branch.name}
type="button"
onClick={() => setViewingIndex(i)}
className={cn(
'flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
viewingIndex === i
? 'border-primary/30 bg-primary/10 text-foreground'
: 'border-border text-muted-foreground hover:bg-accent',
branch.steps && 'pr-2'
)}
>
{branch.name}
{branch.steps && (
<Check className="h-3 w-3 text-green-400" />
)}
</button>
))}
</div>
{/* Current branch detail */}
{currentBranch && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-foreground">{currentBranch.name}</h3>
<p className="text-xs text-muted-foreground">{currentBranch.description}</p>
</div>
</div>
{currentBranch.steps ? (
<div className="space-y-3">
{/* Mini tree preview */}
<div className="rounded-lg border border-border bg-accent/30 p-3">
<NodePreview node={currentBranch.steps} depth={0} />
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleGenerate(currentBranch.name)}
disabled={isLoading}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
>
<RefreshCw className="h-3 w-3" />
Regenerate
</button>
</div>
</div>
) : (
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-border bg-accent/20 py-8">
<p className="text-sm text-muted-foreground">
Generate AI detail for this branch
</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleGenerate(currentBranch.name)}
disabled={isLoading}
className={cn(
'rounded-lg bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
isLoading ? 'cursor-not-allowed opacity-50' : 'hover:opacity-90'
)}
>
Generate Detail
</button>
<button
type="button"
onClick={() => {
if (viewingIndex < selectedBranches.length - 1) {
setViewingIndex(viewingIndex + 1)
}
}}
className="flex items-center gap-1 rounded-lg border border-border px-3 py-2 text-sm text-muted-foreground hover:bg-accent"
>
<SkipForward className="h-3.5 w-3.5" />
Skip
</button>
</div>
</div>
)}
</div>
)}
{/* Error */}
{error && (
<div className="rounded-lg border border-red-400/20 bg-red-400/5 px-3 py-2 text-sm text-red-400">
{error}
</div>
)}
{/* Navigation */}
<div className="flex items-center justify-between border-t border-border pt-3">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setViewingIndex(Math.max(0, viewingIndex - 1))}
disabled={viewingIndex === 0}
className="flex items-center gap-1 rounded-lg border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent disabled:opacity-30"
>
<ChevronLeft className="h-3.5 w-3.5" />
Previous
</button>
<button
type="button"
onClick={() =>
setViewingIndex(Math.min(selectedBranches.length - 1, viewingIndex + 1))
}
disabled={viewingIndex === selectedBranches.length - 1}
className="flex items-center gap-1 rounded-lg border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent disabled:opacity-30"
>
Next
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">
{branchesWithDetail}/{selectedBranches.length} detailed
</span>
<button
type="button"
onClick={handleAssemble}
disabled={!allBranchesHaveDetail || isLoading}
className={cn(
'rounded-lg bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
allBranchesHaveDetail && !isLoading
? 'hover:opacity-90'
: 'cursor-not-allowed opacity-50'
)}
>
Assemble Tree
</button>
</div>
</div>
</div>
)
}
/** Recursive mini-preview of a node tree */
function NodePreview({ node, depth }: { node: Record<string, unknown>; depth: number }) {
const type = node.type as string
const label =
type === 'decision'
? (node.question as string)
: (node.title as string) || 'Untitled'
const children = (node.children as Record<string, unknown>[]) || []
const typeColors: Record<string, string> = {
decision: 'bg-blue-400',
action: 'bg-amber-400',
solution: 'bg-green-400',
}
return (
<div style={{ marginLeft: depth * 16 }}>
<div className="flex items-center gap-2 py-0.5">
<div className={cn('h-2 w-2 rounded-full', typeColors[type] || 'bg-muted-foreground')} />
<span className="text-xs text-foreground truncate">{label}</span>
<span className="text-[10px] font-label text-muted-foreground">{type}</span>
</div>
{children.map((child, i) => (
<NodePreview key={i} node={child} depth={depth + 1} />
))}
</div>
)
}

View File

@@ -0,0 +1,280 @@
import { useState } from 'react'
import { GripVertical, Plus, X, Pencil, Check } from 'lucide-react'
import { useAIFlowBuilderStore } from '@/store/aiFlowBuilderStore'
import { cn } from '@/lib/utils'
import type { AIBranch } from '@/types'
export function BranchSelector() {
const {
suggestedBranches,
selectedBranches,
selectBranches,
setPhase,
error,
} = useAIFlowBuilderStore()
const [editingIndex, setEditingIndex] = useState<number | null>(null)
const [editName, setEditName] = useState('')
const [editDesc, setEditDesc] = useState('')
const [showAddForm, setShowAddForm] = useState(false)
const [newName, setNewName] = useState('')
const [newDesc, setNewDesc] = useState('')
const toggleBranch = (branch: AIBranch) => {
const isSelected = selectedBranches.some((b) => b.name === branch.name)
if (isSelected) {
selectBranches(selectedBranches.filter((b) => b.name !== branch.name))
} else {
selectBranches([...selectedBranches, branch])
}
}
const startEditing = (index: number) => {
const branch = selectedBranches[index]
setEditingIndex(index)
setEditName(branch.name)
setEditDesc(branch.description)
}
const saveEdit = () => {
if (editingIndex === null || !editName.trim()) return
const updated = [...selectedBranches]
updated[editingIndex] = {
...updated[editingIndex],
name: editName.trim(),
description: editDesc.trim(),
}
selectBranches(updated)
setEditingIndex(null)
}
const addCustomBranch = () => {
if (!newName.trim()) return
const branch: AIBranch = {
name: newName.trim(),
description: newDesc.trim(),
isCustom: true,
}
selectBranches([...selectedBranches, branch])
setNewName('')
setNewDesc('')
setShowAddForm(false)
}
const moveBranch = (fromIndex: number, direction: 'up' | 'down') => {
const toIndex = direction === 'up' ? fromIndex - 1 : fromIndex + 1
if (toIndex < 0 || toIndex >= selectedBranches.length) return
const updated = [...selectedBranches]
;[updated[fromIndex], updated[toIndex]] = [updated[toIndex], updated[fromIndex]]
selectBranches(updated)
}
const canProceed = selectedBranches.length >= 2
return (
<div className="space-y-4">
<div>
<p className="text-sm text-muted-foreground">
AI suggested {suggestedBranches.length} branches. Select, reorder, rename, or add your own.
</p>
</div>
{/* Branch list */}
<div className="space-y-2">
{suggestedBranches.map((branch) => {
const isSelected = selectedBranches.some((b) => b.name === branch.name)
const selectedIndex = selectedBranches.findIndex((b) => b.name === branch.name)
return (
<div
key={branch.name}
className={cn(
'flex items-start gap-3 rounded-lg border p-3 transition-colors cursor-pointer',
isSelected
? 'border-primary/30 bg-primary/5'
: 'border-border bg-card hover:bg-accent/50'
)}
onClick={() => toggleBranch(branch)}
>
<div
className={cn(
'mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border',
isSelected
? 'border-primary bg-primary text-white'
: 'border-border'
)}
>
{isSelected && <Check className="h-3 w-3" />}
</div>
<div className="flex-1 min-w-0">
{editingIndex !== null && selectedIndex === editingIndex ? (
<div className="space-y-2" onClick={(e) => e.stopPropagation()}>
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="w-full rounded border border-border bg-card px-2 py-1 text-sm text-foreground focus:border-primary focus:outline-none"
autoFocus
/>
<input
type="text"
value={editDesc}
onChange={(e) => setEditDesc(e.target.value)}
className="w-full rounded border border-border bg-card px-2 py-1 text-xs text-muted-foreground focus:border-primary focus:outline-none"
/>
<div className="flex gap-1">
<button
type="button"
onClick={saveEdit}
className="rounded bg-primary px-2 py-0.5 text-xs text-white"
>
Save
</button>
<button
type="button"
onClick={() => setEditingIndex(null)}
className="rounded border border-border px-2 py-0.5 text-xs text-muted-foreground"
>
Cancel
</button>
</div>
</div>
) : (
<>
<div className="text-sm font-medium text-foreground">{branch.name}</div>
<div className="text-xs text-muted-foreground">{branch.description}</div>
</>
)}
</div>
{isSelected && editingIndex !== selectedIndex && (
<div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
<button
type="button"
onClick={() => moveBranch(selectedIndex, 'up')}
disabled={selectedIndex === 0}
className="rounded p-1 text-muted-foreground hover:text-foreground disabled:opacity-30"
title="Move up"
>
<GripVertical className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => startEditing(selectedIndex)}
className="rounded p-1 text-muted-foreground hover:text-foreground"
title="Edit"
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
)
})}
{/* Custom branches (not in suggested) */}
{selectedBranches
.filter((b) => b.isCustom)
.map((branch, i) => {
return (
<div
key={`custom-${i}`}
className="flex items-start gap-3 rounded-lg border border-primary/30 bg-primary/5 p-3"
>
<div className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border border-primary bg-primary text-white">
<Check className="h-3 w-3" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground">{branch.name}</div>
<div className="text-xs text-muted-foreground">{branch.description}</div>
<span className="mt-1 inline-block rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-label text-primary">
Custom
</span>
</div>
<button
type="button"
onClick={() =>
selectBranches(selectedBranches.filter((b) => b.name !== branch.name))
}
className="rounded p-1 text-muted-foreground hover:text-red-400"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)
})}
</div>
{/* Add custom branch */}
{showAddForm ? (
<div className="space-y-2 rounded-lg border border-dashed border-border p-3">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Branch name"
className="w-full rounded border border-border bg-card px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
autoFocus
/>
<input
type="text"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
placeholder="Brief description"
className="w-full rounded border border-border bg-card px-2 py-1.5 text-xs text-muted-foreground placeholder:text-muted-foreground/60 focus:border-primary focus:outline-none"
/>
<div className="flex gap-1">
<button
type="button"
onClick={addCustomBranch}
disabled={!newName.trim()}
className="rounded bg-primary px-2.5 py-1 text-xs text-white disabled:opacity-50"
>
Add
</button>
<button
type="button"
onClick={() => setShowAddForm(false)}
className="rounded border border-border px-2.5 py-1 text-xs text-muted-foreground"
>
Cancel
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setShowAddForm(true)}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
>
<Plus className="h-4 w-4" />
Add custom branch
</button>
)}
{/* Error */}
{error && (
<div className="rounded-lg border border-red-400/20 bg-red-400/5 px-3 py-2 text-sm text-red-400">
{error}
</div>
)}
{/* Footer */}
<div className="flex items-center justify-between pt-2">
<span className="text-xs text-muted-foreground">
{selectedBranches.length} branch{selectedBranches.length !== 1 ? 'es' : ''} selected (min 2)
</span>
<button
type="button"
onClick={() => setPhase('detailing')}
disabled={!canProceed}
className={cn(
'rounded-lg bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
canProceed ? 'hover:opacity-90' : 'cursor-not-allowed opacity-50'
)}
>
Continue to Detail
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,163 @@
import { useState } from 'react'
import { useAIFlowBuilderStore } from '@/store/aiFlowBuilderStore'
import { QuotaDisplay } from './QuotaDisplay'
import { cn } from '@/lib/utils'
export function FoundationForm() {
const { metadata, setMetadata, quota, start, isLoading, error } = useAIFlowBuilderStore()
const [tagInput, setTagInput] = useState('')
const canSubmit =
metadata.name.trim().length > 0 &&
metadata.description.trim().length > 0 &&
!isLoading &&
(quota?.allowed !== false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!canSubmit) return
await start()
}
const addTag = () => {
const tag = tagInput.trim()
if (tag && !metadata.environment_tags.includes(tag)) {
setMetadata({ environment_tags: [...metadata.environment_tags, tag] })
}
setTagInput('')
}
const removeTag = (tag: string) => {
setMetadata({ environment_tags: metadata.environment_tags.filter((t) => t !== tag) })
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
{quota && <QuotaDisplay quota={quota} />}
{/* Flow Type */}
<div>
<label className="mb-2 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
Flow Type
</label>
<div className="flex gap-2">
{(['troubleshooting', 'procedural'] as const).map((type) => (
<button
key={type}
type="button"
onClick={() => setMetadata({ flow_type: type })}
className={cn(
'flex-1 rounded-lg border px-3 py-2.5 text-sm font-medium transition-colors',
metadata.flow_type === type
? 'border-primary/30 bg-primary/10 text-foreground'
: 'border-border bg-card text-muted-foreground hover:bg-accent'
)}
>
{type === 'troubleshooting' ? 'Troubleshooting' : 'Procedural'}
</button>
))}
</div>
</div>
{/* Name */}
<div>
<label className="mb-2 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
Flow Name
</label>
<input
type="text"
value={metadata.name}
onChange={(e) => setMetadata({ name: e.target.value })}
placeholder="e.g. DNS Resolution Failures"
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
maxLength={255}
/>
</div>
{/* Description */}
<div>
<label className="mb-2 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
Description
</label>
<textarea
value={metadata.description}
onChange={(e) => setMetadata({ description: e.target.value })}
placeholder="Describe what this flow covers. The more detail you provide, the better the AI suggestions will be."
rows={4}
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20 resize-none"
maxLength={2000}
/>
<p className="mt-1 text-right text-[10px] text-muted-foreground">
{metadata.description.length}/2000
</p>
</div>
{/* Environment Tags */}
<div>
<label className="mb-2 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
Environment Tags <span className="normal-case tracking-normal text-muted-foreground/60">(optional)</span>
</label>
<div className="flex gap-2">
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addTag()
}
}}
placeholder="e.g. Windows Server, Active Directory"
className="flex-1 rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
/>
<button
type="button"
onClick={addTag}
className="rounded-lg border border-border px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
Add
</button>
</div>
{metadata.environment_tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{metadata.environment_tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded-full bg-card border border-border px-2.5 py-0.5 font-label text-xs text-muted-foreground"
>
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="ml-0.5 text-muted-foreground/60 hover:text-foreground"
>
&times;
</button>
</span>
))}
</div>
)}
</div>
{/* Error */}
{error && (
<div className="rounded-lg border border-red-400/20 bg-red-400/5 px-3 py-2 text-sm text-red-400">
{error}
</div>
)}
{/* Submit */}
<button
type="submit"
disabled={!canSubmit}
className={cn(
'w-full rounded-lg bg-gradient-brand py-2.5 text-sm font-medium text-white shadow-lg shadow-primary/20',
canSubmit ? 'hover:opacity-90' : 'cursor-not-allowed opacity-50'
)}
>
{isLoading ? 'Creating...' : 'Continue to AI Scaffold'}
</button>
</form>
)
}

View File

@@ -0,0 +1,33 @@
import { useEffect, useState } from 'react'
import { Sparkles } from 'lucide-react'
const MESSAGES = [
'Analyzing your flow requirements...',
'Building decision paths...',
'Generating troubleshooting logic...',
'Crafting resolution steps...',
'Structuring the flow...',
]
export function GeneratingAnimation() {
const [messageIndex, setMessageIndex] = useState(0)
useEffect(() => {
const interval = setInterval(() => {
setMessageIndex((prev) => (prev + 1) % MESSAGES.length)
}, 3000)
return () => clearInterval(interval)
}, [])
return (
<div className="flex flex-col items-center justify-center gap-4 py-12">
<div className="relative">
<div className="h-12 w-12 animate-spin rounded-full border-4 border-border border-t-primary" />
<Sparkles className="absolute left-1/2 top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 text-primary" />
</div>
<p className="text-sm text-muted-foreground animate-pulse">
{MESSAGES[messageIndex]}
</p>
</div>
)
}

View File

@@ -0,0 +1,48 @@
import { cn } from '@/lib/utils'
import type { AIQuotaStatus } from '@/types'
interface QuotaDisplayProps {
quota: AIQuotaStatus
compact?: boolean
}
export function QuotaDisplay({ quota, compact = false }: QuotaDisplayProps) {
if (!quota.ai_enabled) return null
const monthlyRemaining =
quota.monthly_limit !== null
? Math.max(0, quota.monthly_limit - quota.monthly_used)
: null
const getColor = () => {
if (!quota.allowed) return 'text-red-400'
if (monthlyRemaining !== null && monthlyRemaining <= 1) return 'text-amber-400'
return 'text-green-400'
}
if (compact) {
return (
<span className={cn('text-xs font-label', getColor())}>
{monthlyRemaining !== null
? `${monthlyRemaining}/${quota.monthly_limit} builds`
: 'Unlimited'}
</span>
)
}
return (
<div className="flex items-center gap-2 rounded-lg border border-border bg-accent/50 px-3 py-1.5">
<div className={cn('h-2 w-2 rounded-full', getColor().replace('text-', 'bg-'))} />
<span className="text-xs text-muted-foreground">
{monthlyRemaining !== null ? (
<>
<span className={cn('font-medium', getColor())}>{monthlyRemaining}</span>
{' '}of {quota.monthly_limit} AI builds remaining
</>
) : (
'Unlimited AI builds'
)}
</span>
</div>
)
}

View File

@@ -0,0 +1,85 @@
import { GitBranch, Layers, CheckCircle, ArrowRight, RotateCcw } from 'lucide-react'
import { useAIFlowBuilderStore } from '@/store/aiFlowBuilderStore'
import { cn } from '@/lib/utils'
interface TreePreviewCardProps {
onOpenInEditor: () => void
}
export function TreePreviewCard({ onOpenInEditor }: TreePreviewCardProps) {
const { assembledTree, reset, isLoading } = useAIFlowBuilderStore()
if (!assembledTree) return null
const { summary } = assembledTree
const stats = [
{ label: 'Nodes', value: summary.node_count, icon: Layers },
{ label: 'Decisions', value: summary.decision_count, icon: GitBranch },
{ label: 'Solutions', value: summary.solution_count, icon: CheckCircle },
{ label: 'Depth', value: summary.depth, icon: Layers },
]
return (
<div className="space-y-4">
<div className="text-center">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-green-400/10">
<CheckCircle className="h-6 w-6 text-green-400" />
</div>
<h3 className="text-lg font-semibold text-foreground">
Tree Assembled
</h3>
<p className="mt-1 text-sm text-muted-foreground">
&quot;{assembledTree.suggested_name}&quot; is ready to review in the editor.
</p>
</div>
{/* Stats grid */}
<div className="grid grid-cols-4 gap-2">
{stats.map(({ label, value, icon: Icon }) => (
<div
key={label}
className="flex flex-col items-center rounded-lg border border-border bg-accent/30 p-2.5"
>
<Icon className="mb-1 h-4 w-4 text-muted-foreground" />
<span className="text-lg font-semibold text-gradient-brand">{value}</span>
<span className="text-[10px] font-label uppercase tracking-wide text-muted-foreground">
{label}
</span>
</div>
))}
</div>
{/* Description */}
{assembledTree.suggested_description && (
<div className="rounded-lg border border-border bg-accent/20 p-3">
<p className="text-xs text-muted-foreground">{assembledTree.suggested_description}</p>
</div>
)}
{/* Actions */}
<div className="flex gap-2">
<button
type="button"
onClick={onOpenInEditor}
disabled={isLoading}
className={cn(
'flex flex-1 items-center justify-center gap-2 rounded-lg bg-gradient-brand py-2.5 text-sm font-medium text-white shadow-lg shadow-primary/20',
'hover:opacity-90'
)}
>
<ArrowRight className="h-4 w-4" />
Open in Editor
</button>
<button
type="button"
onClick={reset}
className="flex items-center gap-2 rounded-lg border border-border px-4 py-2.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
<RotateCcw className="h-4 w-4" />
Start Over
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,70 @@
import { Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { AIWizardPhase } from '@/types'
const STEPS = [
{ key: 'foundation', label: 'Foundation' },
{ key: 'scaffolding', label: 'Scaffold' },
{ key: 'detailing', label: 'Detail' },
{ key: 'reviewing', label: 'Review' },
] as const
const PHASE_ORDER: Record<string, number> = {
foundation: 0,
scaffolding: 1,
generating: 1,
detailing: 2,
reviewing: 3,
completed: 4,
error: -1,
}
interface WizardStepIndicatorProps {
phase: AIWizardPhase
}
export function WizardStepIndicator({ phase }: WizardStepIndicatorProps) {
const currentIndex = PHASE_ORDER[phase] ?? 0
return (
<div className="flex items-center gap-1 px-2">
{STEPS.map((step, i) => {
const isCompleted = currentIndex > i
const isCurrent = currentIndex === i
return (
<div key={step.key} className="flex items-center gap-1">
{i > 0 && (
<div
className={cn(
'h-px w-4 sm:w-6',
isCompleted ? 'bg-primary' : 'bg-border'
)}
/>
)}
<div className="flex items-center gap-1.5">
<div
className={cn(
'flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-medium',
isCompleted && 'bg-primary text-white',
isCurrent && 'bg-primary/20 text-primary ring-1 ring-primary/40',
!isCompleted && !isCurrent && 'bg-accent text-muted-foreground'
)}
>
{isCompleted ? <Check className="h-3 w-3" /> : i + 1}
</div>
<span
className={cn(
'hidden text-xs sm:inline',
isCurrent ? 'font-medium text-foreground' : 'text-muted-foreground'
)}
>
{step.label}
</span>
</div>
</div>
)
})}
</div>
)
}