-
-
{title}
- {subtitle &&
{subtitle}
}
+
+ {dot &&
}
+
);
}
-function Empty({ message, hint }: { message: string; hint?: string }) {
+function DetailLayout({ children }: { children: React.ReactNode }) {
+ return
{children}
;
+}
+
+function DetailRow({
+ label, value, dot, href,
+}: {
+ label: string; value: string; dot?: string; href?: string;
+}) {
return (
-
-
{message}
- {hint &&
{hint}
}
+
+
{label}
+
+ {dot && }
+ {href ? (
+
+ {value}
+
+ ) : value}
+
);
}
-function Center({ children }: { children: React.ReactNode }) {
+function Inline({ children }: { children: React.ReactNode }) {
return (
{children}
+ display: "flex", alignItems: "center", gap: 8,
+ padding: "12px 14px", fontSize: "0.82rem", color: INK.mid,
+ background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 8,
+ }}>
+ {children}
+
);
}
-// ──────────────────────────────────────────────────────────────────────
+function Empty({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+// ──────────────────────────────────────────────────
// Helpers
-// ──────────────────────────────────────────────────────────────────────
+// ──────────────────────────────────────────────────
function primaryHost(fqdn: string) {
return fqdn.split(",")[0]?.trim().replace(/^https?:\/\//, "").replace(/\/$/, "") || fqdn;
}
-function hrefForFqdn(fqdn: string) {
- const host = primaryHost(fqdn);
- return host.startsWith("http") ? host : `https://${host}`;
-}
function hostOf(url: string) {
try { return new URL(url).host; } catch { return url; }
}
@@ -216,7 +331,7 @@ function statusColor(status: string) {
const s = status.toLowerCase();
if (s.includes("running") || s.includes("healthy")) return "#2e7d32";
if (s.includes("starting") || s.includes("deploying")) return "#d4a04a";
- if (s.includes("exited") || s.includes("failed") || s.includes("unhealthy")) return "#c5392b";
+ if (s.includes("exit") || s.includes("fail") || s.includes("unhealthy")) return "#c5392b";
return "#a09a90";
}
function formatRelative(iso: string) {
@@ -226,13 +341,12 @@ function formatRelative(iso: string) {
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
- const d = Math.floor(hr / 24);
- return `${d}d ago`;
+ return `${Math.floor(hr / 24)}d ago`;
}
-// ──────────────────────────────────────────────────────────────────────
+// ──────────────────────────────────────────────────
// Tokens
-// ──────────────────────────────────────────────────────────────────────
+// ──────────────────────────────────────────────────
const INK = {
ink: "#1a1a1a",
@@ -249,51 +363,75 @@ const pageWrap: React.CSSProperties = {
fontFamily: INK.fontSans,
color: INK.ink,
};
-const inner: React.CSSProperties = {
- maxWidth: 960, margin: "0 auto",
- display: "flex", flexDirection: "column", gap: 16,
+const grid: React.CSSProperties = {
+ display: "grid",
+ gridTemplateColumns: "minmax(280px, 360px) minmax(0, 1fr)",
+ gap: 28,
+ maxWidth: 1400,
+ margin: "0 auto",
+ alignItems: "stretch",
};
-const bannerBox: React.CSSProperties = {
- padding: "14px 18px", borderRadius: 10,
- background: "#fff7e8", border: "1px solid #f0deb6",
+const leftCol: React.CSSProperties = {
+ minWidth: 0, display: "flex", flexDirection: "column", gap: 18,
};
-const bannerTitle: React.CSSProperties = {
- fontWeight: 600, color: "#7a5818", fontSize: "0.88rem", marginBottom: 4,
+const rightCol: React.CSSProperties = {
+ minWidth: 0, display: "flex", flexDirection: "column",
};
-const bannerBody: React.CSSProperties = { color: "#7a5818", fontSize: "0.82rem", lineHeight: 1.5 };
-const sectionWrap: React.CSSProperties = {
- background: INK.cardBg, border: `1px solid ${INK.border}`, borderRadius: 10, overflow: "hidden",
+const heading: React.CSSProperties = {
+ fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.12em",
+ textTransform: "uppercase", color: INK.muted, margin: "0 0 14px",
};
-const sectionHeader: React.CSSProperties = {
- display: "flex", alignItems: "center", gap: 8,
- padding: "14px 18px", borderBottom: `1px solid ${INK.borderSoft}`,
+const railGroup: React.CSSProperties = {
+ display: "flex", flexDirection: "column",
};
-const sectionTitle: React.CSSProperties = {
- fontSize: "0.78rem", fontWeight: 600, letterSpacing: "0.06em", textTransform: "uppercase", color: INK.ink,
+const railGroupHeader: React.CSSProperties = {
+ display: "flex", alignItems: "center", justifyContent: "space-between",
+ padding: "0 4px 8px",
+};
+const railGroupTitle: React.CSSProperties = {
+ fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.12em",
+ textTransform: "uppercase", color: INK.muted,
};
const countPill: React.CSSProperties = {
- marginLeft: "auto", fontSize: "0.72rem", fontWeight: 600, color: INK.mid,
- padding: "2px 8px", borderRadius: 999, background: "#f3eee4",
+ fontSize: "0.7rem", fontWeight: 600, color: INK.mid,
+ padding: "1px 7px", borderRadius: 999, background: "#f3eee4",
};
-const sectionBody: React.CSSProperties = { display: "flex", flexDirection: "column" };
-const rowWrap: React.CSSProperties = {
- display: "flex", alignItems: "center", gap: 14,
- padding: "12px 18px", borderTop: `1px solid ${INK.borderSoft}`,
+const railItems: React.CSSProperties = {
+ display: "flex", flexDirection: "column", gap: 8,
};
-const rowTitle: React.CSSProperties = { fontSize: "0.88rem", fontWeight: 600, color: INK.ink };
-const rowSubtitle: React.CSSProperties = { fontSize: "0.74rem", color: INK.mid, marginTop: 2 };
-const statusPill: React.CSSProperties = {
- display: "inline-flex", alignItems: "center", gap: 6, flexShrink: 0,
+const railItem: React.CSSProperties = {
+ display: "flex", alignItems: "center", gap: 10,
+ width: "100%", padding: "10px 12px",
+ border: `1px solid ${INK.borderSoft}`, borderRadius: 8,
+ cursor: "pointer", font: "inherit", color: "inherit",
+ transition: "border-color 0.12s, background 0.12s, box-shadow 0.12s",
};
-const openLink: React.CSSProperties = {
- display: "inline-flex", alignItems: "center", gap: 5,
- fontSize: "0.78rem", color: INK.mid, textDecoration: "none",
- border: `1px solid ${INK.borderSoft}`, borderRadius: 6, padding: "4px 8px",
- flexShrink: 0,
+const railEmpty: React.CSSProperties = {
+ padding: "10px 12px", fontSize: "0.74rem", color: INK.muted,
+ fontStyle: "italic", border: `1px dashed ${INK.borderSoft}`, borderRadius: 8,
+ lineHeight: 1.4,
};
-const emptyWrap: React.CSSProperties = {
- padding: "20px 18px", textAlign: "center",
- borderTop: `1px solid ${INK.borderSoft}`,
+const tileLabel: React.CSSProperties = {
+ fontSize: "0.85rem", fontWeight: 600, color: INK.ink, marginBottom: 2,
+};
+const tileHint: React.CSSProperties = {
+ fontSize: "0.74rem", color: INK.mid, lineHeight: 1.4,
+};
+const panel: React.CSSProperties = {
+ background: INK.cardBg, border: `1px solid ${INK.border}`, borderRadius: 10,
+ padding: 16, flex: 1, minHeight: 0, display: "flex", flexDirection: "column",
+};
+const detailRow: React.CSSProperties = {
+ display: "flex", alignItems: "center", justifyContent: "space-between",
+ padding: "12px 4px", borderBottom: `1px solid ${INK.borderSoft}`,
+};
+const detailLabel: React.CSSProperties = {
+ fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.06em",
+ textTransform: "uppercase", color: INK.muted,
+};
+const detailValue: React.CSSProperties = {
+ fontSize: "0.85rem", color: INK.ink, display: "inline-flex", alignItems: "center",
+};
+const detailLink: React.CSSProperties = {
+ color: INK.ink, textDecoration: "none", display: "inline-flex", alignItems: "center", gap: 6,
};
-const emptyMsg: React.CSSProperties = { fontSize: "0.82rem", color: INK.mid, marginBottom: 4 };
-const emptyHint: React.CSSProperties = { fontSize: "0.74rem", color: INK.muted, fontStyle: "italic" };
diff --git a/app/[workspace]/project/[projectId]/(home)/product/page.tsx b/app/[workspace]/project/[projectId]/(home)/product/page.tsx
index 27154694..4899bb83 100644
--- a/app/[workspace]/project/[projectId]/(home)/product/page.tsx
+++ b/app/[workspace]/project/[projectId]/(home)/product/page.tsx
@@ -2,29 +2,43 @@
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
-import { Loader2, AlertCircle, ChevronDown, ChevronRight, Box } from "lucide-react";
+import { Loader2, AlertCircle, ChevronDown, ChevronRight, Box, Server, CircleDot } from "lucide-react";
import { GiteaFileTree } from "@/components/project/gitea-file-tree";
import { GiteaFileViewer } from "@/components/project/gitea-file-viewer";
+import { DevContainerDetail } from "@/components/project/dev-container-detail";
import { useAnatomy } from "@/components/project/use-anatomy";
/**
- * Product tab — IDE-style.
+ * Product tab — the build surface.
*
- * Reads codebases from the shared /anatomy endpoint. Left column is
- * a stack of expandable codebase tiles, each with its own inline
- * Gitea file tree. Clicking a file previews its content on the right.
+ * Left rail (top → bottom):
+ * - Workspace section: dev container tile (the vibn-dev-* service
+ * where the AI edits code; clicking it shows status + active
+ * dev servers in the right pane).
+ * - Codebases section: one tile per codebase, each expanding inline
+ * into its Gitea file tree. Clicking a file previews it.
+ *
+ * Right pane swaps between three view kinds based on the active
+ * selection: "devContainer", "file", or "empty".
*/
+type Selection =
+ | { type: "devContainer"; uuid: string }
+ | { type: "file"; codebaseId: string; path: string }
+ | null;
+
export default function ProductTab() {
const params = useParams();
const projectId = params.projectId as string;
const { anatomy, loading, error } = useAnatomy(projectId);
- const codebases = anatomy?.codebases ?? null;
- const reason = anatomy?.codebasesReason;
+ const codebases = anatomy?.codebases ?? null;
+ const reason = anatomy?.codebasesReason;
+ const devContainer = anatomy?.product.devContainers[0]; // only one per project
+ const previewUrls = anatomy?.hosting.previewUrls ?? [];
const [expanded, setExpanded] = useState
>(new Set());
- const [selectedFile, setSelectedFile] = useState<{ codebaseId: string; path: string } | null>(null);
+ const [selection, setSelection] = useState(null);
// Auto-expand the first codebase whenever anatomy lands
useEffect(() => {
@@ -33,9 +47,9 @@ export default function ProductTab() {
}
}, [codebases]);
- // Reset selection when project changes
+ // Reset on project change
useEffect(() => {
- setSelectedFile(null);
+ setSelection(null);
setExpanded(new Set());
}, [projectId]);
@@ -48,25 +62,50 @@ export default function ProductTab() {
});
};
- const showLoading = loading && !codebases;
- const showError = !!error;
+ const showLoading = loading && !anatomy;
+ const showError = !!error;
return (
- {/* ── Left: codebases column ── */}
+ {/* ── Left: workspace + codebases ── */}
- Codebases
+ {/* Workspace section */}
+ Workspace
{showLoading && (
-
- Loading…
-
+
Loading…
)}
+ {!showLoading && devContainer && (
+
+ )}
+ {!showLoading && !devContainer && (
+
No dev container provisioned yet.
+ )}
+
+
+ {/* Codebases section */}
+ Codebases
+
{showError && (
-
- {error}
-
+
{error}
)}
{codebases && codebases.length === 0 && (
@@ -87,10 +126,10 @@ export default function ProductTab() {
>
{isOpen
- ?
- : }
+ ?
+ : }
-
+
{cb.label}
{cb.hint &&
{cb.hint}
}
@@ -102,10 +141,12 @@ export default function ProductTab() {
projectId={projectId}
rootPath={cb.path}
selectedPath={
- selectedFile?.codebaseId === cb.id ? selectedFile.path : undefined
+ selection?.type === "file" && selection.codebaseId === cb.id
+ ? selection.path
+ : undefined
}
onSelectFile={(p) =>
- setSelectedFile({ codebaseId: cb.id, path: p })
+ setSelection({ type: "file", codebaseId: cb.id, path: p })
}
/>
@@ -116,16 +157,19 @@ export default function ProductTab() {
- {/* ── Right: file preview ── */}
+ {/* ── Right: contextual preview ── */}
@@ -133,27 +177,50 @@ export default function ProductTab() {
);
}
+// ──────────────────────────────────────────────────
+
+function previewHeading(s: Selection): string {
+ if (!s) return "Preview";
+ if (s.type === "devContainer") return "Preview · Dev container";
+ return `Preview · ${shortPath(s.path)}`;
+}
function shortPath(p: string) {
const parts = p.split("/");
if (parts.length <= 2) return p;
return ".../" + parts.slice(-2).join("/");
}
+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 Inline({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
-// ──────────────────────────────────────────────────────────────────────
-// Styles
-// ──────────────────────────────────────────────────────────────────────
+function Empty({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+// ──────────────────────────────────────────────────
const INK = {
ink: "#1a1a1a",
@@ -170,7 +237,6 @@ const pageWrap: React.CSSProperties = {
fontFamily: INK.fontSans,
color: INK.ink,
};
-
const grid: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "minmax(280px, 360px) minmax(0, 1fr)",
@@ -179,88 +245,47 @@ const grid: React.CSSProperties = {
margin: "0 auto",
alignItems: "stretch",
};
-
const leftCol: React.CSSProperties = {
- minWidth: 0,
- display: "flex",
- flexDirection: "column",
+ minWidth: 0, display: "flex", flexDirection: "column",
};
-
const rightCol: React.CSSProperties = {
- minWidth: 0,
- display: "flex",
- flexDirection: "column",
+ minWidth: 0, display: "flex", flexDirection: "column",
};
-
const heading: React.CSSProperties = {
- fontSize: "0.72rem",
- fontWeight: 600,
- letterSpacing: "0.12em",
- textTransform: "uppercase",
- color: INK.muted,
- margin: "0 0 14px",
+ fontSize: "0.72rem", fontWeight: 600, letterSpacing: "0.12em",
+ textTransform: "uppercase", color: INK.muted, margin: "0 0 14px",
};
-
const stack: React.CSSProperties = {
- display: "flex",
- flexDirection: "column",
- gap: 10,
+ display: "flex", flexDirection: "column", gap: 10,
+};
+const flatTile: React.CSSProperties = {
+ display: "flex", alignItems: "center", gap: 10,
+ width: "100%", padding: "12px 14px",
+ background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 10,
+ cursor: "pointer", font: "inherit", color: "inherit",
+ transition: "border-color 0.12s, background 0.12s, box-shadow 0.12s",
};
-
const codebaseTile: React.CSSProperties = {
- background: INK.cardBg,
- border: `1px solid ${INK.borderSoft}`,
- borderRadius: 10,
- overflow: "hidden",
+ background: INK.cardBg, border: `1px solid ${INK.borderSoft}`, borderRadius: 10, overflow: "hidden",
};
-
const tileHeader: React.CSSProperties = {
- display: "flex",
- alignItems: "center",
- gap: 8,
- width: "100%",
- padding: "12px 14px",
- background: "transparent",
- border: "none",
- borderBottom: `1px solid transparent`,
- cursor: "pointer",
- font: "inherit",
- color: "inherit",
+ display: "flex", alignItems: "center", gap: 8, width: "100%",
+ padding: "12px 14px", background: "transparent", border: "none",
+ cursor: "pointer", font: "inherit", color: "inherit",
};
-
const tileLabel: React.CSSProperties = {
- fontSize: "0.85rem",
- fontWeight: 600,
- color: INK.ink,
- marginBottom: 2,
+ fontSize: "0.85rem", fontWeight: 600, color: INK.ink, marginBottom: 2,
};
-
const tileHint: React.CSSProperties = {
- fontSize: "0.74rem",
- color: INK.mid,
- lineHeight: 1.4,
+ fontSize: "0.74rem", color: INK.mid, lineHeight: 1.4,
};
-
const tileBody: React.CSSProperties = {
- padding: "8px 10px 12px",
- borderTop: `1px solid ${INK.borderSoft}`,
+ padding: "8px 10px 12px", borderTop: `1px solid ${INK.borderSoft}`,
};
-
const chevronCell: React.CSSProperties = {
- width: 14,
- display: "inline-flex",
- alignItems: "center",
- justifyContent: "center",
- flexShrink: 0,
+ width: 14, display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
};
-
const previewPanel: React.CSSProperties = {
- background: INK.cardBg,
- border: `1px solid ${INK.border}`,
- borderRadius: 10,
- padding: 16,
- flex: 1,
- minHeight: 0,
- display: "flex",
- flexDirection: "column",
+ background: INK.cardBg, border: `1px solid ${INK.border}`, borderRadius: 10,
+ padding: 16, flex: 1, minHeight: 0, display: "flex", flexDirection: "column",
};
diff --git a/app/api/projects/[projectId]/anatomy/route.ts b/app/api/projects/[projectId]/anatomy/route.ts
index 4e60e45e..e828a1fb 100644
--- a/app/api/projects/[projectId]/anatomy/route.ts
+++ b/app/api/projects/[projectId]/anatomy/route.ts
@@ -50,6 +50,13 @@ interface DevService {
status?: string;
}
+/** Dev container = the vibn-dev-* Coolify service this project edits in. */
+interface DevContainer {
+ uuid: string;
+ name: string;
+ status?: string;
+}
+
interface PreviewUrl {
id: string;
name: string;
@@ -68,6 +75,9 @@ interface Anatomy {
project: { id: string; name: string; gitea?: string; coolifyProjectUuid?: string };
codebases: Codebase[];
codebasesReason?: "no_repo" | "empty_repo";
+ product: {
+ devContainers: DevContainer[];
+ };
hosting: {
production: ProductionApp[];
services: DevService[];
@@ -188,22 +198,22 @@ async function loadProductionApps(giteaRepo: string | undefined): Promise
{
+/** Returns ALL services in the Coolify project. Caller splits dev
+ * containers from deployed services by name prefix. */
+async function loadAllServices(coolifyProjectUuid: string | undefined): Promise {
if (!coolifyProjectUuid) return [];
try {
- const services = await listServicesInProject(coolifyProjectUuid);
- return services.map((s: CoolifyService) => ({
- uuid: s.uuid,
- name: s.name,
- serviceType: s.service_type,
- status: s.status,
- }));
+ return await listServicesInProject(coolifyProjectUuid);
} catch (err) {
console.error("[anatomy] listServicesInProject failed:", err);
return [];
}
}
+function isDevContainer(svc: CoolifyService): boolean {
+ return svc.name.startsWith("vibn-dev-");
+}
+
async function loadPreviewUrls(projectId: string): Promise {
try {
const rows = await query<{
@@ -291,7 +301,7 @@ export async function GET(
"Project";
// Run the slow bits in parallel
- const [codebasesResult, production, services, previews] = await Promise.all([
+ const [codebasesResult, production, allServices, previews] = await Promise.all([
giteaRepo
? discoverCodebases(giteaRepo).catch(err => {
console.error("[anatomy] discoverCodebases failed:", err);
@@ -299,10 +309,27 @@ export async function GET(
})
: Promise.resolve({ codebases: [] as Codebase[], reason: undefined as undefined }),
loadProductionApps(giteaRepo),
- loadDevServices(coolifyProjectUuid),
+ loadAllServices(coolifyProjectUuid),
loadPreviewUrls(projectId),
]);
+ // Split services: vibn-dev-* belong to Product (the dev workbench).
+ // Everything else is a deployed service that belongs in Hosting.
+ const devContainers: DevContainer[] = [];
+ const deployedServices: DevService[] = [];
+ for (const s of allServices) {
+ if (isDevContainer(s)) {
+ devContainers.push({ uuid: s.uuid, name: s.name, status: s.status });
+ } else {
+ deployedServices.push({
+ uuid: s.uuid,
+ name: s.name,
+ serviceType: s.service_type,
+ status: s.status,
+ });
+ }
+ }
+
const codebasesReason: "no_repo" | "empty_repo" | undefined = !giteaRepo
? "no_repo"
: codebasesResult.reason;
@@ -316,9 +343,10 @@ export async function GET(
},
codebases: codebasesResult.codebases,
codebasesReason,
+ product: { devContainers },
hosting: {
production,
- services,
+ services: deployedServices,
previewUrls: previews,
domains: dedupeDomains(production, previews),
},
diff --git a/components/project/dev-container-detail.tsx b/components/project/dev-container-detail.tsx
new file mode 100644
index 00000000..5c2d77c0
--- /dev/null
+++ b/components/project/dev-container-detail.tsx
@@ -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 (
+
+
+
+ {container.name}
+
+
+ {container.status ?? "unknown"}
+
+
+
+
+ {previewUrls.length === 0 ? (
+
+ ) : (
+ previewUrls.map(p => (
+
+ ))
+ )}
+
+
+ );
+}
+
+// ──────────────────────────────────────────────────
+
+function Section({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+ );
+}
+
+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 (
+
+ );
+}
+
+function Empty({ message, hint }: { message: string; hint?: string }) {
+ return (
+
+
{message}
+ {hint &&
{hint}
}
+
+ );
+}
+
+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",
+};
diff --git a/components/project/use-anatomy.ts b/components/project/use-anatomy.ts
index 865d6dc7..b2d3ba7c 100644
--- a/components/project/use-anatomy.ts
+++ b/components/project/use-anatomy.ts
@@ -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;