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,586 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useSession } from "next-auth/react";
import { Plus_Jakarta_Sans } from "next/font/google";
import { Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { ProjectCreationModal } from "@/components/project-creation-modal";
import { isClientDevProjectBypass } from "@/lib/dev-bypass";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
const justineJakarta = Plus_Jakarta_Sans({
subsets: ["latin"],
weight: ["400", "500", "600", "700", "800"],
variable: "--font-justine-jakarta",
display: "swap",
});
interface ProjectWithStats {
id: string;
productName: string;
productVision?: string;
status?: string;
updatedAt: string | null;
stats: { sessions: number; costs: number };
}
const ICON_BG = ["#6366F1", "#8B5CF6", "#06B6D4", "#EC4899", "#9CA3AF"];
function timeAgo(dateStr?: string | null): string {
if (!dateStr) return "—";
const date = new Date(dateStr);
if (isNaN(date.getTime())) return "—";
const diff = (Date.now() - date.getTime()) / 1000;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
const days = Math.floor(diff / 86400);
if (days === 1) return "Yesterday";
if (days < 7) return `${days}d ago`;
if (days < 30) return `${Math.floor(days / 7)}w ago`;
return `${Math.floor(days / 30)}mo ago`;
}
function greetingName(session: { user?: { name?: string | null; email?: string | null } } | null): string {
const n = session?.user?.name?.trim();
if (n) return n.split(/\s+/)[0] ?? "there";
const e = session?.user?.email;
if (e) return e.split("@")[0] ?? "there";
return "there";
}
function greetingPrefix(): string {
const h = new Date().getHours();
if (h < 12) return "Good morning";
if (h < 17) return "Good afternoon";
return "Good evening";
}
function StatusPill({ status }: { status?: string }) {
if (status === "live") {
return (
<span className="pill pill-live">
<span className="dot-live" />
Live
</span>
);
}
if (status === "building") {
return (
<span className="pill pill-building">
<span className="dot-building" />
Building
</span>
);
}
return <span className="pill pill-draft">Defining</span>;
}
export function JustineWorkspaceProjectsDashboard({ workspace }: { workspace: string }) {
const { data: session, status: authStatus } = useSession();
const [projects, setProjects] = useState<ProjectWithStats[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [showNew, setShowNew] = useState(false);
const [projectToDelete, setProjectToDelete] = useState<ProjectWithStats | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [theme, setTheme] = useState<"light" | "dark">("light");
useEffect(() => {
try {
const t = localStorage.getItem("jd-dashboard-theme");
if (t === "dark") setTheme("dark");
} catch {
/* ignore */
}
}, []);
const toggleTheme = useCallback(() => {
setTheme((prev) => {
const next = prev === "light" ? "dark" : "light";
try {
localStorage.setItem("jd-dashboard-theme", next);
} catch {
/* ignore */
}
return next;
});
}, []);
const fetchProjects = useCallback(async () => {
try {
setLoading(true);
const res = await fetch("/api/projects");
if (!res.ok) throw new Error("Failed");
const data = await res.json();
setProjects(data.projects ?? []);
} catch {
/* silent */
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (isClientDevProjectBypass()) {
void fetchProjects();
return;
}
if (authStatus === "authenticated") fetchProjects();
else if (authStatus === "unauthenticated") setLoading(false);
}, [authStatus, fetchProjects]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return projects;
return projects.filter((p) => p.productName.toLowerCase().includes(q));
}, [projects, search]);
const liveN = projects.filter((p) => p.status === "live").length;
const buildingN = projects.filter((p) => p.status === "building").length;
const totalCosts = projects.reduce((s, p) => s + (p.stats?.costs ?? 0), 0);
const userInitial =
session?.user?.name?.[0]?.toUpperCase() ?? session?.user?.email?.[0]?.toUpperCase() ?? "?";
const displayName = session?.user?.name?.trim() || session?.user?.email?.split("@")[0] || "Account";
const handleDelete = async () => {
if (!projectToDelete) return;
setIsDeleting(true);
try {
const res = await fetch("/api/projects/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId: projectToDelete.id }),
});
if (res.ok) {
toast.success("Project deleted");
setProjectToDelete(null);
fetchProjects();
} else {
const err = await res.json();
toast.error(err.error || "Failed to delete");
}
} catch {
toast.error("An error occurred");
} finally {
setIsDeleting(false);
}
};
const firstName = greetingName(session);
return (
<div
data-justine-dashboard
data-theme={theme === "dark" ? "dark" : undefined}
className={`${justineJakarta.variable} justine-dashboard-root`}
>
<nav
className="jd-topnav"
style={{
background: "rgba(250,250,250,0.97)",
borderBottom: "1px solid #E5E7EB",
height: 56,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 24px",
flexShrink: 0,
backdropFilter: "blur(8px)",
}}
>
<Link href="/" style={{ display: "flex", alignItems: "center", gap: 9, textDecoration: "none" }}>
<div
style={{
width: 27,
height: 27,
background: "linear-gradient(135deg,#2E2A5E,#4338CA)",
borderRadius: 6,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<span className="f" style={{ fontSize: 13, fontWeight: 700, color: "#FFFFFF" }}>
V
</span>
</div>
<span className="f" style={{ fontSize: 17, fontWeight: 700, color: "var(--ink)", letterSpacing: "-0.02em" }}>
vibn
</span>
</Link>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<button type="button" className="btn-secondary mob-hide" onClick={toggleTheme} style={{ fontSize: 12.5, padding: "7px 14px" }}>
{theme === "dark" ? "☀️ Light" : "🌙 Dark"}
</button>
<Link
href={`/${workspace}/settings`}
style={{
display: "flex",
alignItems: "center",
gap: 8,
cursor: "pointer",
padding: "5px 8px",
borderRadius: 8,
border: "none",
background: "transparent",
fontFamily: "var(--sans)",
textDecoration: "none",
}}
>
<div
style={{
width: 29,
height: 29,
borderRadius: "50%",
background: "#6366F1",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
color: "#FFFFFF",
fontWeight: 600,
flexShrink: 0,
}}
>
{userInitial}
</div>
<div className="mob-hide" style={{ textAlign: "left" }}>
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--ink)", lineHeight: 1.2 }}>{displayName}</div>
<div style={{ fontSize: 10.5, color: "var(--muted)" }}>Workspace · {workspace}</div>
</div>
</Link>
</div>
</nav>
<div className="app-shell">
<aside className="proj-nav">
<div style={{ flexShrink: 0 }}>
<div style={{ padding: "10px 8px 8px" }}>
<Link
href={`/${workspace}/projects`}
id="nav-dashboard"
style={{
display: "flex",
alignItems: "center",
gap: 11,
padding: "11px 12px",
borderRadius: 10,
border: "none",
background: "rgba(255,255,255,0.55)",
cursor: "pointer",
width: "100%",
textAlign: "left",
fontFamily: "var(--sans)",
transition: "background 0.18s ease",
backdropFilter: "blur(4px)",
textDecoration: "none",
}}
>
<div
style={{
width: 32,
height: 32,
borderRadius: 8,
background: "linear-gradient(135deg,#4338CA,#6366F1)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 15,
flexShrink: 0,
color: "#FFFFFF",
}}
>
</div>
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: "var(--ink)" }}>Dashboard</div>
<div style={{ fontSize: 10.5, color: "var(--muted)" }}>Overview</div>
</div>
</Link>
</div>
<div style={{ padding: "10px 8px 8px", marginTop: 2 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0 4px", marginBottom: 8 }}>
<div className="nav-group-label" style={{ margin: 0 }}>
Projects
</div>
<button
type="button"
onClick={() => setShowNew(true)}
style={{
background: "none",
border: "none",
cursor: "pointer",
fontSize: 18,
color: "var(--indigo)",
padding: "0 4px",
lineHeight: 1,
fontWeight: 300,
}}
title="New project"
>
+
</button>
</div>
<div className="nav-search-wrap">
<svg
style={{ position: "absolute", left: 9, top: "50%", transform: "translateY(-50%)", pointerEvents: "none" }}
width={12}
height={12}
viewBox="0 0 20 20"
fill="none"
aria-hidden
>
<path
d="M8.5 3a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 8.5a6.5 6.5 0 1111.436 4.23l3.857 3.857a.75.75 0 01-1.06 1.06l-3.857-3.857A6.5 6.5 0 012 8.5z"
fill="#9CA3AF"
/>
</svg>
<input
className="nav-search"
type="search"
placeholder="Search projects…"
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label="Search projects"
/>
</div>
</div>
</div>
<div className="proj-list">
{loading && (
<div style={{ display: "flex", justifyContent: "center", padding: 24 }}>
<Loader2 style={{ width: 22, height: 22, color: "var(--muted)" }} className="animate-spin" />
</div>
)}
{!loading && filtered.length === 0 && projects.length === 0 && (
<div style={{ padding: "20px 8px 12px", textAlign: "center" }}>
<div
style={{
width: 36,
height: 36,
borderRadius: 10,
background: "var(--indigo-dim)",
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: "0 auto 10px",
fontSize: 18,
}}
>
</div>
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--ink)", marginBottom: 4 }}>No projects yet</div>
<div style={{ fontSize: 11, color: "var(--muted)", marginBottom: 12, lineHeight: 1.5 }}>
Start building your first product with vibn.
</div>
<button type="button" className="btn-primary" style={{ fontSize: 11.5, padding: "7px 14px", width: "100%" }} onClick={() => setShowNew(true)}>
+ Create first project
</button>
</div>
)}
{!loading && projects.length > 0 && filtered.length === 0 && (
<div style={{ padding: "16px 12px", textAlign: "center", fontSize: 12, color: "var(--muted)" }}>No projects match your search.</div>
)}
{!loading &&
filtered.map((p, i) => (
<div key={p.id} className="proj-row">
<Link
href={`/${workspace}/project/${p.id}`}
style={{ flex: 1, minWidth: 0, display: "flex", alignItems: "flex-start", gap: 10, textDecoration: "none", color: "inherit" }}
>
<div
className="proj-icon"
style={{
background: ICON_BG[i % ICON_BG.length],
}}
>
{(p.productName[0] ?? "P").toUpperCase()}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="proj-row-name">
{p.productName} <StatusPill status={p.status} />
</div>
<div className="proj-row-metric" style={{ fontWeight: 400, color: "var(--muted)" }}>
{p.productVision ? `${p.productVision.slice(0, 42)}${p.productVision.length > 42 ? "…" : ""}` : "Personal"}
</div>
<div className="proj-row-time">{timeAgo(p.updatedAt)}</div>
</div>
</Link>
<button
type="button"
className="proj-edit-btn"
title="Delete project"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setProjectToDelete(p);
}}
>
<Trash2 style={{ width: 13, height: 13 }} />
</button>
</div>
))}
</div>
<div style={{ flex: 1, padding: "12px 8px 16px", display: "flex", flexDirection: "column" }}>
<div className="nav-group-label" style={{ marginBottom: 4 }}>
Workspace
</div>
<Link href={`/${workspace}/activity`} className="nav-item-btn" style={{ textDecoration: "none", color: "inherit" }}>
<div className="nav-icon"></div>
<div>
<div className="nav-label">Activity</div>
<div className="nav-sub">Timeline & runs</div>
</div>
</Link>
<Link href={`/${workspace}/settings`} className="nav-item-btn" style={{ textDecoration: "none", color: "inherit" }}>
<div className="nav-icon"></div>
<div className="nav-label">Settings</div>
</Link>
<div style={{ height: 1, background: "var(--border)", margin: "8px 4px" }} />
<div className="nav-group-label" style={{ marginBottom: 4 }}>
Account
</div>
<button type="button" className="nav-item-btn" onClick={() => toast.message("Help — docs coming soon.")}>
<div className="nav-icon">?</div>
<div className="nav-label">Help</div>
</button>
</div>
</aside>
<main className="workspace">
<div id="ws-dashboard" className="ws-section active">
<div className="ws-inner">
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 20, marginBottom: 28 }}>
<div>
<h1 className="f" style={{ fontSize: 28, fontWeight: 700, color: "var(--ink)", letterSpacing: "-0.03em", marginBottom: 7 }}>
{greetingPrefix()}, {firstName}.
</h1>
<p style={{ fontSize: 14, color: "var(--muted)", lineHeight: 1.5 }}>
Open a project from the sidebar or start a new one.
</p>
</div>
<div className="dash-header-actions" style={{ display: "flex", gap: 9, alignItems: "center", flexShrink: 0, paddingTop: 4 }}>
<button type="button" className="btn-primary" onClick={() => setShowNew(true)}>
+ New project
</button>
</div>
</div>
{!loading && projects.length === 0 && (
<div style={{ marginBottom: 36 }}>
<div
style={{
background: "var(--white)",
border: "1px solid var(--border)",
borderRadius: 16,
padding: "48px 36px",
textAlign: "center",
maxWidth: 480,
margin: "0 auto",
}}
>
<div
style={{
width: 56,
height: 56,
borderRadius: 14,
background: "linear-gradient(135deg,rgba(99,102,241,0.12),rgba(139,92,246,0.12))",
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: "0 auto 20px",
fontSize: 26,
}}
>
</div>
<h2 className="f" style={{ fontSize: 20, fontWeight: 700, color: "var(--ink)", marginBottom: 8, letterSpacing: "-0.02em" }}>
Build your first product
</h2>
<p style={{ fontSize: 14, color: "var(--muted)", lineHeight: 1.6, marginBottom: 24 }}>
Describe your idea, and vibn will architect, design, and help you ship it no code required.
</p>
<button type="button" className="btn-primary" style={{ padding: "11px 28px", fontSize: 14 }} onClick={() => setShowNew(true)}>
+ Start a new project
</button>
</div>
</div>
)}
<div className="dash-section-title">Portfolio snapshot</div>
<div className="snap-grid">
<div className="snap-card">
<div className="snap-value">{projects.length}</div>
<div className="snap-label">Active projects</div>
</div>
<div className="snap-card">
<div className="snap-value" style={{ color: "var(--green)" }}>
{liveN}
</div>
<div className="snap-label">Live products</div>
</div>
<div className="snap-card">
<div className="snap-value" style={{ color: "#4338CA" }}>
{buildingN}
</div>
<div className="snap-label">Building now</div>
</div>
<div className="snap-card" style={{ borderColor: "var(--amber-border)", background: "var(--amber-dim)" }}>
<div className="snap-value" style={{ color: "#92400E" }}>
{totalCosts > 0 ? `$${totalCosts.toFixed(2)}` : "—"}
</div>
<div className="snap-label" style={{ color: "#B45309" }}>
API spend (est.)
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<ProjectCreationModal
open={showNew}
onOpenChange={(open) => {
setShowNew(open);
if (!open) fetchProjects();
}}
workspace={workspace}
/>
<AlertDialog open={!!projectToDelete} onOpenChange={(open) => !open && setProjectToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete &quot;{projectToDelete?.productName}&quot;?</AlertDialogTitle>
<AlertDialogDescription>
This will remove the project record. Sessions will be preserved but unlinked. The Gitea repo will not be deleted automatically.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isDeleting} className="bg-red-600 hover:bg-red-700">
{isDeleting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Trash2 className="mr-2 h-4 w-4" />}
Delete project
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}