-
Product Brief
+
Project Objective
The high-level business case and elevator pitch.
diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/security/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/security/page.tsx
deleted file mode 100644
index a76f5e7..0000000
--- a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/security/page.tsx
+++ /dev/null
@@ -1,119 +0,0 @@
-"use client";
-
-import { Shield, Settings } from "lucide-react";
-
-export default function SecurityPage() {
- return (
-
-
-
-
- Security
-
-
- Manage your permissions and security rules.{" "}
-
- Learn more
-
-
-
-
-
-
-
-
-
-
-
- Check the security of your app
-
-
- Review your configuration, identify potential risks, and learn how to
- strengthen your app's protection
-
-
-
-
- );
-}
diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/services/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/services/page.tsx
deleted file mode 100644
index ab25c71..0000000
--- a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/services/page.tsx
+++ /dev/null
@@ -1,696 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { useParams } from "next/navigation";
-import {
- Loader2,
- AlertCircle,
- ExternalLink,
- Globe,
- RefreshCw,
- CircleDot,
- ChevronDown,
- ChevronRight,
- Copy,
- Check,
- Terminal,
- Server,
-} from "lucide-react";
-import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy";
-
-/**
- * Hosting tab — user-facing: "Is my thing live? How do I reach it?"
- *
- * One endpoint = one card. Each card shows:
- * - Live URL (open in new tab)
- * - Status dot + plain-language status
- * - Redeploy button
- * - Domain(s) list
- * - Last build (time + status)
- * - Expandable recent logs
- *
- * No master-detail split — with 1-3 services the overhead isn't worth it.
- * Previews (dev server URLs) shown below in a secondary section.
- */
-
-// ──────────────────────────────────────────────────
-// Types
-// ──────────────────────────────────────────────────
-
-type LiveItem = Anatomy["hosting"]["live"][number];
-type Preview = Anatomy["hosting"]["previews"][number];
-
-// ──────────────────────────────────────────────────
-// Main component
-// ──────────────────────────────────────────────────
-
-export default function ServicesPage() {
- const params = useParams();
- const projectId = params.projectId as string;
- const { anatomy, loading, error } = useAnatomy(projectId, { pollMs: 8000 });
- const showLoading = loading && !anatomy;
-
- return (
-
- {showLoading && (
-
-
-
- Loading…
-
-
- )}
- {error && !showLoading && (
-
- )}
-
- {anatomy && (
- <>
- {/* ── Live endpoints ── */}
-
-
- {anatomy.hosting.live.length === 0 ? (
- }
- title="Nothing deployed yet"
- hint="Ask the AI to deploy your app and it will appear here."
- promptSuggestion="Deploy my app to production"
- />
- ) : (
-
- {anatomy.hosting.live.map((item) => (
-
- ))}
-
- )}
-
-
- {/* ── Previews ── */}
- {anatomy.hosting.previews.length > 0 && (
-
-
-
- {anatomy.hosting.previews.map((p) => (
-
- ))}
-
-
- )}
- >
- )}
-
- );
-}
-
-// ──────────────────────────────────────────────────
-// Live card
-// ──────────────────────────────────────────────────
-
-function LiveCard({ item, projectId }: { item: LiveItem; projectId: string }) {
- const [deploying, setDeploying] = useState(false);
- const [logsOpen, setLogsOpen] = useState(false);
- const [logs, setLogs] = useState
(null);
- const [logsLoading, setLogsLoading] = useState(false);
- const [copied, setCopied] = useState(false);
-
- const primaryUrl = item.fqdn ? `https://${item.fqdn}` : null;
- const phase = classifyPhase(item.status);
- const { color: statusColor, label: statusLabel } = phaseDisplay(phase, item);
-
- const redeploy = async () => {
- if (deploying) return;
- setDeploying(true);
- try {
- await fetch(`/api/mcp`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- action: "apps.deploy",
- params: { uuid: item.uuid, projectId },
- }),
- });
- } finally {
- setTimeout(() => setDeploying(false), 3000);
- }
- };
-
- const openLogs = async () => {
- if (!logsOpen) {
- setLogsOpen(true);
- setLogsLoading(true);
- try {
- const r = await fetch(`/api/mcp`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- action: "apps.logs",
- params: { uuid: item.uuid, lines: 60 },
- }),
- });
- const d = await r.json();
- setLogs(
- typeof d.result === "string"
- ? d.result
- : JSON.stringify(d.result ?? d.error, null, 2),
- );
- } catch {
- setLogs("Failed to load logs.");
- } finally {
- setLogsLoading(false);
- }
- } else {
- setLogsOpen(false);
- }
- };
-
- const copyUrl = () => {
- if (!primaryUrl) return;
- navigator.clipboard.writeText(primaryUrl);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- };
-
- return (
-
- {/* ── Card header ── */}
-
-
-
- {item.name}
-
- {item.source === "repo" ? "built" : "image"}
-
-
-
-
-
-
-
- {/* ── Status line ── */}
-
-
- {statusLabel}
-
- {item.lastBuild && (
-
- · Last build {item.lastBuild.status}{" "}
- {formatRelative(item.lastBuild.finishedAt)}
-
- )}
-
-
- {/* ── Live URL ── */}
- {primaryUrl ? (
-
- ) : (
-
-
-
- No domain attached — ask the AI to add one.
-
-
- )}
-
- {/* ── Extra domains ── */}
- {item.domains.length > 1 && (
-
- )}
-
- {/* ── Logs toggle ── */}
-
-
-
- {logsOpen && (
-
- {logsLoading ? (
-
- Loading…
-
- ) : (
-
{logs || "(no logs)"}
- )}
-
- )}
-
-
- );
-}
-
-// ──────────────────────────────────────────────────
-// Preview row
-// ──────────────────────────────────────────────────
-
-function PreviewRow({ preview }: { preview: Preview }) {
- const running = preview.state === "running";
- return (
-
-
-
-
- {preview.name}
-
-
- port {preview.port}
-
-
- {preview.state}
-
- {preview.url && running && (
-
- )}
-
-
- );
-}
-
-// ──────────────────────────────────────────────────
-// Helpers
-// ──────────────────────────────────────────────────
-
-type Phase = "up" | "deploying" | "down" | "unknown";
-
-function classifyPhase(status: string | undefined): Phase {
- const s = (status ?? "").toLowerCase();
- if (!s || s === "unknown") return "unknown";
- if (/^(running|healthy)/.test(s)) return "up";
- if (
- /^(starting|restarting|created|deploying|building|in_progress|queued|paused)/.test(
- s,
- )
- )
- return "deploying";
- if (/^(exited|dead|failed|stopped|unhealthy|error)/.test(s)) return "down";
- return "unknown";
-}
-
-function phaseDisplay(
- phase: Phase,
- item: LiveItem,
-): { color: string; label: string } {
- if (item.inFlightBuild)
- return {
- color: AMBER,
- label: `Deploying (${item.inFlightBuild.status ?? "in progress"})`,
- };
- switch (phase) {
- case "up":
- return { color: GREEN, label: "Live" };
- case "deploying":
- return { color: AMBER, label: "Starting…" };
- case "down":
- return { color: DANGER, label: "Down" };
- default:
- return { color: INK.muted, label: "Unknown" };
- }
-}
-
-function formatRelative(iso: string | undefined) {
- if (!iso) return "";
- const ms = Date.now() - new Date(iso).getTime();
- if (Number.isNaN(ms)) return "";
- const min = Math.floor(ms / 60_000);
- if (min < 1) return "just now";
- if (min < 60) return `${min}m ago`;
- const hr = Math.floor(min / 60);
- if (hr < 24) return `${hr}h ago`;
- return `${Math.floor(hr / 24)}d ago`;
-}
-
-// ──────────────────────────────────────────────────
-// Sub-components
-// ──────────────────────────────────────────────────
-
-function SectionHeader({ title, count }: { title: string; count: number }) {
- return (
-
- {title}
- {count}
-
- );
-}
-
-function EmptySection({
- icon,
- title,
- hint,
- promptSuggestion,
-}: {
- icon: React.ReactNode;
- title: string;
- hint: string;
- promptSuggestion?: string;
-}) {
- return (
-
-
{icon}
-
- {title}
-
-
- {hint}
-
- {promptSuggestion && (
-
-
- Try asking:
-
-
- "{promptSuggestion}"
-
-
- )}
-
- );
-}
-
-// ──────────────────────────────────────────────────
-// Tokens
-// ──────────────────────────────────────────────────
-
-const INK = {
- ink: "#1a1a1a",
- mid: "#5f5e5a",
- muted: "#a09a90",
- border: "#e8e4dc",
- borderSoft: "#efebe1",
- cardBg: "#fff",
- fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
-} as const;
-const GREEN = "#10b981";
-const AMBER = "#f59e0b";
-const DANGER = "#ef4444";
-
-// ──────────────────────────────────────────────────
-// Styles
-// ──────────────────────────────────────────────────
-
-const pageWrap: React.CSSProperties = {
- padding: "28px 48px 64px",
- fontFamily: INK.fontSans,
- color: INK.ink,
- maxWidth: 860,
-};
-const centeredMsg: React.CSSProperties = {
- display: "flex",
- alignItems: "center",
- gap: 10,
- padding: "24px 0",
-};
-const sectionHeader: React.CSSProperties = {
- display: "flex",
- alignItems: "center",
- gap: 8,
- marginBottom: 14,
-};
-const sectionTitle: React.CSSProperties = {
- fontSize: "0.68rem",
- fontWeight: 700,
- letterSpacing: "0.12em",
- textTransform: "uppercase",
- color: INK.muted,
-};
-const countPill: React.CSSProperties = {
- fontSize: "0.7rem",
- fontWeight: 600,
- color: INK.mid,
- padding: "1px 7px",
- borderRadius: 999,
- background: "#f3eee4",
-};
-const card: React.CSSProperties = {
- background: INK.cardBg,
- border: `1px solid ${INK.border}`,
- borderRadius: 10,
- padding: "18px 20px",
-};
-const cardHeader: React.CSSProperties = {
- display: "flex",
- alignItems: "center",
- justifyContent: "space-between",
- gap: 12,
- marginBottom: 6,
-};
-const cardTitle: React.CSSProperties = {
- fontSize: "0.95rem",
- fontWeight: 700,
- color: INK.ink,
-};
-const statusLine: React.CSSProperties = {
- fontSize: "0.8rem",
- color: INK.mid,
- marginBottom: 12,
- display: "flex",
- alignItems: "center",
- gap: 6,
- flexWrap: "wrap",
-};
-const urlRow: React.CSSProperties = {
- display: "flex",
- alignItems: "center",
- gap: 8,
- background: "#f8f5f0",
- borderRadius: 6,
- padding: "8px 12px",
- marginBottom: 2,
-};
-const urlLink: React.CSSProperties = {
- fontSize: "0.85rem",
- color: INK.ink,
- textDecoration: "none",
- flex: 1,
- minWidth: 0,
- overflow: "hidden",
- textOverflow: "ellipsis",
- whiteSpace: "nowrap",
- display: "inline-flex",
- alignItems: "center",
- gap: 4,
-};
-const actionBtn: React.CSSProperties = {
- display: "inline-flex",
- alignItems: "center",
- gap: 6,
- padding: "6px 12px",
- border: `1px solid ${INK.border}`,
- borderRadius: 6,
- background: "#fff",
- cursor: "pointer",
- font: "inherit",
- fontSize: "0.78rem",
- fontWeight: 600,
- color: INK.mid,
- transition: "background 0.1s, border-color 0.1s",
-};
-const iconBtn: React.CSSProperties = {
- display: "inline-flex",
- alignItems: "center",
- justifyContent: "center",
- width: 26,
- height: 26,
- border: "none",
- background: "transparent",
- cursor: "pointer",
- color: INK.muted,
- borderRadius: 4,
- flexShrink: 0,
-};
-const logsToggleBtn: React.CSSProperties = {
- display: "inline-flex",
- alignItems: "center",
- gap: 6,
- fontSize: "0.75rem",
- fontWeight: 600,
- color: INK.mid,
- background: "none",
- border: "none",
- cursor: "pointer",
- font: "inherit",
- padding: 0,
-};
-const logsBox: React.CSSProperties = {
- marginTop: 10,
- background: "#1a1a1a",
- borderRadius: 6,
- padding: "12px 14px",
- maxHeight: 320,
- overflowY: "auto",
-};
-const logsPre: React.CSSProperties = {
- margin: 0,
- fontFamily: "ui-monospace, monospace",
- fontSize: "0.72rem",
- color: "#d4d0c8",
- lineHeight: 1.6,
- whiteSpace: "pre-wrap",
- wordBreak: "break-all",
-};
-
-const emptyBox: React.CSSProperties = {
- border: `1px dashed ${INK.border}`,
- borderRadius: 10,
- padding: "36px 28px",
- textAlign: "center",
- display: "flex",
- flexDirection: "column",
- alignItems: "center",
-};
-const promptChip: React.CSSProperties = {
- display: "inline-flex",
- alignItems: "center",
- background: "#f3eee4",
- borderRadius: 6,
- padding: "6px 12px",
- fontSize: "0.8rem",
-};
-
-function sourcePill(source: "repo" | "image"): React.CSSProperties {
- const isRepo = source === "repo";
- return {
- fontSize: "0.62rem",
- fontWeight: 700,
- letterSpacing: "0.08em",
- textTransform: "uppercase",
- color: isRepo ? "#2e6d2e" : "#3b5a78",
- background: isRepo ? "#eaf3e8" : "#e9eff5",
- padding: "1px 6px",
- borderRadius: 4,
- flexShrink: 0,
- };
-}
diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/settings/app/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/settings/app/page.tsx
deleted file mode 100644
index 438e8be..0000000
--- a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/settings/app/page.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-"use client";
-
-export default function AppSettingsPage() {
- return (
-
-
-
- App Settings
-
-
- General configuration for your application.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Delete Application
-
-
- Permanently delete this app and all of its data.
-
-
-
-
-
-
- );
-}
diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/settings/auth/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/settings/auth/page.tsx
deleted file mode 100644
index bba4eed..0000000
--- a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/settings/auth/page.tsx
+++ /dev/null
@@ -1,121 +0,0 @@
-"use client";
-
-export default function AuthSettingsPage() {
- return (
-
-
-
- Authentication
-
-
- Configure how users sign in to your app.
-
-
-
-
-
-
-
- Email & Password
-
-
- Allow users to sign up with an email and password.
-
-
-
-
-
-
-
-
-
-
- Google OAuth
-
-
- Allow users to sign in with their Google account.
-
-
-
-
-
-
- );
-}
diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/settings/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/settings/page.tsx
index 29a691d..08957ce 100644
--- a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/settings/page.tsx
+++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/settings/page.tsx
@@ -1,6 +1,233 @@
-import { redirect } from "next/navigation";
+"use client";
-export default async function SettingsPage({ params }: { params: Promise<{ workspace: string; projectId: string }> }) {
- const { workspace, projectId } = await params;
- redirect(`/${workspace}/project/${projectId}/settings/app`);
+import { useState } from "react";
+import { useParams, useRouter } from "next/navigation";
+import { Settings, Trash2, AlertTriangle, Loader2, ArrowLeft } from "lucide-react";
+import { WorkspaceKeysPanel } from "@/components/workspace/WorkspaceKeysPanel";
+import Link from "next/link";
+
+/**
+ * Project settings page.
+ * Accessible via the gear icon in the project header.
+ *
+ * Sections:
+ * - General (name, description — future)
+ * - Danger zone: delete project
+ */
+
+export default function ProjectSettingsPage() {
+ const params = useParams();
+ const router = useRouter();
+ const projectId = params.projectId as string;
+ const workspace = params.workspace as string;
+
+ const [deletePhase, setDeletePhase] = useState<"idle" | "confirm" | "deleting" | "done">("idle");
+ const [confirmInput, setConfirmInput] = useState("");
+ const [deleteError, setDeleteError] = useState(null);
+
+ const projectBackUrl = `/${workspace}/project/${projectId}/plan`;
+
+ const handleDelete = async () => {
+ if (deletePhase === "idle") {
+ setDeletePhase("confirm");
+ return;
+ }
+ if (deletePhase !== "confirm") return;
+ if (confirmInput.toLowerCase() !== "delete") return;
+
+ setDeletePhase("deleting");
+ setDeleteError(null);
+
+ try {
+ const r = await fetch("/api/projects/delete", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ projectId }),
+ });
+ const d = await r.json();
+ if (!r.ok) throw new Error(d.error || "Delete failed");
+ setDeletePhase("done");
+ setTimeout(() => router.push(`/${workspace}/projects`), 1500);
+ } catch (e) {
+ setDeleteError(e instanceof Error ? e.message : String(e));
+ setDeletePhase("confirm");
+ }
+ };
+
+ return (
+
+ {/* Back link */}
+
+
Back to project
+
+
+
+ Project settings
+
+
+
+
+ {/* ── Danger zone ── */}
+
+
+
+ Danger zone
+
+
+
+
+
+
Delete this project
+
+ Removes all project data from Vibn. Coolify services and databases
+ are not automatically stopped — use the chat to clean those
+ up first, or remove them from Coolify directly.
+
+
+
+ {deletePhase === "idle" && (
+
+ )}
+
+ {deletePhase === "confirm" && (
+
+
+ Type delete to confirm
+
+
+ setConfirmInput(e.target.value)}
+ onKeyDown={e => e.key === "Enter" && confirmInput.toLowerCase() === "delete" && handleDelete()}
+ placeholder="delete"
+ style={confirmInput_}
+ />
+
+
+
+ {deleteError && (
+
{deleteError}
+ )}
+
+ )}
+
+ {deletePhase === "deleting" && (
+
+ )}
+
+ {deletePhase === "done" && (
+
+ Project deleted. Redirecting…
+
+ )}
+
+
+
+
+ );
}
+
+// ──────────────────────────────────────────────────
+// Tokens
+// ──────────────────────────────────────────────────
+
+const DANGER = "#c5392b";
+
+const INK = {
+ ink: "#1a1a1a",
+ mid: "#5f5e5a",
+ muted: "#a09a90",
+ border: "#e8e4dc",
+ borderSoft: "#efebe1",
+ fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
+} as const;
+
+// ──────────────────────────────────────────────────
+// Styles
+// ──────────────────────────────────────────────────
+
+const pageWrap: React.CSSProperties = {
+ padding: "28px 48px 64px",
+ fontFamily: INK.fontSans,
+ color: INK.ink,
+ maxWidth: 720,
+};
+const backLink: React.CSSProperties = {
+ display: "inline-flex", alignItems: "center", gap: 6,
+ fontSize: "0.8rem", color: INK.mid, textDecoration: "none",
+ marginBottom: 24,
+};
+const pageTitle: React.CSSProperties = {
+ display: "flex", alignItems: "center", gap: 10,
+ fontSize: "1.25rem", fontWeight: 700, color: INK.ink,
+ marginBottom: 36, marginTop: 0,
+};
+const dangerSection: React.CSSProperties = { marginTop: 32 };
+const sectionTitle: React.CSSProperties = {
+ display: "flex", alignItems: "center", gap: 8,
+ fontSize: "0.72rem", fontWeight: 700, letterSpacing: "0.12em",
+ textTransform: "uppercase", color: INK.muted,
+ marginBottom: 12,
+};
+const dangerCard: React.CSSProperties = {
+ border: `1px solid #f0cac5`,
+ borderRadius: 10,
+ background: "#fffaf9",
+};
+const dangerCardBody: React.CSSProperties = {
+ padding: "18px 20px",
+ display: "flex",
+ alignItems: "flex-start",
+ justifyContent: "space-between",
+ gap: 24,
+ flexWrap: "wrap",
+};
+const dangerItemTitle: React.CSSProperties = {
+ fontWeight: 600, fontSize: "0.9rem", color: INK.ink, marginBottom: 4,
+};
+const dangerItemDesc: React.CSSProperties = {
+ fontSize: "0.8rem", color: INK.mid, lineHeight: 1.55, maxWidth: 380,
+};
+const dangerBtn: React.CSSProperties = {
+ display: "inline-flex", alignItems: "center", gap: 6,
+ padding: "7px 14px", border: `1px solid ${DANGER}`,
+ borderRadius: 6, background: "#fff", cursor: "pointer",
+ font: "inherit", fontSize: "0.8rem", fontWeight: 600, color: DANGER,
+ whiteSpace: "nowrap", flexShrink: 0,
+};
+const cancelBtn: React.CSSProperties = {
+ display: "inline-flex", alignItems: "center",
+ padding: "7px 12px", border: `1px solid ${INK.border}`,
+ borderRadius: 6, background: "#fff", cursor: "pointer",
+ font: "inherit", fontSize: "0.8rem", color: INK.mid,
+ whiteSpace: "nowrap",
+};
+const confirmBox: React.CSSProperties = { display: "flex", flexDirection: "column" };
+const confirmInput_: React.CSSProperties = {
+ padding: "7px 10px",
+ border: `1px solid ${DANGER}`,
+ borderRadius: 6,
+ font: "inherit",
+ fontSize: "0.85rem",
+ outline: "none",
+ width: 100,
+};
diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/storage/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/storage/page.tsx
deleted file mode 100644
index 6f3a3a1..0000000
--- a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/storage/page.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-"use client";
-
-import { HardDrive } from "lucide-react";
-
-export default function StoragePage() {
- return (
-
-
-
- Storage
-
-
- Manage your cloud storage buckets and assets.
-
-
-
-
-
-
-
-
- No buckets found
-
-
- Create an S3-compatible storage bucket to start uploading user files,
- avatars, and application assets.
-
-
-
-
- );
-}
diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/users/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/users/page.tsx
deleted file mode 100644
index 3535ef5..0000000
--- a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/users/page.tsx
+++ /dev/null
@@ -1,247 +0,0 @@
-"use client";
-
-import { Search, ChevronDown, ListFilter } from "lucide-react";
-
-export default function UsersPage() {
- return (
-
-
-
-
- Users
-
-
- Manage the app's users and their roles
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Users
-
-
-
-
-
-
-
-
-
-
-
-
- |
- Name
- |
-
- Role
- |
-
- Email
- |
-
-
-
-
- |
-
- Mark Henderson
-
-
- Owner
-
- |
-
- admin
- |
-
- markhenderson1977@gmail.com
- |
-
-
-
-
-
- );
-}
diff --git a/vibn-frontend/app/api/chat/route.ts b/vibn-frontend/app/api/chat/route.ts
index bd079a5..a7b0b8a 100644
--- a/vibn-frontend/app/api/chat/route.ts
+++ b/vibn-frontend/app/api/chat/route.ts
@@ -427,10 +427,10 @@ If the user tells you the preview is blank, not loading, or shows nothing:
**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 \`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.
-- **Next dev:** \`npx next dev -H 0.0.0.0 --no-turbopack\` (WSS HMR works automatically through the proxy without extra config). **Always use \`--no-turbopack\`** — Turbopack\'s per-route lazy compilation causes cold-start 503s in the remote container (the health probe passes on \`/\` but unvisited routes hang on first hit until Turbopack compiles them). webpack compiles all routes upfront and is significantly more stable in a containerised environment.
+- **Next dev:** \`next dev -H 0.0.0.0 --no-turbopack\` (WSS HMR works automatically through the proxy without extra config). **Always use \`--no-turbopack\`** — Turbopack\'s per-route lazy compilation causes cold-start 503s in the remote container (the health probe passes on \`/\` but unvisited routes hang on first hit until Turbopack compiles them). webpack compiles all routes upfront and is significantly more stable in a containerised environment.
- **Express / plain Node:** bind \`0.0.0.0\` (we set \`HOST=0.0.0.0\` env, but verify your framework respects it).
-**Build-me-X recipe:** \`devcontainer_ensure\` → \`apps_templates_scaffold { templateName }\` (if matching "dashboard" or "pitch-deck") OR \`shell_exec npx create-next-app@latest . --yes\` → \`fs_edit\` / \`fs_write\` to customize → **wire Sentry (see below)** → \`dev_server_start { command: 'npx next dev -H 0.0.0.0 --no-turbopack', port: 3000 }\` and **share the previewUrl in your reply — that's the turn's stopping point**. When the user says "ship it", call \`ship { projectId, commitMsg }\` (commits to Gitea and triggers prod deploy in one shot). If a project is multi-service (frontend + API + worker), pick the user-facing service (usually the frontend) and start ITS dev server first, even if the others aren't done yet — a clickable shell beats a complete-but-invisible stack.
+**Build-me-X recipe:** \`devcontainer_ensure\` → \`apps_templates_scaffold { templateName }\` (if matching "dashboard" or "pitch-deck") OR \`shell_exec npx create-next-app@latest . --yes\` → \`fs_edit\` / \`fs_write\` to customize → **wire Sentry (see below)** → \`dev_server_start { command: 'next dev -H 0.0.0.0 --no-turbopack', port: 3000 }\` and **share the previewUrl in your reply — that's the turn's stopping point**. When the user says "ship it", call \`ship { projectId, commitMsg }\` (commits to Gitea and triggers prod deploy in one shot). If a project is multi-service (frontend + API + worker), pick the user-facing service (usually the frontend) and start ITS dev server first, even if the others aren't done yet — a clickable shell beats a complete-but-invisible stack.
**Sentry is auto-provisioned per Vibn project.** When you scaffold a Next.js or Vite app, wire Sentry from day one so the user gets de-minified error capture + Session Replay on first deploy. The DSN (\`NEXT_PUBLIC_SENTRY_DSN\`) and shared org auth token (\`SENTRY_AUTH_TOKEN\`) are injected into the Coolify app's env automatically by \`apps_create\` — you don't set them. Get the project's Sentry slug from \`projects_get { projectId }\` (field: \`sentry.slug\`); pass it to \`withSentryConfig({ org: "vibnai", project: "", ... })\`. The reference recipe (instrumentation.ts, instrumentation-client.ts, app/global-error.tsx, next.config.ts wrapper, Dockerfile ARG declarations) is in \`vibn-frontend/lib/scaffold/sentry-snippets.ts\` — read it once via \`fs_*\` if you're unsure, then copy the snippets into the user's project verbatim. Skip Sentry for non-app projects (CLIs, library-only repos).
diff --git a/vibn-frontend/app/api/mcp/route.ts b/vibn-frontend/app/api/mcp/route.ts
index f3679b1..56e93e4 100644
--- a/vibn-frontend/app/api/mcp/route.ts
+++ b/vibn-frontend/app/api/mcp/route.ts
@@ -5311,17 +5311,20 @@ async function toolDevServerStart(
workspace: principal.workspace,
});
- // We mark it healthy immediately. Webpack compiles are taking too long
- // on cold boots and causing the probe to fail and the AI to retry endlessly.
- // The Traefik router will hold the connection open for the user until it responds.
- let isHealthy = true;
+ // Instead of firing-and-forgetting, we now wait for the server to ACTUALLY
+ // spin up and serve HTTP traffic before we return success to the AI.
+ // This allows the AI to see the exact health check failure synchronously.
+ let isHealthy = false;
let failureOutput = "";
- // We still fire the probe in the background so it eventually logs if it fails,
- // but we don't await it.
- probeDevServerReadiness(project.id, row.id, row.port).catch((err) => {
- console.error("[dev_server.start] Async probe failed later:", err);
- });
+ try {
+ await probeDevServerReadiness(project.id, row.id, row.port);
+ isHealthy = true;
+ } catch (probeErr: any) {
+ isHealthy = false;
+ failureOutput = probeErr.message || String(probeErr);
+ console.error("[dev_server.start] Synchronous probe failed:", probeErr);
+ }
if (!isHealthy) {
let recentLogs = "";
diff --git a/vibn-frontend/app/api/projects/[projectId]/anatomy/route.ts b/vibn-frontend/app/api/projects/[projectId]/anatomy/route.ts
index 57e32c4..a906ca6 100644
--- a/vibn-frontend/app/api/projects/[projectId]/anatomy/route.ts
+++ b/vibn-frontend/app/api/projects/[projectId]/anatomy/route.ts
@@ -793,7 +793,72 @@ async function loadPreviews(projectId: string): Promise {
[projectId],
);
- return sortDevPreviewsFrontendFirst(rows).map((r) => ({
+ // Filter out zombies: if a server is marked 'running' but the URL returns a 50x
+ // Gateway error or times out, the process died. We mark it stopped so the
+ // UI can trigger an auto-restart.
+ const activePreviews: typeof rows = [];
+
+ await Promise.all(
+ rows.map(async (r) => {
+ if (r.state !== "running") {
+ activePreviews.push(r);
+ return;
+ }
+
+ try {
+ const controller = new AbortController();
+ // We use a short timeout because we don't want to block the anatomy
+ // response. A slow response doesn't mean it's dead (Next.js might
+ // just be compiling) — we ONLY want to catch instant 502/503s from Traefik.
+ const timeout = setTimeout(() => controller.abort(), 2000);
+ const ping = await fetch(r.preview_url, {
+ method: "HEAD",
+ signal: controller.signal,
+ });
+ clearTimeout(timeout);
+
+ // 502/503/504 means Traefik is up but the container isn't answering.
+ // 404 means Traefik doesn't even know about the route.
+ if (
+ ping.status === 502 ||
+ ping.status === 503 ||
+ ping.status === 504 ||
+ ping.status === 404
+ ) {
+ console.warn(
+ `[anatomy] Preview zombie detected for ${r.preview_url} (HTTP ${ping.status}). Marking stopped.`,
+ );
+ await query(
+ `UPDATE fs_dev_servers SET state = 'stopped' WHERE id = $1`,
+ [r.id],
+ ).catch(() => {});
+ } else {
+ activePreviews.push(r);
+ }
+ } catch (e: any) {
+ // If the fetch aborts due to our 2s timeout, the server is just slow
+ // (likely doing a cold Webpack compile). DO NOT mark it as a zombie!
+ // Only kill it if we get a hard DNS/network error that isn't a timeout.
+ if (
+ e.name === "AbortError" ||
+ e.type === "aborted" ||
+ e.message?.includes("timeout")
+ ) {
+ activePreviews.push(r); // Benefit of the doubt — it's thinking
+ } else {
+ console.warn(
+ `[anatomy] Preview zombie detected for ${r.preview_url} (${e.message}). Marking stopped.`,
+ );
+ await query(
+ `UPDATE fs_dev_servers SET state = 'stopped' WHERE id = $1`,
+ [r.id],
+ ).catch(() => {});
+ }
+ }
+ }),
+ );
+
+ return sortDevPreviewsFrontendFirst(activePreviews).map((r) => ({
id: r.id,
name: r.name,
command: r.command ?? undefined,
diff --git a/vibn-frontend/app/api/projects/[projectId]/dev-server/ensure/route.ts b/vibn-frontend/app/api/projects/[projectId]/dev-server/ensure/route.ts
index 608c2ca..54ab9e9 100644
--- a/vibn-frontend/app/api/projects/[projectId]/dev-server/ensure/route.ts
+++ b/vibn-frontend/app/api/projects/[projectId]/dev-server/ensure/route.ts
@@ -101,12 +101,9 @@ export async function POST(
const forceStart =
new URL(request.url).searchParams.get("forceStart") === "true";
- // If there's no history, we STILL want to auto-start! We just assume it's a standard
- // Next.js app on port 3000. Forcing the user to hit "Start Preview" on a new project
- // is unnecessary friction.
- const commandToRun = last?.command || "npx next dev -H 0.0.0.0 --webpack";
- const portToRun = last?.port || 3000;
- const previewUrlToUse = last?.preview_url ?? null;
+ if (!last && !forceStart) {
+ return NextResponse.json({ status: "no_history" });
+ }
// 3. Load workspace
if (!project.vibn_workspace_id) {
@@ -119,11 +116,12 @@ export async function POST(
}
// 4. Fire restart in background — don't block the response.
+ // If forceStart is true but we have no history, default to Next.js start command.
const restartOpts = {
projectId: project.id,
projectSlug,
- command: commandToRun,
- port: portToRun,
+ command: last?.command || "next dev -H 0.0.0.0 --no-turbopack",
+ port: last?.port || 3000,
workspace,
};
@@ -136,16 +134,8 @@ export async function POST(
workspace,
});
const row = await startDevServer(restartOpts);
-
- // We immediately set it to running instead of waiting for the probe.
- // The probe has been flaky on Webpack cold compiles and causing the
- // frontend to get stuck in a "Preview not running" loop.
- await query(`UPDATE fs_dev_servers SET state = 'running' WHERE id = $1`, [
- row.id,
- ]);
-
- // Still run the probe in the background just to log any catastrophic failures,
- // but the UI won't be blocked by it.
+ // Run the readiness probe in background so state transitions
+ // from 'starting' → 'running' (or 'failed') in the DB.
probeDevServerReadiness(project.id, row.id, row.port).catch((err) => {
console.error("[dev-server/ensure] probe failed:", err?.message);
});
@@ -156,7 +146,7 @@ export async function POST(
return NextResponse.json({
status: "starting",
- previewUrl: previewUrlToUse,
+ previewUrl: last?.preview_url ?? null,
command: restartOpts.command,
port: restartOpts.port,
});
diff --git a/vibn-frontend/components/project/dashboard-sidebar.tsx b/vibn-frontend/components/project/dashboard-sidebar.tsx
index 2672083..6257216 100644
--- a/vibn-frontend/components/project/dashboard-sidebar.tsx
+++ b/vibn-frontend/components/project/dashboard-sidebar.tsx
@@ -1,341 +1,80 @@
"use client";
-import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
- Search,
- LayoutGrid,
- ClipboardList,
- Database,
- BarChart2,
- Globe,
- Plug,
- ShieldCheck,
Code2,
- Terminal,
+ ClipboardList,
+ Globe,
+ Database,
Settings,
- ChevronDown,
- ChevronRight,
- Users,
- HardDrive,
- Blocks,
+ CreditCard,
+ PlaneTakeoff,
} from "lucide-react";
-import { useAnatomy } from "@/components/project/use-anatomy";
-
-export function DashboardSidebar({
- workspace,
- projectId,
- children,
-}: {
- workspace: string;
- projectId: string;
- children: React.ReactNode;
-}) {
+export function DashboardSidebar({ workspace, projectId, children }: { workspace: string, projectId: string, children: React.ReactNode }) {
const pathname = usePathname() ?? "";
const projectBase = `/${workspace}/project/${projectId}`;
- const isPreview =
- pathname === `${projectBase}/preview` ||
- pathname.startsWith(`${projectBase}/preview/`);
-
- const [expandedSections, setExpandedSections] = useState<
- Record
- >({
- settings: true,
- data: true,
- });
-
- const [searchQuery, setSearchQuery] = useState("");
-
- const { anatomy } = useAnatomy(projectId);
- const databases = anatomy?.infrastructure?.databases ?? [];
+ const isPreview = pathname === `${projectBase}/preview` || pathname.startsWith(`${projectBase}/preview/`);
if (isPreview) {
return <>{children}>;
}
- const handleSectionClick = (segment: string) => {
- if (!expandedSections[segment]) {
- setExpandedSections((prev) => ({ ...prev, [segment]: true }));
- }
- };
-
- const menuItems = [
- { segment: "overview", label: "Overview", Icon: LayoutGrid },
- { segment: "plan", label: "Plan & Specs", Icon: ClipboardList },
- { segment: "code", label: "Code", Icon: Code2 },
- {
- segment: "data",
- label: "Data",
- Icon: Database,
- hasChildren: true,
- children: databases.map((db) => ({
- segment: `data/tables?db=${db.uuid}`,
- label: db.name,
- })),
- },
- { segment: "storage", label: "Storage", Icon: HardDrive },
- { segment: "services", label: "Services", Icon: Blocks },
- { segment: "users", label: "Auth / Users", Icon: Users },
- { segment: "integrations", label: "Integrations", Icon: Plug },
- { segment: "security", label: "Security", Icon: ShieldCheck },
- { segment: "logs", label: "Logs", Icon: Terminal },
- { segment: "domains", label: "Domains", Icon: Globe },
- {
- segment: "analytics",
- label: "Analytics",
- Icon: BarChart2,
- badge: "Soon",
- },
- {
- segment: "marketing",
- label: "Marketing",
- Icon: BarChart2,
- badge: "New",
- hasChildren: true,
- children: [
- { segment: "marketing/seo", label: "SEO & GEO" },
- { segment: "marketing/social", label: "Social content" },
- ],
- },
- {
- segment: "settings",
- label: "Settings",
- Icon: Settings,
- hasChildren: true,
- children: [
- { segment: "settings/app", label: "App Settings" },
- { segment: "settings/auth", label: "Authentication" },
- ],
- },
+ const items = [
+ { segment: "plan", label: "Plan", Icon: ClipboardList },
+ { segment: "market", label: "Market", Icon: PlaneTakeoff },
+ { segment: "product", label: "Code", Icon: Code2, aliases: ["code"] },
+ { segment: "hosting", label: "Hosting", Icon: Globe },
+ { segment: "infrastructure", label: "Infra", Icon: Database },
+ { segment: "billing", label: "Billing", Icon: CreditCard },
+ { segment: "settings", label: "Settings", Icon: Settings },
];
- const filteredItems = menuItems.filter(
- (item) =>
- item.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
- (item.children &&
- item.children.some((child) =>
- child.label.toLowerCase().includes(searchQuery.toLowerCase()),
- )),
- );
-
return (
-
-
+
+
Dashboard
-
- {/* Search Bar */}
-
-
- setSearchQuery(e.target.value)}
- style={{
- border: "none",
- background: "transparent",
- outline: "none",
- width: "100%",
- fontSize: "0.8rem",
- color: "#18181b",
- }}
- />
-
-
-
- {filteredItems.map((item) => {
- const isMainActive =
- pathname === `${projectBase}/${item.segment}` ||
- pathname.startsWith(`${projectBase}/${item.segment}/`);
- const isExpanded = expandedSections[item.segment];
-
- return (
-
-
{
- if (item.hasChildren) {
- setExpandedSections((prev) => ({
- ...prev,
- [item.segment]: !prev[item.segment],
- }));
- }
- }}
- >
- {item.hasChildren ? (
-
-
-
- {item.label}
-
-
- ) : (
-
-
-
- {item.label}
-
-
- )}
-
-
- {item.badge && (
-
- {item.badge}
-
- )}
- {item.hasChildren &&
- (isExpanded ? (
-
- ) : (
-
- ))}
-
-
-
- {/* Render Children if expanded */}
- {item.hasChildren && isExpanded && item.children && (
-
- {item.children.map((child) => {
- const href = child.segment.includes("?")
- ? `${projectBase}/${child.segment.split("?")[0]}?${child.segment.split("?")[1]}`
- : `${projectBase}/${child.segment}`;
- let isChildActive = false;
- if (child.segment.includes("?")) {
- const [basePath, searchStr] = child.segment.split("?");
- isChildActive =
- pathname === `${projectBase}/${basePath}` &&
- (typeof window !== "undefined"
- ? window.location.search.includes(searchStr)
- : false);
- } else {
- isChildActive =
- pathname === `${projectBase}/${child.segment}`;
- }
-
- return (
-
- {child.label}
-
- );
- })}
-
- )}
-
- );
- })}
-
+ {items.map(item => {
+ const active = pathname === `${projectBase}/${item.segment}` || (item.aliases && item.aliases.some(a => pathname === `${projectBase}/${a}`));
+ return (
+
+
+ {item.label}
+
+ );
+ })}
-
diff --git a/vibn-frontend/components/project/project-icon-rail.tsx b/vibn-frontend/components/project/project-icon-rail.tsx
index 37364c4..86db64f 100644
--- a/vibn-frontend/components/project/project-icon-rail.tsx
+++ b/vibn-frontend/components/project/project-icon-rail.tsx
@@ -37,9 +37,6 @@ export function ProjectIconRail({ workspace, projectId, actions }: Props) {
fontSize: "0.75rem",
fontWeight: 500,
borderRadius: 6,
- display: "flex",
- alignItems: "center",
- height: 24, // Explicitly match device toggles height
textDecoration: "none",
background: isPreviewActive ? "#ffffff" : "transparent",
color: isPreviewActive ? "#18181b" : "#71717a",
@@ -56,9 +53,6 @@ export function ProjectIconRail({ workspace, projectId, actions }: Props) {
fontSize: "0.75rem",
fontWeight: 500,
borderRadius: 6,
- display: "flex",
- alignItems: "center",
- height: 24, // Explicitly match device toggles height
textDecoration: "none",
background: !isPreviewActive ? "#ffffff" : "transparent",
color: !isPreviewActive ? "#18181b" : "#71717a",
@@ -243,16 +237,6 @@ function PreviewDeviceToggles() {
}}
/>
-
- /
-
-
- setCurrentPath("/" + e.target.value.replace(/^\//, ""))
- }
- placeholder="path (e.g. dashboard)"
+
(null);
- const [isChatMinimized, setIsChatMinimized] = useState(false);
-
- // Auto-minimize when navigating to dashboard, auto-open when navigating to preview
- useEffect(() => {
- setIsChatMinimized(!pathname.includes("/preview"));
- }, [pathname]);
-
const messagesEndRef = useRef(null);
const inputRef = useRef(null);
// AbortController for the in-flight /api/chat fetch. Lives in a ref
@@ -2096,9 +2088,12 @@ export function ChatPanel({
}}
onInput={(e) => {
const el = e.currentTarget;
- // Only resize if height actually changed
- el.style.height = "auto";
- el.style.height = Math.min(el.scrollHeight, 240) + "px";
+ const newlines = (el.value.match(/\n/g) || []).length;
+ if ((el as any).lastNewlines !== newlines) {
+ (el as any).lastNewlines = newlines;
+ el.style.height = "auto";
+ el.style.height = Math.min(el.scrollHeight, 240) + "px";
+ }
}}
/>
-
-

-
+ {workspace ? (
+
+

+
+ ) : null}