feat(ui): hardcode visual preview tab to primary frontend port 3000

This commit is contained in:
2026-05-15 16:03:06 -07:00
parent 4adf7a7659
commit d464ccd19d
2 changed files with 65 additions and 165 deletions

View File

@@ -25,9 +25,9 @@ export default function PreviewTab() {
const { anatomy, loading } = useAnatomy(projectId, { pollMs: 0 }); const { anatomy, loading } = useAnatomy(projectId, { pollMs: 0 });
const previews = anatomy?.hosting.previews ?? []; const previews = anatomy?.hosting.previews ?? [];
const options = previews.filter((p) => p.url); // Find the port 3000 preview if it exists, otherwise fall back to null
const primaryPreview = previews.find(p => p.port === 3000);
const [selectedUrl, setSelectedUrl] = useState<string | null>(null);
const [iframeSrc, setIframeSrc] = useState<string | null>(null); const [iframeSrc, setIframeSrc] = useState<string | null>(null);
const iframeDomRef = useRef<HTMLIFrameElement | null>(null); const iframeDomRef = useRef<HTMLIFrameElement | null>(null);
const bridge = usePreviewBridge(); const bridge = usePreviewBridge();
@@ -35,16 +35,9 @@ export default function PreviewTab() {
const deviceMode = usePreviewToolbarStore((s) => s.deviceMode); const deviceMode = usePreviewToolbarStore((s) => s.deviceMode);
// Auto-select first preview on load
useEffect(() => {
if (!selectedUrl && options.length > 0) {
setSelectedUrl(options[0].url);
}
}, [options, selectedUrl]);
useLayoutEffect(() => { useLayoutEffect(() => {
setIframeSrc(selectedUrl ?? null); setIframeSrc(primaryPreview?.url ?? null);
}, [selectedUrl]); }, [primaryPreview?.url]);
useEffect(() => { useEffect(() => {
if (!bridge || !iframeSrc || !iframeDomRef.current) return; if (!bridge || !iframeSrc || !iframeDomRef.current) return;
@@ -53,23 +46,6 @@ export default function PreviewTab() {
return ( return (
<div style={canvas}> <div style={canvas}>
<div style={toolbar}>
{options.length > 1 && (
<select
value={selectedUrl ?? ""}
onChange={(e) => setSelectedUrl(e.target.value)}
style={select}
>
{options.map((p) => (
<option key={p.id} value={p.url}>
{p.name} :{p.port} {p.state}
{p.command ? ` (${p.command.slice(0, 60)})` : ""}
</option>
))}
</select>
)}
</div>
<div <div
style={{ style={{
flex: 1, flex: 1,
@@ -101,7 +77,7 @@ export default function PreviewTab() {
title="Preview" title="Preview"
ref={(el) => { ref={(el) => {
iframeDomRef.current = el; iframeDomRef.current = el;
bridge?.registerPreviewIframe(el, iframeSrc); if (el) bridge?.registerPreviewIframe(el, iframeSrc);
}} }}
onLoad={() => bridge?.notifyPreviewIframeLoaded()} onLoad={() => bridge?.notifyPreviewIframeLoaded()}
style={{ style={{
@@ -114,7 +90,8 @@ export default function PreviewTab() {
/> />
) : ( ) : (
<div style={loaderWrap}> <div style={loaderWrap}>
<p style={emptyText}>No preview available</p> <p style={emptyText}>Preview not running on port 3000.</p>
<p style={{...emptyText, fontSize: '0.75rem', marginTop: 8}}>Ask the AI to start the dev server.</p>
</div> </div>
)} )}
@@ -137,25 +114,6 @@ const canvas: React.CSSProperties = {
background: "linear-gradient(165deg, #faf8f5 0%, #f4f0ea 42%, #ebe7df 100%)", background: "linear-gradient(165deg, #faf8f5 0%, #f4f0ea 42%, #ebe7df 100%)",
}; };
const toolbar: React.CSSProperties = {
display: "flex",
gap: 8,
marginBottom: 10,
flexWrap: "wrap",
};
const select: React.CSSProperties = {
flex: 1,
maxWidth: 480,
padding: "6px 10px",
borderRadius: 8,
border: "1px solid rgba(26, 26, 26, 0.12)",
background: "rgba(255,255,255,0.85)",
fontSize: "0.8rem",
fontFamily: "inherit",
color: "#1a1a1a",
};
const desktopFrame: React.CSSProperties = { const desktopFrame: React.CSSProperties = {
flex: 1, flex: 1,
width: "100%", width: "100%",
@@ -176,135 +134,77 @@ const mobileFrame: React.CSSProperties = {
height: 844, height: 844,
flexShrink: 0, flexShrink: 0,
borderRadius: 56, borderRadius: 56,
padding: 12, overflow: "hidden",
background: "linear-gradient(160deg, #2a2a2c 0%, #1a1a1c 50%, #0e0e10 100%)", background: "#000",
border: "14px solid #1a1a1a",
boxShadow: boxShadow:
"0 0 0 1px rgba(255,255,255,0.04) inset, 0 0 0 2px #000 inset, 0 28px 60px -12px rgba(0,0,0,0.45), 0 8px 20px -8px rgba(0,0,0,0.35)", "0 0 0 1px rgba(255,255,255,0.1) inset, 0 24px 60px rgba(26, 26, 26, 0.15)",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
overflow: "hidden",
}; };
const homeIndicator: React.CSSProperties = {
position: "absolute",
left: "50%",
bottom: 20,
transform: "translateX(-50%)",
width: 134,
height: 5,
background: "#1a1916",
borderRadius: 999,
opacity: 0.85,
pointerEvents: "none",
zIndex: 10,
};
function MobileChrome() {
return (
<>
<div
style={{
position: "absolute",
top: 22,
left: "50%",
transform: "translateX(-50%)",
width: 124,
height: 36,
background: "#000",
borderRadius: 999,
zIndex: 15,
}}
/>
<div
style={{
position: "absolute",
top: 12,
left: 12,
right: 12,
height: 47,
padding: "18px 26px 0",
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
fontSize: 15,
fontWeight: 600,
letterSpacing: "-0.01em",
color: "#1a1916",
pointerEvents: "none",
zIndex: 10,
}}
>
<span>9:41</span>
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<svg
viewBox="0 0 17 11"
style={{ width: 17, height: 11, fill: "currentColor" }}
aria-hidden
>
<rect x="0" y="7" width="3" height="4" rx="0.6" />
<rect x="4" y="5" width="3" height="6" rx="0.6" />
<rect x="8" y="3" width="3" height="8" rx="0.6" />
<rect x="12" y="0" width="3" height="11" rx="0.6" />
</svg>
<svg
viewBox="0 0 17 11"
style={{ width: 17, height: 11, fill: "currentColor" }}
aria-hidden
>
<path d="M8.5 1.5C5.5 1.5 2.7 2.6 0.5 4.6L2 6.1C3.8 4.5 6.1 3.6 8.5 3.6c2.4 0 4.7 0.9 6.5 2.5l1.5-1.5c-2.2-2-5-3.1-8-3.1zM3.5 7.6L5 9.1c1-0.9 2.2-1.4 3.5-1.4 1.3 0 2.5 0.5 3.5 1.4l1.5-1.5c-1.4-1.3-3.1-2-5-2-1.9 0-3.6 0.7-5 2zM6.5 10.6l2 2 2-2c-0.5-0.5-1.2-0.8-2-0.8s-1.5 0.3-2 0.8z" />
</svg>
<svg
viewBox="0 0 25 11"
style={{ width: 25, height: 11, fill: "currentColor" }}
aria-hidden
>
<rect
x="0.5"
y="0.5"
width="21"
height="10"
rx="2.5"
fill="none"
stroke="currentColor"
strokeOpacity="0.45"
/>
<rect
x="22"
y="3.5"
width="1.5"
height="4"
rx="0.4"
fill="currentColor"
fillOpacity="0.45"
/>
<rect x="2" y="2" width="18" height="7" rx="1.4" />
</svg>
</span>
</div>
</>
);
}
const iframeStyle: React.CSSProperties = { const iframeStyle: React.CSSProperties = {
flex: 1, flex: 1,
width: "100%", width: "100%",
minHeight: 0, height: "100%",
border: "none", border: "none",
background: "#fcfcfb", background: "#fff",
display: "block", pointerEvents: "auto",
}; };
const loaderWrap: React.CSSProperties = { const loaderWrap: React.CSSProperties = {
flex: 1, flex: 1,
display: "flex", display: "flex",
flexDirection: "column",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
minHeight: 200, background: "#fff",
background: "#fcfcfb",
}; };
const emptyText: React.CSSProperties = { const emptyText: React.CSSProperties = {
margin: 0,
fontSize: "0.85rem", fontSize: "0.85rem",
color: "#a09a90", color: "#a1a1aa",
fontFamily: '"Outfit", "Inter", ui-sans-serif, sans-serif', };
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",
}; };

View File

@@ -200,11 +200,11 @@ Each project has a persistent \`vibn-dev\` container. Edit files via \`fs_*\` an
**Iterate:**\n- \`shell_exec { projectId, command }\` — anything: \`ls\`, \`npm install\`, \`npm test\`, \`npx create-next-app .\`, \`git status\`. Cwd defaults to \`/workspace\`. Node (LTS), Python 3.12, and Go 1.23 are pre-installed — no setup needed.\n- \`fs_read\` / \`fs_write\` / \`fs_edit { path, oldString, newString }\` (include 23 lines of context in \`oldString\` for uniqueness; fails fast if missing or non-unique).\n- \`fs_glob\` / \`fs_grep\` (ripgrep, respects .gitignore) / \`fs_list\` / \`fs_delete\`.\n **Iterate:**\n- \`shell_exec { projectId, command }\` — anything: \`ls\`, \`npm install\`, \`npm test\`, \`npx create-next-app .\`, \`git status\`. Cwd defaults to \`/workspace\`. Node (LTS), Python 3.12, and Go 1.23 are pre-installed — no setup needed.\n- \`fs_read\` / \`fs_write\` / \`fs_edit { path, oldString, newString }\` (include 23 lines of context in \`oldString\` for uniqueness; fails fast if missing or non-unique).\n- \`fs_glob\` / \`fs_grep\` (ripgrep, respects .gitignore) / \`fs_list\` / \`fs_delete\`.\n
**Dev servers (preview URL via \`*.preview.vibnai.com\` wildcard):** **Dev servers (preview URL via `*.preview.vibnai.com` wildcard):**
- \`dev_server_start { projectId, command, port?, name? }\` is a **one-shot** call. It kills old processes on the port, checks the port is free, sets HOST=0.0.0.0 + PORT, launches your command, and returns a clickable \`previewUrl\`. Do NOT pre-flight with \`devcontainer_status\`, \`fs_list\`, \`dev_server_logs\`, or manual \`shell_exec\` kills — the function handles all of that. Just call it. The error tells you what to fix: \`PORT_BUSY\` → pick 30013009; \`npm: command not found\` → project needs \`npm install\` first. - `dev_server_start { projectId, command, port: 3000 }` is a **one-shot** call. It kills old processes on the port, checks the port is free, sets HOST=0.0.0.0 + PORT, launches your command, and returns a clickable `previewUrl`. Do NOT pre-flight with `devcontainer_status`, `fs_list`, `dev_server_logs`, or manual `shell_exec` kills — the function handles all of that. Just call it. The error tells you what to fix: `PORT_BUSY` → pick 30013009; `npm: command not found` → project needs `npm install` first.
- \`port\` defaults to 3000, range 30003009 (10 Traefik routers pre-allocated per project). - **Port:** The primary frontend service MUST ALWAYS be bound to port `3000`. Do not use any other port for the user-facing UI. If you are spinning up secondary services (like an API or Storybook) alongside it, you may bind them to ports `30013009`, but port `3000` is reserved exclusively for the primary visual preview.
- **Directory:** The command runs from the root \`/workspace\` directory, but your project code is inside \`/workspace/${activeProject.slug ?? "<slug>"}/\`. You MUST \`cd\` into your project folder first! Example: \`command: "cd ${activeProject.slug ?? "<slug>"} && npm run dev"\`. - **Directory:** The command runs from the root `/workspace` directory, but your project code is inside `/workspace/${activeProject.slug ?? "<slug>"}/`. You MUST `cd` into your project folder first! Example: `command: "cd ${activeProject.slug ?? "<slug>"} && npm run dev"`.
- \`dev_server_stop\` / \`dev_server_list\` / \`dev_server_logs\` — use only AFTER a failed start, and only to diagnose the error the function returned. Never on success. - `dev_server_stop` / `dev_server_list` / `dev_server_logs` — use only AFTER a failed start, and only to diagnose the error the function returned. Never on success.
**HMR through the proxy (apply when scaffolding):** **HMR through the proxy (apply when scaffolding):**
- **Vite (verified working):** in \`vite.config\` set \`server: { host: '0.0.0.0', port: <3000-3009>, strictPort: true, hmr: { clientPort: 443, protocol: 'wss', host: '<the previewUrl host, no protocol>' } }\`. The \`hmr.host\` is REQUIRED — without it Vite's HMR client can guess the wrong host and the WS handshake fails through Traefik. Default localhost binding looks fine locally but breaks HMR through the proxy. - **Vite (verified working):** in \`vite.config\` set \`server: { host: '0.0.0.0', port: <3000-3009>, strictPort: true, hmr: { clientPort: 443, protocol: 'wss', host: '<the previewUrl host, no protocol>' } }\`. The \`hmr.host\` is REQUIRED — without it Vite's HMR client can guess the wrong host and the WS handshake fails through Traefik. Default localhost binding looks fine locally but breaks HMR through the proxy.