Files
vibn-agent-runner/vibn-frontend/app/[workspace]/project/[projectId]/(home)/preview/page.tsx

644 lines
18 KiB
TypeScript

"use client";
import { useParams } from "next/navigation";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Loader2 } from "lucide-react";
import { useAnatomy } from "@/components/project/use-anatomy";
import { usePreviewBridge } from "@/components/project/preview-bridge-context";
import { usePreviewToolbarStore } from "@/components/project/preview-toolbar/preview-toolbar-state";
const SAME_ORIGIN_SANDBOX =
"allow-scripts allow-forms allow-same-origin allow-popups allow-modals allow-downloads" as const;
function sandboxIframe(src: string, origin: string): boolean {
if (!src.startsWith("http://") && !src.startsWith("https://")) return true;
try {
return origin.length > 0 && new URL(src).origin === origin;
} catch {
return true;
}
}
/** Elapsed time since an ISO string, formatted as "1m 23s". Capped at 59m 59s. */
function useElapsed(sinceIso: string | undefined) {
const [elapsed, setElapsed] = useState("");
useEffect(() => {
if (!sinceIso) return;
const update = () => {
const ms = Date.now() - new Date(sinceIso).getTime();
if (ms < 0) return;
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
if (m > 59) {
setElapsed("> 1h");
} else {
setElapsed(m > 0 ? `${m}m ${s % 60}s` : `${s}s`);
}
};
update();
const id = setInterval(update, 1000);
return () => {
clearInterval(id);
setElapsed("");
};
}, [sinceIso]);
return elapsed;
}
export default function PreviewTab() {
const params = useParams();
const projectId = params.projectId as string;
// Poll every 5s so state transitions (starting→running, build complete, etc.)
// surface without a manual refresh.
const { anatomy, loading } = useAnatomy(projectId, { pollMs: 5000 });
const previews = anatomy?.hosting.previews ?? [];
const [now, setNow] = useState<number>(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 10000);
return () => clearInterval(id);
}, []);
// Identify all valid running/starting previews (ignoring stale starting ones)
const validPreviews = previews
.filter(
(p) =>
p.state === "running" ||
(p.state === "starting" &&
now - new Date(p.startedAt).getTime() < 15 * 60 * 1000),
)
.sort((a, b) => a.port - b.port); // sort ports ascending
const [selectedPort, setSelectedPort] = useState<number>(3000);
// Auto-select logic if selectedPort is not in validPreviews
useEffect(() => {
if (validPreviews.length === 0) return;
const hasSelected = validPreviews.some((p) => p.port === selectedPort);
if (!hasSelected) {
// Prefer 3000 if available, else pick the first one
const fallback =
validPreviews.find((p) => p.port === 3000) ?? validPreviews[0];
if (fallback) setSelectedPort(fallback.port);
}
}, [validPreviews, selectedPort]);
// Derive the currently selected preview
const activePreview = validPreviews.find((p) => p.port === selectedPort);
// Split it into running and starting like before
const primaryRunning =
activePreview?.state === "running" ? activePreview : undefined;
const primaryStarting =
activePreview?.state === "starting" ? activePreview : undefined;
// Derive in-flight / recently-failed build from prod apps.
const liveApps = anatomy?.hosting.live ?? [];
const inFlightApp = liveApps.find((a) => a.inFlightBuild);
const failedApp = !inFlightApp
? liveApps.find((a) => a.lastBuild?.status === "failed")
: undefined;
// Fallback URL — the last deployed production app. Shown as a link while the
// dev server is warming up so the user has something to interact with.
const fallbackFqdn =
liveApps.find((a) => a.fqdn && a.status === "running")?.fqdn ?? null;
const fallbackUrl = fallbackFqdn
? fallbackFqdn.startsWith("http")
? fallbackFqdn
: `https://${fallbackFqdn}`
: null;
// ── Auto-ensure: fire a background restart when the pane loads and finds
// no running dev server, but there's a previous config to restart from.
const ensureCalledRef = useRef(false);
const [ensureStatus, setEnsureStatus] = useState<
"idle" | "calling" | "starting" | "no_history" | "error"
>("idle");
useEffect(() => {
// Only trigger once per mount, and only when anatomy has loaded with no running server.
if (ensureCalledRef.current) return;
if (loading || !anatomy) return;
if (primaryRunning || primaryStarting) return; // already up or already starting
ensureCalledRef.current = true;
fetch(`/api/projects/${projectId}/dev-server/ensure`, {
method: "POST",
credentials: "include",
})
.then((r) => r.json())
.then((data: { status?: string }) => {
if (data.status === "no_history" || data.status === "no_container") {
setEnsureStatus("no_history");
} else if (data.status === "starting" || data.status === "running") {
setEnsureStatus("starting");
// The 5s anatomy poll will pick up the new 'starting' row and
// transition the pane automatically — no extra work needed here.
} else {
setEnsureStatus("idle");
}
})
.catch(() => setEnsureStatus("error"));
}, [loading, anatomy, primaryRunning, primaryStarting, projectId]);
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
const iframeDomRef = useRef<HTMLIFrameElement | null>(null);
const bridge = usePreviewBridge();
const origin = typeof window !== "undefined" ? window.location.origin : "";
const deviceMode = usePreviewToolbarStore((s) => s.deviceMode);
const refreshKey = usePreviewToolbarStore((s) => s.refreshKey);
useLayoutEffect(() => {
setIframeSrc(primaryRunning?.url ?? null);
}, [primaryRunning?.url]);
useEffect(() => {
if (!bridge || !iframeSrc || !iframeDomRef.current) return;
bridge.registerPreviewIframe(iframeDomRef.current, iframeSrc);
}, [bridge, iframeSrc]);
// Determine which empty state to show.
const emptyContent = (() => {
if (loading && !anatomy) return <InitialLoader />;
if (inFlightApp) return <BuildingState app={inFlightApp} />;
if (failedApp) return <FailedState app={failedApp} />;
// Dev server is in the process of booting (either picked up from anatomy
// or we just fired the ensure endpoint and are waiting for the DB row).
if (primaryStarting) {
return (
<WarmingUpState
startedAt={primaryStarting.startedAt}
fallbackUrl={fallbackUrl}
port={selectedPort}
/>
);
}
if (ensureStatus === "calling" || ensureStatus === "starting") {
return (
<WarmingUpState
startedAt={undefined}
fallbackUrl={fallbackUrl}
port={selectedPort}
/>
);
}
// Never had a dev server — needs the AI to start one.
return <NotRunningState />;
})();
return (
<div style={canvas}>
{validPreviews.length > 1 && (
<div style={portPickerWrap}>
{validPreviews.map((p) => (
<button
key={p.id}
onClick={() => setSelectedPort(p.port)}
style={
selectedPort === p.port ? portButtonActive : portButtonInactive
}
>
<div
style={p.state === "running" ? dotRunning : dotStarting}
className={p.state === "starting" ? "animate-pulse" : ""}
/>
{p.name && p.name !== `port-${p.port}`
? p.name
: `Port ${p.port}`}
</button>
))}
</div>
)}
<div
style={{
flex: 1,
minHeight: 0,
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
...(deviceMode === "desktop" ? desktopFrame : mobileFrame),
transition: "all 0.3s ease",
}}
>
{deviceMode === "mobile" && <MobileChrome />}
{iframeSrc ? (
<iframe
key={`${iframeSrc}-${refreshKey}`}
src={iframeSrc}
title="Preview"
ref={(el) => {
iframeDomRef.current = el;
if (el) bridge?.registerPreviewIframe(el, iframeSrc);
}}
onLoad={() => bridge?.notifyPreviewIframeLoaded()}
style={{
...iframeStyle,
borderRadius: deviceMode === "mobile" ? 44 : 0,
}}
{...(sandboxIframe(iframeSrc, origin)
? { sandbox: SAME_ORIGIN_SANDBOX }
: {})}
/>
) : (
<div style={loaderWrap}>{emptyContent}</div>
)}
{deviceMode === "mobile" && <div style={homeIndicator} aria-hidden />}
</div>
</div>
</div>
);
}
// ── Empty-state sub-components ────────────────────────────────────────────────
function InitialLoader() {
return (
<Loader2
className="animate-spin"
style={{ width: 22, height: 22, color: "#9c9590" }}
/>
);
}
function WarmingUpState({
startedAt,
fallbackUrl,
port,
}: {
startedAt: string | undefined;
fallbackUrl: string | null;
port?: number;
}) {
const elapsed = useElapsed(startedAt);
return (
<div style={{ textAlign: "center", maxWidth: 280 }}>
<div
style={{ display: "flex", justifyContent: "center", marginBottom: 16 }}
>
<div style={buildRingOuter}>
<div style={buildRingInner} className="animate-spin" />
</div>
</div>
<p style={{ ...emptyTitle, color: "#6366f1" }}>
Dev server {port ? `(:${port}) ` : ""}warming up
{elapsed ? ` · ${elapsed}` : ""}
</p>
<p style={emptySubtext}>
Your preview will appear here automatically once it&apos;s ready.
Usually under 15 seconds.
</p>
{fallbackUrl && (
<a
href={fallbackUrl}
target="_blank"
rel="noopener noreferrer"
style={{
display: "inline-block",
marginTop: 14,
padding: "5px 12px",
borderRadius: 20,
background: "#f5f3ff",
border: "1px solid #c4b5fd",
fontSize: "0.72rem",
color: "#7c3aed",
textDecoration: "none",
}}
>
View last deployed version
</a>
)}
</div>
);
}
function BuildingState({
app,
}: {
app: {
name: string;
inFlightBuild?: { status: string; createdAt?: string; finishedAt?: string };
};
}) {
const elapsed = useElapsed(app.inFlightBuild?.createdAt);
return (
<div style={{ textAlign: "center", maxWidth: 280 }}>
<div
style={{ display: "flex", justifyContent: "center", marginBottom: 16 }}
>
<div style={buildRingOuter}>
<div style={buildRingInner} className="animate-spin" />
</div>
</div>
<p style={{ ...emptyTitle, color: "#3b82f6" }}>Building {app.name}</p>
<p style={emptySubtext}>
Your app is compiling and deploying. The preview will load automatically
when it&apos;s ready.
</p>
<div style={statusPill}>
<span
style={{
display: "inline-block",
width: 6,
height: 6,
borderRadius: "50%",
background: "#3b82f6",
marginRight: 6,
verticalAlign: "middle",
animation: "pulse 2s cubic-bezier(0.4,0,0.6,1) infinite",
}}
/>
<span style={{ verticalAlign: "middle" }}>
{app.inFlightBuild?.status ?? "building"}
{elapsed ? ` · ${elapsed}` : ""}
</span>
</div>
</div>
);
}
function FailedState({
app,
}: {
app: { name: string; lastBuild?: { status: string; commit?: string } };
}) {
return (
<div style={{ textAlign: "center", maxWidth: 280 }}>
<div
style={{
width: 40,
height: 40,
borderRadius: "50%",
background: "#fef2f2",
border: "1.5px solid #fecaca",
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: "0 auto 14px",
fontSize: 18,
}}
>
</div>
<p style={{ ...emptyTitle, color: "#ef4444" }}>Build failed</p>
<p style={emptySubtext}>
The last deploy of <strong>{app.name}</strong> didn&apos;t succeed.
{app.lastBuild?.commit ? ` (${app.lastBuild.commit.slice(0, 7)})` : ""}
</p>
<p style={{ ...emptySubtext, marginTop: 8 }}>
Ask the AI to check the build logs and fix the issue.
</p>
</div>
);
}
function NotRunningState() {
return (
<div style={{ textAlign: "center", maxWidth: 260 }}>
<div
style={{
width: 40,
height: 40,
borderRadius: "50%",
background: "#f4f4f5",
border: "1.5px solid #e4e4e7",
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: "0 auto 14px",
fontSize: 18,
color: "#a1a1aa",
}}
>
</div>
<p style={emptyTitle}>Preview not running</p>
<p style={emptySubtext}>No dev server found on port 3000.</p>
<p style={{ ...emptySubtext, marginTop: 4 }}>
Ask the AI to start the dev server.
</p>
</div>
);
}
// ── Styles ────────────────────────────────────────────────────────────────────
const portPickerWrap: React.CSSProperties = {
display: "flex",
gap: 8,
marginBottom: 12,
padding: "0 4px",
flexWrap: "wrap",
};
const portButtonBase: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 6,
padding: "4px 12px",
borderRadius: 20,
fontSize: "0.75rem",
fontWeight: 500,
cursor: "pointer",
transition: "all 0.15s ease",
border: "1px solid transparent",
};
const portButtonActive: React.CSSProperties = {
...portButtonBase,
background: "#fff",
borderColor: "rgba(26, 26, 26, 0.08)",
color: "#18181b",
boxShadow: "0 1px 2px rgba(0,0,0,0.05)",
};
const portButtonInactive: React.CSSProperties = {
...portButtonBase,
background: "transparent",
color: "#71717a",
border: "1px solid transparent",
};
const dotBase: React.CSSProperties = {
width: 6,
height: 6,
borderRadius: "50%",
};
const dotRunning: React.CSSProperties = {
...dotBase,
background: "#10b981", // green
boxShadow: "0 0 6px rgba(16, 185, 129, 0.4)",
};
const dotStarting: React.CSSProperties = {
...dotBase,
background: "#6366f1", // indigo
boxShadow: "0 0 6px rgba(99, 102, 241, 0.4)",
};
const canvas: React.CSSProperties = {
flex: 1,
minHeight: 0,
width: "100%",
display: "flex",
flexDirection: "column",
alignSelf: "stretch",
boxSizing: "border-box",
padding: "14px 16px 18px",
background: "linear-gradient(165deg, #faf8f5 0%, #f4f0ea 42%, #ebe7df 100%)",
};
const desktopFrame: React.CSSProperties = {
flex: 1,
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
borderRadius: 14,
overflow: "hidden",
background: "#fff",
border: "1px solid rgba(26, 26, 26, 0.07)",
boxShadow:
"0 1px 2px rgba(26, 26, 26, 0.04), 0 12px 40px rgba(26, 26, 26, 0.07), inset 0 1px 0 rgba(255, 255, 255, 0.85)",
};
const mobileFrame: React.CSSProperties = {
position: "relative",
width: 390,
height: 844,
flexShrink: 0,
borderRadius: 56,
overflow: "hidden",
background: "#000",
border: "14px solid #1a1a1a",
boxShadow:
"0 0 0 1px rgba(255,255,255,0.1) inset, 0 24px 60px rgba(26, 26, 26, 0.15)",
display: "flex",
flexDirection: "column",
};
const iframeStyle: React.CSSProperties = {
flex: 1,
width: "100%",
height: "100%",
border: "none",
background: "#fff",
pointerEvents: "auto",
};
const loaderWrap: React.CSSProperties = {
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "#fff",
};
const emptyTitle: React.CSSProperties = {
margin: "0 0 6px",
fontSize: "0.9rem",
fontWeight: 600,
color: "#18181b",
letterSpacing: "-0.01em",
};
const emptySubtext: React.CSSProperties = {
margin: 0,
fontSize: "0.78rem",
color: "#71717a",
lineHeight: 1.5,
};
const statusPill: React.CSSProperties = {
display: "inline-block",
marginTop: 14,
padding: "4px 10px",
borderRadius: 20,
background: "#eff6ff",
border: "1px solid #bfdbfe",
fontSize: "0.72rem",
color: "#1d4ed8",
};
const buildRingOuter: React.CSSProperties = {
width: 40,
height: 40,
borderRadius: "50%",
background: "#eff6ff",
border: "2px solid #bfdbfe",
display: "flex",
alignItems: "center",
justifyContent: "center",
};
const buildRingInner: React.CSSProperties = {
width: 20,
height: 20,
borderRadius: "50%",
borderTop: "2.5px solid #3b82f6",
borderRight: "2.5px solid transparent",
borderBottom: "2.5px solid transparent",
borderLeft: "2.5px solid transparent",
};
function MobileChrome() {
return (
<div style={notchWrap}>
<div style={notch} />
</div>
);
}
const notchWrap: React.CSSProperties = {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 32,
display: "flex",
justifyContent: "center",
zIndex: 10,
pointerEvents: "none",
};
const notch: React.CSSProperties = {
width: 120,
height: 30,
background: "#1a1a1a",
borderBottomLeftRadius: 20,
borderBottomRightRadius: 20,
};
const homeIndicator: React.CSSProperties = {
position: "absolute",
bottom: 8,
left: "50%",
transform: "translateX(-50%)",
width: 140,
height: 5,
borderRadius: 10,
background: "rgba(255, 255, 255, 0.4)",
mixBlendMode: "difference",
zIndex: 10,
pointerEvents: "none",
};