Files
vibn-frontend/app/[workspace]/project/[projectId]/build/page.tsx

1218 lines
66 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 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<string, string> = {
webapp: "Web App", marketing: "Marketing Site", admin: "Admin Panel",
};
const SURFACE_ICONS: Record<string, string> = {
webapp: "◈", marketing: "◌", admin: "◫",
};
// ── 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: "#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 (
<button onClick={onClick} style={{
display: "flex", alignItems: "center", gap: 7, width: "100%", textAlign: "left",
background: active ? "#f0ece4" : "transparent", border: "none", cursor: "pointer",
padding: `5px 12px 5px ${indent ? 22 : 12}px`, borderRadius: 5,
fontSize: "0.78rem", fontWeight: active ? 600 : 440,
color: active ? "#1a1a1a" : "#5a5550", fontFamily: "Outfit, sans-serif",
}}
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
{label}
</button>
);
}
// ── 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>
);
}
// ── Infra content ─────────────────────────────────────────────────────────────
function InfraContent({ tab, projectId, workspace }: { tab: string; projectId: string; workspace: string }) {
const base = `/${workspace}/project/${projectId}/infrastructure`;
const descriptions: Record<string, { icon: string; title: string; desc: string }> = {
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 (
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
<div style={{ padding: "20px 28px 0", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div style={{ fontSize: "0.68rem", fontWeight: 700, color: "#a09a90", letterSpacing: "0.08em", textTransform: "uppercase", fontFamily: "Outfit, sans-serif" }}>{tab}</div>
<Link href={`${base}?tab=${tab}`} style={{ fontSize: "0.72rem", color: "#a09a90", textDecoration: "none", fontFamily: "Outfit, sans-serif" }}>Open full view </Link>
</div>
{d && <Placeholder icon={d.icon} title={d.title} desc={d.desc} />}
</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: "Outfit, sans-serif" }}>Layouts</div>
<Link href={`/${workspace}/project/${projectId}/design?surface=${active?.id ?? ""}`} style={{ fontSize: "0.72rem", color: "#a09a90", textDecoration: "none", fontFamily: "Outfit, 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: "Outfit, sans-serif", marginBottom: 4 }}>
{SURFACE_LABELS[s.id] ?? s.id}
</div>
{s.lockedTheme ? (
<div style={{ fontSize: "0.72rem", color: "#6b6560", fontFamily: "Outfit, sans-serif" }}>Theme: {s.lockedTheme}</div>
) : (
<div style={{ fontSize: "0.72rem", color: "#b5b0a6", fontFamily: "Outfit, sans-serif", fontStyle: "italic" }}>Not configured</div>
)}
</div>
))}
</div>
<div style={{ fontSize: "0.75rem", color: "#b5b0a6", fontFamily: "Outfit, 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" };
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 [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;
}, []);
// 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: "Outfit, sans-serif" }}>Select an app first</div>
<div style={{ fontSize: "0.78rem", color: "#a09a90", fontFamily: "Outfit, 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: "Outfit, sans-serif" }}>Sessions</span>
<span style={{ width: 1, height: 14, background: "#e0dcd5", flexShrink: 0 }} />
{loadingSessions && <span style={{ fontSize: "0.72rem", color: "#b5b0a6", fontFamily: "Outfit, sans-serif" }}>Loading</span>}
{!loadingSessions && sessions.length === 0 && (
<span style={{ fontSize: "0.72rem", color: "#b5b0a6", fontFamily: "Outfit, 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: "Outfit, 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: "Outfit, 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: "Outfit, sans-serif", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{activeSession.task}</span>
{activeSession.started_at && (
<span style={{ fontSize: "0.7rem", color: "#a09a90", fontFamily: "Outfit, 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: "Outfit, sans-serif", whiteSpace: "nowrap" }}>
Stop
</button>
)}
</div>
{/* Output stream */}
<div ref={outputRef} style={{ flex: 1, overflow: "auto", background: "#1a1a1a", padding: "16px 20px", fontFamily: "IBM Plex Mono, monospace", fontSize: "0.72rem", lineHeight: 1.6 }}>
{activeSession.output.length === 0 && (
<span style={{ color: "#555" }}>Starting agent</span>
)}
{activeSession.output.map((line, i) => {
const color = line.type === "error" ? "#f87171" : line.type === "stderr" ? "#fb923c"
: line.type === "info" ? "#60a5fa" : line.type === "step" ? "#a78bfa" : "#d4d4d4";
const prefix = line.type === "step" ? "▶ " : line.type === "error" ? "✗ "
: line.type === "info" ? "→ " : " ";
return (
<div key={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: "Outfit, 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: "Outfit, 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: "Outfit, 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: "Outfit, sans-serif" }}>
Cancel
</button>
</div>
</div>
<div style={{ fontSize: "0.63rem", color: "#b5b0a6", fontFamily: "Outfit, 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: "Outfit, 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: "Outfit, 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: "Outfit, 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: "Outfit, 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: "Outfit, 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: "Outfit, 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: "Outfit, 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: "Outfit, 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: "Outfit, sans-serif" }}>
Cancel
</button>
</div>
<div style={{ fontSize: "0.65rem", color: "#b5b0a6", fontFamily: "Outfit, 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: "Outfit, sans-serif" }}
>
Approve &amp; commit
</button>
<a
href="https://theia.vibnai.com"
target="_blank" rel="noreferrer"
style={{ padding: "7px 16px", background: "#f0ece4", color: "#1a1a1a", border: "1px solid #e8e4dc", borderRadius: 7, fontSize: "0.75rem", cursor: "pointer", fontFamily: "Outfit, sans-serif", textDecoration: "none", display: "inline-flex", alignItems: "center" }}
>
Open in Theia
</a>
</div>
)}
</div>
)}
</div>
)}
</>
) : (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "#b5b0a6", fontSize: "0.8rem", fontFamily: "Outfit, 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: "Outfit, 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: "Outfit, 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: "Outfit, 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: "Outfit, 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: "Outfit, 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: "Outfit, 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: "Outfit, 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: "Outfit, 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: "Outfit, 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: "Outfit, sans-serif" }}>
Cancel
</button>
</div>
</div>
<div style={{ fontSize: "0.63rem", color: "#b5b0a6", fontFamily: "Outfit, 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: "Outfit, 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: "Outfit, 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: "Outfit, 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: "Outfit, 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: "Outfit, sans-serif", whiteSpace: "nowrap",
}}
>
{submitting ? "Starting…" : "Run"}
</button>
</div>
<div style={{ fontSize: "0.65rem", color: "#b5b0a6", marginTop: 5, fontFamily: "Outfit, 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: "Outfit, 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: "Outfit, 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: "Outfit, sans-serif", maxWidth: 340 }}>
{appName
? `Live shell into the ${appName} container via xterm.js + Theia PTY — 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 || 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: "Outfit, sans-serif" }}>Loading</div>}
{!treeLoading && tree.length === 0 && <div style={{ padding: "12px", fontSize: "0.72rem", color: "#b5b0a6", fontFamily: "Outfit, 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; }
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: "Outfit, sans-serif" }}>No deployment URL yet</div>
<div style={{ fontSize: "0.8rem", color: "#a09a90", maxWidth: 320, lineHeight: 1.6, fontFamily: "Outfit, 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") ?? "code";
const activeApp = searchParams.get("app") ?? "";
const activeRoot = searchParams.get("root") ?? "";
const activeInfra = searchParams.get("tab") ?? "builds";
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);
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 });
};
return (
<div style={{ display: "flex", height: "100%", fontFamily: "Outfit, sans-serif", overflow: "hidden" }}>
{/* ── Build content ── */}
<div style={{ flex: 1, display: "flex", overflow: "hidden", minWidth: 0 }}>
{/* Inner nav — contextual items driven by top-bar tool icon */}
<div style={{ width: 200, flexShrink: 0, borderRight: "1px solid #e8e4dc", background: "#faf8f5", display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* Code: app list + file tree */}
{section === "code" && (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div style={{ flexShrink: 0 }}>
<div style={NAV_GROUP_LABEL}>Apps</div>
{apps.length > 0 ? apps.map(app => (
<NavItem key={app.name} label={app.name} indent
active={activeApp === app.name}
onClick={() => navigate({ section: "code", app: app.name, root: app.path })}
/>
)) : (
<div style={{ padding: "8px 22px", fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "Outfit, sans-serif" }}>No apps yet</div>
)}
</div>
{activeApp && activeRoot && (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", borderTop: "1px solid #e8e4dc", marginTop: 6 }}>
<div style={{ padding: "7px 12px 4px", flexShrink: 0, display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: "0.57rem", fontWeight: 700, color: "#b5b0a6", letterSpacing: "0.1em", textTransform: "uppercase", fontFamily: "Outfit, sans-serif" }}>Files</span>
<span style={{ fontSize: "0.62rem", color: "#a09a90", fontFamily: "IBM Plex Mono, monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{activeApp}</span>
</div>
<FileTree projectId={projectId} rootPath={activeRoot} selectedPath={selectedFilePath} onSelectFile={handleSelectFile} />
</div>
)}
</div>
)}
{/* Layouts: surface list */}
{section === "layouts" && (
<div style={{ overflow: "auto", flex: 1 }}>
<div style={NAV_GROUP_LABEL}>Surfaces</div>
{surfaces.length > 0 ? surfaces.map(s => (
<NavItem key={s.id} label={SURFACE_LABELS[s.id] ?? s.id} indent
active={activeSurfaceId === s.id}
onClick={() => { setActiveSurfaceId(s.id); navigate({ section: "layouts", surface: s.id }); }}
/>
)) : (
<div style={{ padding: "8px 22px", fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "Outfit, sans-serif" }}>Not configured</div>
)}
</div>
)}
{/* Infrastructure: item list */}
{section === "infrastructure" && (
<div style={{ overflow: "auto", flex: 1 }}>
<div style={NAV_GROUP_LABEL}>Infrastructure</div>
{INFRA_ITEMS.map(item => (
<NavItem key={item.id} label={item.label} indent
active={activeInfra === item.id}
onClick={() => navigate({ section: "infrastructure", tab: item.id })}
/>
))}
</div>
)}
{/* Preview: deployed apps list */}
{section === "preview" && (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div style={NAV_GROUP_LABEL}>Apps</div>
{previewApps.length > 0 ? previewApps.map(app => (
<NavItem key={app.name} label={app.name} indent
active={activePreviewApp?.name === app.name}
onClick={() => setActivePreviewApp(app)}
/>
)) : (
<div style={{ padding: "8px 22px", fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "Outfit, sans-serif" }}>No deployments yet</div>
)}
</div>
)}
</div>
{/* Main content panel */}
{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 === "infrastructure" && (
<InfraContent tab={activeInfra} projectId={projectId} workspace={workspace} />
)}
{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: "#a09a90", fontFamily: "Outfit, sans-serif", fontSize: "0.85rem" }}>Loading</div>}>
<BuildHubInner />
</Suspense>
);
}