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
This commit is contained in:
2026-04-22 18:05:01 -07:00
parent d6c87a052e
commit 651ddf1e11
105 changed files with 7509 additions and 2319 deletions

View File

@@ -0,0 +1,507 @@
"use client";
import { useEffect, useState, type CSSProperties } from "react";
import { useParams } from "next/navigation";
// Maps each PRD section to the discovery phase that populates it
const PRD_SECTIONS = [
{ id: "executive_summary", label: "Executive Summary", phaseId: "big_picture" },
{ id: "problem_statement", label: "Problem Statement", phaseId: "big_picture" },
{ id: "vision_metrics", label: "Vision & Success Metrics", phaseId: "big_picture" },
{ id: "users_personas", label: "Users & Personas", phaseId: "users_personas" },
{ id: "user_flows", label: "User Flows", phaseId: "users_personas" },
{ id: "feature_requirements", label: "Feature Requirements", phaseId: "features_scope" },
{ id: "screen_specs", label: "Screen Specs", phaseId: "screens_data" },
{ id: "business_model", label: "Business Model", phaseId: "business_model" },
{ id: "integrations", label: "Integrations & Dependencies", phaseId: "features_scope" },
{ id: "non_functional", label: "Non-Functional Reqs", phaseId: "features_scope" },
{ id: "risks", label: "Risks & Mitigations", phaseId: "risks_questions" },
{ id: "open_questions", label: "Open Questions", phaseId: "risks_questions" },
];
interface SavedPhase {
phase: string;
title: string;
summary: string;
data: Record<string, unknown>;
saved_at: string;
}
function formatValue(v: unknown): string {
if (v === null || v === undefined) return "—";
if (Array.isArray(v)) return v.map(item => typeof item === "object" ? JSON.stringify(item) : String(item)).join(", ");
return String(v);
}
function PhaseDataCard({ phase }: { phase: SavedPhase }) {
const [expanded, setExpanded] = useState(false);
const entries = Object.entries(phase.data).filter(([, v]) => v !== null && v !== undefined && v !== "");
return (
<div style={{
marginTop: 10, background: "#f6f4f0", borderRadius: 8,
border: "1px solid #e8e4dc", overflow: "hidden",
}}>
<button
onClick={() => setExpanded(e => !e)}
style={{
width: "100%", textAlign: "left", padding: "10px 14px",
background: "none", border: "none", cursor: "pointer",
display: "flex", alignItems: "center", justifyContent: "space-between",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
}}
>
<span style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.45 }}>
{phase.summary}
</span>
<span style={{ fontSize: "0.7rem", color: "#a09a90", marginLeft: 8, flexShrink: 0 }}>
{expanded ? "▲" : "▼"}
</span>
</button>
{expanded && entries.length > 0 && (
<div style={{ padding: "4px 14px 14px", borderTop: "1px solid #e8e4dc" }}>
{entries.map(([k, v]) => (
<div key={k} style={{ marginTop: 10 }}>
<div style={{
fontSize: "0.6rem", color: "#b5b0a6", textTransform: "uppercase",
letterSpacing: "0.06em", fontWeight: 600, marginBottom: 2,
}}>
{k.replace(/_/g, " ")}
</div>
<div style={{ fontSize: "0.78rem", color: "#2a2824", lineHeight: 1.5 }}>
{formatValue(v)}
</div>
</div>
))}
</div>
)}
</div>
);
}
interface ArchApp { name: string; type: string; description: string; tech?: string[]; screens?: string[] }
interface ArchInfra { name: string; reason: string }
interface ArchPackage { name: string; description: string }
interface ArchIntegration { name: string; required?: boolean; notes?: string }
interface Architecture {
productName?: string;
productType?: string;
summary?: string;
apps?: ArchApp[];
packages?: ArchPackage[];
infrastructure?: ArchInfra[];
integrations?: ArchIntegration[];
designSurfaces?: string[];
riskNotes?: string[];
}
function ArchitectureView({ arch }: { arch: Architecture }) {
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 10 }}>{title}</div>
{children}
</div>
);
const Card = ({ children }: { children: React.ReactNode }) => (
<div style={{ background: "#fff", border: "1px solid #e8e4dc", borderRadius: 9, padding: "14px 16px", marginBottom: 8 }}>{children}</div>
);
const Tag = ({ label }: { label: string }) => (
<span style={{ background: "#f0ece4", borderRadius: 4, padding: "2px 7px", fontSize: "0.68rem", color: "#6b6560", fontFamily: "IBM Plex Mono, monospace", marginRight: 4, display: "inline-block", marginBottom: 3 }}>{label}</span>
);
return (
<div style={{ maxWidth: 760 }}>
{arch.summary && (
<div style={{ background: "#1a1a1a", borderRadius: 10, padding: "18px 22px", marginBottom: 24, color: "#e8e4dc", fontSize: "0.88rem", lineHeight: 1.7 }}>
{arch.summary}
</div>
)}
{(arch.apps ?? []).length > 0 && (
<Section title="Applications">
{arch.apps!.map(a => (
<Card key={a.name}>
<div style={{ display: "flex", alignItems: "baseline", gap: 8, marginBottom: 4 }}>
<span style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a" }}>{a.name}</span>
<span style={{ fontSize: "0.72rem", color: "#9a9490" }}>{a.type}</span>
</div>
<div style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.55, marginBottom: a.tech?.length ? 8 : 0 }}>{a.description}</div>
{a.tech?.map(t => <Tag key={t} label={t} />)}
{a.screens && a.screens.length > 0 && (
<div style={{ marginTop: 6, fontSize: "0.72rem", color: "#a09a90" }}>Screens: {a.screens.join(", ")}</div>
)}
</Card>
))}
</Section>
)}
{(arch.packages ?? []).length > 0 && (
<Section title="Shared Packages">
{arch.packages!.map(p => (
<Card key={p.name}>
<div style={{ display: "flex", gap: 8, alignItems: "baseline" }}>
<span style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "IBM Plex Mono, monospace" }}>{p.name}</span>
<span style={{ fontSize: "0.78rem", color: "#4a4640" }}>{p.description}</span>
</div>
</Card>
))}
</Section>
)}
{(arch.infrastructure ?? []).length > 0 && (
<Section title="Infrastructure">
{arch.infrastructure!.map(i => (
<Card key={i.name}>
<div style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 3 }}>{i.name}</div>
<div style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.5 }}>{i.reason}</div>
</Card>
))}
</Section>
)}
{(arch.integrations ?? []).length > 0 && (
<Section title="Integrations">
{arch.integrations!.map(i => (
<Card key={i.name}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: i.notes ? 4 : 0 }}>
<span style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a" }}>{i.name}</span>
{i.required && <span style={{ fontSize: "0.62rem", background: "#fef3c7", color: "#92400e", padding: "1px 6px", borderRadius: 4 }}>required</span>}
</div>
{i.notes && <div style={{ fontSize: "0.78rem", color: "#4a4640" }}>{i.notes}</div>}
</Card>
))}
</Section>
)}
{(arch.riskNotes ?? []).length > 0 && (
<Section title="Architectural Risks">
{arch.riskNotes!.map((r, i) => (
<div key={i} style={{ display: "flex", gap: 10, alignItems: "flex-start", marginBottom: 8 }}>
<span style={{ fontSize: "0.72rem", color: "#d97706", marginTop: 2, flexShrink: 0 }}></span>
<span style={{ fontSize: "0.82rem", color: "#4a4640", lineHeight: 1.5 }}>{r}</span>
</div>
))}
</Section>
)}
</div>
);
}
export default function TasksPage() {
const params = useParams();
const projectId = params.projectId as string;
const workspace = params.workspace as string;
const [prd, setPrd] = useState<string | null>(null);
const [architecture, setArchitecture] = useState<Architecture | null>(null);
const [savedPhases, setSavedPhases] = useState<SavedPhase[]>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<"prd" | "architecture">("prd");
const [archGenerating, setArchGenerating] = useState(false);
const [archError, setArchError] = useState<string | null>(null);
useEffect(() => {
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]) => {
setPrd(projectData?.project?.prd ?? null);
setArchitecture(projectData?.project?.architecture ?? null);
setSavedPhases(phaseData?.phases ?? []);
setLoading(false);
});
}, [projectId]);
const handleGenerateArchitecture = async () => {
setArchGenerating(true);
setArchError(null);
try {
const res = await fetch(`/api/projects/${projectId}/architecture`, { method: "POST" });
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Generation failed");
setArchitecture(data.architecture);
setActiveTab("architecture");
} catch (e) {
setArchError(e instanceof Error ? e.message : "Something went wrong");
} finally {
setArchGenerating(false);
}
};
const phaseMap = new Map(savedPhases.map(p => [p.phase, p]));
const savedPhaseIds = new Set(savedPhases.map(p => p.phase));
const sections = PRD_SECTIONS.map(s => ({
...s,
savedPhase: s.phaseId ? phaseMap.get(s.phaseId) ?? null : null,
isDone: s.phaseId ? savedPhaseIds.has(s.phaseId) : false,
}));
const doneCount = sections.filter(s => s.isDone).length;
const totalPct = Math.round((doneCount / sections.length) * 100);
if (loading) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#a09a90" }}>
Loading tasks
</div>
);
}
const reqStatus = prd
? "Complete"
: doneCount > 0
? `In progress · ${doneCount}/${sections.length} sections`
: "Not started";
const archStatus = architecture
? "Complete"
: prd
? "Ready to generate"
: "Blocked — finish requirements first";
const taskCardBase: CSSProperties = {
flex: "1 1 240px",
maxWidth: 320,
textAlign: "left" as const,
padding: "14px 16px",
borderRadius: 10,
cursor: "pointer",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
transition: "border-color 0.12s, box-shadow 0.12s",
};
return (
<div style={{ padding: "28px 32px", flex: 1, overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
<header style={{ marginBottom: 24, maxWidth: 720 }}>
<h1 style={{
fontFamily: "var(--font-lora), ui-serif, serif",
fontSize: "1.35rem",
fontWeight: 500,
color: "#1a1a1a",
margin: "0 0 8px",
}}>
Tasks
</h1>
<p style={{ fontSize: "0.82rem", color: "#6b6560", lineHeight: 1.55, margin: 0 }}>
Work is tracked as taskssimilar in spirit to agent task boards like{" "}
<a href="https://github.com/777genius/claude_agent_teams_ui" target="_blank" rel="noopener noreferrer" style={{ color: "#4a4640" }}>
Claude Agent Teams UI
</a>
. Your <strong>product requirements (PRD)</strong> is the first task; technical architecture is the next once requirements are captured.
</p>
</header>
{/* Task selector — PRD is a task; architecture is a follow-on task */}
<div style={{ display: "flex", gap: 12, marginBottom: 28, flexWrap: "wrap" }}>
<button
type="button"
onClick={() => setActiveTab("prd")}
style={{
...taskCardBase,
border: activeTab === "prd" ? "2px solid #1a1a1a" : "1px solid #e8e4dc",
background: activeTab === "prd" ? "#faf8f5" : "#fff",
boxShadow: activeTab === "prd" ? "0 2px 8px #1a1a1a0a" : "0 1px 2px #1a1a1a05",
}}
>
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>
Product requirements
</div>
<div style={{ fontSize: "0.72rem", color: "#888780", lineHeight: 1.4 }}>
PRD · {reqStatus}
</div>
</button>
<button
type="button"
onClick={() => architecture && setActiveTab("architecture")}
disabled={!architecture}
style={{
...taskCardBase,
border: activeTab === "architecture" ? "2px solid #1a1a1a" : "1px solid #e8e4dc",
background: activeTab === "architecture" ? "#faf8f5" : "#fff",
boxShadow: activeTab === "architecture" ? "0 2px 8px #1a1a1a0a" : "0 1px 2px #1a1a1a05",
opacity: architecture ? 1 : 0.72,
cursor: architecture ? "pointer" : "not-allowed",
}}
>
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>
Technical architecture
</div>
<div style={{ fontSize: "0.72rem", color: "#888780", lineHeight: 1.4 }}>
{archStatus}
</div>
</button>
</div>
{/* Next step banner — PRD done but no architecture yet */}
{prd && !architecture && activeTab === "prd" && (
<div style={{
marginBottom: 24, padding: "18px 22px",
background: "#1a1a1a", borderRadius: 10,
display: "flex", alignItems: "center", justifyContent: "space-between",
gap: 16, flexWrap: "wrap",
}}>
<div>
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#fff", marginBottom: 4 }}>
Next: Generate technical architecture
</div>
<div style={{ fontSize: "0.76rem", color: "#a09a90", lineHeight: 1.5 }}>
The AI will read your PRD and recommend the apps, services, and infrastructure your product needs. Takes ~30 seconds.
</div>
{archError && (
<div style={{ fontSize: "0.74rem", color: "#f87171", marginTop: 6 }}> {archError}</div>
)}
</div>
<button
onClick={handleGenerateArchitecture}
disabled={archGenerating}
style={{
padding: "10px 20px", borderRadius: 8, border: "none",
background: archGenerating ? "#4a4640" : "#fff",
color: archGenerating ? "#a09a90" : "#1a1a1a",
fontSize: "0.82rem", fontWeight: 700,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
cursor: archGenerating ? "default" : "pointer",
flexShrink: 0, display: "flex", alignItems: "center", gap: 8,
transition: "opacity 0.15s",
}}
>
{archGenerating && (
<span style={{
width: 12, height: 12, borderRadius: "50%",
border: "2px solid #60606040", borderTopColor: "#a09a90",
animation: "spin 0.7s linear infinite", display: "inline-block",
}} />
)}
{archGenerating ? "Analysing PRD…" : "Generate architecture →"}
</button>
</div>
)}
{/* Architecture tab */}
{activeTab === "architecture" && architecture && (
<ArchitectureView arch={architecture} />
)}
{/* PRD tab — finalized */}
{activeTab === "prd" && prd && (
<div style={{ maxWidth: 760 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20 }}>
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", margin: 0 }}>
Product Requirements
</h3>
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.72rem", color: "#6b6560", background: "#f0ece4", padding: "4px 10px", borderRadius: 5 }}>
PRD complete
</span>
</div>
<div style={{
background: "#fff", borderRadius: 10, border: "1px solid #e8e4dc",
padding: "28px 32px", lineHeight: 1.8,
fontSize: "0.88rem", color: "#2a2824",
whiteSpace: "pre-wrap", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
}}>
{prd}
</div>
</div>
)}
{/* PRD tab — section progress (no finalized PRD yet) */}
{activeTab === "prd" && !prd && (
/* ── Section progress view ── */
<div style={{ maxWidth: 680 }}>
{/* Progress bar */}
<div style={{
display: "flex", alignItems: "center", gap: 16,
padding: "16px 20px", background: "#fff",
border: "1px solid #e8e4dc", borderRadius: 10,
marginBottom: 24, boxShadow: "0 1px 2px #1a1a1a05",
}}>
<div style={{
fontFamily: "IBM Plex Mono, monospace",
fontSize: "1.4rem", fontWeight: 500, color: "#1a1a1a", minWidth: 52,
}}>
{totalPct}%
</div>
<div style={{ flex: 1 }}>
<div style={{ height: 4, borderRadius: 2, background: "#eae6de" }}>
<div style={{
height: "100%", borderRadius: 2,
width: `${totalPct}%`, background: "#1a1a1a",
transition: "width 0.6s ease",
}} />
</div>
</div>
<span style={{ fontSize: "0.75rem", color: "#a09a90" }}>
{doneCount}/{sections.length} sections
</span>
</div>
{/* Sections */}
{sections.map((s, i) => (
<div
key={s.id}
style={{
padding: "14px 18px", marginBottom: 6,
background: "#fff", borderRadius: 10,
border: `1px solid ${s.isDone ? "#a5d6a740" : "#e8e4dc"}`,
animationDelay: `${i * 0.04}s`,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{/* Status icon */}
<div style={{
width: 24, height: 24, borderRadius: 6, flexShrink: 0,
background: s.isDone ? "#2e7d3210" : "#f6f4f0",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.65rem", fontWeight: 700,
color: s.isDone ? "#2e7d32" : "#c5c0b8",
}}>
{s.isDone ? "✓" : "○"}
</div>
<span style={{
flex: 1, fontSize: "0.84rem",
color: s.isDone ? "#1a1a1a" : "#a09a90",
fontWeight: s.isDone ? 500 : 400,
}}>
{s.label}
</span>
{s.isDone && s.savedPhase && (
<span style={{
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
color: "#2e7d32", background: "#2e7d3210",
padding: "2px 7px", borderRadius: 4, fontWeight: 500,
}}>
saved
</span>
)}
{!s.isDone && !s.phaseId && (
<span style={{
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
color: "#b5b0a6", padding: "2px 7px",
}}>
generated
</span>
)}
</div>
{/* Expandable phase data */}
{s.isDone && s.savedPhase && (
<PhaseDataCard phase={s.savedPhase} />
)}
{/* Pending hint */}
{!s.isDone && (
<div style={{ marginTop: 6, marginLeft: 36, fontSize: "0.72rem", color: "#c5c0b8" }}>
{s.phaseId
? `Complete the ${s.savedPhase ? s.savedPhase.title : "discovery"} phase in Vibn`
: "Will be generated when PRD is finalized"}
</div>
)}
</div>
))}
{doneCount === 0 && (
<p style={{ fontSize: "0.78rem", color: "#b5b0a6", marginTop: 20, textAlign: "center" }}>
Continue chatting with Vibn saved phases will appear here automatically.
</p>
)}
</div>
)}
</div>
);
}