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 d54fa05..cf9d3ac 100644 --- a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/plan/page.tsx +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/plan/page.tsx @@ -1,2441 +1,450 @@ "use client"; -/** - * 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 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 { - Loader2, - AlertCircle, - MessageSquare, - ListTodo, - GitBranch, - Target, - Plus, - Trash2, - Check, - RotateCcw, - Pencil, - X, - Eye, - FileText, -} from "lucide-react"; +import useSWR from "swr"; -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 { +// Types mapping to our Postgres plan shape +type Plan = { vision?: string; brief?: 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[]; -} + tasks: Array<{ id: string; title: string; description?: string; status: string }>; + decisions: Array<{ id: string; title: string; choice: string; why?: string }>; +}; -interface Session { - id: string; - title: string; - summary: string | null; - messageCount: number; - updatedAt: string; - createdAt: string; -} +type Tab = "objective" | "prd" | "delegate"; -type Section = "objective" | "sessions" | "tasks" | "decisions"; +// Shared Theme Variables +const INK = { + main: "#1a1918", + muted: "#6b6560", + faint: "#a09a90", + border: "#e8e4dc", + bg: "#ffffff", + bgHover: "#f8f6f2", +}; -interface SectionDef { - key: Section; - label: string; - icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>; - blurb: string; -} +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; +}; -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() { +export default function PlanPageV2() { const params = useParams(); - const projectId = params.projectId as string; - const workspace = params.workspace as string; + const projectId = (params?.projectId as string) || ""; - 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 [activeTab, setActiveTab] = useState("objective"); - 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]); + 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); }; - 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) { + if (!plan && !error) { return ( -
-
- Loading plan… -
+
+
); } + if (error || !plan) { return ( -
-
- - {error ?? "No plan data"} -
+
+ {error || "Failed to load plan"}
); } - const counts: Record = { - objective: plan.vision ? 1 : 0, - sessions: sessionCount, - tasks: plan.tasks.filter((t) => t.status !== "done").length, - decisions: plan.decisions.length, - }; - return ( -
- -
- +
+ + {/* ── HEADER & TABS ── */} +
-
-
- {active === "objective" && ( - - )} - {active === "sessions" && ( - - )} - {active === "tasks" && ( - - )} - {active === "decisions" && ( - - )} -
-
+ +
+ setActiveTab("objective")} icon={} label="Objective" /> + setActiveTab("prd")} icon={} label="PRD" /> + setActiveTab("delegate")} icon={} label="Delegate" /> +
+ + {/* ── 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. + */} +
+ +
+ +
+ +
+ +
+ +
+
); } // ────────────────────────────────────────────────── -// Left rail tile +// 1. OBJECTIVE VIEW +// The business case / 1-page summary // ────────────────────────────────────────────────── - -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 ?? ""); +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 [editing, setEditing] = useState(!plan.brief); - const [editorView, setEditorView] = useState<"write" | "preview">("write"); - const [dirty, setDirty] = useState(false); - + const [generating, setGenerating] = useState(false); + useEffect(() => { - setDraft(plan.brief ?? ""); - setDirty(false); - }, [plan.brief]); + if (plan.vision !== draft) { + setDraft(plan.vision ?? ""); + } + }, [plan.vision]); - const handleSave = async () => { + const save = async (text: string) => { setSaving(true); try { - const res = await fetch(`/api/projects/${projectId}/plan`, { + const r = await fetch(`/api/projects/${projectId}/plan`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ kind: "brief", text: draft }), + body: JSON.stringify({ kind: "vision", text }), }); - if (res.ok) { - onChange({ ...plan, brief: draft }); - setDirty(false); - setEditing(false); - } + const d = await r.json(); + if (d.plan) onChange(d.plan); } finally { setSaving(false); } }; - 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" ? ( -