"use client"; import { Suspense, useState, useEffect, useCallback, useRef } from "react"; import { useParams, useSearchParams, useRouter } from "next/navigation"; import { useSession } from "next-auth/react"; import Link from "next/link"; // ── 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 INFRA_ITEMS = [ { id: "builds", label: "Builds", icon: "⬡" }, { id: "databases", label: "Databases", icon: "◫" }, { id: "services", label: "Services", icon: "◎" }, { id: "environment", label: "Environment", icon: "≡" }, { id: "domains", label: "Domains", icon: "◬" }, { id: "logs", label: "Logs", icon: "≈" }, ]; const SURFACE_LABELS: Record = { webapp: "Web App", marketing: "Marketing Site", admin: "Admin Panel", }; const SURFACE_ICONS: Record = { webapp: "◈", marketing: "◌", admin: "◫", }; // ── Language / syntax helpers ───────────────────────────────────────────────── function langFromName(name: string): string { const ext = name.split(".").pop()?.toLowerCase() ?? ""; const map: Record = { 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
{line}
; } 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 {p}; return {p}; }); return
{tokens.length ? tokens : "\u00a0"}
; }); } // ── 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 ( <> {isDir && node.expanded && node.children?.map(c => )} ); } // ── Left nav shared styles ──────────────────────────────────────────────────── const NAV_GROUP_LABEL: React.CSSProperties = { fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6", letterSpacing: "0.09em", textTransform: "uppercase", padding: "12px 12px 5px", fontFamily: "Outfit, sans-serif", }; function NavItem({ label, active, onClick, indent = false }: { label: string; active: boolean; onClick: () => void; indent?: boolean }) { return ( ); } // ── Placeholder panel ───────────────────────────────────────────────────────── function Placeholder({ icon, title, desc }: { icon: string; title: string; desc: string }) { return (
{icon}
{title}
{desc}
Coming soon
); } // ── Infra content ───────────────────────────────────────────────────────────── function InfraContent({ tab, projectId, workspace }: { tab: string; projectId: string; workspace: string }) { const base = `/${workspace}/project/${projectId}/infrastructure`; const descriptions: Record = { databases: { icon: "◫", title: "Databases", desc: "PostgreSQL, Redis, and other databases — provisioned and managed with connection strings auto-injected." }, services: { icon: "◎", title: "Services", desc: "Background workers, queues, email delivery, file storage, and third-party integrations." }, environment: { icon: "≡", title: "Environment", desc: "Environment variables and secrets, encrypted at rest and auto-injected into your containers." }, domains: { icon: "◬", title: "Domains", desc: "Custom domains and SSL certificates for all your deployed services." }, logs: { icon: "≈", title: "Logs", desc: "Runtime logs, request traces, and error reports streaming from deployed services." }, builds: { icon: "⬡", title: "Builds", desc: "Deployment history, build logs, and rollback controls for all your apps." }, }; const d = descriptions[tab]; return (
{tab}
Open full view →
{d && }
); } // ── 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 ( ); } const active = surfaces.find(s => s.id === activeSurfaceId) ?? surfaces[0]; return (
Layouts
Edit in Design →
{surfaces.map(s => (
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", }}>
{SURFACE_LABELS[s.id] ?? s.id}
{s.lockedTheme ? (
Theme: {s.lockedTheme}
) : (
Not configured
)}
))}
Click a surface to select it, then open the Design editor to configure themes, fonts, and components.
); } // ── 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 = { running: "#3d5afe", done: "#2e7d32", approved: "#1b5e20", failed: "#c62828", stopped: "#a09a90", pending: "#d4a04a", }; const STATUS_LABELS: Record = { running: "Running", done: "Done", approved: "Shipped", failed: "Failed", stopped: "Stopped", pending: "Starting…", }; const FILE_STATUS_COLORS: Record = { 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; } 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([]); const [activeSessionId, setActiveSessionId] = useState(null); const [activeSession, setActiveSession] = useState(null); const [eventLog, setEventLog] = useState([]); const maxStreamSeqRef = useRef(0); const pollOutputRef = useRef([]); 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(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) => { try { const data = JSON.parse(msg.data) as Record; 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 (
Select an app first
Choose an app from the left nav to run agent tasks against it.
); } return (
{/* Horizontal sessions strip */}
Sessions {loadingSessions && Loading…} {!loadingSessions && sessions.length === 0 && ( No sessions yet — run your first task below )} {sessions.map(s => ( ))} {!loadingSessions && sessions.length > 0 && ( )}
{/* Main panel */}
{/* Active session view */} {activeSession ? ( <> {/* Session header */}
{activeSession.task} {activeSession.started_at && ( {["running", "pending"].includes(activeSession.status) ? `${elapsed(activeSession.started_at)} elapsed` : elapsed(activeSession.started_at)} )} {["running", "pending"].includes(activeSession.status) && ( )}
{/* Output stream — prefer persisted event timeline + SSE when available */}
{(() => { 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 Starting agent…; } 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 (
{prefix}{line.text}
); }); })()} {["running", "pending"].includes(activeSession.status) && ( )}
{/* Retry / follow-up panel for failed or stopped sessions */} {["failed", "stopped"].includes(activeSession.status) && (
{showFollowUp ? (
Add a follow-up instruction (optional) then retry: