feat(project): split dev containers into Product; convert Hosting to tile-rail

The vibn-dev-* services that the AI authors code in conceptually
belong to Product (build surface), not Hosting (runtime + reach).
Anatomy endpoint now splits Coolify services by name prefix:
  - vibn-dev-* → product.devContainers[]
  - everything else → hosting.services[]

Product tab gains a "Workspace" section above the codebases stack
with a single dev-container tile. Selecting it shows status +
active dev servers in the right pane. Codebase + file selection
behaves the same as before.

Hosting tab restructured from a stack of always-visible cards to
the same tile-rail pattern Product uses: left rail has 4 always-
present categories (Production / Services / Previews / Domains)
each with a count badge, items inside are clickable tiles, right
pane shows details for the selected item. Empty categories show a
one-liner explaining what would appear there — teaches the user
the model on a brand-new project without being preachy.

Made-with: Cursor
This commit is contained in:
2026-04-28 18:54:19 -07:00
parent ba69a78a5f
commit 3db7191146
5 changed files with 678 additions and 297 deletions

View File

@@ -0,0 +1,187 @@
"use client";
/**
* Right-panel detail view for a vibn-dev container.
* Today: shows status, dev servers running inside it, and active
* preview URLs. Future: tail container logs, restart button.
*/
import { Server, ExternalLink, CircleDot, Zap } from "lucide-react";
import type { Anatomy } from "./use-anatomy";
interface DevContainerDetailProps {
container: Anatomy["product"]["devContainers"][number];
previewUrls: Anatomy["hosting"]["previewUrls"];
}
export function DevContainerDetail({ container, previewUrls }: DevContainerDetailProps) {
const statusColor = colorForStatus(container.status);
return (
<div style={wrap}>
<div style={statusRow}>
<Server size={14} style={{ color: INK.mid }} />
<span style={{ flex: 1, color: INK.ink, fontSize: "0.85rem" }}>{container.name}</span>
<span style={statusPill}>
<CircleDot size={9} style={{ color: statusColor }} />
<span style={{ fontSize: "0.74rem", color: INK.mid }}>{container.status ?? "unknown"}</span>
</span>
</div>
<Section title="Active dev servers">
{previewUrls.length === 0 ? (
<Empty
message="No dev servers running."
hint="Ask Vibn to start one — `npm run dev`, `flask run`, etc."
/>
) : (
previewUrls.map(p => (
<Row
key={p.id}
icon={Zap}
title={`${p.name} :${p.port}`}
subtitle={`${p.state}`}
href={p.url}
hrefLabel={hostOf(p.url)}
/>
))
)}
</Section>
</div>
);
}
// ──────────────────────────────────────────────────
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section style={sectionWrap}>
<header style={sectionHeader}>{title}</header>
<div>{children}</div>
</section>
);
}
function Row({
icon: Icon, title, subtitle, href, hrefLabel,
}: {
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>;
title: string;
subtitle?: string;
href?: string;
hrefLabel?: string;
}) {
return (
<div style={rowStyle}>
<Icon size={13} style={{ color: INK.mid, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: "0.85rem", fontWeight: 600, color: INK.ink }}>{title}</div>
{subtitle && <div style={{ fontSize: "0.74rem", color: INK.mid }}>{subtitle}</div>}
</div>
{href && (
<a href={href} target="_blank" rel="noreferrer" style={openLink}>
<ExternalLink size={11} /> {hrefLabel ?? "open"}
</a>
)}
</div>
);
}
function Empty({ message, hint }: { message: string; hint?: string }) {
return (
<div style={emptyWrap}>
<div style={emptyMsg}>{message}</div>
{hint && <div style={emptyHint}>{hint}</div>}
</div>
);
}
function colorForStatus(s?: string) {
if (!s) return "#a09a90";
if (/running|healthy/i.test(s)) return "#2e7d32";
if (/starting|deploying/i.test(s)) return "#d4a04a";
if (/exit|fail|unhealthy/i.test(s)) return "#c5392b";
return "#a09a90";
}
function hostOf(url: string) {
try { return new URL(url).host; } catch { return url; }
}
const INK = {
ink: "#1a1a1a",
mid: "#5f5e5a",
muted: "#a09a90",
border: "#e8e4dc",
borderSoft: "#efebe1",
} as const;
const wrap: React.CSSProperties = {
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
gap: 14,
margin: "-4px -4px",
};
const statusRow: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
padding: "12px 14px",
border: `1px solid ${INK.borderSoft}`,
borderRadius: 8,
};
const statusPill: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 5,
flexShrink: 0,
};
const sectionWrap: React.CSSProperties = {
border: `1px solid ${INK.borderSoft}`,
borderRadius: 8,
overflow: "hidden",
};
const sectionHeader: React.CSSProperties = {
padding: "10px 14px",
fontSize: "0.72rem",
fontWeight: 600,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: INK.mid,
borderBottom: `1px solid ${INK.borderSoft}`,
};
const rowStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
padding: "10px 14px",
borderBottom: `1px solid ${INK.borderSoft}`,
};
const openLink: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 5,
fontSize: "0.76rem",
color: INK.mid,
textDecoration: "none",
border: `1px solid ${INK.borderSoft}`,
borderRadius: 6,
padding: "3px 8px",
flexShrink: 0,
};
const emptyWrap: React.CSSProperties = {
padding: "16px 14px",
textAlign: "center",
};
const emptyMsg: React.CSSProperties = {
fontSize: "0.82rem",
color: INK.mid,
marginBottom: 4,
};
const emptyHint: React.CSSProperties = {
fontSize: "0.74rem",
color: INK.muted,
fontStyle: "italic",
};

View File

@@ -12,6 +12,9 @@ export interface Anatomy {
project: { id: string; name: string; gitea?: string; coolifyProjectUuid?: string };
codebases: Array<{ id: string; label: string; path: string; hint?: string }>;
codebasesReason?: "no_repo" | "empty_repo";
product: {
devContainers: Array<{ uuid: string; name: string; status?: string }>;
};
hosting: {
production: Array<{
uuid: string;