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

@@ -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<Produc
}
}
async function loadDevServices(coolifyProjectUuid: string | undefined): Promise<DevService[]> {
/** Returns ALL services in the Coolify project. Caller splits dev
* containers from deployed services by name prefix. */
async function loadAllServices(coolifyProjectUuid: string | undefined): Promise<CoolifyService[]> {
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<PreviewUrl[]> {
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),
},