feat(ui): hardcode visual preview tab to primary frontend port 3000
This commit is contained in:
@@ -25,9 +25,9 @@ export default function PreviewTab() {
|
||||
const { anatomy, loading } = useAnatomy(projectId, { pollMs: 0 });
|
||||
|
||||
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 iframeDomRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const bridge = usePreviewBridge();
|
||||
@@ -35,16 +35,9 @@ export default function PreviewTab() {
|
||||
|
||||
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(() => {
|
||||
setIframeSrc(selectedUrl ?? null);
|
||||
}, [selectedUrl]);
|
||||
setIframeSrc(primaryPreview?.url ?? null);
|
||||
}, [primaryPreview?.url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bridge || !iframeSrc || !iframeDomRef.current) return;
|
||||
@@ -53,23 +46,6 @@ export default function PreviewTab() {
|
||||
|
||||
return (
|
||||
<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
|
||||
style={{
|
||||
flex: 1,
|
||||
@@ -101,7 +77,7 @@ export default function PreviewTab() {
|
||||
title="Preview"
|
||||
ref={(el) => {
|
||||
iframeDomRef.current = el;
|
||||
bridge?.registerPreviewIframe(el, iframeSrc);
|
||||
if (el) bridge?.registerPreviewIframe(el, iframeSrc);
|
||||
}}
|
||||
onLoad={() => bridge?.notifyPreviewIframeLoaded()}
|
||||
style={{
|
||||
@@ -114,7 +90,8 @@ export default function PreviewTab() {
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -137,25 +114,6 @@ const canvas: React.CSSProperties = {
|
||||
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 = {
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
@@ -176,135 +134,77 @@ const mobileFrame: React.CSSProperties = {
|
||||
height: 844,
|
||||
flexShrink: 0,
|
||||
borderRadius: 56,
|
||||
padding: 12,
|
||||
background: "linear-gradient(160deg, #2a2a2c 0%, #1a1a1c 50%, #0e0e10 100%)",
|
||||
overflow: "hidden",
|
||||
background: "#000",
|
||||
border: "14px solid #1a1a1a",
|
||||
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",
|
||||
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 = {
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
minHeight: 0,
|
||||
height: "100%",
|
||||
border: "none",
|
||||
background: "#fcfcfb",
|
||||
display: "block",
|
||||
background: "#fff",
|
||||
pointerEvents: "auto",
|
||||
};
|
||||
|
||||
const loaderWrap: React.CSSProperties = {
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: 200,
|
||||
background: "#fcfcfb",
|
||||
background: "#fff",
|
||||
};
|
||||
|
||||
const emptyText: React.CSSProperties = {
|
||||
margin: 0,
|
||||
fontSize: "0.85rem",
|
||||
color: "#a09a90",
|
||||
fontFamily: '"Outfit", "Inter", ui-sans-serif, sans-serif',
|
||||
color: "#a1a1aa",
|
||||
};
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
@@ -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 2–3 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_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 3001–3009; \`npm: command not found\` → project needs \`npm install\` first.
|
||||
- \`port\` defaults to 3000, range 3000–3009 (10 Traefik routers pre-allocated per project).
|
||||
- **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 servers (preview URL via `*.preview.vibnai.com` wildcard):**
|
||||
- `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 3001–3009; `npm: command not found` → project needs `npm install` first.
|
||||
- **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 `3001–3009`, 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"`.
|
||||
- `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):**
|
||||
- **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.
|
||||
|
||||
Reference in New Issue
Block a user