diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/preview/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/preview/page.tsx index 02a90777..44209798 100644 --- a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/preview/page.tsx +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/preview/page.tsx @@ -55,20 +55,44 @@ export default function PreviewTab() { const previews = anatomy?.hosting.previews ?? []; - // Only load the iframe for a server that is fully running. - const primaryRunning = previews.find( - (p) => p.port === 3000 && p.state === "running", - ); - // Also track a starting entry so we show the warm-up state instead of blank. - // Ignore ghosts older than 15 minutes. - const primaryStarting = !primaryRunning - ? previews.find( - (p) => - p.port === 3000 && - p.state === "starting" && - Date.now() - new Date(p.startedAt).getTime() < 15 * 60 * 1000, - ) - : undefined; + const [now, setNow] = useState(() => 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(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 ?? []; @@ -150,11 +174,18 @@ export default function PreviewTab() { ); } if (ensureStatus === "calling" || ensureStatus === "starting") { - return ; + return ( + + ); } // Never had a dev server — needs the AI to start one. return ; @@ -162,6 +193,27 @@ export default function PreviewTab() { return (
+ {validPreviews.length > 1 && ( +
+ {validPreviews.map((p) => ( + + ))} +
+ )}

- Dev server warming up + Dev server {port ? `(:${port}) ` : ""}warming up {elapsed ? ` · ${elapsed}` : ""}

@@ -386,6 +440,60 @@ function NotRunningState() { // ── 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,