From 602debfc823a5584d8b079cfc680a1fe5d02a4b7 Mon Sep 17 00:00:00 2001 From: mawkone Date: Tue, 19 May 2026 19:54:54 -0700 Subject: [PATCH] fix(ui): re-add remarkGfm plugin to markdown renderer for PRD tab --- .../project/[projectId]/(home)/plan/page.tsx | 2707 ++++++++++++++--- 1 file changed, 2349 insertions(+), 358 deletions(-) diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/plan/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/plan/page.tsx index afc4e82a..d54fa053 100644 --- a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/plan/page.tsx +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/plan/page.tsx @@ -1,450 +1,2441 @@ "use client"; -import React, { useState, useEffect, useCallback } from "react"; +/** + * Plan tab — the home of the project's thinking. + * + * Sub-areas (always visible, even when empty, so a founder learns the + * model on a brand-new project): + * 1. Vision — the elevator pitch + audience + * 2. Ideas — the "park-it" bin for raw thoughts + * 3. Tasks — what needs to happen next (open / done) + * 4. Decisions — log of "we chose X over Y because Z" + * + * Same UI rhythm as Infrastructure: section tiles in a horizontal bar + * (with counts), full-width detail panel below. Everything writes through + * `/api/projects/[projectId]/plan` and persists under + * `fs_projects.data.plan`. The AI chat reads the plan as context so + * decisions don't get re-litigated. + */ + +import { useCallback, useEffect, useState } 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"; +import { + Loader2, + AlertCircle, + MessageSquare, + ListTodo, + GitBranch, + Target, + Plus, + Trash2, + Check, + RotateCcw, + Pencil, + X, + Eye, + FileText, +} from "lucide-react"; -// Types mapping to our Postgres plan shape -type Plan = { +interface Idea { + id: string; + text: string; + createdAt: string; +} +type TaskStatus = "open" | "in_progress" | "review" | "done" | "blocked"; +interface Task { + id: string; + title: string; + description?: string; + status: TaskStatus; + agent?: { + runId: string; + startedAt: string; + finishedAt?: string; + status: "queued" | "running" | "succeeded" | "failed"; + } | null; + createdAt: string; + startedAt?: string; + doneAt?: string; + // Legacy single-line tasks (pre-markdown migration) carry text instead of title. + text?: string; +} +interface Decision { + id: string; + title: string; + choice: string; + why?: string; + createdAt: string; +} +interface 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 }>; -}; + brief_filename?: string; // stored as `vision` server-side, surfaced as "Objective" in the UI + ideas: Idea[]; // legacy bin — no longer surfaced; kept on the model for backward compat + tasks: Task[]; + decisions: Decision[]; +} -type Tab = "objective" | "prd" | "delegate"; +interface Session { + id: string; + title: string; + summary: string | null; + messageCount: number; + updatedAt: string; + createdAt: string; +} -// Shared Theme Variables -const INK = { - main: "#1a1918", - muted: "#6b6560", - faint: "#a09a90", - border: "#e8e4dc", - bg: "#ffffff", - bgHover: "#f8f6f2", -}; +type Section = "objective" | "sessions" | "tasks" | "decisions"; -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; -}; +interface SectionDef { + key: Section; + label: string; + icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>; + blurb: string; +} -export default function PlanPageV2() { +const SECTIONS: SectionDef[] = [ + { + key: "objective", + label: "Objective", + icon: Target, + blurb: "What you're building, who it's for, and why.", + }, + { + key: "tasks", + label: "Features", + icon: ListTodo, + blurb: "High-level capabilities to be delegated to the AI.", + }, + { + key: "decisions", + label: "Decisions", + icon: GitBranch, + blurb: "Choices already made — so we stop re-litigating them.", + }, + { + key: "sessions", + label: "Sessions", + icon: MessageSquare, + blurb: "Past chat sessions for this project.", + }, +]; + +// ────────────────────────────────────────────────── +// Page +// ────────────────────────────────────────────────── + +export default function PlanTab() { const params = useParams(); - const projectId = (params?.projectId as string) || ""; + const projectId = params.projectId as string; + const workspace = params.workspace as string; - const [activeTab, setActiveTab] = useState("objective"); + const [plan, setPlan] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [active, setActive] = useState
("objective"); + const [sessionCount, setSessionCount] = useState(0); - const { data: plan, error, mutate: mutatePlan } = useSWR( - 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); }; + const load = useCallback(async () => { + try { + const res = await fetch(`/api/projects/${projectId}/plan`, { + credentials: "include", + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + setPlan(data.plan); + setError(null); + } catch (e: any) { + setError(e?.message ?? "Failed to load plan"); + } finally { + setLoading(false); + } + }, [projectId]); - if (!plan && !error) { + useEffect(() => { + load(); + // Only poll the plan API periodically if we aren't currently editing or saving + // something, otherwise we risk overwriting user input or causing layout thrashing. + const intervalId = setInterval(load, 15000); // Backed off from 5s to 15s to reduce API spam + return () => clearInterval(intervalId); + }, [load]); + + if (loading && !plan) { return ( -
- -
- ); - } - - if (error || !plan) { - return ( -
- {error || "Failed to load plan"} -
- ); - } - - return ( -
- - {/* ── HEADER & TABS ── */} -
- - -
- setActiveTab("objective")} icon={} label="Objective" /> - setActiveTab("prd")} icon={} label="PRD" /> - setActiveTab("delegate")} icon={} label="Delegate" /> +
+
+ Loading plan…
- - {/* ── 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. - */} -
- -
- -
- + ); + } + if (error || !plan) { + return ( +
+
+ + {error ?? "No plan data"} +
+ ); + } -
- -
+ const counts: Record = { + objective: plan.vision ? 1 : 0, + sessions: sessionCount, + tasks: plan.tasks.filter((t) => t.status !== "done").length, + decisions: plan.decisions.length, + }; + return ( +
+ +
+ + +
+
+ {active === "objective" && ( + + )} + {active === "sessions" && ( + + )} + {active === "tasks" && ( + + )} + {active === "decisions" && ( + + )} +
+
+
); } // ────────────────────────────────────────────────── -// 1. OBJECTIVE VIEW -// The business case / 1-page summary +// Left rail tile // ────────────────────────────────────────────────── -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) => { +function SectionTile({ + def, + count, + active, + onClick, +}: { + def: SectionDef; + count: number; + active: boolean; + onClick: () => void; +}) { + const Icon = def.icon; + return ( + + ); +} + +// ────────────────────────────────────────────────── +// Vision +// ────────────────────────────────────────────────── + +function BriefPanel({ + plan, + projectId, + onChange, +}: { + plan: Plan; + projectId: string; + onChange: (p: Plan) => void; +}) { + const [draft, setDraft] = useState(plan.brief ?? ""); + const [saving, setSaving] = useState(false); + const [editing, setEditing] = useState(!plan.brief); + const [editorView, setEditorView] = useState<"write" | "preview">("write"); + const [dirty, setDirty] = useState(false); + + useEffect(() => { + setDraft(plan.brief ?? ""); + setDirty(false); + }, [plan.brief]); + + const handleSave = async () => { setSaving(true); try { - const r = await fetch(`/api/projects/${projectId}/plan`, { + const res = await fetch(`/api/projects/${projectId}/plan`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ kind: "vision", text }), + body: JSON.stringify({ kind: "brief", text: draft }), }); - const d = await r.json(); - if (d.plan) onChange(d.plan); + if (res.ok) { + onChange({ ...plan, brief: draft }); + setDirty(false); + setEditing(false); + } } 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); + const handleFileUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // For now we just read text files (txt, md, csv). + // In the future you can send PDFs to an extraction API. + const reader = new FileReader(); + reader.onload = async (event) => { + const content = event.target?.result as string; + setDraft(content); + setDirty(true); + setEditing(true); + }; + reader.readAsText(file); + }; + + if (!editing && plan.brief) { + return ( +
+ +
+ ( +

+ ), + h1: ({ node, ...props }) => ( +

+ ), + h2: ({ node, ...props }) => ( +

+ ), + h3: ({ node, ...props }) => ( +

+ ), + ul: ({ node, ...props }) => ( +
    + ), + ol: ({ node, ...props }) => ( +
      + ), + li: ({ node, ...props }) => ( +
    1. + ), + }} + > + {plan.brief} + +

+
+ ); + } + + return ( +
+ {!plan.brief && ( +
+

+ No project brief uploaded. You can paste your PRD, requirements + document, or notes below. The AI will read this to understand the + full scope of your project. +

+
+ + +
+
+ )} + + {editing && ( +
+
+ + +
+
+ + {editorView === "write" ? ( +