feat(preview): support multiple preview ports with styled picker

This commit is contained in:
2026-06-11 11:27:44 -07:00
parent bcf47b5c6c
commit 371ae37cc2

View File

@@ -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<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 ?? [];
@@ -150,11 +174,18 @@ export default function PreviewTab() {
<WarmingUpState
startedAt={primaryStarting.startedAt}
fallbackUrl={fallbackUrl}
port={selectedPort}
/>
);
}
if (ensureStatus === "calling" || ensureStatus === "starting") {
return <WarmingUpState startedAt={undefined} fallbackUrl={fallbackUrl} />;
return (
<WarmingUpState
startedAt={undefined}
fallbackUrl={fallbackUrl}
port={selectedPort}
/>
);
}
// Never had a dev server — needs the AI to start one.
return <NotRunningState />;
@@ -162,6 +193,27 @@ export default function PreviewTab() {
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,
@@ -222,9 +274,11 @@ function InitialLoader() {
function WarmingUpState({
startedAt,
fallbackUrl,
port,
}: {
startedAt: string | undefined;
fallbackUrl: string | null;
port?: number;
}) {
const elapsed = useElapsed(startedAt);
@@ -239,7 +293,7 @@ function WarmingUpState({
</div>
<p style={{ ...emptyTitle, color: "#6366f1" }}>
Dev server warming up
Dev server {port ? `(:${port}) ` : ""}warming up
{elapsed ? ` · ${elapsed}` : ""}
</p>
<p style={emptySubtext}>
@@ -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,