This repository has been archived on 2026-06-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
master-ai/vibn-frontend/app/[workspace]/project/[projectId]/(home)/plan/page.tsx

451 lines
18 KiB
TypeScript

"use client";
import React, { useState, useEffect, useCallback } from "react";
import { useParams } from "next/navigation";
import { Loader2, Target, BookOpen, Layers, GitBranch, Pencil, FileText, Check, Plus, ListTodo } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import useSWR from "swr";
// Types mapping to our Postgres plan shape
type Plan = {
vision?: string;
brief?: string;
tasks: Array<{ id: string; title: string; description?: string; status: string }>;
decisions: Array<{ id: string; title: string; choice: string; why?: string }>;
};
type Tab = "objective" | "prd" | "delegate";
// Shared Theme Variables
const INK = {
main: "#1a1918",
muted: "#6b6560",
faint: "#a09a90",
border: "#e8e4dc",
bg: "#ffffff",
bgHover: "#f8f6f2",
};
const fetcher = async (url: string) => {
const res = await fetch(url, { credentials: "include" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return data.plan;
};
export default function PlanPageV2() {
const params = useParams();
const projectId = (params?.projectId as string) || "";
const [activeTab, setActiveTab] = useState<Tab>("objective");
const { data: plan, error, mutate: mutatePlan } = useSWR<Plan>(
projectId ? `/api/projects/${projectId}/plan` : null,
fetcher,
{ revalidateOnFocus: false, revalidateIfStale: false }
);
// Wrapper for child components to update SWR cache optimally
const setPlan = (newPlan: Plan) => { mutatePlan(newPlan, false); };
if (!plan && !error) {
return (
<div style={{ display: "flex", justifyContent: "center", padding: 100 }}>
<Loader2 className="animate-spin" size={24} color={INK.muted} />
</div>
);
}
if (error || !plan) {
return (
<div style={{ padding: 40, color: "red", textAlign: "center" }}>
{error || "Failed to load plan"}
</div>
);
}
return (
<div style={{ maxWidth: 1000, margin: "0 auto", padding: "40px 20px" }}>
{/* ── HEADER & TABS ── */}
<div style={{ marginBottom: 32 }}>
<div style={{
display: "flex",
gap: 8,
borderBottom: `1px solid ${INK.border}`,
paddingBottom: 0
}}>
<TabButton active={activeTab === "objective"} onClick={() => setActiveTab("objective")} icon={<Target size={14} />} label="Objective" />
<TabButton active={activeTab === "prd"} onClick={() => setActiveTab("prd")} icon={<FileText size={14} />} label="PRD" />
<TabButton active={activeTab === "delegate"} onClick={() => setActiveTab("delegate")} icon={<Layers size={14} />} label="Delegate" />
</div>
</div>
{/* ── CONTENT AREA ── */}
{/*
By rendering them all but toggling display: none, we fix the "always needs to load"
issue you mentioned. The DOM nodes stay mounted, preserving state and preventing jitter.
*/}
<div style={{ display: activeTab === "objective" ? "block" : "none" }}>
<ObjectiveView plan={plan} projectId={projectId} onChange={setPlan} />
</div>
<div style={{ display: activeTab === "prd" ? "flex" : "none", height: "calc(100vh - 200px)" }}>
<PRDView plan={plan} projectId={projectId} onChange={setPlan} />
</div>
<div style={{ display: activeTab === "delegate" ? "block" : "none" }}>
<DelegateView plan={plan} projectId={projectId} onChange={setPlan} />
</div>
</div>
);
}
// ──────────────────────────────────────────────────
// 1. OBJECTIVE VIEW
// The business case / 1-page summary
// ──────────────────────────────────────────────────
function ObjectiveView({ plan, projectId, onChange }: { plan: Plan, projectId: string, onChange: (p: Plan) => void }) {
const [draft, setDraft] = useState(plan.vision ?? "");
const [saving, setSaving] = useState(false);
const [generating, setGenerating] = useState(false);
useEffect(() => {
if (plan.vision !== draft) {
setDraft(plan.vision ?? "");
}
}, [plan.vision]);
const save = async (text: string) => {
setSaving(true);
try {
const r = await fetch(`/api/projects/${projectId}/plan`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "vision", text }),
});
const d = await r.json();
if (d.plan) onChange(d.plan);
} finally {
setSaving(false);
}
};
const handleGenerate = async () => {
if (!draft.trim()) {
alert("Please write an objective first before generating the PRD.");
return;
}
if (!confirm("This will overwrite the PRD and Execution Plan based on this objective. Continue?")) return;
setGenerating(true);
try {
await fetch(`/api/projects/${projectId}/plan`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "vision", text: draft }),
});
const r = await fetch(`/api/projects/${projectId}/plan/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ objective: draft }),
});
const d = await r.json();
if (d.plan) onChange(d.plan);
alert("Blueprint generated successfully! Check the PRD and Delegate tabs.");
} finally {
setGenerating(false);
}
};
return (
<div className="panel-container">
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{saving && <span style={{ fontSize: "0.75rem", color: INK.faint }}>Saving...</span>}
<button
onClick={handleGenerate}
disabled={generating || !draft.trim()}
className="btn-primary"
style={{ padding: "8px 16px", borderRadius: 8, fontWeight: 600 }}
>
{generating ? (
<><Loader2 size={14} className="animate-spin" /> Generating Blueprint...</>
) : (
"Generate Complete PRD"
)}
</button>
</div>
</div>
<div style={{ border: `1px solid ${INK.border}`, borderRadius: 8, overflow: "hidden", background: "#fff", boxShadow: "0 1px 3px rgba(0,0,0,0.02)" }}>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => save(draft)}
style={{
width: "100%", minHeight: 400, padding: 24, fontSize: "0.95rem", lineHeight: 1.6,
border: "none", outline: "none", resize: "vertical", fontFamily: "var(--font-sans)",
color: INK.main
}}
placeholder="Describe the business objective..."
/>
</div>
</div>
);
}
// ──────────────────────────────────────────────────
// 2. PRD VIEW
// ──────────────────────────────────────────────────
function PRDView({ plan, projectId, onChange }: { plan: Plan, projectId: string, onChange: (p: Plan) => void }) {
const [activeDoc, setActiveDoc] = useState("spec");
return (
<div style={{ display: "flex", width: "100%", height: "100%", gap: 24, alignItems: "flex-start" }}>
{/* LEFT COLUMN: The Document Index */}
<div style={{
width: 250,
flexShrink: 0,
display: "flex",
flexDirection: "column",
gap: 8
}}>
<button
onClick={() => setActiveDoc("spec")}
style={{
textAlign: "left",
padding: "12px 16px",
borderRadius: 8,
background: activeDoc === "spec" ? "#fff" : "transparent",
border: activeDoc === "spec" ? `1px solid ${INK.border}` : "1px solid transparent",
boxShadow: activeDoc === "spec" ? "0 1px 3px rgba(0,0,0,0.02)" : "none",
color: activeDoc === "spec" ? INK.main : INK.muted,
fontWeight: activeDoc === "spec" ? 600 : 500,
cursor: "pointer",
transition: "all 0.15s ease",
display: "flex",
alignItems: "center",
gap: 8
}}
>
<BookOpen size={16} /> Product Spec
</button>
<button
onClick={() => setActiveDoc("plan")}
style={{
textAlign: "left",
padding: "12px 16px",
borderRadius: 8,
background: activeDoc === "plan" ? "#fff" : "transparent",
border: activeDoc === "plan" ? `1px solid ${INK.border}` : "1px solid transparent",
boxShadow: activeDoc === "plan" ? "0 1px 3px rgba(0,0,0,0.02)" : "none",
color: activeDoc === "plan" ? INK.main : INK.muted,
fontWeight: activeDoc === "plan" ? 600 : 500,
cursor: "pointer",
transition: "all 0.15s ease",
display: "flex",
alignItems: "center",
gap: 8
}}
>
<GitBranch size={16} /> Tech Architecture
</button>
</div>
{/* RIGHT COLUMN: The Document Content */}
<div style={{
flex: 1,
background: "#fff",
border: `1px solid ${INK.border}`,
borderRadius: 8,
padding: 40,
height: "100%",
overflowY: "auto",
boxShadow: "0 1px 3px rgba(0,0,0,0.02)"
}}>
{activeDoc === "spec" && (
<div className="markdown-prose">
{plan.decisions?.find(d => d.id === "prd_spec")?.why ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{plan.decisions.find(d => d.id === "prd_spec")!.why!}
</ReactMarkdown>
) : (
<>
<h1>Product Specification</h1>
<p style={{ color: INK.muted }}>The Product Specification document outlines the core user journeys, functional requirements, and acceptance criteria.</p>
<hr style={{ margin: "24px 0", borderTop: `1px dashed ${INK.border}`, borderBottom: "none" }} />
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", padding: 60, color: INK.muted, textAlign: "center" }}>
<BookOpen size={32} style={{ marginBottom: 16, opacity: 0.5 }} />
<p style={{ fontWeight: 500 }}>Spec is empty.</p>
<p style={{ fontSize: "0.85rem", maxWidth: 300, margin: "8px 0 0" }}>Click "Generate Complete PRD" on the Objective tab to generate this document.</p>
</div>
</>
)}
</div>
)}
{activeDoc === "plan" && (
<div className="markdown-prose">
{plan.decisions?.find(d => d.id === "prd_arch")?.why ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{plan.decisions.find(d => d.id === "prd_arch")!.why!}
</ReactMarkdown>
) : (
<>
<h1>Technical Architecture</h1>
<p style={{ color: INK.muted }}>The Technical Architecture plan defines the tech stack, database models, and API interfaces required to support the product spec.</p>
<hr style={{ margin: "24px 0", borderTop: `1px dashed ${INK.border}`, borderBottom: "none" }} />
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", padding: 60, color: INK.muted, textAlign: "center" }}>
<GitBranch size={32} style={{ marginBottom: 16, opacity: 0.5 }} />
<p style={{ fontWeight: 500 }}>Plan is empty.</p>
<p style={{ fontSize: "0.85rem", maxWidth: 300, margin: "8px 0 0" }}>Click "Generate Complete PRD" on the Objective tab to generate this document.</p>
</div>
</>
)}
</div>
)}
</div>
</div>
);
}
// ──────────────────────────────────────────────────
// 3. DELEGATE VIEW (The Factory)
// ──────────────────────────────────────────────────
function DelegateView({ plan, projectId, onChange }: { plan: Plan, projectId: string, onChange: (p: Plan) => void }) {
const [delegating, setDelegating] = useState(false);
const openTasks = plan.tasks.filter(t => t.status === "open");
const handleDelegate = async () => {
if (!confirm("Start the background runner to build this feature? You can safely close your browser while it works.")) return;
setDelegating(true);
try {
const r = await fetch(`/api/projects/${projectId}/agent/sessions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
appName: "frontend",
appPath: ".",
task: "Execute all open tasks in the Execution Plan sequentially.",
}),
});
if (!r.ok) {
const err = await r.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${r.status}`);
}
alert("Background runner started successfully!");
} catch (err) {
alert(`Failed to start runner: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setDelegating(false);
}
};
return (
<div className="panel-container">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 24 }}>
<div>
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, margin: 0, color: INK.main }}>Execution Plan</h2>
<p style={{ color: INK.muted, fontSize: "0.85rem", margin: "4px 0 0" }}>The prioritized roadmap for the AI background runner to execute.</p>
</div>
<button className="btn-primary" onClick={handleDelegate} disabled={delegating || openTasks.length === 0}>
{delegating ? "Starting Jarvis..." : "Delegate Build"}
</button>
</div>
<div style={{ display: "grid", gap: 12 }}>
{openTasks.length > 0 ? openTasks.map(t => (
<div key={t.id} style={{
border: `1px solid ${INK.border}`,
borderRadius: 8,
padding: 16,
background: "#fff",
display: "flex",
justifyContent: "space-between",
alignItems: "center"
}}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ width: 16, height: 16, borderRadius: "50%", border: `2px solid ${INK.muted}` }} />
<div>
<div style={{ fontWeight: 600, fontSize: "0.95rem", color: INK.main }}>{t.title}</div>
{t.description && <div style={{ fontSize: "0.8rem", color: INK.muted, marginTop: 4 }}>{t.description}</div>}
</div>
</div>
<span style={{ fontSize: "0.8rem", color: INK.faint, background: INK.bgHover, padding: "4px 8px", borderRadius: 4 }}>Queued</span>
</div>
)) : (
<div style={{ padding: 40, textAlign: "center", border: `1px solid ${INK.border}`, borderRadius: 8, color: INK.muted }}>
<ListTodo size={32} style={{ margin: "0 auto 16px", opacity: 0.5 }} />
<p style={{ fontWeight: 500 }}>No tasks queued.</p>
<p style={{ fontSize: "0.85rem", maxWidth: 300, margin: "8px auto 0" }}>Use Architect mode to generate the task breakdown.</p>
</div>
)}
</div>
</div>
);
}
// ── Shared UI Components ──────────────────────────────────────────────────────
function TabButton({ active, onClick, icon, label }: { active: boolean, onClick: () => void, icon: React.ReactNode, label: string }) {
return (
<button
onClick={onClick}
style={{
display: "flex", alignItems: "center", gap: 6,
padding: "10px 16px",
background: "transparent",
border: "none",
borderBottom: active ? `2px solid ${INK.main}` : "2px solid transparent",
color: active ? INK.main : INK.muted,
fontWeight: active ? 600 : 500,
fontSize: "0.9rem",
cursor: "pointer",
transition: "all 0.15s ease",
marginBottom: -1 // Overlap the container's bottom border
}}
>
{icon}
{label}
</button>
);
}
// Global styles injected here for the prototype so it renders cleanly
const styleTag = `
.btn-primary, .btn-secondary, .btn-ghost {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
padding: 8px 16px; border-radius: 6px; font-size: 0.85rem; font-weight: 500;
cursor: pointer; transition: all 0.15s ease; border: 1px solid transparent;
}
.btn-primary { background: #1a1918; color: white; }
.btn-primary:hover { background: #333; }
.btn-secondary { background: #fff; border-color: #e8e4dc; color: #1a1918; }
.btn-secondary:hover { background: #f8f6f2; }
.btn-ghost { background: transparent; color: #6b6560; }
.btn-ghost:hover { background: #f8f6f2; color: #1a1918; }
.markdown-prose h1 { font-size: 1.5rem; font-weight: 700; margin-top: 0; }
.markdown-prose h2 { font-size: 1.25rem; font-weight: 600; margin-top: 1.5rem; }
.markdown-prose p { margin-top: 0.5rem; margin-bottom: 1rem; line-height: 1.6; }
.markdown-prose ul { padding-left: 1.5rem; margin-bottom: 1rem; }
.markdown-prose li { margin-bottom: 0.25rem; }
`;
if (typeof document !== "undefined" && !document.getElementById("plan-v2-styles")) {
const style = document.createElement("style");
style.id = "plan-v2-styles";
style.innerHTML = styleTag;
document.head.appendChild(style);
}