450 lines
18 KiB
TypeScript
450 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 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);
|
|
}
|