Files
vibn-frontend/app/[workspace]/project/[projectId]/(workspace)/build/page.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

1627 lines
81 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { Suspense, useState, useEffect, useCallback, useRef } from "react";
import { useParams, useSearchParams, useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { isClientDevProjectBypass } from "@/lib/dev-bypass";
import Link from "next/link";
import { JM } from "@/components/project-creation/modal-theme";
import { AtlasChat } from "@/components/AtlasChat";
import { PRD_PLAN_SECTIONS } from "@/lib/prd-sections";
import {
type ChatContextRef,
contextRefKey,
} from "@/lib/chat-context-refs";
// ── Types ─────────────────────────────────────────────────────────────────────
interface AppEntry { name: string; path: string; }
interface SurfaceEntry { id: string; label: string; lockedTheme?: string; }
interface FileItem { name: string; path: string; type: "file" | "dir" | "symlink"; }
interface TreeNode {
name: string; path: string; type: "file" | "dir";
children?: TreeNode[]; expanded?: boolean; loaded?: boolean;
}
// ── Constants ─────────────────────────────────────────────────────────────────
const SURFACE_LABELS: Record<string, string> = {
webapp: "Web App", marketing: "Marketing Site", admin: "Admin Panel",
};
const SURFACE_ICONS: Record<string, string> = {
webapp: "◈", marketing: "◌", admin: "◫",
};
/** Growth pagestyle left rail: cream panel, icon + label rows */
const BUILD_LEFT_BG = "#faf8f5";
const BUILD_LEFT_BORDER = "#e8e4dc";
const BUILD_NAV_GROUP: React.CSSProperties = {
fontSize: "0.6rem",
fontWeight: 700,
color: "#b5b0a6",
letterSpacing: "0.09em",
textTransform: "uppercase",
padding: "14px 12px 6px",
fontFamily: JM.fontSans,
};
const BUILD_PRIMARY = [
{ id: "chat", label: "Chat", icon: "◆" },
{ id: "code", label: "Code", icon: "◇" },
{ id: "layouts", label: "Layouts", icon: "◈" },
{ id: "tasks", label: "Agent", icon: "◎" },
{ id: "preview", label: "Preview", icon: "▢" },
] as const;
function BuildGrowthNavRow({
icon,
label,
active,
onClick,
}: {
icon: string;
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
textAlign: "left",
background: active ? "#f0ece4" : "transparent",
border: "none",
cursor: "pointer",
padding: "6px 12px",
borderRadius: 5,
fontSize: "0.78rem",
fontWeight: active ? 600 : 440,
color: active ? "#1a1a1a" : "#5a5550",
fontFamily: JM.fontSans,
}}
onMouseEnter={e => {
if (!active) (e.currentTarget as HTMLElement).style.background = "#f6f4f0";
}}
onMouseLeave={e => {
if (!active) (e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
<span style={{ fontSize: "0.65rem", opacity: 0.55, width: 14, textAlign: "center", flexShrink: 0 }}>
{icon}
</span>
{label}
</button>
);
}
// ── Language / syntax helpers ─────────────────────────────────────────────────
function langFromName(name: string): string {
const ext = name.split(".").pop()?.toLowerCase() ?? "";
const map: Record<string, string> = {
ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript",
json: "json", md: "markdown", css: "css", scss: "css", html: "html",
py: "python", sh: "shell", yaml: "yaml", yml: "yaml",
toml: "toml", prisma: "prisma", sql: "sql",
};
return map[ext] ?? "text";
}
function highlightCode(code: string, lang: string): React.ReactNode[] {
return code.split("\n").map((line, i) => {
const commentPrefixes = ["//", "#", "--"];
if (commentPrefixes.some(p => line.trimStart().startsWith(p))) {
return <div key={i}><span style={{ color: "#6a9955" }}>{line}</span></div>;
}
const kwRe = /\b(import|export|from|const|let|var|function|return|if|else|async|await|type|interface|class|extends|new|default|null|undefined|true|false|void|string|number|boolean|React)\b/g;
const parts = line.split(kwRe);
const tokens = parts.map((p, j) => {
if (!p) return null;
if (/^(import|export|from|const|let|var|function|return|if|else|async|await|type|interface|class|extends|new|default|null|undefined|true|false|void|string|number|boolean|React)$/.test(p))
return <span key={j} style={{ color: "#569cd6" }}>{p}</span>;
return <span key={j}>{p}</span>;
});
return <div key={i} style={{ minHeight: "1.4em" }}>{tokens.length ? tokens : "\u00a0"}</div>;
});
}
// ── File tree row ─────────────────────────────────────────────────────────────
function TreeRow({ node, depth, selectedPath, onSelect, onToggle }: {
node: TreeNode; depth: number; selectedPath: string | null;
onSelect: (p: string) => void; onToggle: (p: string) => void;
}) {
const active = selectedPath === node.path;
const isDir = node.type === "dir";
const ext = node.name.split(".").pop()?.toLowerCase() ?? "";
const fileColor = ext === "tsx" || ext === "ts" ? "#3178c6" : ext === "jsx" || ext === "js" ? "#f0db4f"
: ext === "css" || ext === "scss" ? "#e879f9" : "#b5b0a6";
return (
<>
<button onClick={() => isDir ? onToggle(node.path) : onSelect(node.path)} style={{
display: "flex", alignItems: "center", gap: 5, width: "100%", textAlign: "left",
background: active ? "#eceae5" : "transparent", border: "none", cursor: "pointer",
padding: `4px 8px 4px ${10 + depth * 12}px`, borderRadius: 4,
fontFamily: "IBM Plex Mono, monospace", fontSize: "0.72rem",
color: active ? "#1a1a1a" : "#4a4640",
}}
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "#f0ece4"; }}
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
{isDir
? <span style={{ fontSize: "0.48rem", color: "#a09a90", display: "inline-block", transition: "transform 0.1s", transform: node.expanded ? "rotate(90deg)" : "none" }}></span>
: <span style={{ color: fileColor, fontSize: "0.65rem" }}></span>}
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{node.name}</span>
</button>
{isDir && node.expanded && node.children?.map(c =>
<TreeRow key={c.path} node={c} depth={depth + 1} selectedPath={selectedPath} onSelect={onSelect} onToggle={onToggle} />
)}
</>
);
}
// ── Left nav shared styles ────────────────────────────────────────────────────
const NAV_GROUP_LABEL: React.CSSProperties = {
fontSize: "0.6rem", fontWeight: 700, color: JM.muted,
letterSpacing: "0.09em", textTransform: "uppercase",
padding: "12px 12px 5px", fontFamily: JM.fontSans,
};
// ── Placeholder panel ─────────────────────────────────────────────────────────
function Placeholder({ icon, title, desc }: { icon: string; title: string; desc: string }) {
return (
<div style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 14, padding: 60, textAlign: "center" }}>
<div style={{ width: 52, height: 52, borderRadius: 13, background: "#f0ece4", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "1.4rem", color: "#b5b0a6" }}>{icon}</div>
<div>
<div style={{ fontSize: "0.9rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6 }}>{title}</div>
<div style={{ fontSize: "0.8rem", color: "#a09a90", maxWidth: 320, lineHeight: 1.6 }}>{desc}</div>
</div>
<div style={{ marginTop: 4, padding: "7px 18px", background: "#1a1a1a", color: "#fff", borderRadius: 7, fontSize: "0.77rem", fontWeight: 500, opacity: 0.35 }}>Coming soon</div>
</div>
);
}
// ── Layouts content ───────────────────────────────────────────────────────────
function LayoutsContent({ surfaces, projectId, workspace, activeSurfaceId, onSelectSurface }: {
surfaces: SurfaceEntry[]; projectId: string; workspace: string;
activeSurfaceId: string | null; onSelectSurface: (id: string) => void;
}) {
if (surfaces.length === 0) {
return (
<Placeholder icon="◌" title="No design surfaces yet" desc="Add design surfaces from the Design page to configure your web app, marketing site, or admin panel." />
);
}
const active = surfaces.find(s => s.id === activeSurfaceId) ?? surfaces[0];
return (
<div style={{ flex: 1, display: "flex", flexDirection: "column", padding: "24px 28px", gap: 20 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div style={{ fontSize: "0.68rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Layouts</div>
<Link href={`/${workspace}/project/${projectId}/design?surface=${active?.id ?? ""}`} style={{ fontSize: "0.72rem", color: "#a09a90", textDecoration: "none", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Edit in Design </Link>
</div>
<div style={{ display: "flex", gap: 14, flexWrap: "wrap" }}>
{surfaces.map(s => (
<div key={s.id} onClick={() => onSelectSurface(s.id)} style={{
background: active?.id === s.id ? "#fff" : "#faf8f5",
border: `1px solid ${active?.id === s.id ? "#1a1a1a" : "#e8e4dc"}`,
borderRadius: 10, padding: "16px 20px", cursor: "pointer",
minWidth: 180, flex: "1 1 180px", maxWidth: 240,
transition: "border-color 0.1s",
}}>
<div style={{ fontSize: "0.85rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", marginBottom: 4 }}>
{SURFACE_LABELS[s.id] ?? s.id}
</div>
{s.lockedTheme ? (
<div style={{ fontSize: "0.72rem", color: "#6b6560", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Theme: {s.lockedTheme}</div>
) : (
<div style={{ fontSize: "0.72rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontStyle: "italic" }}>Not configured</div>
)}
</div>
))}
</div>
<div style={{ fontSize: "0.75rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
Click a surface to select it, then open the Design editor to configure themes, fonts, and components.
</div>
</div>
);
}
// ── Shared mode tab bar ───────────────────────────────────────────────────────
// ── Agent mode ────────────────────────────────────────────────────────────────
interface AgentSession {
id: string;
app_name: string;
task: string;
status: "pending" | "running" | "done" | "approved" | "failed" | "stopped";
output: Array<{ ts: string; type: string; text: string }>;
changed_files: Array<{ path: string; status: string }>;
error: string | null;
created_at: string;
started_at: string | null;
completed_at: string | null;
}
const STATUS_COLORS: Record<string, string> = {
running: "#3d5afe", done: "#2e7d32", approved: "#1b5e20",
failed: "#c62828", stopped: "#a09a90", pending: "#d4a04a",
};
const STATUS_LABELS: Record<string, string> = {
running: "Running", done: "Done", approved: "Shipped",
failed: "Failed", stopped: "Stopped", pending: "Starting…",
};
const FILE_STATUS_COLORS: Record<string, string> = { added: "#2e7d32", modified: "#d4a04a", deleted: "#c62828" };
/** Maps persisted / SSE agent event row to terminal line (output.* mirrors legacy outputLine types). */
interface AgentLogLine {
seq?: number;
ts: string;
type: string;
text: string;
}
interface StreamEventRow {
seq: number;
ts: string;
type: string;
payload?: Record<string, unknown>;
}
function streamRowToLine(e: StreamEventRow): AgentLogLine {
const text =
typeof e.payload?.text === "string"
? e.payload.text
: "";
if (e.type.startsWith("output.")) {
const sub = e.type.slice("output.".length);
return { seq: e.seq, ts: e.ts, type: sub, text: text || e.type };
}
return { seq: e.seq, ts: e.ts, type: "info", text: text || e.type };
}
function elapsed(start: string | null): string {
if (!start) return "";
const s = Math.floor((Date.now() - new Date(start).getTime()) / 1000);
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`;
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
}
function AgentMode({ projectId, appName, appPath }: { projectId: string; appName: string; appPath: string }) {
const [task, setTask] = useState("");
const [sessions, setSessions] = useState<AgentSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [activeSession, setActiveSession] = useState<AgentSession | null>(null);
const [eventLog, setEventLog] = useState<AgentLogLine[]>([]);
const maxStreamSeqRef = useRef(0);
const pollOutputRef = useRef<AgentSession["output"]>([]);
const [submitting, setSubmitting] = useState(false);
const [loadingSessions, setLoadingSessions] = useState(true);
const [approving, setApproving] = useState(false);
const [approveMsg, setApproveMsg] = useState("");
const [showApproveInput, setShowApproveInput] = useState(false);
const [approveResult, setApproveResult] = useState<string | null>(null);
const [retrying, setRetrying] = useState(false);
const [followUp, setFollowUp] = useState("");
const [showFollowUp, setShowFollowUp] = useState(false);
const outputRef = useCallback((el: HTMLDivElement | null) => {
if (el) el.scrollTop = el.scrollHeight;
}, []);
useEffect(() => {
pollOutputRef.current = activeSession?.output ?? [];
}, [activeSession?.output]);
// Load historical events + live SSE tail (replaces tight polling for log lines when events exist)
useEffect(() => {
if (!activeSessionId || !appName) return;
let cancelled = false;
let es: EventSource | null = null;
(async () => {
setEventLog([]);
maxStreamSeqRef.current = 0;
try {
const evRes = await fetch(
`/api/projects/${projectId}/agent/sessions/${activeSessionId}/events?afterSeq=0`
);
if (evRes.ok) {
const d = (await evRes.json()) as { events?: StreamEventRow[]; maxSeq?: number };
if (!cancelled) {
const mapped = (d.events ?? []).map(streamRowToLine);
setEventLog(mapped);
maxStreamSeqRef.current = typeof d.maxSeq === "number" ? d.maxSeq : 0;
}
}
} catch {
/* migration not applied or network */
}
if (cancelled) return;
let status = "";
try {
const sRes = await fetch(`/api/projects/${projectId}/agent/sessions/${activeSessionId}`);
const sJson = (await sRes.json()) as { session?: { status: string } };
status = sJson.session?.status ?? "";
} catch {
return;
}
if (status !== "running" && status !== "pending") return;
const url = `/api/projects/${projectId}/agent/sessions/${activeSessionId}/events/stream?afterSeq=${maxStreamSeqRef.current}`;
es = new EventSource(url);
es.onmessage = (msg: MessageEvent<string>) => {
try {
const data = JSON.parse(msg.data) as Record<string, unknown>;
if (data.type === "_heartbeat") return;
if (data.type === "_stream.end" || data.type === "_stream.error") {
es?.close();
return;
}
if (typeof data.seq !== "number") return;
maxStreamSeqRef.current = data.seq as number;
const line = streamRowToLine(data as StreamEventRow);
setEventLog((prev) => {
if (prev.some((p) => p.seq === line.seq)) return prev;
if (prev.length === 0 && pollOutputRef.current.length > 0) {
const seed = pollOutputRef.current.map((l, i) => ({
ts: l.ts,
type: l.type,
text: l.text,
seq: -(i + 1),
}));
return [...seed, line];
}
return [...prev, line];
});
} catch {
/* ignore malformed */
}
};
es.onerror = () => {
es?.close();
};
})();
return () => {
cancelled = true;
es?.close();
};
}, [activeSessionId, projectId, appName]);
// Load session list — auto-select the most recent active or last session
useEffect(() => {
if (!appName) return;
setLoadingSessions(true);
fetch(`/api/projects/${projectId}/agent/sessions`)
.then(r => r.json())
.then(d => {
const list: AgentSession[] = d.sessions ?? [];
setSessions(list);
setLoadingSessions(false);
// Auto-select: prefer live session, then the most recent
if (list.length > 0 && !activeSessionId) {
const live = list.find(s => ["running", "pending"].includes(s.status));
const pick = live ?? list[0];
setActiveSessionId(pick.id);
setActiveSession(pick);
}
})
.catch(() => setLoadingSessions(false));
}, [projectId, appName]); // eslint-disable-line react-hooks/exhaustive-deps
// Adaptive polling — 500ms while running, 5s when idle
useEffect(() => {
if (!activeSessionId) return;
let cancelled = false;
const poll = async () => {
try {
const r = await fetch(`/api/projects/${projectId}/agent/sessions/${activeSessionId}`);
const d = await r.json();
if (!cancelled && d.session) {
setActiveSession(d.session);
// Refresh session list so status dots update in sidebar
if (!["running", "pending"].includes(d.session.status)) {
fetch(`/api/projects/${projectId}/agent/sessions`)
.then(r2 => r2.json())
.then(d2 => { if (!cancelled) setSessions(d2.sessions ?? []); })
.catch(() => {});
}
}
} catch { /* network hiccup — ignore */ }
};
poll();
const isLive = ["running", "pending"].includes(activeSession?.status ?? "");
const interval = setInterval(poll, isLive ? 500 : 5000);
return () => { cancelled = true; clearInterval(interval); };
}, [activeSessionId, projectId, activeSession?.status]);
const handleRun = async () => {
if (!task.trim() || !appName) return;
setSubmitting(true);
try {
const r = await fetch(`/api/projects/${projectId}/agent/sessions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ appName, appPath, task }),
});
const d = await r.json();
if (d.sessionId) {
setActiveSessionId(d.sessionId);
setTask("");
// Refresh list
const list = await fetch(`/api/projects/${projectId}/agent/sessions`).then(r2 => r2.json());
setSessions(list.sessions ?? []);
}
} finally { setSubmitting(false); }
};
const handleStop = async () => {
if (!activeSessionId) return;
await fetch(`/api/projects/${projectId}/agent/sessions/${activeSessionId}/stop`, { method: "POST" });
setActiveSession(prev => prev ? { ...prev, status: "stopped" } : null);
};
const handleRetry = async (continueTask?: string) => {
if (!activeSessionId) return;
setRetrying(true);
try {
const r = await fetch(`/api/projects/${projectId}/agent/sessions/${activeSessionId}/retry`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ continueTask: continueTask?.trim() || undefined }),
});
const d = await r.json();
if (d.sessionId) {
setActiveSession(prev => prev ? { ...prev, status: "running", output: [], error: null } : null);
setShowFollowUp(false);
setFollowUp("");
}
} finally { setRetrying(false); }
};
const handleApprove = async () => {
if (!activeSessionId || !approveMsg.trim()) return;
setApproving(true);
setApproveResult(null);
try {
const r = await fetch(`/api/projects/${projectId}/agent/sessions/${activeSessionId}/approve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ commitMessage: approveMsg.trim() }),
});
const d = await r.json() as { ok?: boolean; committed?: boolean; deployed?: boolean; message?: string; error?: string };
if (d.ok) {
setApproveResult(d.deployed
? `✓ Committed & deployment triggered — ${d.message}`
: `✓ Committed — ${d.message}`);
setShowApproveInput(false);
setApproveMsg("");
// Refresh session
const s = await fetch(`/api/projects/${projectId}/agent/sessions/${activeSessionId}`).then(r2 => r2.json());
if (s.session) setActiveSession(s.session);
} else {
setApproveResult(`${d.error ?? "Failed to commit"}`);
}
} catch (e) {
setApproveResult("✗ Network error — please try again");
} finally {
setApproving(false);
}
};
if (!appName) {
return (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", flexDirection: "column", gap: 10, padding: 40, textAlign: "center" }}>
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Select an app first</div>
<div style={{ fontSize: "0.78rem", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Choose an app from the left nav to run agent tasks against it.</div>
</div>
);
}
return (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* Horizontal sessions strip */}
<div style={{ flexShrink: 0, height: 38, borderBottom: "1px solid #e8e4dc", background: "#faf8f5", display: "flex", alignItems: "center", gap: 8, padding: "0 16px", overflowX: "auto" }}>
<span style={{ fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6", letterSpacing: "0.09em", textTransform: "uppercase", whiteSpace: "nowrap", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Sessions</span>
<span style={{ width: 1, height: 14, background: "#e0dcd5", flexShrink: 0 }} />
{loadingSessions && <span style={{ fontSize: "0.72rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Loading</span>}
{!loadingSessions && sessions.length === 0 && (
<span style={{ fontSize: "0.72rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", whiteSpace: "nowrap" }}>No sessions yet run your first task below</span>
)}
{sessions.map(s => (
<button key={s.id} onClick={() => { setActiveSessionId(s.id); setActiveSession(s); }} style={{
padding: "3px 10px", border: `1px solid ${activeSessionId === s.id ? "#1a1a1a" : "#e0dcd5"}`,
borderRadius: 20, background: activeSessionId === s.id ? "#1a1a1a" : "transparent",
color: activeSessionId === s.id ? "#fff" : "#5a5550",
fontSize: "0.68rem", cursor: "pointer", whiteSpace: "nowrap",
display: "flex", alignItems: "center", gap: 5,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", flexShrink: 0,
}}>
<span style={{ width: 5, height: 5, borderRadius: "50%", background: activeSessionId === s.id ? "#fff" : (STATUS_COLORS[s.status] ?? "#a09a90"), display: "inline-block", flexShrink: 0 }} />
{s.task.length > 30 ? s.task.slice(0, 30) + "…" : s.task}
</button>
))}
{!loadingSessions && sessions.length > 0 && (
<button onClick={() => { setActiveSession(null); setActiveSessionId(null); setTask(""); }} style={{
marginLeft: "auto", padding: "3px 10px", border: "1px solid #e0dcd5", borderRadius: 20,
background: "transparent", color: "#a09a90", fontSize: "0.68rem", cursor: "pointer",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", whiteSpace: "nowrap", flexShrink: 0,
}}>+ New</button>
)}
</div>
{/* Main panel */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* Active session view */}
{activeSession ? (
<>
{/* Session header */}
<div style={{ padding: "12px 20px", borderBottom: "1px solid #e8e4dc", background: "#fff", display: "flex", alignItems: "center", gap: 12, flexShrink: 0 }}>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: STATUS_COLORS[activeSession.status], flexShrink: 0, display: "inline-block" }} />
<span style={{ fontSize: "0.8rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{activeSession.task}</span>
{activeSession.started_at && (
<span style={{ fontSize: "0.7rem", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", whiteSpace: "nowrap" }}>
{["running", "pending"].includes(activeSession.status) ? `${elapsed(activeSession.started_at)} elapsed` : elapsed(activeSession.started_at)}
</span>
)}
{["running", "pending"].includes(activeSession.status) && (
<button onClick={handleStop} style={{ padding: "4px 12px", background: "#fee2e2", color: "#c62828", border: "1px solid #fca5a5", borderRadius: 5, fontSize: "0.72rem", cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", whiteSpace: "nowrap" }}>
Stop
</button>
)}
</div>
{/* Output stream — prefer persisted event timeline + SSE when available */}
<div ref={outputRef} style={{ flex: 1, overflow: "auto", background: "#1a1a1a", padding: "16px 20px", fontFamily: "IBM Plex Mono, monospace", fontSize: "0.72rem", lineHeight: 1.6 }}>
{(() => {
const displayLines: AgentLogLine[] =
eventLog.length > 0 ? eventLog : activeSession.output.map((l) => ({ ts: l.ts, type: l.type, text: l.text }));
if (displayLines.length === 0) {
return <span style={{ color: "#555" }}>Starting agent</span>;
}
return displayLines.map((line, i) => {
const color = line.type === "error" ? "#f87171" : line.type === "stderr" ? "#fb923c"
: line.type === "info" ? "#60a5fa" : line.type === "step" ? "#a78bfa" : line.type === "done" ? "#86efac" : "#d4d4d4";
const prefix = line.type === "step" ? "▶ " : line.type === "error" ? "✗ "
: line.type === "info" ? "→ " : line.type === "done" ? "✓ " : " ";
return (
<div key={line.seq ?? `o-${i}`} style={{ color, marginBottom: 1 }}>
{prefix}{line.text}
</div>
);
});
})()}
{["running", "pending"].includes(activeSession.status) && (
<span style={{ color: "#555", animation: "pulse 1.5s infinite" }}></span>
)}
</div>
{/* Retry / follow-up panel for failed or stopped sessions */}
{["failed", "stopped"].includes(activeSession.status) && (
<div style={{ borderTop: "1px solid #e8e4dc", background: "#fff", padding: "12px 20px", flexShrink: 0 }}>
{showFollowUp ? (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ fontSize: "0.72rem", color: "#6b6560", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
Add a follow-up instruction (optional) then retry:
</div>
<div style={{ display: "flex", gap: 8, alignItems: "flex-end" }}>
<textarea
autoFocus
value={followUp}
onChange={e => setFollowUp(e.target.value)}
onKeyDown={e => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleRetry(followUp); if (e.key === "Escape") setShowFollowUp(false); }}
placeholder="e.g. Also update the TypeScript types, or just leave blank to retry as-is…"
rows={2}
style={{ flex: 1, resize: "none", border: "1px solid #e8e4dc", borderRadius: 7, padding: "8px 11px", fontSize: "0.78rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", outline: "none", background: "#faf8f5" }}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<button onClick={() => handleRetry(followUp)} disabled={retrying}
style={{ padding: "8px 14px", background: "#1a1a1a", color: "#fff", border: "none", borderRadius: 7, fontSize: "0.75rem", fontWeight: 600, cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", whiteSpace: "nowrap" }}>
{retrying ? "Retrying…" : "Retry"}
</button>
<button onClick={() => setShowFollowUp(false)}
style={{ padding: "6px 14px", background: "transparent", color: "#a09a90", border: "1px solid #e8e4dc", borderRadius: 7, fontSize: "0.73rem", cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
Cancel
</button>
</div>
</div>
<div style={{ fontSize: "0.63rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
to retry · Esc to cancel · Same session, fresh run
</div>
</div>
) : (
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<div style={{ fontSize: "0.75rem", color: activeSession.status === "failed" ? "#c62828" : "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", flex: 1 }}>
{activeSession.status === "failed" ? "Session failed — retry without losing context" : "Session stopped"}
</div>
<button onClick={() => handleRetry()} disabled={retrying}
style={{ padding: "7px 14px", background: "#fef9f0", color: "#92400e", border: "1px solid #fde68a", borderRadius: 7, fontSize: "0.75rem", fontWeight: 600, cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", whiteSpace: "nowrap" }}>
{retrying ? "Retrying…" : "↺ Retry"}
</button>
<button onClick={() => setShowFollowUp(true)}
style={{ padding: "7px 14px", background: "#f0ece4", color: "#1a1a1a", border: "1px solid #e8e4dc", borderRadius: 7, fontSize: "0.75rem", cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", whiteSpace: "nowrap" }}>
Follow up
</button>
</div>
)}
</div>
)}
{/* Changed files */}
{activeSession.changed_files.length > 0 && (
<div style={{ borderTop: "1px solid #e8e4dc", background: "#fff", padding: "12px 20px", flexShrink: 0 }}>
<div style={{ fontSize: "0.65rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", marginBottom: 8 }}>Changed Files</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{activeSession.changed_files.map((f, i) => (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 5, background: "#faf8f5", border: "1px solid #e8e4dc", borderRadius: 5, padding: "3px 8px" }}>
<span style={{ color: FILE_STATUS_COLORS[f.status] ?? "#a09a90", fontSize: "0.65rem", fontWeight: 700 }}>{f.status === "added" ? "+" : f.status === "deleted" ? "" : "~"}</span>
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.7rem", color: "#4a4640" }}>{f.path}</span>
</div>
))}
</div>
{activeSession.status === "approved" && (
<div style={{ marginTop: 8, fontSize: "0.73rem", color: "#1b5e20", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
Auto-committed these changes are live.
</div>
)}
{activeSession.status === "done" && (
<div style={{ marginTop: 12 }}>
{approveResult && (
<div style={{
marginBottom: 10, padding: "8px 12px", borderRadius: 6, fontSize: "0.74rem",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
background: approveResult.startsWith("✓") ? "#f0fdf4" : "#fef2f2",
color: approveResult.startsWith("✓") ? "#166534" : "#991b1b",
border: `1px solid ${approveResult.startsWith("✓") ? "#bbf7d0" : "#fecaca"}`,
}}>
{approveResult}
</div>
)}
{showApproveInput ? (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<input
autoFocus
value={approveMsg}
onChange={e => setApproveMsg(e.target.value)}
onKeyDown={e => { if (e.key === "Enter") handleApprove(); if (e.key === "Escape") setShowApproveInput(false); }}
placeholder="Commit message…"
style={{
width: "100%", padding: "8px 11px", border: "1px solid #e8e4dc",
borderRadius: 6, fontSize: "0.78rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
outline: "none", background: "#faf8f5", boxSizing: "border-box",
}}
/>
<div style={{ display: "flex", gap: 6 }}>
<button
onClick={handleApprove}
disabled={approving || !approveMsg.trim()}
style={{
padding: "7px 16px", background: approveMsg.trim() ? "#1a1a1a" : "#e8e4dc",
color: approveMsg.trim() ? "#fff" : "#a09a90", border: "none", borderRadius: 7,
fontSize: "0.75rem", fontWeight: 600, cursor: approveMsg.trim() ? "pointer" : "default",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
}}
>
{approving ? "Committing…" : "Commit & push"}
</button>
<button onClick={() => setShowApproveInput(false)} style={{ padding: "7px 12px", background: "transparent", color: "#a09a90", border: "1px solid #e8e4dc", borderRadius: 7, fontSize: "0.75rem", cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
Cancel
</button>
</div>
<div style={{ fontSize: "0.65rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
Enter to commit · Esc to cancel · Coolify will auto-deploy after push
</div>
</div>
) : (
<div style={{ display: "flex", gap: 8 }}>
<button
onClick={() => { setShowApproveInput(true); setApproveMsg(`agent: ${activeSession.task.slice(0, 60)}`); }}
style={{ padding: "7px 16px", background: "#1a1a1a", color: "#fff", border: "none", borderRadius: 7, fontSize: "0.75rem", fontWeight: 600, cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
>
Approve &amp; commit
</button>
</div>
)}
</div>
)}
</div>
)}
</>
) : (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "#b5b0a6", fontSize: "0.8rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
Select a session or run a new task
</div>
)}
{/* Task input — context-aware */}
{(() => {
const st = activeSession?.status;
// Agent is actively working — lock input
if (st === "running" || st === "pending") {
return (
<div style={{ borderTop: "1px solid #e8e4dc", background: "#fff", padding: "12px 16px", flexShrink: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#3d5afe", flexShrink: 0, display: "inline-block" }} />
<span style={{ fontSize: "0.78rem", color: "#6b6560", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", flex: 1 }}>
Agent is working wait for it to finish, or stop it above.
</span>
</div>
</div>
);
}
// Session auto-committed — show confirmation, offer next task
if (st === "approved") {
return (
<div style={{ borderTop: "1px solid #e8e4dc", background: "#fff", padding: "12px 16px", flexShrink: 0 }}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<span style={{ fontSize: "0.75rem", color: "#1b5e20", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", flex: 1 }}>
Shipped changes committed and deployment triggered automatically.
</span>
<button onClick={() => setShowFollowUp(true)}
style={{ padding: "7px 14px", background: "#f0ece4", color: "#1a1a1a", border: "1px solid #e8e4dc", borderRadius: 7, fontSize: "0.75rem", fontWeight: 600, cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", whiteSpace: "nowrap" }}>
+ Follow up
</button>
<button onClick={() => { setActiveSession(null); setActiveSessionId(null); setTask(""); }}
style={{ padding: "7px 14px", background: "transparent", color: "#a09a90", border: "1px solid #e8e4dc", borderRadius: 7, fontSize: "0.75rem", cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", whiteSpace: "nowrap" }}>
New task
</button>
</div>
{showFollowUp && (
<div style={{ display: "flex", gap: 8, alignItems: "flex-end", marginTop: 10 }}>
<textarea
autoFocus
value={task}
onChange={e => setTask(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { handleRun(); setShowFollowUp(false); }
if (e.key === "Escape") { setShowFollowUp(false); setTask(""); }
}}
placeholder="What should the agent build next?"
rows={2}
style={{ flex: 1, resize: "none", border: "1px solid #e8e4dc", borderRadius: 7, padding: "8px 11px", fontSize: "0.78rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", outline: "none", background: "#faf8f5" }}
/>
<button onClick={() => { handleRun(); setShowFollowUp(false); }} disabled={submitting || !task.trim()}
style={{ padding: "9px 14px", background: task.trim() ? "#1a1a1a" : "#e8e4dc", color: task.trim() ? "#fff" : "#a09a90", border: "none", borderRadius: 7, fontSize: "0.75rem", fontWeight: 600, cursor: task.trim() ? "pointer" : "default", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
{submitting ? "Starting…" : "Run"}
</button>
</div>
)}
</div>
);
}
// Session completed successfully — offer follow-up or new task
if (st === "done") {
return (
<div style={{ borderTop: "1px solid #e8e4dc", background: "#fff", padding: "12px 16px", flexShrink: 0 }}>
{showFollowUp ? (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ fontSize: "0.72rem", color: "#6b6560", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
Describe the next thing to build (continues from this session's context):
</div>
<div style={{ display: "flex", gap: 8, alignItems: "flex-end" }}>
<textarea
autoFocus
value={task}
onChange={e => setTask(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { handleRun(); setShowFollowUp(false); }
if (e.key === "Escape") { setShowFollowUp(false); setTask(""); }
}}
placeholder="e.g. Now add email notifications for the same feature…"
rows={2}
style={{
flex: 1, resize: "none", border: "1px solid #e8e4dc", borderRadius: 7,
padding: "8px 11px", fontSize: "0.78rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
outline: "none", background: "#faf8f5",
}}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<button onClick={() => { handleRun(); setShowFollowUp(false); }} disabled={submitting || !task.trim()}
style={{ padding: "8px 14px", background: task.trim() ? "#1a1a1a" : "#e8e4dc", color: task.trim() ? "#fff" : "#a09a90", border: "none", borderRadius: 7, fontSize: "0.75rem", fontWeight: 600, cursor: task.trim() ? "pointer" : "default", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", whiteSpace: "nowrap" }}>
{submitting ? "Starting…" : "Run"}
</button>
<button onClick={() => { setShowFollowUp(false); setTask(""); }}
style={{ padding: "6px 14px", background: "transparent", color: "#a09a90", border: "1px solid #e8e4dc", borderRadius: 7, fontSize: "0.73rem", cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
Cancel
</button>
</div>
</div>
<div style={{ fontSize: "0.63rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
to run · Esc to cancel · Starts a new session for this task
</div>
</div>
) : (
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<span style={{ fontSize: "0.75rem", color: "#2e7d32", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", flex: 1 }}>
Done approve the changes above, or continue building.
</span>
<button onClick={() => setShowFollowUp(true)}
style={{ padding: "7px 14px", background: "#f0ece4", color: "#1a1a1a", border: "1px solid #e8e4dc", borderRadius: 7, fontSize: "0.75rem", fontWeight: 600, cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", whiteSpace: "nowrap" }}>
+ Follow up
</button>
<button onClick={() => { setActiveSession(null); setActiveSessionId(null); setTask(""); }}
style={{ padding: "7px 14px", background: "transparent", color: "#a09a90", border: "1px solid #e8e4dc", borderRadius: 7, fontSize: "0.75rem", cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", whiteSpace: "nowrap" }}>
New task
</button>
</div>
)}
</div>
);
}
// No active session (or failed/stopped handled above) — new task input
if (!activeSession) {
return (
<div style={{ borderTop: "1px solid #e8e4dc", background: "#fff", padding: "12px 16px", flexShrink: 0 }}>
<div style={{ display: "flex", gap: 8, alignItems: "flex-end" }}>
<textarea
value={task}
onChange={e => setTask(e.target.value)}
onKeyDown={e => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleRun(); }}
placeholder={`Describe what to build in ${appName || "this app"}`}
rows={2}
style={{
flex: 1, resize: "none", border: "1px solid #e8e4dc", borderRadius: 8,
padding: "9px 12px", fontSize: "0.8rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
color: "#1a1a1a", outline: "none", background: "#faf8f5",
}}
/>
<button
onClick={handleRun}
disabled={submitting || !task.trim()}
style={{
padding: "10px 18px", background: task.trim() ? "#1a1a1a" : "#e8e4dc",
color: task.trim() ? "#fff" : "#a09a90", border: "none", borderRadius: 8,
fontSize: "0.78rem", fontWeight: 600, cursor: task.trim() ? "pointer" : "default",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", whiteSpace: "nowrap",
}}
>
{submitting ? "Starting…" : "Run"}
</button>
</div>
<div style={{ fontSize: "0.65rem", color: "#b5b0a6", marginTop: 5, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
to start · The agent works autonomously until done · You approve before anything is committed
</div>
</div>
);
}
return null;
})()}
</div>
</div>
);
}
// ── Terminal panel — collapsible bottom strip ─────────────────────────────────
function TerminalPanel({ appName }: { appName: string }) {
const [open, setOpen] = useState(false);
const PANEL_HEIGHT = 220;
return (
<div style={{ flexShrink: 0, borderTop: "1px solid #2d2d2d", background: "#1a1a1a", display: "flex", flexDirection: "column", height: open ? PANEL_HEIGHT : 32, transition: "height 0.2s ease", overflow: "hidden" }}>
{/* Header / toggle bar */}
<button
onClick={() => setOpen(o => !o)}
style={{
display: "flex", alignItems: "center", gap: 8, padding: "0 14px",
height: 32, flexShrink: 0, width: "100%", border: "none",
background: "transparent", cursor: "pointer", textAlign: "left",
}}
>
<span style={{ fontSize: "0.58rem", color: "#555", transform: open ? "rotate(180deg)" : "none", transition: "transform 0.2s", display: "inline-block" }}></span>
<span style={{ fontSize: "0.68rem", fontWeight: 600, color: "#6b6560", letterSpacing: "0.07em", textTransform: "uppercase", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Terminal</span>
{appName && <span style={{ fontSize: "0.65rem", color: "#3a3a3a", fontFamily: "IBM Plex Mono, monospace" }}>{appName}</span>}
<span style={{ marginLeft: "auto", fontSize: "0.6rem", color: "#3a3a3a", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Phase 4</span>
</button>
{/* Body */}
{open && (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", flexDirection: "column", gap: 10, padding: "16px 24px", textAlign: "center" }}>
<div style={{ fontSize: "0.78rem", color: "#6b6560", lineHeight: 1.6, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", maxWidth: 340 }}>
{appName
? `Live shell into the ${appName} container — coming in Phase 4.`
: "Select an app from the left, then open a live shell into its container."}
</div>
</div>
)}
</div>
);
}
// ── File tree (lives in sidebar B) ───────────────────────────────────────────
function FileTree({ projectId, rootPath, selectedPath, onSelectFile }: {
projectId: string; rootPath: string; selectedPath: string | null;
onSelectFile: (path: string) => void;
}) {
const { status } = useSession();
const [tree, setTree] = useState<TreeNode[]>([]);
const [treeLoading, setTreeLoading] = useState(false);
const fetchDir = useCallback(async (path: string): Promise<TreeNode[]> => {
const res = await fetch(`/api/projects/${projectId}/file?path=${encodeURIComponent(path)}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error);
const items: FileItem[] = data.items ?? [];
return items.filter(i => i.type !== "symlink")
.sort((a, b) => a.type === "dir" && b.type !== "dir" ? -1 : a.type !== "dir" && b.type === "dir" ? 1 : a.name.localeCompare(b.name))
.map(i => ({ name: i.name, path: i.path, type: i.type === "dir" ? "dir" : "file", expanded: false, loaded: i.type !== "dir", children: i.type === "dir" ? [] : undefined }));
}, [projectId]);
useEffect(() => {
if (!rootPath) return;
if (!isClientDevProjectBypass() && status !== "authenticated") return;
setTree([]); setTreeLoading(true);
fetchDir(rootPath).then(nodes => { setTree(nodes); setTreeLoading(false); }).catch(() => setTreeLoading(false));
}, [rootPath, status, fetchDir]);
const handleToggle = useCallback(async (path: string) => {
setTree(prev => {
const toggle = (nodes: TreeNode[]): TreeNode[] => nodes.map(n => n.path === path ? { ...n, expanded: !n.expanded } : n.children ? { ...n, children: toggle(n.children) } : n);
return toggle(prev);
});
const findNode = (nodes: TreeNode[], p: string): TreeNode | null => { for (const n of nodes) { if (n.path === p) return n; if (n.children) { const f = findNode(n.children, p); if (f) return f; } } return null; };
const node = findNode(tree, path);
if (node && !node.loaded) {
const children = await fetchDir(path).catch(() => []);
setTree(prev => {
const update = (nodes: TreeNode[]): TreeNode[] => nodes.map(n => n.path === path ? { ...n, children, loaded: true } : n.children ? { ...n, children: update(n.children) } : n);
return update(prev);
});
}
}, [tree, fetchDir]);
return (
<div style={{ flex: 1, overflow: "auto", padding: "4px" }}>
{treeLoading && <div style={{ padding: "12px", fontSize: "0.72rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Loading</div>}
{!treeLoading && tree.length === 0 && <div style={{ padding: "12px", fontSize: "0.72rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Empty.</div>}
{tree.map(n => <TreeRow key={n.path} node={n} depth={0} selectedPath={selectedPath} onSelect={onSelectFile} onToggle={handleToggle} />)}
</div>
);
}
// ── File viewer (right panel) ─────────────────────────────────────────────────
function FileViewer({ selectedPath, fileContent, fileLoading, fileName, rootPath }: {
selectedPath: string | null; fileContent: string | null;
fileLoading: boolean; fileName: string | null; rootPath: string;
}) {
const lang = fileName ? langFromName(fileName) : "text";
const lines = (fileContent ?? "").split("\n");
return (
<div style={{ flex: 1, display: "flex", flexDirection: "column", background: "#1e1e1e", overflow: "hidden" }}>
{/* File path header */}
<div style={{ padding: "9px 18px", borderBottom: "1px solid #2d2d2d", background: "#252526", display: "flex", alignItems: "center", gap: 8, flexShrink: 0 }}>
{selectedPath ? (
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.71rem", color: "#a09a90" }}>
{(() => {
const rel = selectedPath.startsWith(rootPath + "/") ? selectedPath.slice(rootPath.length + 1) : selectedPath;
return rel.split("/").map((s, i, a) => (
<span key={i}>
{i > 0 && <span style={{ color: "#555", margin: "0 3px" }}>/</span>}
<span style={{ color: i === a.length - 1 ? "#d4d4d4" : "#888" }}>{s}</span>
</span>
));
})()}
</span>
) : (
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.71rem", color: "#555" }}>Select a file from the tree</span>
)}
{fileName && <span style={{ marginLeft: "auto", fontFamily: "IBM Plex Mono, monospace", fontSize: "0.62rem", color: "#555", textTransform: "uppercase" }}>{lang}</span>}
</div>
{/* Content */}
<div style={{ flex: 1, overflow: "auto", display: "flex" }}>
{!selectedPath && !fileLoading && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "100%", flexDirection: "column", gap: 10, color: "#3a3a3a" }}>
<span style={{ fontSize: "1.6rem" }}></span>
<span style={{ fontSize: "0.75rem", fontFamily: "IBM Plex Mono, monospace", color: "#555" }}>Select a file to view</span>
</div>
)}
{fileLoading && <div style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "100%", color: "#555", fontSize: "0.78rem", fontFamily: "IBM Plex Mono, monospace" }}>Loading</div>}
{!fileLoading && fileContent !== null && (
<div style={{ display: "flex", width: "100%" }}>
<div style={{ padding: "14px 0", background: "#1e1e1e", borderRight: "1px solid #2d2d2d", textAlign: "right", userSelect: "none", flexShrink: 0, minWidth: 40 }}>
{lines.map((_, i) => <div key={i} style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.71rem", lineHeight: "1.4em", color: "#555", padding: "0 10px 0 6px" }}>{i + 1}</div>)}
</div>
<div style={{ padding: "14px 22px", fontFamily: "IBM Plex Mono, monospace", fontSize: "0.71rem", lineHeight: "1.4em", color: "#d4d4d4", flex: 1, whiteSpace: "pre", overflow: "auto" }}>
{highlightCode(fileContent, lang)}
</div>
</div>
)}
</div>
</div>
);
}
// ── Preview panel ─────────────────────────────────────────────────────────────
interface PreviewApp { name: string; url: string | null; status: string; }
// ── PRD Content ───────────────────────────────────────────────────────────────
interface SavedPhase { phase: string; title: string; summary: string; data: Record<string, unknown>; saved_at: string; }
function PrdContent({ projectId }: { projectId: string }) {
const [prd, setPrd] = useState<string | null>(null);
const [savedPhases, setSavedPhases] = useState<SavedPhase[]>([]);
const [loading, setLoading] = useState(true);
const [expandedPhase, setExpandedPhase] = 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);
setSavedPhases(phaseData?.phases ?? []);
setLoading(false);
});
}, [projectId]);
if (loading) return (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "#a09a90", fontSize: "0.82rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Loading</div>
);
const phaseMap = new Map(savedPhases.map(p => [p.phase, p]));
const savedPhaseIds = new Set(savedPhases.map(p => p.phase));
const sections = PRD_PLAN_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);
return (
<div style={{ flex: 1, overflow: "auto", padding: "24px 28px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
{prd ? (
<div style={{ maxWidth: 720 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20 }}>
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.15rem", fontWeight: 400, color: "#1a1a1a", margin: 0 }}>Product Requirements</h3>
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.68rem", color: "#2e7d32", background: "#2e7d3210", padding: "3px 9px", borderRadius: 5 }}>PRD complete</span>
</div>
<div style={{ background: "#fff", borderRadius: 10, border: "1px solid #e8e4dc", padding: "24px 28px", lineHeight: 1.8, fontSize: "0.86rem", color: "#2a2824", whiteSpace: "pre-wrap" }}>
{prd}
</div>
</div>
) : (
<div style={{ maxWidth: 640 }}>
{/* Progress bar */}
<div style={{ display: "flex", alignItems: "center", gap: 14, padding: "14px 18px", background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10, marginBottom: 20 }}>
<div style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "1.3rem", fontWeight: 500, color: "#1a1a1a", minWidth: 46 }}>{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.72rem", color: "#a09a90" }}>{doneCount}/{sections.length} sections</span>
</div>
{sections.map((s, i) => (
<div key={s.id} style={{ padding: "12px 16px", marginBottom: 5, background: "#fff", borderRadius: 10, border: `1px solid ${s.isDone ? "#a5d6a740" : "#e8e4dc"}` }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ width: 22, height: 22, borderRadius: 6, flexShrink: 0, background: s.isDone ? "#2e7d3210" : "#f6f4f0", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.6rem", fontWeight: 700, color: s.isDone ? "#2e7d32" : "#c5c0b8" }}>
{s.isDone ? "✓" : "○"}
</div>
<span style={{ flex: 1, fontSize: "0.82rem", color: s.isDone ? "#1a1a1a" : "#a09a90", fontWeight: s.isDone ? 500 : 400 }}>{s.label}</span>
{s.isDone && s.savedPhase && (
<button onClick={() => setExpandedPhase(expandedPhase === s.id ? null : s.id)} style={{ background: "none", border: "none", cursor: "pointer", fontSize: "0.62rem", color: "#2e7d32", fontFamily: "IBM Plex Mono, monospace", padding: "2px 6px" }}>
{expandedPhase === s.id ? "▲" : "▼"}
</button>
)}
</div>
{s.isDone && s.savedPhase && expandedPhase === s.id && (
<div style={{ marginTop: 10, paddingTop: 10, borderTop: "1px solid #e8e4dc", paddingLeft: 32 }}>
<div style={{ fontSize: "0.75rem", color: "#4a4640", lineHeight: 1.5, marginBottom: 8 }}>{s.savedPhase.summary}</div>
{Object.entries(s.savedPhase.data).filter(([, v]) => v !== null && v !== undefined && v !== "").map(([k, v]) => (
<div key={k} style={{ marginTop: 8 }}>
<div style={{ fontSize: "0.57rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 600, marginBottom: 2 }}>{k.replace(/_/g, " ")}</div>
<div style={{ fontSize: "0.76rem", color: "#2a2824", lineHeight: 1.5 }}>{Array.isArray(v) ? v.join(", ") : String(v)}</div>
</div>
))}
</div>
)}
{!s.isDone && (
<div style={{ marginTop: 5, marginLeft: 32, fontSize: "0.7rem", color: "#c5c0b8" }}>
Complete this phase in Vibn
</div>
)}
</div>
))}
{doneCount === 0 && (
<p style={{ fontSize: "0.76rem", color: "#b5b0a6", marginTop: 16, textAlign: "center" }}>Continue chatting with Vibn saved phases appear here automatically.</p>
)}
</div>
)}
</div>
);
}
function PreviewContent({ projectId, apps, activePreviewApp, onSelectApp }: {
projectId: string;
apps: PreviewApp[];
activePreviewApp: PreviewApp | null;
onSelectApp: (app: PreviewApp) => void;
}) {
const [urlInput, setUrlInput] = useState(activePreviewApp?.url ?? "");
const [iframeSrc, setIframeSrc] = useState(activePreviewApp?.url ?? "");
const [loading, setLoading] = useState(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
// Sync when active app changes
useEffect(() => {
const u = activePreviewApp?.url ?? "";
setUrlInput(u);
setIframeSrc(u);
}, [activePreviewApp]);
const navigate = (url: string) => {
const u = url.startsWith("http") ? url : `https://${url}`;
setUrlInput(u);
setIframeSrc(u);
setLoading(true);
};
if (!activePreviewApp?.url) {
return (
<div style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 16, padding: 60, textAlign: "center", background: "#faf8f5" }}>
<div style={{ width: 52, height: 52, borderRadius: 13, background: "#f0ece4", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "1.5rem" }}>🖥</div>
<div>
<div style={{ fontSize: "0.92rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>No deployment URL yet</div>
<div style={{ fontSize: "0.8rem", color: "#a09a90", maxWidth: 320, lineHeight: 1.6, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
Deploy an app via Coolify to see a live preview here. Once deployed, the URL will appear automatically.
</div>
</div>
</div>
);
}
return (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* Browser chrome */}
<div style={{
height: 44, flexShrink: 0, display: "flex", alignItems: "center",
gap: 8, padding: "0 12px",
background: "#fff", borderBottom: "1px solid #e8e4dc",
}}>
{/* Reload */}
<button
onClick={() => { setIframeSrc(""); setTimeout(() => setIframeSrc(urlInput), 50); setLoading(true); }}
title="Reload"
style={{ width: 28, height: 28, border: "none", background: "transparent", cursor: "pointer", borderRadius: 6, display: "flex", alignItems: "center", justifyContent: "center", color: "#9a9490", fontSize: "0.85rem" }}
onMouseEnter={e => (e.currentTarget as HTMLElement).style.background = "#f0ece4"}
onMouseLeave={e => (e.currentTarget as HTMLElement).style.background = "transparent"}
></button>
{/* URL bar */}
<form onSubmit={e => { e.preventDefault(); navigate(urlInput); }} style={{ flex: 1, display: "flex" }}>
<input
value={urlInput}
onChange={e => setUrlInput(e.target.value)}
style={{
flex: 1, height: 30, border: "1px solid #e8e4dc", borderRadius: 7,
padding: "0 10px", fontSize: "0.76rem", fontFamily: "IBM Plex Mono, monospace",
color: "#1a1a1a", background: "#faf8f5", outline: "none",
}}
onFocus={e => (e.currentTarget as HTMLElement).style.borderColor = "#1a1a1a"}
onBlur={e => (e.currentTarget as HTMLElement).style.borderColor = "#e8e4dc"}
/>
</form>
{/* Open in new tab */}
<a
href={urlInput}
target="_blank"
rel="noreferrer"
title="Open in new tab"
style={{ width: 28, height: 28, border: "none", background: "transparent", cursor: "pointer", borderRadius: 6, display: "flex", alignItems: "center", justifyContent: "center", color: "#9a9490", fontSize: "0.78rem", textDecoration: "none" }}
onMouseEnter={e => (e.currentTarget as HTMLElement).style.background = "#f0ece4"}
onMouseLeave={e => (e.currentTarget as HTMLElement).style.background = "transparent"}
></a>
{/* Loading indicator */}
{loading && (
<div style={{ width: 6, height: 6, borderRadius: "50%", background: "#3d5afe", flexShrink: 0, animation: "pulse 1s infinite" }} />
)}
</div>
{/* iframe */}
{iframeSrc && (
<iframe
ref={iframeRef}
src={iframeSrc}
onLoad={() => setLoading(false)}
style={{ flex: 1, border: "none", background: "#fff" }}
title="App preview"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
/>
)}
</div>
);
}
// ── Main Build hub ────────────────────────────────────────────────────────────
function BuildHubInner() {
const params = useParams();
const searchParams = useSearchParams();
const router = useRouter();
const projectId = params.projectId as string;
const workspace = params.workspace as string;
const section = searchParams.get("section") ?? "chat";
const tasksSubTabRaw = searchParams.get("tab") ?? "tasks";
const tasksSubTab = tasksSubTabRaw === "prd" ? "requirements" : tasksSubTabRaw;
const activeApp = searchParams.get("app") ?? "";
const activeRoot = searchParams.get("root") ?? "";
const activeSurfaceParam = searchParams.get("surface") ?? "";
const [apps, setApps] = useState<AppEntry[]>([]);
const [surfaces, setSurfaces] = useState<SurfaceEntry[]>([]);
const [activeSurfaceId, setActiveSurfaceId] = useState<string>(activeSurfaceParam);
// Preview state
const [previewApps, setPreviewApps] = useState<PreviewApp[]>([]);
const [activePreviewApp, setActivePreviewApp] = useState<PreviewApp | null>(null);
// File viewer state — shared between inner nav (tree) and viewer panel
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string | null>(null);
const [fileLoading, setFileLoading] = useState(false);
const [fileName, setFileName] = useState<string | null>(null);
const [chatContextRefs, setChatContextRefs] = useState<ChatContextRef[]>([]);
const addAppToChat = useCallback((app: AppEntry) => {
setChatContextRefs(prev => {
const next: ChatContextRef = { kind: "app", label: app.name, path: app.path };
const k = contextRefKey(next);
if (prev.some(r => contextRefKey(r) === k)) return prev;
return [...prev, next];
});
}, []);
const removeChatContextRef = useCallback((key: string) => {
setChatContextRefs(prev => prev.filter(r => contextRefKey(r) !== key));
}, []);
useEffect(() => {
if (searchParams.get("section") !== "infrastructure") return;
const t = searchParams.get("tab") ?? "builds";
router.replace(`/${workspace}/project/${projectId}/run?tab=${encodeURIComponent(t)}`, { scroll: false });
}, [searchParams, workspace, projectId, router]);
useEffect(() => {
if (searchParams.get("section") !== "mvp") return;
router.replace(`/${workspace}/project/${projectId}/mvp-setup/launch`, { scroll: false });
}, [searchParams, workspace, projectId, router]);
useEffect(() => {
fetch(`/api/projects/${projectId}/preview-url`)
.then(r => r.json())
.then(d => {
const pa: PreviewApp[] = d.apps ?? [];
setPreviewApps(pa);
if (pa.length > 0 && !activePreviewApp) setActivePreviewApp(pa[0]);
}).catch(() => {});
fetch(`/api/projects/${projectId}/apps`)
.then(r => r.json())
.then(d => { setApps(d.apps ?? []); }).catch(() => {});
fetch(`/api/projects/${projectId}/design-surfaces`).then(r => r.json()).then(d => {
const ids: string[] = d.surfaces ?? [];
const themes: Record<string, string> = d.surfaceThemes ?? {};
setSurfaces(ids.map(id => ({ id, label: SURFACE_LABELS[id] ?? id, lockedTheme: themes[id] })));
if (!activeSurfaceId && ids.length > 0) setActiveSurfaceId(ids[0]);
}).catch(() => {});
}, [projectId]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
setSelectedFilePath(null);
setFileContent(null);
setFileName(null);
}, [activeApp]);
const handleSelectFile = async (path: string) => {
setSelectedFilePath(path);
setFileName(path.split("/").pop() ?? null);
setFileContent(null);
setFileLoading(true);
try {
const res = await fetch(`/api/projects/${projectId}/file?path=${encodeURIComponent(path)}`);
const data = await res.json();
setFileContent(data.content ?? "");
} catch { setFileContent("// Failed to load"); }
finally { setFileLoading(false); }
};
const navigate = (navParams: Record<string, string>) => {
const sp = new URLSearchParams({ section, ...navParams });
router.push(`/${workspace}/project/${projectId}/build?${sp.toString()}`, { scroll: false });
};
const workspaceAppActive = (app: AppEntry) => {
if (section === "chat") {
return chatContextRefs.some(r => r.kind === "app" && r.path === app.path);
}
if (section === "code" || (section === "tasks" && tasksSubTab !== "requirements")) {
return activeApp === app.name;
}
return false;
};
const onWorkspaceApp = (app: AppEntry) => {
if (section === "chat") addAppToChat(app);
else if (section === "code") navigate({ section: "code", app: app.name, root: app.path });
else if (section === "tasks" && tasksSubTab !== "requirements") {
navigate({ section: "tasks", tab: "tasks", app: app.name, root: app.path });
}
else navigate({ section: "code", app: app.name, root: app.path });
};
return (
<div style={{ display: "flex", height: "100%", fontFamily: JM.fontSans, overflow: "hidden", background: JM.inputBg }}>
{/* Growth-style left rail */}
<div style={{
width: 200,
flexShrink: 0,
borderRight: `1px solid ${BUILD_LEFT_BORDER}`,
background: BUILD_LEFT_BG,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}>
<div style={BUILD_NAV_GROUP}>Build</div>
{BUILD_PRIMARY.map(p => (
<BuildGrowthNavRow
key={p.id}
icon={p.icon}
label={p.label}
active={section === p.id}
onClick={() => {
if (p.id === "tasks") navigate({ section: "tasks", tab: "tasks" });
else navigate({ section: p.id });
}}
/>
))}
<div style={{
flex: 1,
minHeight: 0,
overflowY: "auto",
borderTop: `1px solid ${BUILD_LEFT_BORDER}`,
marginTop: 6,
paddingTop: 4,
}}>
{section === "chat" && (
<div style={{ padding: "8px 12px 12px", fontSize: 11, color: JM.mid, lineHeight: 1.45, fontFamily: JM.fontSans }}>
Attach monorepo apps from <strong style={{ fontWeight: 600 }}>Workspace</strong> below. Live preview stays on the right when deployed.
</div>
)}
{section === "code" && activeApp && activeRoot && (
<div style={{ display: "flex", flexDirection: "column", paddingBottom: 10, borderBottom: `1px solid ${BUILD_LEFT_BORDER}`, marginBottom: 8 }}>
<div style={NAV_GROUP_LABEL}>Files</div>
<div style={{
padding: "2px 12px 6px",
fontSize: "0.62rem",
color: JM.mid,
fontFamily: "IBM Plex Mono, monospace",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}>
{activeApp}
</div>
<div style={{ flex: 1, minHeight: 0, maxHeight: 280, overflow: "auto" }}>
<FileTree projectId={projectId} rootPath={activeRoot} selectedPath={selectedFilePath} onSelectFile={handleSelectFile} />
</div>
</div>
)}
{section === "layouts" && (
<div style={{ paddingBottom: 8 }}>
<div style={NAV_GROUP_LABEL}>Surfaces</div>
{surfaces.length > 0 ? surfaces.map(s => (
<BuildGrowthNavRow
key={s.id}
icon={SURFACE_ICONS[s.id] ?? "◈"}
label={SURFACE_LABELS[s.id] ?? s.id}
active={activeSurfaceId === s.id}
onClick={() => { setActiveSurfaceId(s.id); navigate({ section: "layouts", surface: s.id }); }}
/>
)) : (
<div style={{ padding: "8px 12px", fontSize: "0.74rem", color: JM.muted, fontFamily: JM.fontSans }}>Not configured</div>
)}
</div>
)}
{section === "tasks" && (
<div style={{ paddingBottom: 8 }}>
<div style={{ padding: "8px 10px", borderBottom: `1px solid ${BUILD_LEFT_BORDER}`, display: "flex", gap: 4, marginBottom: 8 }}>
{[{ id: "tasks", label: "Runs" }, { id: "requirements", label: "Requirements" }].map(item => {
const isActive = tasksSubTab === item.id;
return (
<button key={item.id} type="button" onClick={() => navigate({ section: "tasks", tab: item.id })} style={{
flex: 1, padding: "5px 0", border: "none", borderRadius: 6, cursor: "pointer",
fontSize: "0.72rem", fontWeight: 600, fontFamily: JM.fontSans,
background: isActive ? JM.primaryGradient : "transparent",
color: isActive ? "#fff" : JM.mid,
boxShadow: isActive ? JM.primaryShadow : "none",
transition: "all 0.12s",
}}>
{item.label}
</button>
);
})}
</div>
</div>
)}
{section === "preview" && (
<div style={{ paddingBottom: 8 }}>
<div style={NAV_GROUP_LABEL}>Deployed</div>
{previewApps.length > 0 ? previewApps.map(app => (
<BuildGrowthNavRow
key={app.name}
icon="▢"
label={app.name}
active={activePreviewApp?.name === app.name}
onClick={() => setActivePreviewApp(app)}
/>
)) : (
<div style={{ padding: "8px 12px", fontSize: "0.74rem", color: JM.muted, fontFamily: JM.fontSans }}>No deployments yet</div>
)}
</div>
)}
</div>
<div style={{ flexShrink: 0, borderTop: `1px solid ${BUILD_LEFT_BORDER}`, paddingBottom: 10, background: BUILD_LEFT_BG }}>
{apps.length > 0 && (
<>
<div style={BUILD_NAV_GROUP}>Workspace</div>
{apps.map(app => (
<BuildGrowthNavRow
key={app.name}
icon="◈"
label={app.name}
active={workspaceAppActive(app)}
onClick={() => onWorkspaceApp(app)}
/>
))}
</>
)}
{section === "chat" && previewApps.length > 0 && (
<>
<div style={BUILD_NAV_GROUP}>Live</div>
{previewApps.map(app => (
<BuildGrowthNavRow
key={`live-${app.name}`}
icon="▢"
label={app.name}
active={activePreviewApp?.name === app.name}
onClick={() => setActivePreviewApp(app)}
/>
))}
</>
)}
<Link
href={`/${workspace}/project/${projectId}/tasks`}
style={{
display: "block",
margin: "10px 12px 0",
fontSize: 12,
fontWeight: 600,
color: JM.indigo,
textDecoration: "none",
fontFamily: JM.fontSans,
}}
>
Open Tasks
</Link>
</div>
</div>
{/* Main content */}
<div style={{ flex: 1, display: "flex", overflow: "hidden", minWidth: 0 }}>
{section === "chat" && (
<div style={{ flex: 1, display: "flex", minWidth: 0, overflow: "hidden" }}>
<div style={{ flex: 1, minWidth: 0, minHeight: 0 }}>
<AtlasChat
projectId={projectId}
conversationScope="build"
contextEmptyLabel="Workspace"
emptyStateHint="Plan and implement in your monorepo. Attach apps from the left, preview on the right when deployed."
chatContextRefs={chatContextRefs}
onRemoveChatContextRef={removeChatContextRef}
/>
</div>
<div style={{
width: 400,
flexShrink: 0,
minWidth: 280,
borderLeft: `1px solid ${JM.border}`,
display: "flex",
flexDirection: "column",
overflow: "hidden",
background: JM.inputBg,
}}>
<PreviewContent
projectId={projectId}
apps={previewApps}
activePreviewApp={activePreviewApp}
onSelectApp={setActivePreviewApp}
/>
</div>
</div>
)}
{section === "code" && (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
<FileViewer
selectedPath={selectedFilePath}
fileContent={fileContent}
fileLoading={fileLoading}
fileName={fileName}
rootPath={activeRoot}
/>
<TerminalPanel appName={activeApp} />
</div>
)}
{section === "layouts" && (
<LayoutsContent surfaces={surfaces} projectId={projectId} workspace={workspace} activeSurfaceId={activeSurfaceId} onSelectSurface={id => { setActiveSurfaceId(id); navigate({ section: "layouts", surface: id }); }} />
)}
{section === "tasks" && tasksSubTab !== "requirements" && (
<AgentMode projectId={projectId} appName={activeApp} appPath={activeRoot} />
)}
{section === "tasks" && tasksSubTab === "requirements" && (
<PrdContent projectId={projectId} />
)}
{section === "preview" && (
<PreviewContent
projectId={projectId}
apps={previewApps}
activePreviewApp={activePreviewApp}
onSelectApp={setActivePreviewApp}
/>
)}
</div>
</div>
);
}
export default function BuildPage() {
return (
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: JM.muted, fontFamily: JM.fontSans, fontSize: "0.85rem" }}>Loading</div>}>
<BuildHubInner />
</Suspense>
);
}