feat(preview): support multiple preview ports with styled picker
This commit is contained in:
@@ -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(
|
||||
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.port === 3000 &&
|
||||
p.state === "starting" &&
|
||||
Date.now() - new Date(p.startedAt).getTime() < 15 * 60 * 1000,
|
||||
p.state === "running" ||
|
||||
(p.state === "starting" &&
|
||||
now - new Date(p.startedAt).getTime() < 15 * 60 * 1000),
|
||||
)
|
||||
: undefined;
|
||||
.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,
|
||||
|
||||
Reference in New Issue
Block a user