Files
vibn-frontend/components/project-main/BuildLivePlanPanel.tsx
Mark Henderson 651ddf1e11 Rip out Theia, ship P5.1 attach E2E + Justine UI work-in-progress
Theia rip-out:
- Delete app/api/theia-auth/route.ts (Traefik ForwardAuth shim)
- Delete app/api/projects/[projectId]/workspace/route.ts and
  app/api/projects/prewarm/route.ts (Cloud Run Theia provisioning)
- Delete lib/cloud-run-workspace.ts and lib/coolify-workspace.ts
- Strip provisionTheiaWorkspace + theiaWorkspaceUrl/theiaAppUuid/
  theiaError from app/api/projects/create/route.ts response
- Remove Theia callbackUrl branch in app/auth/page.tsx
- Drop "Open in Theia" button + xterm/Theia PTY copy in build/page.tsx
- Drop theiaWorkspaceUrl from deployment/page.tsx Project type
- Strip Theia IDE line + theia-code-os from advisor + agent-chat
  context strings
- Scrub Theia mention from lib/auth/workspace-auth.ts comment

P5.1 (custom apex domains + DNS):
- lib/coolify.ts + lib/opensrs.ts: nameserver normalization, OpenSRS
  XML auth, Cloud DNS plumbing
- scripts/smoke-attach-e2e.ts: full prod GCP + sandbox OpenSRS +
  prod Coolify smoke covering register/zone/A/NS/PATCH/cleanup

In-progress (Justine onboarding/build, MVP setup, agent telemetry):
- New (justine)/stories, project (home) layouts, mvp-setup, run, tasks
  routes + supporting components
- Project shell + sidebar + nav refactor for the Stackless palette
- Agent session API hardening (sessions, events, stream, approve,
  retry, stop) + atlas-chat, advisor, design-surfaces refresh
- New scripts/sync-db-url-from-coolify.mjs +
  scripts/prisma-db-push.mjs + docker-compose.local-db.yml for
  local Prisma workflows
- lib/dev-bypass.ts, lib/chat-context-refs.ts, lib/prd-sections.ts
- Misc: stories CSS, debug/prisma route, modal-theme, BuildLivePlanPanel

Made-with: Cursor
2026-04-22 18:05:01 -07:00

287 lines
9.8 KiB
TypeScript

"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { JM, JV } from "@/components/project-creation/modal-theme";
import { PRD_PLAN_SECTIONS, isSectionFilled } from "@/lib/prd-sections";
import {
type ChatContextRef,
contextRefKey,
} from "@/lib/chat-context-refs";
interface SavedPhase {
phase: string;
title: string;
summary: string;
data: Record<string, unknown>;
saved_at: string;
}
export function BuildLivePlanPanel({
projectId,
workspace,
chatContextRefs,
onAddSectionRef,
compactHeader,
}: {
projectId: string;
workspace: string;
chatContextRefs: ChatContextRef[];
onAddSectionRef: (label: string, phaseId: string | null) => void;
/** When true, hide subtitle to save space in narrow tabs */
compactHeader?: boolean;
}) {
const [prdText, setPrdText] = useState<string | null>(null);
const [savedPhases, setSavedPhases] = useState<SavedPhase[]>([]);
const [loading, setLoading] = useState(true);
const refresh = useCallback(() => {
Promise.all([
fetch(`/api/projects/${projectId}`).then(r => r.json()).catch(() => ({})),
fetch(`/api/projects/${projectId}/save-phase`).then(r => r.json()).catch(() => ({ phases: [] })),
]).then(([projectData, phaseData]) => {
setPrdText(projectData?.project?.prd ?? null);
setSavedPhases(phaseData?.phases ?? []);
setLoading(false);
});
}, [projectId]);
useEffect(() => {
refresh();
const t = setInterval(refresh, 8000);
return () => clearInterval(t);
}, [refresh]);
const savedPhaseIds = useMemo(() => new Set(savedPhases.map(p => p.phase)), [savedPhases]);
const phaseMap = useMemo(() => new Map(savedPhases.map(p => [p.phase, p])), [savedPhases]);
const rows = useMemo(() => {
let firstOpenIndex = -1;
const list = PRD_PLAN_SECTIONS.map((s, index) => {
const done = isSectionFilled(s.phaseId, savedPhaseIds);
if (!done && firstOpenIndex < 0) firstOpenIndex = index;
return {
...s,
done,
active: !done && index === firstOpenIndex,
pending: !done && index > firstOpenIndex,
savedPhase: s.phaseId ? phaseMap.get(s.phaseId) ?? null : null,
};
});
return list;
}, [savedPhaseIds, phaseMap]);
const doneCount = rows.filter(r => r.done).length;
const tasksHref = `/${workspace}/project/${projectId}/tasks`;
const attached = useCallback(
(label: string) => chatContextRefs.some(r => r.kind === "section" && r.label === label),
[chatContextRefs]
);
if (loading) {
return (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: JV.prdPanelBg,
color: JM.muted,
fontSize: 13,
fontFamily: JM.fontSans,
}}
>
Loading plan
</div>
);
}
if (prdText) {
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
background: JV.prdPanelBg,
borderLeft: `1px solid ${JM.border}`,
fontFamily: JM.fontSans,
}}
>
<div style={{ padding: "16px 16px 12px", borderBottom: `1px solid ${JM.border}`, flexShrink: 0 }}>
<div style={{ fontSize: 10.5, fontWeight: 700, color: JM.muted, textTransform: "uppercase", letterSpacing: "0.06em" }}>
Your plan
</div>
<div style={{ fontSize: 15, fontWeight: 700, color: JM.ink, marginTop: 4 }}>PRD ready</div>
{!compactHeader && (
<div style={{ fontSize: 12, color: JM.muted, marginTop: 4, lineHeight: 1.45 }}>
Full document saved open Task to edit or keep refining in chat.
</div>
)}
</div>
<div style={{ flex: 1, padding: 16, overflow: "auto" }}>
<Link
href={tasksHref}
style={{
display: "block",
textAlign: "center",
padding: "11px 14px",
borderRadius: 10,
background: JM.primaryGradient,
color: "#fff",
fontSize: 13,
fontWeight: 600,
textDecoration: "none",
boxShadow: JM.primaryShadow,
}}
>
View full PRD
</Link>
</div>
</div>
);
}
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
background: JV.prdPanelBg,
borderLeft: `1px solid ${JM.border}`,
fontFamily: JM.fontSans,
minWidth: 0,
}}
>
<style>{`
@keyframes buildPlanFadeUp {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
<div style={{ padding: "16px 16px 10px", borderBottom: `1px solid ${JM.border}`, flexShrink: 0 }}>
<div style={{ fontSize: 10.5, fontWeight: 700, color: JM.muted, textTransform: "uppercase", letterSpacing: "0.06em" }}>
Your plan
</div>
<div style={{ fontSize: 15, fontWeight: 700, color: JM.ink, marginTop: 4 }}>Fills as you chat</div>
{!compactHeader && (
<div style={{ fontSize: 12, color: JM.muted, marginTop: 4, lineHeight: 1.45 }}>
Tap a section to attach it your next message prioritizes it.
</div>
)}
<div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 12 }}>
<div style={{ fontSize: 22, fontWeight: 700, color: JM.indigo, minWidth: 40 }}>{Math.round((doneCount / rows.length) * 100)}%</div>
<div style={{ flex: 1 }}>
<div style={{ height: 4, borderRadius: 2, background: "#e0e7ff" }}>
<div
style={{
height: "100%",
borderRadius: 2,
width: `${(doneCount / rows.length) * 100}%`,
background: JM.primaryGradient,
transition: "width 0.5s ease",
}}
/>
</div>
</div>
<span style={{ fontSize: 11, color: JM.muted, whiteSpace: "nowrap" }}>
{doneCount}/{rows.length}
</span>
</div>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: "12px 14px 8px" }}>
{rows.map((r, i) => {
const isAttached = attached(r.label);
const hint = r.done && r.savedPhase?.summary
? r.savedPhase.summary.slice(0, 120) + (r.savedPhase.summary.length > 120 ? "…" : "")
: r.active
? "Answer in chat — this block updates when the phase saves."
: "Tap to attach — chat uses this section as context.";
return (
<button
key={r.id}
type="button"
onClick={() => onAddSectionRef(r.label, r.phaseId)}
style={{
display: "block",
width: "100%",
textAlign: "left",
padding: "11px 12px",
marginBottom: 8,
borderRadius: 9,
border: r.active ? `1px solid ${JV.bubbleAiBorder}` : `1px solid ${JM.border}`,
borderLeftWidth: r.active ? 3 : 1,
borderLeftColor: r.active ? JM.indigo : JM.border,
background: r.active ? "#fafaff" : r.done ? "#fff" : "#fff",
opacity: r.pending && !r.done ? 0.55 : 1,
borderStyle: r.pending && !r.done ? "dashed" : "solid",
cursor: "pointer",
boxShadow: r.active ? "0 0 0 3px rgba(99,102,241,0.08), 0 2px 12px rgba(99,102,241,0.07)" : "0 1px 8px rgba(99,102,241,0.05)",
animation: `buildPlanFadeUp 0.35s ease ${i * 0.02}s both`,
fontFamily: JM.fontSans,
}}
>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 8 }}>
<div style={{ fontSize: 9.5, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase", color: r.done ? JM.mid : JM.muted }}>
{r.label}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
{r.done && <span style={{ fontSize: 10, color: JM.indigo, fontWeight: 700 }}></span>}
{isAttached && <span style={{ fontSize: 9, fontWeight: 600, color: JM.indigo, background: JV.violetTint, padding: "2px 6px", borderRadius: 4 }}>Attached</span>}
</div>
</div>
<div style={{ fontSize: 12, lineHeight: 1.5, color: r.done ? JM.ink : JM.muted, marginTop: 4 }}>{hint}</div>
</button>
);
})}
</div>
<div
style={{
flexShrink: 0,
padding: "10px 14px 14px",
borderTop: `1px solid ${JM.border}`,
display: "flex",
flexDirection: "column",
gap: 8,
alignItems: "stretch",
}}
>
<Link
href={tasksHref}
style={{
textAlign: "center",
padding: "10px 14px",
borderRadius: 9,
fontSize: 13,
fontWeight: 600,
color: JM.indigo,
background: "#eef2ff",
border: `1px solid ${JV.bubbleAiBorder}`,
textDecoration: "none",
}}
>
Open requirements view
</Link>
</div>
</div>
);
}
export function addSectionContextRef(
prev: ChatContextRef[],
label: string,
phaseId: string | null
): ChatContextRef[] {
const next: ChatContextRef = { kind: "section", label, phaseId };
const k = contextRefKey(next);
if (prev.some(r => contextRefKey(r) === k)) return prev;
return [...prev, next];
}