Files
vibn-frontend/app/[workspace]/project/[projectId]/build/page.tsx
Mark Henderson bedd7d3470 feat(build): AI architecture recommendation with review + confirm flow
- New /api/projects/[projectId]/architecture (GET/POST/PATCH) — reads PRD
  + phases, calls AI to generate structured monorepo architecture JSON,
  persists to fs_projects.data.architecture; PATCH sets confirmed flag
- Rebuilt Build tab to show the AI-generated recommendation: expandable
  app cards (tech stack, key screens), shared packages, infrastructure,
  integrations, and risk notes; confirm button + "adjustable later" note

Made-with: Cursor
2026-03-03 21:02:06 -08:00

476 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
interface App {
name: string;
type: string;
description: string;
tech: string[];
screens: string[];
}
interface Package {
name: string;
description: string;
}
interface Infra {
name: string;
reason: string;
}
interface Integration {
name: string;
required: boolean;
notes: string;
}
interface Architecture {
productName: string;
productType: string;
summary: string;
apps: App[];
packages: Package[];
infrastructure: Infra[];
integrations: Integration[];
designSurfaces: string[];
riskNotes: string[];
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div style={{
fontSize: "0.6rem", fontWeight: 700, color: "#a09a90",
letterSpacing: "0.12em", textTransform: "uppercase",
marginBottom: 10, marginTop: 28,
}}>
{children}
</div>
);
}
function AppCard({ app }: { app: App }) {
const [open, setOpen] = useState(false);
const icons: Record<string, string> = {
web: "🌐", api: "⚡", simulator: "🎮", admin: "🔧",
mobile: "📱", worker: "⚙️", engine: "🎯",
};
const icon = Object.entries(icons).find(([k]) => app.name.toLowerCase().includes(k))?.[1] ?? "📦";
return (
<div style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
marginBottom: 8, overflow: "hidden",
}}>
<button
onClick={() => setOpen(o => !o)}
style={{
width: "100%", textAlign: "left", background: "none", border: "none",
cursor: "pointer", padding: "14px 18px",
display: "flex", alignItems: "center", gap: 12,
fontFamily: "Outfit, sans-serif",
}}
>
<span style={{ fontSize: "1.2rem" }}>{icon}</span>
<div style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a" }}>
apps/{app.name}
</span>
<span style={{
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
color: "#6b6560", background: "#f0ece4",
padding: "2px 7px", borderRadius: 4,
}}>
{app.type}
</span>
</div>
<div style={{ fontSize: "0.78rem", color: "#8a8478", marginTop: 2 }}>
{app.description}
</div>
</div>
<span style={{ fontSize: "0.7rem", color: "#c5c0b8" }}>{open ? "▲" : "▼"}</span>
</button>
{open && (
<div style={{ padding: "0 18px 16px", borderTop: "1px solid #f0ece4" }}>
{app.tech.length > 0 && (
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 6 }}>Stack</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>
{app.tech.map((t, i) => (
<span key={i} style={{
fontSize: "0.72rem", fontFamily: "IBM Plex Mono, monospace",
color: "#4a4640", background: "#f6f4f0",
border: "1px solid #e8e4dc", padding: "2px 8px", borderRadius: 4,
}}>{t}</span>
))}
</div>
</div>
)}
{app.screens.length > 0 && (
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 6 }}>Key screens</div>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
{app.screens.map((s, i) => (
<div key={i} style={{ fontSize: "0.78rem", color: "#4a4640", display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ color: "#c5c0b8" }}></span> {s}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
export default function BuildPage() {
const params = useParams();
const projectId = params.projectId as string;
const workspace = params.workspace as string;
const [prd, setPrd] = useState<string | null>(null);
const [architecture, setArchitecture] = useState<Architecture | null>(null);
const [architectureConfirmed, setArchitectureConfirmed] = useState(false);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [confirming, setConfirming] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch(`/api/projects/${projectId}/architecture`)
.then(r => r.json())
.then(d => {
setPrd(d.prd);
setArchitecture(d.architecture ?? null);
setLoading(false);
})
.catch(() => setLoading(false));
// Also check confirmed flag
fetch(`/api/projects/${projectId}`)
.then(r => r.json())
.then(d => setArchitectureConfirmed(d.project?.architectureConfirmed === true))
.catch(() => {});
}, [projectId]);
const handleGenerate = async (force = false) => {
setGenerating(true);
setError(null);
try {
const res = await fetch(`/api/projects/${projectId}/architecture`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ forceRegenerate: force }),
});
const d = await res.json();
if (!res.ok) throw new Error(d.error || "Generation failed");
setArchitecture(d.architecture);
} catch (e) {
setError(e instanceof Error ? e.message : "Something went wrong");
} finally {
setGenerating(false);
}
};
const handleConfirm = async () => {
setConfirming(true);
try {
await fetch(`/api/projects/${projectId}/architecture`, { method: "PATCH" });
setArchitectureConfirmed(true);
} catch { /* swallow */ } finally {
setConfirming(false);
}
};
if (loading) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif", color: "#a09a90" }}>
Loading
</div>
);
}
// No PRD yet
if (!prd) {
return (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: 40, fontFamily: "Outfit, sans-serif" }}>
<div style={{ textAlign: "center", maxWidth: 360 }}>
<div style={{ fontSize: "2.5rem", marginBottom: 16 }}>🔒</div>
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.3rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 8 }}>
Complete your PRD first
</h3>
<p style={{ fontSize: "0.82rem", color: "#a09a90", lineHeight: 1.6, marginBottom: 20 }}>
Finish your discovery conversation with Atlas, then the architect will unlock automatically.
</p>
<Link href={`/${workspace}/project/${projectId}/overview`} style={{
display: "inline-block", padding: "9px 20px", borderRadius: 7,
background: "#1a1a1a", color: "#fff",
fontSize: "0.78rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
textDecoration: "none",
}}>
Continue with Atlas
</Link>
</div>
</div>
);
}
// PRD exists but no architecture yet — prompt to generate
if (!architecture) {
return (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: 40, fontFamily: "Outfit, sans-serif" }}>
<div style={{ textAlign: "center", maxWidth: 440 }}>
<div style={{ fontSize: "2.5rem", marginBottom: 16 }}>🏗</div>
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.4rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 10 }}>
Ready to architect {architecture ? (architecture as Architecture).productName : "your product"}
</h3>
<p style={{ fontSize: "0.84rem", color: "#6b6560", lineHeight: 1.65, marginBottom: 28, maxWidth: 380, margin: "0 auto 28px" }}>
The AI will read your PRD and recommend the technical structure apps, services, database, and integrations. You'll review it before anything gets built.
</p>
{error && (
<div style={{ marginBottom: 16, padding: "10px 14px", background: "#fff0f0", border: "1px solid #ffcdd2", borderRadius: 8, fontSize: "0.78rem", color: "#c62828" }}>
{error}
</div>
)}
<button
onClick={() => handleGenerate(false)}
disabled={generating}
style={{
padding: "11px 28px", borderRadius: 8,
background: generating ? "#8a8478" : "#1a1a1a",
color: "#fff", border: "none", cursor: generating ? "default" : "pointer",
fontSize: "0.84rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
transition: "background 0.15s",
}}
>
{generating ? "Analysing PRD…" : "Generate architecture →"}
</button>
{generating && (
<p style={{ fontSize: "0.72rem", color: "#a09a90", marginTop: 12 }}>
This takes about 1530 seconds
</p>
)}
</div>
</div>
);
}
// Architecture loaded — show full review UI
return (
<div style={{ padding: "28px 32px", overflow: "auto", fontFamily: "Outfit, sans-serif", maxWidth: 780 }}>
{/* Header */}
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 4 }}>
<div>
<h2 style={{ fontFamily: "Newsreader, serif", fontSize: "1.35rem", fontWeight: 400, color: "#1a1a1a", margin: 0 }}>
Architecture
</h2>
<p style={{ fontSize: "0.75rem", color: "#a09a90", marginTop: 4 }}>
{architecture.productType}
</p>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{architectureConfirmed && (
<span style={{
fontSize: "0.72rem", fontFamily: "IBM Plex Mono, monospace",
color: "#2e7d32", background: "#2e7d3210",
border: "1px solid #a5d6a740", padding: "4px 10px", borderRadius: 5,
}}>
✓ Confirmed
</span>
)}
<button
onClick={() => handleGenerate(true)}
disabled={generating}
style={{
padding: "6px 14px", borderRadius: 6,
background: "none", border: "1px solid #e0dcd4",
fontSize: "0.72rem", color: "#8a8478", cursor: "pointer",
fontFamily: "Outfit, sans-serif",
}}
>
{generating ? "Regenerating…" : "Regenerate"}
</button>
</div>
</div>
{/* Summary */}
<div style={{
marginTop: 18, padding: "16px 20px",
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
fontSize: "0.84rem", color: "#2a2824", lineHeight: 1.7,
}}>
{architecture.summary}
</div>
{/* Apps */}
<SectionLabel>Apps — monorepo/apps/</SectionLabel>
{architecture.apps.map((app, i) => <AppCard key={i} app={app} />)}
{/* Packages */}
<SectionLabel>Shared packages — monorepo/packages/</SectionLabel>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
{architecture.packages.map((pkg, i) => (
<div key={i} style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
padding: "12px 16px",
}}>
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "IBM Plex Mono, monospace" }}>
packages/{pkg.name}
</div>
<div style={{ fontSize: "0.76rem", color: "#8a8478", marginTop: 4, lineHeight: 1.5 }}>
{pkg.description}
</div>
</div>
))}
</div>
{/* Infrastructure */}
{architecture.infrastructure.length > 0 && (
<>
<SectionLabel>Infrastructure</SectionLabel>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{architecture.infrastructure.map((infra, i) => (
<div key={i} style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 8,
padding: "10px 16px", display: "flex", gap: 12, alignItems: "flex-start",
}}>
<span style={{
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
color: "#3d5afe", background: "#3d5afe0d",
border: "1px solid #3d5afe20", padding: "2px 7px", borderRadius: 4,
flexShrink: 0, marginTop: 1,
}}>
{infra.name}
</span>
<span style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.5 }}>
{infra.reason}
</span>
</div>
))}
</div>
</>
)}
{/* Integrations */}
{architecture.integrations.length > 0 && (
<>
<SectionLabel>External integrations</SectionLabel>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{architecture.integrations.map((intg, i) => (
<div key={i} style={{
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 8,
padding: "10px 16px", display: "flex", gap: 12, alignItems: "flex-start",
}}>
<span style={{
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
color: intg.required ? "#9a7b3a" : "#8a8478",
background: intg.required ? "#d4a04a12" : "#f6f4f0",
border: `1px solid ${intg.required ? "#d4a04a30" : "#e8e4dc"}`,
padding: "2px 7px", borderRadius: 4, flexShrink: 0, marginTop: 1,
}}>
{intg.required ? "required" : "optional"}
</span>
<div>
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a" }}>{intg.name}</div>
<div style={{ fontSize: "0.75rem", color: "#8a8478", marginTop: 2 }}>{intg.notes}</div>
</div>
</div>
))}
</div>
</>
)}
{/* Risk notes */}
{architecture.riskNotes.length > 0 && (
<>
<SectionLabel>Architecture risks</SectionLabel>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{architecture.riskNotes.map((risk, i) => (
<div key={i} style={{
background: "#fff8f0", border: "1px solid #ffe0b2",
borderRadius: 8, padding: "10px 16px",
fontSize: "0.78rem", color: "#6d4c00", lineHeight: 1.55,
display: "flex", gap: 8,
}}>
<span>⚠️</span><span>{risk}</span>
</div>
))}
</div>
</>
)}
{/* Confirm section */}
<div style={{
marginTop: 32, padding: "20px 24px",
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 12,
borderLeft: "3px solid #1a1a1a",
}}>
{architectureConfirmed ? (
<div>
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6 }}>
✓ Architecture confirmed
</div>
<p style={{ fontSize: "0.78rem", color: "#6b6560", margin: "0 0 14px" }}>
You can still regenerate or adjust the architecture before scaffolding begins. Nothing has been built yet.
</p>
<Link href={`/${workspace}/project/${projectId}/design`} style={{
display: "inline-block", padding: "9px 20px", borderRadius: 7,
background: "#1a1a1a", color: "#fff",
fontSize: "0.78rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
textDecoration: "none",
}}>
Choose your design →
</Link>
</div>
) : (
<div>
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6 }}>
Does this look right?
</div>
<p style={{ fontSize: "0.78rem", color: "#6b6560", margin: "0 0 16px", lineHeight: 1.6 }}>
Review the structure above. You can regenerate if something's off, or confirm to move to design.
You can always come back and adjust before the build starts nothing gets scaffolded yet.
</p>
<div style={{ display: "flex", gap: 10 }}>
<button
onClick={handleConfirm}
disabled={confirming}
style={{
padding: "9px 22px", borderRadius: 7,
background: confirming ? "#8a8478" : "#1a1a1a",
color: "#fff", border: "none",
fontSize: "0.78rem", fontWeight: 600,
fontFamily: "Outfit, sans-serif", cursor: confirming ? "default" : "pointer",
}}
>
{confirming ? "Confirming…" : "Confirm architecture →"}
</button>
<button
onClick={() => handleGenerate(true)}
disabled={generating}
style={{
padding: "9px 18px", borderRadius: 7,
background: "none", border: "1px solid #e0dcd4",
fontSize: "0.78rem", color: "#8a8478",
fontFamily: "Outfit, sans-serif", cursor: "pointer",
}}
>
Regenerate
</button>
</div>
</div>
)}
</div>
<div style={{ height: 40 }} />
</div>
);
}