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
287 lines
9.8 KiB
TypeScript
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];
|
|
}
|