-
-
-
{t.title}
- {t.description &&
{t.description}
}
+
+ {/* TO DO COLUMN */}
+
+
+
To Do
+ {openTasks.length}
+
+
+ {openTasks.length > 0 ? (
+ openTasks.map((t) =>
)
+ ) : (
+
-
-
Queued
+ )}
- )) : (
-
-
-
No tasks queued.
-
Use Architect mode to generate the task breakdown.
+
+
+ {/* IN PROGRESS COLUMN */}
+
+
+
In Progress
+ {activeTasks.length}
- )}
+
+ {activeTasks.length > 0 ? (
+ activeTasks.map((t) =>
)
+ ) : (
+
+ )}
+
+
+
+ {/* DONE COLUMN */}
+
+
+
Done
+ {doneTasks.length}
+
+
+ {doneTasks.length > 0 ? (
+ doneTasks.map((t) =>
)
+ ) : (
+
+ )}
+
+
);
}
-// ── Shared UI Components ──────────────────────────────────────────────────────
+// ──────────────────────────────────────────────────
+// Styles (Mapped to infrastructure/product design language)
+// ──────────────────────────────────────────────────
-function TabButton({ active, onClick, icon, label }: { active: boolean, onClick: () => void, icon: React.ReactNode, label: string }) {
- return (
-
- {icon}
- {label}
-
- );
-}
+const INK = {
+ ink: "#1a1a1a",
+ mid: "#5f5e5a",
+ muted: "#a09a90",
+ border: "#e8e4dc",
+ borderSoft: "#efebe1",
+ cardBg: "#fff",
+ bgHover: "#fafaf6",
+ fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
+} as const;
-// Global styles injected here for the prototype so it renders cleanly
+const pageWrap: React.CSSProperties = {
+ padding: "28px 48px 48px",
+ fontFamily: INK.fontSans,
+ color: INK.ink,
+};
+
+const grid: React.CSSProperties = {
+ display: "grid",
+ gridTemplateColumns: "minmax(200px, 280px) minmax(0, 1fr)",
+ gap: 28,
+ maxWidth: 1400,
+ margin: "0 auto",
+ alignItems: "stretch",
+};
+
+const leftCol: React.CSSProperties = {
+ minWidth: 0,
+ display: "flex",
+ flexDirection: "column",
+ gap: 24,
+};
+
+const rightCol: React.CSSProperties = {
+ minWidth: 0,
+ display: "flex",
+ flexDirection: "column",
+};
+
+const centeredMsg: React.CSSProperties = {
+ display: "flex",
+ alignItems: "center",
+ gap: 10,
+ padding: "24px 0",
+ justifyContent: "center",
+};
+
+const railGroup: React.CSSProperties = {
+ display: "flex",
+ flexDirection: "column",
+};
+const railGroupHeader: React.CSSProperties = {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ padding: "0 4px 8px",
+};
+const railGroupTitle: React.CSSProperties = {
+ fontSize: "0.68rem",
+ fontWeight: 700,
+ letterSpacing: "0.12em",
+ textTransform: "uppercase",
+ color: INK.muted,
+};
+const railItems: React.CSSProperties = {
+ display: "flex",
+ flexDirection: "column",
+ gap: 4,
+};
+
+const flatTile: React.CSSProperties = {
+ display: "flex",
+ alignItems: "center",
+ gap: 10,
+ width: "100%",
+ padding: "10px 14px",
+ borderRadius: 8,
+ cursor: "pointer",
+ font: "inherit",
+ transition: "background 0.1s, border-color 0.1s, box-shadow 0.1s",
+ border: "1px solid transparent",
+ textAlign: "left",
+};
+
+const panel: React.CSSProperties = {
+ background: INK.cardBg,
+ border: `1px solid ${INK.border}`,
+ borderRadius: 10,
+ padding: 32,
+ flex: 1,
+ minHeight: "calc(100vh - 150px)",
+ display: "flex",
+ flexDirection: "column",
+ boxShadow: "0 1px 3px rgba(0,0,0,0.02)",
+};
+
+const panelHeader: React.CSSProperties = {
+ display: "flex",
+ justifyContent: "space-between",
+ alignItems: "center",
+ marginBottom: 24,
+ flexShrink: 0,
+};
+const panelTitle: React.CSSProperties = {
+ fontSize: "1.2rem",
+ fontWeight: 600,
+ margin: 0,
+ color: INK.ink,
+};
+const panelDesc: React.CSSProperties = {
+ color: INK.muted,
+ fontSize: "0.85rem",
+ margin: "4px 0 0",
+};
+
+const actionBtn: React.CSSProperties = {
+ display: "inline-flex",
+ alignItems: "center",
+ gap: 6,
+ padding: "8px 16px",
+ border: `1px solid ${INK.border}`,
+ borderRadius: 8,
+ background: "#fff",
+ cursor: "pointer",
+ font: "inherit",
+ fontSize: "0.85rem",
+ fontWeight: 500,
+ color: INK.ink,
+ boxShadow: "0 1px 2px rgba(0,0,0,0.02)",
+};
+
+const editorContainer: React.CSSProperties = {
+ border: `1px solid ${INK.border}`,
+ borderRadius: 10,
+ overflow: "hidden",
+ background: INK.cardBg,
+ boxShadow: "0 1px 3px rgba(0,0,0,0.02)",
+ display: "flex",
+ flexDirection: "column",
+ flex: 1,
+ minHeight: 0,
+};
+const editorTabs: React.CSSProperties = {
+ display: "flex",
+ background: INK.bgHover,
+ borderBottom: `1px solid ${INK.borderSoft}`,
+ padding: "8px 16px",
+ gap: 16,
+ flexShrink: 0,
+};
+const editorTabActive: React.CSSProperties = {
+ display: "flex",
+ alignItems: "center",
+ gap: 6,
+ padding: "6px 12px",
+ borderRadius: 6,
+ background: INK.cardBg,
+ border: `1px solid ${INK.borderSoft}`,
+ boxShadow: "0 1px 2px rgba(0,0,0,0.04)",
+ color: INK.ink,
+ fontWeight: 500,
+ fontSize: "0.85rem",
+ cursor: "pointer",
+};
+const editorTabInactive: React.CSSProperties = {
+ ...editorTabActive,
+ background: "transparent",
+ border: "1px solid transparent",
+ boxShadow: "none",
+ color: INK.muted,
+};
+
+const textAreaStyle: React.CSSProperties = {
+ width: "100%",
+ flex: 1,
+ minHeight: 0,
+ padding: 40,
+ fontSize: "0.85rem",
+ lineHeight: 1.6,
+ border: "none",
+ outline: "none",
+ resize: "none",
+ fontFamily: "var(--font-sans)",
+ color: INK.ink,
+ display: "block",
+ boxSizing: "border-box",
+ margin: 0,
+};
+const previewAreaStyle: React.CSSProperties = {
+ flex: 1,
+ minHeight: 0,
+ padding: 40,
+ boxSizing: "border-box",
+ overflowY: "auto",
+};
+
+const emptyBox: React.CSSProperties = {
+ border: `1px dashed ${INK.borderSoft}`,
+ borderRadius: 10,
+ padding: "48px 32px",
+ textAlign: "center",
+ color: INK.muted,
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ margin: "auto 0",
+};
+const emptyBoxSmall: React.CSSProperties = {
+ padding: 32,
+ textAlign: "center",
+ border: `1px dashed ${INK.border}`,
+ borderRadius: 8,
+ color: INK.muted,
+ fontSize: "0.85rem",
+};
+
+const kanbanCol: React.CSSProperties = {
+ flex: 1,
+ minWidth: 300,
+ display: "flex",
+ flexDirection: "column",
+ height: "100%",
+};
+const kanbanColHeader: React.CSSProperties = {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ marginBottom: 12,
+};
+const kanbanColTitle: React.CSSProperties = {
+ fontSize: "0.95rem",
+ fontWeight: 600,
+ color: INK.ink,
+ margin: 0,
+};
+const kanbanCount: React.CSSProperties = {
+ fontSize: "0.75rem",
+ background: INK.borderSoft,
+ padding: "2px 8px",
+ borderRadius: 12,
+ color: INK.muted,
+ fontWeight: 600,
+};
+const kanbanList: React.CSSProperties = {
+ display: "flex",
+ flexDirection: "column",
+ gap: 12,
+ overflowY: "auto",
+ paddingRight: 4,
+ paddingBottom: 24,
+};
+
+const taskCard: React.CSSProperties = {
+ border: `1px solid ${INK.border}`,
+ borderRadius: 8,
+ padding: 16,
+ background: INK.cardBg,
+ display: "flex",
+ flexDirection: "column",
+ gap: 8,
+ boxShadow: "0 1px 2px rgba(0,0,0,0.02)",
+};
+const taskStatusDot: React.CSSProperties = {
+ width: 16,
+ height: 16,
+ borderRadius: "50%",
+ marginTop: 2,
+ flexShrink: 0,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+};
+const taskBadge: React.CSSProperties = {
+ fontSize: "0.75rem",
+ color: INK.muted,
+ background: INK.bgHover,
+ padding: "4px 8px",
+ borderRadius: 4,
+ fontWeight: 500,
+};
+
+// Global styles
const styleTag = `
.btn-primary, .btn-secondary, .btn-ghost {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
padding: 8px 16px; border-radius: 6px; font-size: 0.85rem; font-weight: 500;
cursor: pointer; transition: all 0.15s ease; border: 1px solid transparent;
}
- .btn-primary { background: #1a1918; color: white; }
+ .btn-primary { background: #1a1a1a; color: white; }
.btn-primary:hover { background: #333; }
- .btn-secondary { background: #fff; border-color: #e8e4dc; color: #1a1918; }
- .btn-secondary:hover { background: #f8f6f2; }
- .btn-ghost { background: transparent; color: #6b6560; }
- .btn-ghost:hover { background: #f8f6f2; color: #1a1918; }
-
- .markdown-prose h1 { font-size: 1.5rem; font-weight: 700; margin-top: 0; }
- .markdown-prose h2 { font-size: 1.25rem; font-weight: 600; margin-top: 1.5rem; }
+ .btn-secondary { background: #fff; border-color: #e8e4dc; color: #1a1a1a; }
+ .btn-secondary:hover { background: #fafaf6; }
+ .btn-ghost { background: transparent; color: #a09a90; }
+ .btn-ghost:hover { background: #fafaf6; color: #1a1a1a; }
+
+ .markdown-prose { font-size: 0.85rem; color: #1a1a1a; }
+ .markdown-prose h1 { font-size: 1.25rem; font-weight: 700; margin-top: 0; }
+ .markdown-prose h2 { font-size: 1.15rem; font-weight: 600; margin-top: 1.5rem; }
+ .markdown-prose h3 { font-size: 1.05rem; font-weight: 600; margin-top: 1.25rem; }
.markdown-prose p { margin-top: 0.5rem; margin-bottom: 1rem; line-height: 1.6; }
.markdown-prose ul { padding-left: 1.5rem; margin-bottom: 1rem; }
- .markdown-prose li { margin-bottom: 0.25rem; }
+ .markdown-prose li { margin-bottom: 0.5rem; line-height: 1.6; }
+ .markdown-prose strong { font-weight: 600; }
`;
-if (typeof document !== "undefined" && !document.getElementById("plan-v2-styles")) {
+if (
+ typeof document !== "undefined" &&
+ !document.getElementById("plan-v2-styles")
+) {
const style = document.createElement("style");
style.id = "plan-v2-styles";
style.innerHTML = styleTag;
diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts
index ed5c6b5d..c427a22e 100644
--- a/app/api/chat/route.ts
+++ b/app/api/chat/route.ts
@@ -68,15 +68,97 @@ async function ensureChatTables() {
chatTablesReady = true;
}
+interface DBProject {
+ id: string;
+ name: string;
+ slug?: string;
+ productName?: string;
+ status?: string;
+ productVision?: string;
+ audience?: string;
+ kickoff?: { mode: string; sourceData: unknown };
+ designKit?: unknown;
+ giteaCloneUrl?: string;
+ plan?: {
+ decisions?: { title: string; choice: string; why?: string }[];
+ tasks?: { text: string; status: "open" | "done" }[];
+ ideas?: { text: string }[];
+ brief?: string;
+ };
+}
+
export async function buildSystemPrompt(
- projects: any[],
+ projects: DBProject[],
workspace: string,
- activeProject?: any,
+ activeProject?: DBProject,
+ chatMode: "vibe" | "collaborate" | "delegate" = "vibe",
): Promise
{
+ const modeInstructions =
+ chatMode === "collaborate"
+ ? `
+# MODE: Architect (Collaborate)
+You are an Architect and Product Strategist using Spec-Driven Development.
+**DO NOT WRITE CODE OR USE FILE SYSTEM TOOLS (e.g., fs_edit, fs_write, ship, shell_exec).**
+Your job is to interview the user to understand their requirements, and then generate a structured PRD (Product Requirements Document) and Execution Plan.
+
+## Step 1: Draft the PRD (Spec)
+Do not guess. Ask the user clarifying questions. When the requirements are clear, use \`plan_vision_set\` to save the PRD.
+The PRD MUST strictly follow this Markdown template:
+
+# Feature Specification: [FEATURE NAME]
+**Status**: Draft
+
+## User Scenarios & Testing
+User stories MUST be prioritized as user journeys ordered by importance. Each user story MUST be INDEPENDENTLY TESTABLE.
+### User Story 1 - [Brief Title] (Priority: P1)
+[Describe this user journey in plain language]
+**Independent Test**: [Describe how this can be tested independently]
+**Acceptance Scenarios**:
+1. **Given** [initial state], **When** [action], **Then** [expected outcome]
+
+### User Story 2 - [Brief Title] (Priority: P2)
+[Continue for all stories...]
+
+## Functional Requirements
+- **FR-001**: System MUST [specific capability]
+- **FR-002**: Users MUST be able to [key interaction]
+
+## Key Entities
+- **[Entity 1]**: [What it represents, key attributes]
+- **[Entity 2]**: [Relationships to other entities]
+
+## Success Criteria
+- **SC-001**: [Measurable, technology-agnostic metric, e.g., "Users can complete checkout in under 3 minutes"]
+
+## Step 2: The Architecture Plan
+Once the PRD is saved, decide HOW to build it. Use \`plan_decision_log\` to record the specific technologies:
+- Database (e.g. Postgres)
+- Stack (e.g. Next.js, Tailwind)
+- Auth (e.g. NextAuth)
+
+## Step 3: The Execution Plan (Tasks)
+Once the architecture is logged, break the PRD into an actionable development checklist using \`plan_task_add\`.
+You MUST organize tasks strictly by User Story using bracket prefixes.
+Each task must be atomic and specify the exact file path to be edited.
+Example:
+- \`plan_task_add { title: "[Phase 1] Initialize Next.js project and setup Prisma DB" }\`
+- \`plan_task_add { title: "[US1] Create User table in schema.prisma" }\`
+- \`plan_task_add { title: "[US1] Build /api/auth POST endpoint" }\`
+- \`plan_task_add { title: "[US2] Build frontend Dashboard form in src/app/dashboard/page.tsx" }\`
+
+Your turn ends when the user's PRD is saved via plan_vision_set, decisions are logged, and the task list is fully populated.
+`
+ : `
+# MODE: Vibe Code (Full Engineering)
+You are a Lead Software Engineer who is permitted to write code, edit files, create backend endpoints, and deploy apps.
+- Use \`fs_write\`, \`fs_edit\`, \`ship\`, and other developer tools directly to build features based on the saved Plan.
+- Always run \`request_visual_qa\` before returning a preview URL to the user to guarantee visual quality.
+`;
+
const projectsText = projects.length
? projects
.map(
- (p: any) =>
+ (p: DBProject) =>
`- "${p.productName || p.name}" (id: ${p.id}, status: ${p.status || "defining"})${p.productVision ? ": " + p.productVision.slice(0, 120) : ""}`,
)
.join("\n")
@@ -90,11 +172,7 @@ export async function buildSystemPrompt(
// the user to re-decide settled questions and knows what's queued up.
// Decisions are first-class: they encode the founder's intent and
// should be honored unless the user explicitly revisits one.
- const plan = (activeProject?.plan ?? {}) as {
- decisions?: { title: string; choice: string; why?: string }[];
- tasks?: { text: string; status: "open" | "done" }[];
- ideas?: { text: string }[];
- };
+ const plan = activeProject?.plan ?? {};
const decisionsBlock = plan.decisions?.length
? `\n**Decisions already made for this project (DO NOT re-litigate unless the user asks):**\n${plan.decisions
.slice(0, 20)
@@ -117,8 +195,8 @@ export async function buildSystemPrompt(
.join("\n")}\n`
: "";
- const briefBlock = (plan as any).brief
- ? `\n**[PROJECT BRIEF / SCOPE DOCUMENT]**\nThe user has uploaded a detailed project brief. You MUST read and adhere to these requirements when making architectural or product decisions:\n${(plan as any).brief.slice(0, 5000)}\n`
+ const briefBlock = plan.brief
+ ? `\n**[PROJECT BRIEF / SCOPE DOCUMENT]**\nThe user has uploaded a detailed project brief. You MUST read and adhere to these requirements when making architectural or product decisions:\n${plan.brief.slice(0, 5000)}\n`
: "";
const designKitBlock = buildDesignKitPromptSection(activeProject);
@@ -147,6 +225,8 @@ After every assistant turn, the harness automatically runs \`git add -A && git c
return `You are Vibn AI — the technical co-founder of every Vibn user. You turn ideas into shipped software. Treat their projects like they're your own.
+${modeInstructions}
+
You're talking to the owner of the "${workspace}" workspace. They have admin access to their Gitea org, a fleet of Coolify projects, and a persistent dev container per project. You can read and write any of it.
## Mode: respond first, act second
@@ -260,11 +340,11 @@ For NEW repos / branches: \`gitea_repos_list\`, \`gitea_repo_get\`, \`gitea_repo
## Plan tab — be the user's scribe
The Plan tab (Vision · Tasks · Decisions · Ideas) is the project's persistent memory. Capture things in the moment so the user doesn't context-switch.
-- \`plan_decision_log\` PROACTIVELY when a non-trivial choice settles (DB engine, auth, framework, region, pricing, brand voice). Don't ask permission. One-liner ack ("logged Postgres"), move on.
+- \`plan_vision_set\` PROACTIVELY when the user articulates or refines the high-level business case, elevator pitch, or primary objective of what they're building. The Objective is your north star and is separate from the technical Blueprint.
+- \`plan_document_update\` PROACTIVELY when the user asks you to write, edit, or update a specific Technical or Product Specification document inside the Blueprint. You MUST use this tool to overwrite specific sections of the Blueprint. The valid document IDs are exactly: \`stories\`, \`acceptance\`, \`success\`, \`ui_design\`, \`tech_context\`, \`data_model\`, \`file_structure\`, \`tasks\`, and \`checklist\`. Do NOT use \`fs_write\` or \`ship\` to edit markdown files when asked to update the plan—the plan lives in the database. Don't ask permission. One-liner ack ("Updated the UI Design spec"), and move on.
- \`plan_task_add\` when you commit to multi-step work, the user says "remind me to X", or a chain ends with an obvious user follow-up (add Stripe webhook URL). One task per real next-action.
- \`plan_task_edit\` to update a task or change its status. Put a task in "review" status when you finish it, unless the user explicitly said it is "done".
- \`plan_idea_add\` sparingly, only for something worth remembering that isn't a task or decision.
-- \`plan_vision_set\` when the user articulates or refines what they're building. The vision is your north star.
## Hard rules (non-negotiable)
- **Cite the tool result, don't claim from memory.** Before stating "I edited X" or "the server is running," you must point to a tool result from THIS turn. If you can't, say "I have not yet made that change — running the tool now" and then run it. A claim without a citable tool result is a hallucination.
@@ -284,7 +364,7 @@ The Plan tab (Vision · Tasks · Decisions · Ideas) is the project's persistent
- After \`ship\` or \`apps_deploy\`, the result is authoritative. Don't call \`gitea_*\` / \`shell_exec\` / \`apps_*\` to "verify" — read the response and report.
- Never fake success. Never imply something worked if it didn't.
-${activeBlock}## Current workspace projects
+${activeBlock}${briefBlock}## Current workspace projects
${projectsText}
Today's date: ${new Date().toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" })}.`;
@@ -323,6 +403,7 @@ export async function POST(request: Request) {
message: string;
workspace: string;
mcp_token?: string;
+ chatMode?: "vibe" | "collaborate" | "delegate";
};
try {
body = await request.json();
@@ -330,7 +411,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
- const { thread_id, message, workspace, mcp_token } = body;
+ const { thread_id, message, workspace, mcp_token, chatMode = "vibe" } = body;
if (!thread_id || !message?.trim()) {
return NextResponse.json(
{ error: "thread_id and message are required" },
@@ -351,7 +432,7 @@ export async function POST(request: Request) {
const threadProjectId = threads[0].project_id;
// Load message history (last 40 messages)
- const rows = await query(
+ const rows = await query<{ data: ChatMessage }>(
`SELECT data FROM fs_chat_messages WHERE thread_id = $1 ORDER BY created_at DESC LIMIT 40`,
[thread_id],
);
@@ -362,53 +443,37 @@ export async function POST(request: Request) {
// followed by tool messages responding to each 'tool_call_id'."
// Gemini silently tolerates stale toolCalls, so we only hit this on
// non-Gemini providers.
- const history: ChatMessage[] = rows.reverse().map((r: any) => {
- const msg = r.data;
- if (msg.role === "assistant" && msg.toolCalls?.length) {
- const rawResults = msg._rawToolResults ?? [];
- const summary = msg.toolCalls
- .map((tc: any) => {
- const tr = rawResults.find((r: any) => r.name === tc.name);
- let resultSig = "(no result captured)";
- if (tr) {
- try {
- const parsed =
- typeof tr.result === "string"
- ? JSON.parse(tr.result)
- : tr.result;
- if (parsed && typeof parsed === "object") {
- if (parsed.ok === false) {
- resultSig = `ERROR: ${parsed.error ?? "unknown"}`;
- } else if (parsed.sha256) {
- resultSig = `ok bytes=${parsed.bytes} sha=${parsed.sha256.slice(0, 8)}`;
- } else if (parsed.previewUrl) {
- resultSig = `ok previewUrl=${parsed.previewUrl} health=${parsed.healthCheck?.status ?? "?"}`;
- } else if (parsed.uuid) {
- resultSig = `ok uuid=${parsed.uuid}`;
- } else {
- resultSig = "ok";
- }
- }
- } catch {
- resultSig = String(tr.result).slice(0, 80);
- }
- }
- const argSig = JSON.stringify(tc.args ?? {}).slice(0, 100);
- return ` - ${tc.name}(${argSig}) → ${resultSig}`;
- })
- .join("\n");
- const suffix = `\n\n[tools executed this turn:\n${summary}\n]`;
- msg.content = (msg.content ?? "") + suffix;
- msg.toolCalls = undefined;
- }
- if (typeof msg.content === "string") {
- msg.content = msg.content
- .replace(/[\s\S]*?<\/tool_calls>/g, "")
- .replace(/[\s\S]*?<\/think>/g, "")
- .trim();
- }
- return msg;
- });
+ const history: ChatMessage[] = rows
+ .reverse()
+ .map((r: { data: ChatMessage }) => {
+ const msg = r.data as unknown as {
+ role: string;
+ content?: string;
+ toolCalls?: unknown;
+ _rawToolResults?: unknown;
+ };
+ if (
+ msg.role === "assistant" &&
+ Array.isArray(msg.toolCalls) &&
+ msg.toolCalls.length
+ ) {
+ // Remove any tool calls completely from the history payload.
+ // This is the clean, standard way to pass assistant history without
+ // polluting the context or inducing model hallucinations.
+ msg.toolCalls = undefined;
+ msg._rawToolResults = undefined;
+ }
+ if (typeof msg.content === "string") {
+ msg.content = msg.content
+ .replace(/[\s\S]*?<\/tool_calls>/g, "")
+ .replace(/[\s\S]*?<\/think>/g, "")
+ // Completely strip any legacy leaked "[tools executed this turn]" strings in case they exist in older messages
+ .replace(/\n\n\[tools executed this turn:[\s\S]*?\]/g, "")
+ .trim();
+ }
+
+ return msg as unknown as ChatMessage;
+ });
// Add user message
const userMsg: ChatMessage = { role: "user", content: message.trim() };
@@ -418,6 +483,11 @@ export async function POST(request: Request) {
[thread_id, email, JSON.stringify(userMsg)],
);
+ // Strip the hidden tool summaries out of the history array we pass to the LLM
+ // wait no, we WANT the LLM to see them, so we leave them in the history array.
+ // BUT we don't want to persist them to the DB, so we strip them when we construct
+ // the final assistant message at the end of the route.
+
// Update thread updatedAt
await query(
`UPDATE fs_chat_threads SET updated_at = NOW(), data = data || $2 WHERE id = $1`,
@@ -425,24 +495,24 @@ export async function POST(request: Request) {
);
// Load projects for system prompt context
- const projectRows = await query(
+ const projectRows = await query<{ data: DBProject }>(
`SELECT p.data FROM fs_projects p
JOIN fs_users u ON u.id = p.user_id
WHERE u.data->>'email' = $1
ORDER BY (p.data->>'updatedAt') DESC NULLS LAST LIMIT 20`,
[email],
);
- const projects = projectRows.map((r: any) => r.data);
+ const projects = projectRows.map((r: { data: DBProject }) => r.data);
// If the thread is project-scoped, pull the active project's data
// (preferring fs_projects since the projects array is capped at 20).
- let activeProject: any = null;
+ let activeProject: DBProject | null = null;
if (threadProjectId) {
- const found = projects.find((p: any) => p.id === threadProjectId);
+ const found = projects.find((p: DBProject) => p.id === threadProjectId);
if (found) {
activeProject = found;
} else {
- const r = await query<{ data: any }>(
+ const r = await query<{ data: DBProject }>(
`SELECT p.data FROM fs_projects p
JOIN fs_users u ON u.id = p.user_id
WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
@@ -456,6 +526,7 @@ export async function POST(request: Request) {
projects,
workspace,
activeProject,
+ chatMode,
);
// Sentry-as-product Stage 4: auto-surface unresolved errors at
@@ -576,7 +647,7 @@ export async function POST(request: Request) {
// Emit turnId immediately so the client can log/correlate.
emit({ type: "turn_start", turnId });
- let messages = [...history];
+ const messages = [...history];
let round = 0;
let assistantText = "";
// Per-round text segments. The model emits one `resp.text` per
@@ -604,7 +675,7 @@ export async function POST(request: Request) {
let toolCallsSinceText = 0;
let loopBreakReason: string | null = null;
- function fingerprintToolCall(tc: any) {
+ function fingerprintToolCall(tc: ToolCall) {
if (tc.name === "shell_exec") {
const cmd = String(tc.args?.command ?? "").trim();
// First non-cd verb (pkill, npm, curl, etc.)
@@ -887,13 +958,12 @@ export async function POST(request: Request) {
// so we never abandon the user with silent ✓ pills. Confirmed
// failure mode in prod: turn persisted with content_len=0 and
// 20 toolCalls, user had to re-prompt to get any answer.
- const lastTurnHadTools =
- messages.length > 0 && messages[messages.length - 1].role === "tool";
+ const anyToolsExecuted = assistantToolCalls.length > 0;
// C-07: Also recover when the model has been running tools without
// any text for >=4 rounds — the user is staring at silence.
const needsRecovery =
!aborted &&
- lastTurnHadTools &&
+ anyToolsExecuted &&
(round >= MAX_TOOL_ROUNDS ||
!!loopBreakReason ||
assistantText.trim().length === 0 ||
@@ -950,15 +1020,28 @@ export async function POST(request: Request) {
// segmentation it shows during streaming. Older messages
// (pre-this-fix) won't have textSegments and fall back to
// single-bubble content rendering.
+
+ // Ensure we strip the `[tools executed this turn...]` block if the AI accidentally hallucinated it
+ assistantText = assistantText.replace(
+ /\n\n\[tools executed this turn:[\s\S]*?\]/g,
+ "",
+ );
+
const finalMsg: ChatMessage & {
textSegments?: string[];
- _rawToolResults?: Array<{ name: string; args: any; result: string }>;
+ _rawToolResults?: Array<{
+ name: string;
+ args: Record;
+ result: string;
+ }>;
} = {
role: "assistant",
content: assistantText,
toolCalls: assistantToolCalls.length ? assistantToolCalls : undefined,
textSegments: assistantTextSegments.length
- ? assistantTextSegments
+ ? assistantTextSegments.map((seg) =>
+ seg.replace(/\n\n\[tools executed this turn:[\s\S]*?\]/g, ""),
+ )
: undefined,
_rawToolResults: assistantToolCalls.length ? [] : undefined,
};
@@ -1036,7 +1119,12 @@ export async function POST(request: Request) {
const result = (await Promise.race([
commitPromise,
timeoutPromise,
- ])) as any;
+ ])) as {
+ committed: boolean;
+ sha?: string;
+ pushed?: boolean;
+ reason?: string;
+ };
if (result.committed) {
emit({ type: "commit", sha: result.sha, pushed: result.pushed });
@@ -1122,28 +1210,7 @@ export async function POST(request: Request) {
}
})().catch(() => {});
- // Fire-and-forget: auto-extract plan updates (tasks, decisions,
- // vision) from the conversation using a cheap Gemini Flash model.
- // Deduplicates against existing plan items by title.
- (async () => {
- try {
- if (!threadProjectId) return;
- const allMessages = [...history, finalMsg];
- if (allMessages.length < 2) return;
- const transcript = allMessages
- .map((m) => {
- const text =
- typeof m.content === "string"
- ? m.content
- : JSON.stringify(m.content);
- return `${m.role.toUpperCase()}: ${text.slice(0, 1200)}`;
- })
- .join("\n\n");
- } catch (err) {
- console.warn("[chat] plan-extract failed (non-fatal):", err);
- }
- })().catch(() => {});
-
+ // Plan extraction is handled inline during tool calls or proactively.
emit({ type: "done" });
safeClose();
} catch (e) {
diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts
index 186371aa..5efb5309 100644
--- a/app/api/mcp/route.ts
+++ b/app/api/mcp/route.ts
@@ -295,6 +295,9 @@ export async function POST(request: Request) {
case "projects.get":
return await toolProjectsGet(principal, params);
+ case "email.send":
+ return await toolEmailSend(principal, params);
+
case "project.recent_errors":
case "project.recent.errors":
return await toolProjectRecentErrors(principal, params);
@@ -507,8 +510,8 @@ export async function POST(request: Request) {
return await toolPlanTaskEdit(principal, params);
case "plan.task.complete":
return await toolPlanTaskComplete(principal, params);
- case "plan.decision.log":
- return await toolPlanDecisionLog(principal, params);
+ case "plan.document.update":
+ return await toolPlanDocumentUpdate(principal, params);
case "tech_stack_analyze":
case "tech.stack.analyze":
@@ -795,6 +798,47 @@ async function toolProjectsGet(
});
}
+async function toolEmailSend(
+ principal: Principal,
+ params: Record,
+) {
+ const projectId = String(params.projectId ?? params.id ?? "").trim();
+ if (!projectId) {
+ return NextResponse.json(
+ { error: 'Param "projectId" is required' },
+ { status: 400 },
+ );
+ }
+
+ const { to, subject, text, mjml } = params;
+ if (!to || !subject || !text) {
+ return NextResponse.json(
+ { error: "Missing required email parameters (to, subject, text)." },
+ { status: 400 },
+ );
+ }
+
+ // Future feature: Look up project.email_config in Postgres for custom domains/API keys.
+ // For now, it uses the global ENV variable.
+ try {
+ const { sendEmail } = await import("@/lib/email/mailgun");
+ const response = await sendEmail({
+ to,
+ subject,
+ text,
+ mjml,
+ });
+
+ if (!response.success) {
+ return NextResponse.json({ error: response.error }, { status: 500 });
+ }
+
+ return NextResponse.json({ result: response });
+ } catch (error: any) {
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+}
+
/**
* Tenant-safe lookup: confirms the project belongs to the caller's
* workspace before exposing Sentry data. Returns null + error
@@ -2122,11 +2166,17 @@ async function toolAppsCreate(
buildPack: (params.buildPack as any) ?? "nixpacks",
name: appName,
domains: toDomainsString([fqdn]),
- isAutoDeployEnabled: true,
+ isAutoDeployEnabled: true,
instantDeploy: false,
- installCommand: params.installCommand ? String(params.installCommand) : undefined,
- buildCommand: params.buildCommand ? String(params.buildCommand) : undefined,
- startCommand: params.startCommand ? String(params.startCommand) : undefined,
+ installCommand: params.installCommand
+ ? String(params.installCommand)
+ : undefined,
+ buildCommand: params.buildCommand
+ ? String(params.buildCommand)
+ : undefined,
+ startCommand: params.startCommand
+ ? String(params.startCommand)
+ : undefined,
dockerComposeLocation: params.dockerComposeLocation
? String(params.dockerComposeLocation)
: undefined,
@@ -2153,11 +2203,17 @@ async function toolAppsCreate(
buildPack: (params.buildPack as any) ?? "nixpacks",
name: appName,
domains: toDomainsString([fqdn]),
- isAutoDeployEnabled: true,
+ isAutoDeployEnabled: true,
instantDeploy: false,
- installCommand: params.installCommand ? String(params.installCommand) : undefined,
- buildCommand: params.buildCommand ? String(params.buildCommand) : undefined,
- startCommand: params.startCommand ? String(params.startCommand) : undefined,
+ installCommand: params.installCommand
+ ? String(params.installCommand)
+ : undefined,
+ buildCommand: params.buildCommand
+ ? String(params.buildCommand)
+ : undefined,
+ startCommand: params.startCommand
+ ? String(params.startCommand)
+ : undefined,
dockerComposeLocation: params.dockerComposeLocation
? String(params.dockerComposeLocation)
: undefined,
@@ -4525,7 +4581,9 @@ function normalizeFsPath(
// Workspace-level fallback (legacy behaviour)
if (!norm.startsWith(FS_ROOT) && norm !== FS_ROOT) {
return NextResponse.json(
- { error: `Path "${p}" is outside ${FS_ROOT}; use shell.exec for system paths.` },
+ {
+ error: `Path "${p}" is outside ${FS_ROOT}; use shell.exec for system paths.`,
+ },
{ status: 400 },
);
}
@@ -4906,7 +4964,7 @@ async function toolFsWrite(principal: Principal, params: Record) {
{ status: 500 },
);
}
- const { createHash } = require('crypto');
+ const { createHash } = require("crypto");
const bytes = Buffer.byteLength(content, "utf8");
const sha256 = createHash("sha256").update(content, "utf8").digest("hex");
return NextResponse.json({
@@ -4998,17 +5056,17 @@ print(n)`;
{ status },
);
}
- const stdoutParts = r.stdout.split('---');
+ const stdoutParts = r.stdout.split("---");
const replacementsStr = stdoutParts[0].trim();
- const hashAndSize = stdoutParts[1] ? stdoutParts[1].trim().split('\n') : [];
-
+ const hashAndSize = stdoutParts[1] ? stdoutParts[1].trim().split("\n") : [];
+
return NextResponse.json({
- result: {
- ok: true,
- path,
+ result: {
+ ok: true,
+ path,
replacements: parseInt(replacementsStr || "0", 10),
sha256: hashAndSize[0] ? hashAndSize[0].trim() : undefined,
- bytes: hashAndSize[1] ? parseInt(hashAndSize[1].trim(), 10) : undefined
+ bytes: hashAndSize[1] ? parseInt(hashAndSize[1].trim(), 10) : undefined,
},
});
}
@@ -5018,7 +5076,10 @@ async function toolFsList(principal: Principal, params: Record) {
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
- const path = normalizeFsPath(String(params.path ?? "/workspace"), project.slug);
+ const path = normalizeFsPath(
+ String(params.path ?? "/workspace"),
+ project.slug,
+ );
if (path instanceof NextResponse) return path;
const cmd = `cd ${shq(path)} && ls -lA --time-style=long-iso 2>&1 | head -200`;
const r = await runFsCmd(principal, project, cmd);
@@ -5511,8 +5572,21 @@ interface PlanDecision {
why?: string;
createdAt: string;
}
+export interface BlueprintDocs {
+ stories?: string;
+ acceptance?: string;
+ success?: string;
+ ui_design?: string;
+ tech_context?: string;
+ data_model?: string;
+ file_structure?: string;
+ tasks?: string;
+ checklist?: string;
+}
+
interface PlanShape {
vision?: string;
+ blueprint?: BlueprintDocs;
ideas: PlanIdea[];
tasks: PlanTask[];
decisions: PlanDecision[];
@@ -5557,6 +5631,7 @@ function readPlanFromData(data: any): PlanShape {
}));
return {
vision: data?.productVision ?? raw.vision,
+ blueprint: raw.blueprint ?? {},
ideas: Array.isArray(raw.ideas) ? raw.ideas : [],
tasks,
decisions: Array.isArray(raw.decisions) ? raw.decisions : [],
@@ -5586,6 +5661,15 @@ async function writePlanForProject(
[projectId, JSON.stringify({ ...plan, vision: undefined })],
);
}
+ // Immediately notify the SSE connections that this project has changed!
+ try {
+ // Note: Postgres NOTIFY does not support parameterized queries ($1) for the payload.
+ // It requires the payload string to be directly injected or sent as a literal.
+ // Since projectId is a safe, known UUID/slug generated by us, we can safely template it.
+ await query(`NOTIFY project_updates, '${projectId}'`);
+ } catch (e) {
+ console.error("Failed to notify project updates:", e);
+ }
}
async function toolPlanGet(principal: Principal, params: Record) {
@@ -5784,17 +5868,20 @@ async function toolPlanTaskComplete(
});
}
-async function toolPlanDecisionLog(
+async function toolPlanDocumentUpdate(
principal: Principal,
params: Record,
) {
const projectId = String(params.projectId ?? "").trim();
- const title = String(params.title ?? "").trim();
- const choice = String(params.choice ?? "").trim();
- const why = params.why ? String(params.why).trim() : undefined;
- if (!projectId || !title || !choice) {
+ // Strip any accidental 'prd_' prefix if the AI happens to hallucinate it,
+ // but natively expect clean keys like "stories"
+ const rawDocId = String(params.docId ?? "").trim();
+ const blueprintKey = rawDocId.replace(/^prd_/, "") as keyof BlueprintDocs;
+ const content = String(params.content ?? "").trim();
+
+ if (!projectId || !blueprintKey || !content) {
return NextResponse.json(
- { error: "projectId, title, and choice required" },
+ { error: "projectId, docId, and content required" },
{ status: 400 },
);
}
@@ -5804,22 +5891,25 @@ async function toolPlanDecisionLog(
{ error: "Project not found in workspace" },
{ status: 404 },
);
+
const plan = readPlanFromData(project.data);
- const decision: PlanDecision = {
- id: planNewId(),
- title,
- choice,
- why,
- createdAt: new Date().toISOString(),
- };
- plan.decisions.unshift(decision);
+
+ if (!plan.blueprint) {
+ plan.blueprint = {};
+ }
+
+ // Update the strongly typed blueprint object
+ plan.blueprint[blueprintKey] = content;
+
+ // We explicitly purge any legacy fallback copies of this document from the
+ // decisions array. The decisions array is ONLY for tiny choices like "use Stripe".
+ if (Array.isArray(plan.decisions)) {
+ plan.decisions = plan.decisions.filter((d) => !d.id.startsWith("prd_"));
+ }
+
await writePlanForProject(projectId, plan);
return NextResponse.json({
- result: {
- ok: true,
- decision,
- summaryHint: `Decision logged to Plan → Decisions. Brief acknowledgment only.`,
- },
+ result: { ok: true },
});
}
@@ -6034,7 +6124,10 @@ async function toolFsTree(principal: Principal, params: Record) {
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
- const path = normalizeFsPath(String(params.path ?? "/workspace"), project.slug);
+ const path = normalizeFsPath(
+ String(params.path ?? "/workspace"),
+ project.slug,
+ );
if (path instanceof NextResponse) return path;
// Use find to generate a tree structure, ignoring node_modules and .git
diff --git a/app/api/projects/[projectId]/plan/generate/route.ts b/app/api/projects/[projectId]/plan/generate/route.ts
index b13bf2e7..b64ef4b1 100644
--- a/app/api/projects/[projectId]/plan/generate/route.ts
+++ b/app/api/projects/[projectId]/plan/generate/route.ts
@@ -7,7 +7,7 @@ import { callVibnChat } from "@/lib/ai/vibn-chat-model";
export async function POST(
req: Request,
- ctx: { params: Promise<{ projectId: string }> }
+ ctx: { params: Promise<{ projectId: string }> },
) {
const { projectId } = await ctx.params;
const session = await authSession();
@@ -19,153 +19,490 @@ export async function POST(
`SELECT fs_projects.data, fs_projects.slug FROM fs_projects
JOIN fs_users u ON u.id = fs_projects.user_id
WHERE fs_projects.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
- [projectId, session.user.email]
+ [projectId, session.user.email],
);
- if (!projectRow) return NextResponse.json({ error: "Project not found" }, { status: 404 });
+ if (!projectRow)
+ return NextResponse.json({ error: "Project not found" }, { status: 404 });
const body = await req.json().catch(() => ({}));
- const objective = String(body.objective || projectRow.data?.plan?.vision || "").trim();
+ const objective = String(
+ body.objective || projectRow.data?.plan?.vision || "",
+ ).trim();
if (!objective) {
- return NextResponse.json({ error: "Objective is required to generate a PRD." }, { status: 400 });
+ return NextResponse.json(
+ { error: "Objective is required to generate a PRD." },
+ { status: 400 },
+ );
}
- // We run three sequential LLM calls to construct the complete Spec Kit documentation
+ // We run sequential LLM calls to construct the complete Spec Kit documentation
- // 1. Generate the Product Spec (User Stories & Requirements)
- const specPrompt = `You are a Principal Product Manager writing a Product Requirements Document (PRD).
-Based on the following Objective, generate a structured PRD using Markdown.
-DO NOT talk about technology (databases, frameworks, APIs). Focus purely on user value and capabilities.
+ // 1. Generate User Stories
+ const storiesPrompt = `You are a Principal Product Manager. Your goal is to write DEEP, COMPREHENSIVE documentation that engineers and AI agents can reference when building the product.
+Do not be brief. Exhaustively detail the user journeys. Do not talk about technology (databases, frameworks); focus purely on user value and behavior.
OBJECTIVE:
${objective}
-You MUST follow this exact structure:
+You MUST follow this exact structure for every core actor/role in the platform. Be exhaustive.
-## User Scenarios & Testing
-List the 3-5 most critical User Stories, prioritized.
-### User Story 1 - [Brief Title] (Priority: P1)
-[Description]
-**Acceptance Scenarios**:
-1. **Given** [state], **When** [action], **Then** [outcome]
+# User Stories & Journeys
-### User Story 2 - [Brief Title] (Priority: P2)
-[Repeat...]
+## Actor: [e.g., Buyer / Seller / Admin]
+### [US-1.1] [Action-oriented title] (Priority: P1)
+**Description:** [Deeply describe the journey from start to finish]
+**Business Value:** [Why are we building this?]
+**Edge Cases & Error States:**
+- What happens if [edge case]?
+- How do we handle [error state]?
+**Out of Scope (v1):** [What are we explicitly NOT building yet?]
-## Functional Requirements
-- **FR-001**: System MUST [capability]
-- **FR-002**: Users MUST be able to [interaction]
+### [US-1.2] ...`;
-## Success Criteria
-- **SC-001**: [Measurable business outcome]`;
-
- const specResponse = await callVibnChat({
- systemPrompt: specPrompt,
- messages: [{ role: "user", content: "Generate the PRD." }],
- temperature: 0.2
+ const storiesResponse = await callVibnChat({
+ systemPrompt: storiesPrompt,
+ messages: [
+ { role: "user", content: "Generate deep, comprehensive User Stories." },
+ ],
+ temperature: 0.3,
});
- const specMd = specResponse.text || "Failed to generate PRD.";
+ const storiesMd = storiesResponse.text || "Failed to generate User Stories.";
- // 2. Generate the Technical Architecture (Plan)
- const planPrompt = `You are a Principal Software Architect.
-Based on the following PRD, define the Technical Architecture required to build it.
-Your choices must default to: Next.js (App Router), Postgres, Tailwind CSS, NextAuth, and Stripe (if payments).
+ // 2. Generate Acceptance Criteria
+ const acceptancePrompt = `You are a strict QA Engineer. Based on the User Stories, write EXHAUSTIVE Acceptance Criteria using BDD (Behavior-Driven Development) Given/When/Then syntax.
+These criteria must be deep enough that an AI Agent can write unit tests and E2E tests against them.
-PRD:
-${specMd}
+USER STORIES:
+${storiesMd}
You MUST follow this exact structure:
-## Technical Context
-- **Language/Framework**: Next.js 15 (App Router), TypeScript
-- **Database**: Postgres (via Prisma or raw SQL)
-- **Styling**: Tailwind CSS
-- **Authentication**: [Decide auth strategy]
+# Acceptance Criteria
-## Data Model
-List the core database tables and their relationships.
-- **[EntityName]**: [fields...]
+## Feature: [Title from User Story]
+
+### Scenario 1: Happy Path - [Description]
+**Given** [initial context/state]
+**And** [additional context]
+**When** [action is taken]
+**Then** [expected outcome]
+**And** [additional outcome]
+
+### Scenario 2: Edge Case - [Description]
+**Given** [initial context/state]
+...
+
+## Global Functional Requirements
+- **FR-001**: System MUST [specific, testable capability]
+- **FR-002**: Users MUST be able to [interaction]`;
+
+ const acceptanceResponse = await callVibnChat({
+ systemPrompt: acceptancePrompt,
+ messages: [
+ { role: "user", content: "Generate exhaustive Acceptance Criteria." },
+ ],
+ temperature: 0.2,
+ });
+ const acceptanceMd =
+ acceptanceResponse.text || "Failed to generate Acceptance Criteria.";
+
+ // 3. Generate UI & Design Requirements
+ const uiDesignPrompt = `You are a Lead Product Designer. Define the complete UI, UX, and Design System requirements. Be highly specific so frontend engineers and AI coders know exactly what Tailwind classes and components to use.
+
+USER STORIES:
+${storiesMd}
+
+You MUST follow this exact structure:
+
+# Design System
+
+## Global Theme
+**Vibe/Personality:** [Describe the aesthetic - e.g., "Minimalist, high-trust, SaaS"]
+**Component Library:** [e.g., shadcn/ui, Tremor, raw Tailwind]
+**Spacing & Radii:** [e.g., "Use 8px (rounded-lg) for cards, 4px (rounded-md) for buttons"]
+
+## Color Palette
+- **Primary:** [Hex code] - [Usage: CTAs, active states]
+- **Secondary:** [Hex code] - [Usage: Secondary buttons, subtle highlights]
+- **Background:** [Hex code] - [Usage: App background]
+- **Surface:** [Hex code] - [Usage: Cards, modals]
+- **Text:** [Hex codes for headings vs body]
+
+## Typography
+- **Headings:** [Font Name] - [Weights used]
+- **Body:** [Font Name] - [Weights used]
+- **Monospace:** [Font Name] - [Usage]
+
+## Key Layouts
+### 1. [Screen Name]
+- **Structure:** [e.g., Sidebar left, content right]
+- **Key Components:** [e.g., Data table with pagination, Hero section]
+- **Mobile Behavior:** [How it collapses on small screens]`;
+
+ const uiDesignResponse = await callVibnChat({
+ systemPrompt: uiDesignPrompt,
+ messages: [
+ { role: "user", content: "Generate deep UI Design Requirements." },
+ ],
+ temperature: 0.3,
+ });
+ const uiDesignMd =
+ uiDesignResponse.text || "Failed to generate UI Design Requirements.";
+
+ // 4. Generate Measurable Success
+ const successPrompt = `You are a Data Analyst and Growth Lead. Define the precise, measurable success metrics for this project.
+Do not use vague metrics. Use specific KPIs, target conversion rates, and performance benchmarks.
+
+USER STORIES:
+${storiesMd}
+
+You MUST follow this exact structure:
+
+# Success Metrics & KPIs
+
+## Business Outcomes
+- **SC-001 (Activation):** [e.g., "20% of signups complete onboarding within 24h"]
+- **SC-002 (Retention):** [e.g., "40% MAU retention after 3 months"]
+
+## Technical Benchmarks
+- **TM-001 (Latency):** [e.g., "95th percentile API response < 200ms"]
+- **TM-002 (Lighthouse):** [e.g., "Google Lighthouse performance score > 90"]
+
+## North Star Metric
+**[Metric Name]:** [Why this matters above all else]`;
+
+ const successResponse = await callVibnChat({
+ systemPrompt: successPrompt,
+ messages: [{ role: "user", content: "Generate strict Success Criteria." }],
+ temperature: 0.2,
+ });
+ const successMd =
+ successResponse.text || "Failed to generate Success Criteria.";
+
+ // 5. Generate Technical Context
+ const techPrompt = `You are a Principal Software Architect. Define the definitive Technical Context and Infrastructure roadmap for this project.
+Be exhaustive. If a tool isn't listed here, the AI coders will not use it.
+Your choices MUST default to: Next.js 15 (App Router), Postgres, Tailwind CSS, and NextAuth.js (unless the objective demands otherwise).
+
+USER STORIES:
+${storiesMd}
+UI DESIGN:
+${uiDesignMd}
+
+You MUST follow this exact structure:
+
+# Technical Context & Architecture
+
+## Core Stack
+- **Framework:** Next.js 15 (App Router)
+- **Language:** TypeScript (Strict mode enabled)
+- **Database:** PostgreSQL
+- **ORM:** Prisma
+- **Authentication:** NextAuth.js v5
+- **Styling:** Tailwind CSS + shadcn/ui
+
+## Infrastructure & Hosting
+- **Deployment:** [e.g., Coolify, Vercel, Docker]
+- **File Storage:** [e.g., AWS S3, local, Vercel Blob]
+- **Background Jobs:** [e.g., In-memory, Redis/BullMQ, Inngest]
+
+## Integrations & APIs
+- **[Integration 1]:** [What it does, e.g., "Stripe for payments and subscriptions"]
+- **[Integration 2]:** [e.g., "OpenAI/Gemini for LLM processing"]
+
+## Technical Constraints & Standards
+- [e.g., "No client-side data fetching; use React Server Components where possible"]
+- [e.g., "All DB queries must go through a dedicated data-access layer (DAL)"]`;
+
+ const techResponse = await callVibnChat({
+ systemPrompt: techPrompt,
+ messages: [
+ { role: "user", content: "Generate comprehensive Technical Context." },
+ ],
+ temperature: 0.2,
+ });
+ const techMd = techResponse.text || "Failed to generate Technical Context.";
+
+ // 6. Generate Data Model
+ const dataModelPrompt = `You are a Staff Database Architect. Write an EXHAUSTIVE data model based on the Technical Context and User Stories.
+This model must be detailed enough that an AI coder can directly translate it into a Prisma schema.md or SQL migration. Include relationships, indexes, and enums.
+
+TECHNICAL CONTEXT:
+${techMd}
+USER STORIES:
+${storiesMd}
+
+You MUST follow this exact structure:
+
+# Data Model
+
+## Enums
+- **[EnumName]**: [VALUE_1, VALUE_2]
+
+## Tables / Models
+
+### \`[TableName]\`
+**Purpose:** [What this stores]
+**Fields:**
+- \`id\` (String/UUID, PK) - Primary Key
+- \`createdAt\` (DateTime) - Auto-generated timestamp
+- \`[fieldName]\` ([Type]) - [Description, Default values, Nullability]
+**Relationships:**
+- \`[relationName]\` -> \`[OtherTable]\` (One-to-Many, Many-to-Many)
+**Indexes:**
+- [List any columns that require indexing for performance]
+
+*(Repeat for all necessary tables)*`;
+
+ const dataModelResponse = await callVibnChat({
+ systemPrompt: dataModelPrompt,
+ messages: [
+ { role: "user", content: "Generate a deep, exhaustive Data Model." },
+ ],
+ temperature: 0.2,
+ });
+ const dataModelMd =
+ dataModelResponse.text || "Failed to generate Data Model.";
+
+ // 7. Generate File Structure
+ const fileStructurePrompt = `You are a Staff Software Engineer. Map out the EXACT file structure for this Next.js App Router application.
+This tree will be the literal blueprint the AI agents use to scaffold the application. It must be exhaustive. Include critical \`layout.tsx\`, \`page.tsx\`, \`route.ts\`, and component files.
+
+TECHNICAL CONTEXT:
+${techMd}
+DATA MODEL:
+${dataModelMd}
+
+You MUST output a detailed ASCII file tree with comments explaining the purpose of each critical directory and file.
+
+# File Structure
-## File Structure
-Provide a high-level file tree representing the required Next.js pages and API routes.
\`\`\`text
-src/app/
- page.tsx
- api/
-\`\`\``;
+/
+├── package.json
+├── prisma/
+│ └── schema.prisma # Database schema
+├── src/
+│ ├── app/
+│ │ ├── (auth)/ # Authentication route group
+│ │ │ ├── login/page.tsx
+│ │ │ └── register/page.tsx
+│ │ ├── api/ # Backend API endpoints
+│ │ │ └── webhooks/stripe/route.ts
+│ │ ├── layout.tsx # Root layout (Providers, Fonts)
+│ │ └── page.tsx # Marketing homepage
+│ ├── components/
+│ │ ├── ui/ # shadcn/ui generic components
+│ │ └── shared/ # App-specific shared components (e.g., Navbar)
+│ └── lib/
+│ ├── db.ts # Prisma client singleton
+│ └── utils.ts # Tailwind merge utilities
+\`\`\`
+*(Expand this tree to cover all routes and components required by the User Stories)*`;
- const planResponse = await callVibnChat({
- systemPrompt: planPrompt,
- messages: [{ role: "user", content: "Generate the Technical Architecture." }],
- temperature: 0.1
+ const fileStructureResponse = await callVibnChat({
+ systemPrompt: fileStructurePrompt,
+ messages: [
+ { role: "user", content: "Generate a comprehensive File Structure." },
+ ],
+ temperature: 0.2,
});
- const planMd = planResponse.text || "Failed to generate Architecture.";
+ const fileStructureMd =
+ fileStructureResponse.text || "Failed to generate File Structure.";
- // 3. Generate the Execution Plan (Tasks)
+ // 8. Generate the Execution Plan (Tasks)
const tasksPrompt = `You are a Technical Project Manager.
-Based on the PRD and Technical Architecture, break the implementation down into an atomic, dependency-ordered Task List.
-You MUST format each task EXACTLY as a markdown bullet with a checkbox, a phase tag, and a description containing the file paths.
+Based on the User Stories and File Structure, break the implementation down into an atomic, dependency-ordered Task List.
+These tasks will be handed to an autonomous AI agent to execute. They MUST be atomic (one clear PR/commit per task), chronological, and highly specific.
-PRD:
-${specMd}
+USER STORIES:
+${storiesMd}
+FILE STRUCTURE:
+${fileStructureMd}
-ARCHITECTURE:
-${planMd}
+You MUST format each task EXACTLY as a markdown bullet with a checkbox, a phase tag, and a description containing the exact file paths.
-You MUST follow this exact structure:
+# Execution Plan
-## Phase 1: Setup & Foundation
-- [ ] [Phase 1] Initialize Next.js project and layout
-- [ ] [Phase 1] Setup Postgres database schema
+## Phase 1: Infrastructure & DB Foundation
+- [ ] [Phase 1] Initialize Next.js project, Tailwind, and layout.tsx
+- [ ] [Phase 1] Setup Prisma schema based on the data model and run initial migration
-## Phase 2: [User Story 1 Title]
-- [ ] [US1] Build API route for [feature]
-- [ ] [US1] Build frontend UI component in src/app/[route]/page.tsx
+## Phase 2: Core User Journeys
+- [ ] [Phase 2] Build the API route for [Feature] in \`src/app/api/.../route.ts\`
+- [ ] [Phase 2] Build the frontend UI component in \`src/app/.../page.tsx\` integrating with the API
-## Phase 3: [User Story 2 Title]
-- [ ] [US2] ...`;
+## Phase 3: Polish & Deployment
+- [ ] [Phase 3] ...`;
const tasksResponse = await callVibnChat({
systemPrompt: tasksPrompt,
- messages: [{ role: "user", content: "Generate the Execution Plan tasks." }],
- temperature: 0.1
+ messages: [
+ { role: "user", content: "Generate an atomic Execution Plan task list." },
+ ],
+ temperature: 0.2,
});
const tasksMd = tasksResponse.text || "";
- // 4. Parse the tasks string into the JSON array expected by the DB
+ // 9. Generate the QA Checklist
+ const checklistPrompt = `You are a strict QA Automation Engineer.
+Write an exhaustive Pre-Flight Checklist. The AI agent MUST verify every single item on this list before declaring the feature "Done" and shipping to production.
+
+ACCEPTANCE CRITERIA:
+${acceptanceMd}
+FILE STRUCTURE:
+${fileStructureMd}
+
+You MUST follow this exact structure:
+
+# Pre-Flight QA Checklist
+
+## Build & Environment
+- [ ] \`npm run build\` completes with 0 TypeScript compilation errors.
+- [ ] All required environment variables are documented in \`.env.example\`.
+- [ ] Prisma migrations are synced with the database.
+
+## Security & Auth
+- [ ] Protected routes correctly redirect unauthenticated users to \`/login\`.
+- [ ] Users cannot access or mutate data belonging to other users (Tenant isolation).
+
+## Core Scenarios Verification
+- [ ] Happy Path: [Specific user action] works end-to-end without console errors.
+- [ ] Edge Case: [Specific edge case] is handled gracefully with a user-facing error message.
+- [ ] ...`;
+
+ const checklistResponse = await callVibnChat({
+ systemPrompt: checklistPrompt,
+ messages: [
+ { role: "user", content: "Generate an exhaustive QA Checklist." },
+ ],
+ temperature: 0.2,
+ });
+ const checklistMd =
+ checklistResponse.text || "Failed to generate QA Checklist.";
+
+ // 6. Parse the tasks string into the JSON array expected by the DB
// Extract all `- [ ] [Group] Task description` lines
const taskLines = tasksMd.match(/- \[[ x]\] \[(.*?)\] (.*)/g) || [];
- const parsedTasks = taskLines.map(line => {
- const match = line.match(/- \[[ x]\] \[(.*?)\] (.*)/);
- if (!match) return null;
- return {
- id: Math.random().toString(36).slice(2, 11),
- title: `[${match[1]}] ${match[2]}`, // Fixed backticks
- description: "",
- status: "open",
- createdAt: new Date().toISOString()
- };
- }).filter(Boolean);
+ const parsedTasks = taskLines
+ .map((line) => {
+ const match = line.match(/- \[[ x]\] \[(.*?)\] (.*)/);
+ if (!match) return null;
+ return {
+ id: Math.random().toString(36).slice(2, 11),
+ title: `[${match[1]}] ${match[2]}`, // Fixed backticks
+ description: "",
+ status: "open",
+ createdAt: new Date().toISOString(),
+ };
+ })
+ .filter(Boolean);
- // 5. Update the Database Plan
+ // Update the Database Plan
const currentPlan = projectRow.data?.plan || {};
-
- // Save the PRD under decisions (we can render it specially in the UI)
- const newDecisions = [
- { id: "prd_spec", title: "Product Specification", choice: "Auto-generated PRD", why: specMd },
- { id: "prd_arch", title: "Technical Architecture", choice: "Auto-generated Plan", why: planMd }
+
+ // Create a map of the new generated decisions
+ const generatedDecisions = [
+ {
+ id: "prd_stories",
+ title: "User Stories",
+ choice: "Auto-generated",
+ why: storiesMd,
+ createdAt: new Date().toISOString(),
+ },
+ {
+ id: "prd_acceptance",
+ title: "Acceptance Criteria",
+ choice: "Auto-generated",
+ why: acceptanceMd,
+ createdAt: new Date().toISOString(),
+ },
+ {
+ id: "prd_success",
+ title: "Success Metrics",
+ choice: "Auto-generated",
+ why: successMd,
+ createdAt: new Date().toISOString(),
+ },
+ {
+ id: "prd_ui_design",
+ title: "UI & Design Requirements",
+ choice: "Auto-generated",
+ why: uiDesignMd,
+ createdAt: new Date().toISOString(),
+ },
+ {
+ id: "prd_tech_context",
+ title: "Technical Context",
+ choice: "Auto-generated",
+ why: techMd,
+ createdAt: new Date().toISOString(),
+ },
+ {
+ id: "prd_data_model",
+ title: "Data Model",
+ choice: "Auto-generated",
+ why: dataModelMd,
+ createdAt: new Date().toISOString(),
+ },
+ {
+ id: "prd_file_structure",
+ title: "File Structure",
+ choice: "Auto-generated",
+ why: fileStructureMd,
+ createdAt: new Date().toISOString(),
+ },
+ {
+ id: "prd_tasks",
+ title: "Task Breakdown",
+ choice: "Auto-generated",
+ why: tasksMd,
+ createdAt: new Date().toISOString(),
+ },
+ {
+ id: "prd_checklist",
+ title: "QA Checklist",
+ choice: "Auto-generated",
+ why: checklistMd,
+ createdAt: new Date().toISOString(),
+ },
];
+ // Merge the new decisions into the existing decisions array (updating if they exist, appending if they don't)
+ const existingDecisions = currentPlan.decisions || [];
+ const mergedDecisions = [...existingDecisions];
+
+ for (const gen of generatedDecisions) {
+ const idx = mergedDecisions.findIndex((d: any) => d.id === gen.id);
+ if (idx >= 0) {
+ mergedDecisions[idx] = gen;
+ } else {
+ mergedDecisions.push(gen);
+ }
+ }
+
const updatedPlan = {
...currentPlan,
vision: objective,
tasks: parsedTasks.length > 0 ? parsedTasks : currentPlan.tasks,
- decisions: newDecisions
+ blueprint: {
+ stories: storiesMd,
+ acceptance: acceptanceMd,
+ success: successMd,
+ ui_design: uiDesignMd,
+ tech_context: techMd,
+ data_model: dataModelMd,
+ file_structure: fileStructureMd,
+ tasks: tasksMd,
+ checklist: checklistMd,
+ },
+ decisions: mergedDecisions, // Legacy fallback
};
await query(
`UPDATE fs_projects SET data = jsonb_set(data, '{plan}', $2::jsonb) WHERE id = $1`,
- [projectId, JSON.stringify(updatedPlan)]
+ [projectId, JSON.stringify(updatedPlan)],
);
return NextResponse.json({ plan: updatedPlan });
diff --git a/app/api/projects/[projectId]/plan/route.ts b/app/api/projects/[projectId]/plan/route.ts
index 3bd0a3ad..b47792f7 100644
--- a/app/api/projects/[projectId]/plan/route.ts
+++ b/app/api/projects/[projectId]/plan/route.ts
@@ -26,7 +26,11 @@ import { NextResponse } from "next/server";
import { authSession } from "@/lib/auth/session-server";
import { query } from "@/lib/db-postgres";
-interface Idea { id: string; text: string; createdAt: string }
+interface Idea {
+ id: string;
+ text: string;
+ createdAt: string;
+}
type TaskStatus = "open" | "in_progress" | "review" | "done" | "blocked";
interface Task {
id: string;
@@ -39,7 +43,12 @@ interface Task {
status: TaskStatus;
// Reserved for Phase 2 (background agent delegation). Set when an
// autonomous agent has been kicked off against this task.
- agent?: { runId: string; startedAt: string; finishedAt?: string; status: "queued" | "running" | "succeeded" | "failed" } | null;
+ agent?: {
+ runId: string;
+ startedAt: string;
+ finishedAt?: string;
+ status: "queued" | "running" | "succeeded" | "failed";
+ } | null;
createdAt: string;
startedAt?: string;
doneAt?: string;
@@ -47,16 +56,36 @@ interface Task {
// present on rows created before the markdown migration.
text?: string;
}
-interface Decision { id: string; title: string; choice: string; why?: string; createdAt: string }
+interface Decision {
+ id: string;
+ title: string;
+ choice: string;
+ why?: string;
+ createdAt: string;
+}
+
+export interface BlueprintDocs {
+ stories?: string;
+ acceptance?: string;
+ success?: string;
+ ui_design?: string;
+ tech_context?: string;
+ data_model?: string;
+ file_structure?: string;
+ tasks?: string;
+ checklist?: string;
+}
+
interface PlanShape {
vision?: string;
+ blueprint?: BlueprintDocs;
ideas: Idea[];
tasks: Task[];
decisions: Decision[];
}
function emptyPlan(): PlanShape {
- return { ideas: [], tasks: [], decisions: [] };
+ return { ideas: [], tasks: [], decisions: [], blueprint: {} };
}
function newId(): string {
@@ -79,12 +108,18 @@ function readPlan(data: any): PlanShape {
// on read, so old rows still render correctly without a one-shot
// migration script. We DON'T persist back here; the next write will
// serialize the new shape.
- const tasksIn = Array.isArray(raw.tasks) ? (raw.tasks as Array>) : [];
+ const tasksIn = Array.isArray(raw.tasks)
+ ? (raw.tasks as Array>)
+ : [];
const tasks: Task[] = tasksIn.map((t) => ({
id: String(t.id ?? newId()),
title: String(t.title ?? t.text ?? "").trim(),
description: typeof t.description === "string" ? t.description : "",
- status: (t.status === "in_progress" || t.status === "done" || t.status === "blocked" ? t.status : "open") as TaskStatus,
+ status: (t.status === "in_progress" ||
+ t.status === "done" ||
+ t.status === "blocked"
+ ? t.status
+ : "open") as TaskStatus,
agent: t.agent ?? null,
createdAt: String(t.createdAt ?? new Date().toISOString()),
startedAt: t.startedAt,
@@ -95,13 +130,18 @@ function readPlan(data: any): PlanShape {
vision: data?.productVision ?? raw.vision,
brief: raw.brief,
brief_filename: raw.brief_filename,
+ blueprint: raw.blueprint ?? {},
ideas: Array.isArray(raw.ideas) ? raw.ideas : [],
tasks,
decisions: Array.isArray(raw.decisions) ? raw.decisions : [],
};
}
-async function writePlan(projectId: string, plan: PlanShape, alsoVision?: string) {
+async function writePlan(
+ projectId: string,
+ plan: PlanShape,
+ alsoVision?: string,
+) {
// Use a single jsonb_set call so we don't race with other writers.
// When the vision changes we also mirror it to productVision since
// it's the canonical field elsewhere in the app.
@@ -123,6 +163,14 @@ async function writePlan(projectId: string, plan: PlanShape, alsoVision?: string
[projectId, JSON.stringify({ ...plan, vision: undefined })],
);
}
+ // Immediately notify the SSE connections that this project has changed!
+ try {
+ // Note: Postgres NOTIFY does not support parameterized queries ($1) for the payload.
+ // It requires the payload string to be directly injected or sent as a literal.
+ await query(`NOTIFY project_updates, '${projectId}'`);
+ } catch (e) {
+ console.error("Failed to notify project updates:", e);
+ }
}
export async function GET(
@@ -131,10 +179,12 @@ export async function GET(
) {
const { projectId } = await ctx.params;
const session = await authSession();
- if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ if (!session?.user?.email)
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const project = await loadOwnedProject(projectId, session.user.email);
- if (!project) return NextResponse.json({ error: "Not found" }, { status: 404 });
+ if (!project)
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json({ plan: readPlan(project.data) });
}
@@ -145,10 +195,12 @@ export async function POST(
) {
const { projectId } = await ctx.params;
const session = await authSession();
- if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ if (!session?.user?.email)
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const project = await loadOwnedProject(projectId, session.user.email);
- if (!project) return NextResponse.json({ error: "Not found" }, { status: 404 });
+ if (!project)
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
const body = await req.json().catch(() => ({}));
const kind = String(body.kind ?? "");
@@ -163,7 +215,8 @@ export async function POST(
}
if (kind === "idea") {
const text = String(body.text ?? "").trim();
- if (!text) return NextResponse.json({ error: "text required" }, { status: 400 });
+ if (!text)
+ return NextResponse.json({ error: "text required" }, { status: 400 });
plan.ideas.unshift({ id: newId(), text, createdAt: now });
await writePlan(projectId, plan);
return NextResponse.json({ plan });
@@ -173,8 +226,10 @@ export async function POST(
// still send `text` are mapped to `title` so older AI tool versions
// and any open clients keep working.
const title = String(body.title ?? body.text ?? "").trim();
- const description = typeof body.description === "string" ? body.description : "";
- if (!title) return NextResponse.json({ error: "title required" }, { status: 400 });
+ const description =
+ typeof body.description === "string" ? body.description : "";
+ if (!title)
+ return NextResponse.json({ error: "title required" }, { status: 400 });
plan.tasks.unshift({
id: newId(),
title,
@@ -189,7 +244,11 @@ export async function POST(
const title = String(body.title ?? "").trim();
const choice = String(body.choice ?? "").trim();
const why = body.why ? String(body.why).trim() : undefined;
- if (!title || !choice) return NextResponse.json({ error: "title and choice required" }, { status: 400 });
+ if (!title || !choice)
+ return NextResponse.json(
+ { error: "title and choice required" },
+ { status: 400 },
+ );
plan.decisions.unshift({ id: newId(), title, choice, why, createdAt: now });
await writePlan(projectId, plan);
return NextResponse.json({ plan });
@@ -203,10 +262,12 @@ export async function PATCH(
) {
const { projectId } = await ctx.params;
const session = await authSession();
- if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ if (!session?.user?.email)
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const project = await loadOwnedProject(projectId, session.user.email);
- if (!project) return NextResponse.json({ error: "Not found" }, { status: 404 });
+ if (!project)
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
const body = await req.json().catch(() => ({}));
const { kind, id } = body;
@@ -214,33 +275,48 @@ export async function PATCH(
if (kind === "task" && id) {
const t = plan.tasks.find((x) => x.id === id);
- if (!t) return NextResponse.json({ error: "task not found" }, { status: 404 });
+ if (!t)
+ return NextResponse.json({ error: "task not found" }, { status: 404 });
if (typeof body.title === "string") t.title = body.title.trim();
if (typeof body.description === "string") t.description = body.description;
// Backward compat: PATCH body { text } updates the title.
- if (typeof body.text === "string" && typeof body.title !== "string") t.title = body.text.trim();
- const validStatuses: TaskStatus[] = ["open", "in_progress", "done", "blocked"];
+ if (typeof body.text === "string" && typeof body.title !== "string")
+ t.title = body.text.trim();
+ const validStatuses: TaskStatus[] = [
+ "open",
+ "in_progress",
+ "done",
+ "blocked",
+ ];
if (validStatuses.includes(body.status)) {
const next = body.status as TaskStatus;
const now = new Date().toISOString();
t.status = next;
if (next === "in_progress" && !t.startedAt) t.startedAt = now;
if (next === "done") t.doneAt = now;
- if (next === "open") { t.doneAt = undefined; t.startedAt = undefined; }
+ if (next === "open") {
+ t.doneAt = undefined;
+ t.startedAt = undefined;
+ }
}
await writePlan(projectId, plan);
return NextResponse.json({ plan });
}
if (kind === "idea" && id) {
const i = plan.ideas.find((x) => x.id === id);
- if (!i) return NextResponse.json({ error: "idea not found" }, { status: 404 });
+ if (!i)
+ return NextResponse.json({ error: "idea not found" }, { status: 404 });
if (typeof body.text === "string") i.text = body.text.trim();
await writePlan(projectId, plan);
return NextResponse.json({ plan });
}
if (kind === "decision" && id) {
const d = plan.decisions.find((x) => x.id === id);
- if (!d) return NextResponse.json({ error: "decision not found" }, { status: 404 });
+ if (!d)
+ return NextResponse.json(
+ { error: "decision not found" },
+ { status: 404 },
+ );
if (typeof body.title === "string") d.title = body.title.trim();
if (typeof body.choice === "string") d.choice = body.choice.trim();
if (typeof body.why === "string") d.why = body.why.trim();
@@ -256,19 +332,22 @@ export async function DELETE(
) {
const { projectId } = await ctx.params;
const session = await authSession();
- if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ if (!session?.user?.email)
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const project = await loadOwnedProject(projectId, session.user.email);
- if (!project) return NextResponse.json({ error: "Not found" }, { status: 404 });
+ if (!project)
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
const { searchParams } = new URL(req.url);
const kind = searchParams.get("kind") || "";
const id = searchParams.get("id") || "";
const plan = readPlan(project.data);
- if (kind === "task") plan.tasks = plan.tasks.filter((x) => x.id !== id);
- if (kind === "idea") plan.ideas = plan.ideas.filter((x) => x.id !== id);
- if (kind === "decision") plan.decisions = plan.decisions.filter((x) => x.id !== id);
+ if (kind === "task") plan.tasks = plan.tasks.filter((x) => x.id !== id);
+ if (kind === "idea") plan.ideas = plan.ideas.filter((x) => x.id !== id);
+ if (kind === "decision")
+ plan.decisions = plan.decisions.filter((x) => x.id !== id);
await writePlan(projectId, plan);
return NextResponse.json({ plan });
diff --git a/app/api/projects/[projectId]/stream/route.ts b/app/api/projects/[projectId]/stream/route.ts
new file mode 100644
index 00000000..0c321755
--- /dev/null
+++ b/app/api/projects/[projectId]/stream/route.ts
@@ -0,0 +1,77 @@
+import { getPool } from "@/lib/db-postgres";
+import { authSession } from "@/lib/auth/session-server";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(
+ req: Request,
+ ctx: { params: Promise<{ projectId: string }> },
+) {
+ const { projectId } = await ctx.params;
+ const session = await authSession();
+
+ if (!session?.user?.email) {
+ return new Response("Unauthorized", { status: 401 });
+ }
+
+ const encoder = new TextEncoder();
+
+ const stream = new ReadableStream({
+ async start(controller) {
+ const pool = getPool();
+ let client: import("pg").PoolClient;
+
+ try {
+ client = await pool.connect();
+ } catch (err) {
+ console.error("[SSE] Failed to connect to pg pool:", err);
+ controller.close();
+ return;
+ }
+
+ const notifyHandler = (msg: import("pg").Notification) => {
+ if (msg.payload === projectId) {
+ try {
+ controller.enqueue(encoder.encode(`data: {"event":"updated"}\n\n`));
+ } catch {
+ // controller might be closed
+ }
+ }
+ };
+
+ client.on("notification", notifyHandler);
+ await client.query("LISTEN project_updates");
+
+ // Keep alive ping every 15s to prevent browser/proxy from dropping the connection
+ const keepAlive = setInterval(() => {
+ try {
+ controller.enqueue(encoder.encode(`: ping\n\n`));
+ } catch {
+ cleanup();
+ }
+ }, 15000);
+
+ const cleanup = () => {
+ clearInterval(keepAlive);
+ if (client) {
+ client.removeListener("notification", notifyHandler);
+ client.release();
+ }
+ };
+
+ // When the client disconnects, clean up the DB connection
+ req.signal.addEventListener("abort", cleanup);
+ },
+ cancel() {
+ // Handled by abort listener
+ },
+ });
+
+ return new Response(stream, {
+ headers: {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache, no-transform",
+ Connection: "keep-alive",
+ },
+ });
+}
diff --git a/app/globals.css b/app/globals.css
index 061d4582..b3ac4558 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -3,179 +3,242 @@
@custom-variant dark (&:is(.dark *));
-@keyframes vibn-enter { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:translateY(0); } }
-@keyframes vibn-blink { 0%,100%{opacity:.2} 50%{opacity:.8} }
-@keyframes vibn-breathe { 0%,100%{transform:scale(1)} 50%{transform:scale(1.15)} }
-.vibn-enter { animation: vibn-enter 0.35s ease both; }
+@keyframes vibn-enter {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+@keyframes vibn-blink {
+ 0%,
+ 100% {
+ opacity: 0.2;
+ }
+ 50% {
+ opacity: 0.8;
+ }
+}
+@keyframes vibn-breathe {
+ 0%,
+ 100% {
+ transform: scale(1);
+ }
+ 50% {
+ transform: scale(1.15);
+ }
+}
+@keyframes animate-pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
+@keyframes animate-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.vibn-enter {
+ animation: vibn-enter 0.35s ease both;
+}
+.animate-pulse {
+ animation: animate-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+.animate-spin {
+ animation: animate-spin 1s linear infinite;
+}
/* Marketing — Justine ink & parchment (no blue/purple chrome) */
.vibn-gradient-text {
- background-image: linear-gradient(90deg, var(--vibn-mid) 0%, var(--vibn-ink) 100%);
- -webkit-background-clip: text;
- background-clip: text;
- color: transparent;
+ background-image: linear-gradient(
+ 90deg,
+ var(--vibn-mid) 0%,
+ var(--vibn-ink) 100%
+ );
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
}
.vibn-cta-surface {
- background-image: linear-gradient(to bottom right, var(--vibn-cream), var(--vibn-parch));
+ background-image: linear-gradient(
+ to bottom right,
+ var(--vibn-cream),
+ var(--vibn-parch)
+ );
}
@theme inline {
- --color-background: var(--background);
- --color-foreground: var(--foreground);
- --font-sans: var(--font-inter);
- --font-serif: var(--font-lora);
- --font-mono: var(--font-ibm-plex-mono);
- --color-sidebar-ring: var(--sidebar-ring);
- --color-sidebar-border: var(--sidebar-border);
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
- --color-sidebar-accent: var(--sidebar-accent);
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
- --color-sidebar-primary: var(--sidebar-primary);
- --color-sidebar-foreground: var(--sidebar-foreground);
- --color-sidebar: var(--sidebar);
- --color-chart-5: var(--chart-5);
- --color-chart-4: var(--chart-4);
- --color-chart-3: var(--chart-3);
- --color-chart-2: var(--chart-2);
- --color-chart-1: var(--chart-1);
- --color-ring: var(--ring);
- --color-input: var(--input);
- --color-border: var(--border);
- --color-destructive: var(--destructive);
- --color-accent-foreground: var(--accent-foreground);
- --color-accent: var(--accent);
- --color-muted-foreground: var(--muted-foreground);
- --color-muted: var(--muted);
- --color-secondary-foreground: var(--secondary-foreground);
- --color-secondary: var(--secondary);
- --color-primary-foreground: var(--primary-foreground);
- --color-primary: var(--primary);
- --color-popover-foreground: var(--popover-foreground);
- --color-popover: var(--popover);
- --color-card-foreground: var(--card-foreground);
- --color-card: var(--card);
- --radius-sm: calc(var(--radius) - 4px);
- --radius-md: calc(var(--radius) - 2px);
- --radius-lg: var(--radius);
- --radius-xl: calc(var(--radius) + 4px);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --font-sans: var(--font-inter);
+ --font-serif: var(--font-lora);
+ --font-mono: var(--font-ibm-plex-mono);
+ --color-sidebar-ring: var(--sidebar-ring);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar: var(--sidebar);
+ --color-chart-5: var(--chart-5);
+ --color-chart-4: var(--chart-4);
+ --color-chart-3: var(--chart-3);
+ --color-chart-2: var(--chart-2);
+ --color-chart-1: var(--chart-1);
+ --color-ring: var(--ring);
+ --color-input: var(--input);
+ --color-border: var(--border);
+ --color-destructive: var(--destructive);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-accent: var(--accent);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-muted: var(--muted);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-secondary: var(--secondary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-primary: var(--primary);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
}
:root {
- --radius: 0.5rem;
- /* Justine UX pack — ink & parchment (aligned with master-ai/justine/00_design-tokens.css) */
- --vibn-ink: #1a1510;
- --vibn-ink2: #2c2c2a;
- --vibn-ink3: #444441;
- --vibn-mid: #5f5e5a;
- --vibn-muted: #888780;
- --vibn-stone: #b4b2a9;
- --vibn-parch: #d3d1c7;
- --vibn-cream: #f1efe8;
- --vibn-paper: #f7f4ee;
- --vibn-white: #fdfcfa;
- --vibn-border: #e8e2d9;
+ --radius: 0.5rem;
+ /* Justine UX pack — ink & parchment (aligned with master-ai/justine/00_design-tokens.css) */
+ --vibn-ink: #1a1510;
+ --vibn-ink2: #2c2c2a;
+ --vibn-ink3: #444441;
+ --vibn-mid: #5f5e5a;
+ --vibn-muted: #888780;
+ --vibn-stone: #b4b2a9;
+ --vibn-parch: #d3d1c7;
+ --vibn-cream: #f1efe8;
+ --vibn-paper: #f7f4ee;
+ --vibn-white: #fdfcfa;
+ --vibn-border: #e8e2d9;
- --background: var(--vibn-paper);
- --foreground: var(--vibn-ink);
- --card: var(--vibn-white);
- --card-foreground: var(--vibn-ink);
- --popover: var(--vibn-white);
- --popover-foreground: var(--vibn-ink);
- --primary: var(--vibn-ink);
- --primary-foreground: var(--vibn-paper);
- --secondary: var(--vibn-cream);
- --secondary-foreground: var(--vibn-ink);
- --muted: var(--vibn-cream);
- --muted-foreground: var(--vibn-muted);
- --accent: var(--vibn-cream);
- --accent-foreground: var(--vibn-ink);
- --destructive: #b42318;
- --border: var(--vibn-border);
- --input: var(--vibn-border);
- --ring: var(--vibn-stone);
- --chart-1: oklch(0.70 0.15 60);
- --chart-2: oklch(0.70 0.12 210);
- --chart-3: oklch(0.55 0.10 220);
- --chart-4: oklch(0.40 0.08 230);
- --chart-5: oklch(0.75 0.15 70);
- --sidebar: var(--vibn-white);
- --sidebar-foreground: var(--vibn-ink);
- --sidebar-primary: var(--vibn-ink);
- --sidebar-primary-foreground: var(--vibn-paper);
- --sidebar-accent: var(--vibn-paper);
- --sidebar-accent-foreground: var(--vibn-ink);
- --sidebar-border: var(--vibn-border);
- --sidebar-ring: var(--vibn-stone);
+ --background: var(--vibn-paper);
+ --foreground: var(--vibn-ink);
+ --card: var(--vibn-white);
+ --card-foreground: var(--vibn-ink);
+ --popover: var(--vibn-white);
+ --popover-foreground: var(--vibn-ink);
+ --primary: var(--vibn-ink);
+ --primary-foreground: var(--vibn-paper);
+ --secondary: var(--vibn-cream);
+ --secondary-foreground: var(--vibn-ink);
+ --muted: var(--vibn-cream);
+ --muted-foreground: var(--vibn-muted);
+ --accent: var(--vibn-cream);
+ --accent-foreground: var(--vibn-ink);
+ --destructive: #b42318;
+ --border: var(--vibn-border);
+ --input: var(--vibn-border);
+ --ring: var(--vibn-stone);
+ --chart-1: oklch(0.7 0.15 60);
+ --chart-2: oklch(0.7 0.12 210);
+ --chart-3: oklch(0.55 0.1 220);
+ --chart-4: oklch(0.4 0.08 230);
+ --chart-5: oklch(0.75 0.15 70);
+ --sidebar: var(--vibn-white);
+ --sidebar-foreground: var(--vibn-ink);
+ --sidebar-primary: var(--vibn-ink);
+ --sidebar-primary-foreground: var(--vibn-paper);
+ --sidebar-accent: var(--vibn-paper);
+ --sidebar-accent-foreground: var(--vibn-ink);
+ --sidebar-border: var(--vibn-border);
+ --sidebar-ring: var(--vibn-stone);
}
.dark {
- --background: oklch(0.145 0 0);
- --foreground: oklch(0.985 0 0);
- --card: oklch(0.205 0 0);
- --card-foreground: oklch(0.985 0 0);
- --popover: oklch(0.205 0 0);
- --popover-foreground: oklch(0.985 0 0);
- --primary: oklch(0.922 0 0);
- --primary-foreground: oklch(0.205 0 0);
- --secondary: oklch(0.269 0 0);
- --secondary-foreground: oklch(0.985 0 0);
- --muted: oklch(0.269 0 0);
- --muted-foreground: oklch(0.708 0 0);
- --accent: oklch(0.269 0 0);
- --accent-foreground: oklch(0.985 0 0);
- --destructive: oklch(0.704 0.191 22.216);
- --border: oklch(1 0 0 / 10%);
- --input: oklch(1 0 0 / 15%);
- --ring: oklch(0.556 0 0);
- --chart-1: oklch(0.488 0.243 264.376);
- --chart-2: oklch(0.696 0.17 162.48);
- --chart-3: oklch(0.769 0.188 70.08);
- --chart-4: oklch(0.627 0.265 303.9);
- --chart-5: oklch(0.645 0.246 16.439);
- --sidebar: oklch(0.205 0 0);
- --sidebar-foreground: oklch(0.985 0 0);
- --sidebar-primary: oklch(0.85 0.02 85);
- --sidebar-primary-foreground: oklch(0.18 0.02 60);
- --sidebar-accent: oklch(0.269 0 0);
- --sidebar-accent-foreground: oklch(0.985 0 0);
- --sidebar-border: oklch(1 0 0 / 10%);
- --sidebar-ring: oklch(0.556 0 0);
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.85 0.02 85);
+ --sidebar-primary-foreground: oklch(0.18 0.02 60);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
}
@layer base {
- * {
- @apply border-border outline-ring/50;
- }
- body {
- @apply bg-background text-foreground;
- font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
- }
- h1, h2, h3 {
- font-family: var(--font-lora), ui-serif, Georgia, serif;
- }
- button {
- font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
- cursor: pointer;
- }
- input, textarea, select {
- font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
- }
- input::placeholder {
- color: var(--muted-foreground);
- }
- ::selection {
- background: var(--foreground);
- color: var(--background);
- }
- ::-webkit-scrollbar {
- width: 4px;
- height: 4px;
- }
- ::-webkit-scrollbar-track {
- background: transparent;
- }
- ::-webkit-scrollbar-thumb {
- background: var(--vibn-stone);
- border-radius: 10px;
- }
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
+ }
+ h1,
+ h2,
+ h3 {
+ font-family: var(--font-lora), ui-serif, Georgia, serif;
+ }
+ button {
+ font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
+ cursor: pointer;
+ }
+ input,
+ textarea,
+ select {
+ font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
+ }
+ input::placeholder {
+ color: var(--muted-foreground);
+ }
+ ::selection {
+ background: var(--foreground);
+ color: var(--background);
+ }
+ ::-webkit-scrollbar {
+ width: 4px;
+ height: 4px;
+ }
+ ::-webkit-scrollbar-track {
+ background: transparent;
+ }
+ ::-webkit-scrollbar-thumb {
+ background: var(--vibn-stone);
+ border-radius: 10px;
+ }
}
diff --git a/components/AtlasChat.tsx b/components/AtlasChat.tsx
deleted file mode 100644
index 3366b19c..00000000
--- a/components/AtlasChat.tsx
+++ /dev/null
@@ -1,880 +0,0 @@
-"use client";
-
-import { useEffect, useRef, useState, useCallback } from "react";
-import { useParams } from "next/navigation";
-import Link from "next/link";
-import { ChevronDown, Plus, Trash2, X } from "lucide-react";
-import { JM, JV } from "@/components/project-creation/modal-theme";
-import {
- type ChatContextRef,
- contextRefKey,
-} from "@/lib/chat-context-refs";
-
-interface ChatMessage {
- role: "user" | "assistant";
- content: string;
-}
-
-interface AtlasChatProps {
- projectId: string;
- projectName?: string;
- /** Sidebar picks — shown as chips; sent with each user message until removed */
- chatContextRefs?: ChatContextRef[];
- onRemoveChatContextRef?: (key: string) => void;
- /** Separate thread from overview discovery chat (stored in DB per scope). */
- conversationScope?: "overview" | "build";
- /** Shown in the composer when no context refs (e.g. Discovery vs Workspace). */
- contextEmptyLabel?: string;
- /** Empty-state subtitle under the Vibn title */
- emptyStateHint?: string;
-}
-
-// ---------------------------------------------------------------------------
-// Markers — Atlas appends these at end of messages to signal UI actions
-// ---------------------------------------------------------------------------
-
-const PHASE_MARKER_RE = /\[\[PHASE_COMPLETE:(.*?)\]\]/s;
-const NEXT_STEP_RE = /\[\[NEXT_STEP:(.*?)\]\]/s;
-
-interface PhasePayload {
- phase: string;
- title: string;
- summary: string;
- data: Record;
-}
-
-interface NextStepPayload {
- action: string;
- label: string;
-}
-
-function extractMarkers(text: string): {
- clean: string;
- phase: PhasePayload | null;
- nextStep: NextStepPayload | null;
-} {
- let clean = text;
- let phase: PhasePayload | null = null;
- let nextStep: NextStepPayload | null = null;
-
- const phaseMatch = clean.match(PHASE_MARKER_RE);
- if (phaseMatch) {
- try { phase = JSON.parse(phaseMatch[1]); } catch { /* ignore */ }
- clean = clean.replace(PHASE_MARKER_RE, "").trimEnd();
- }
-
- const nextMatch = clean.match(NEXT_STEP_RE);
- if (nextMatch) {
- try { nextStep = JSON.parse(nextMatch[1]); } catch { /* ignore */ }
- clean = clean.replace(NEXT_STEP_RE, "").trimEnd();
- }
-
- return { clean, phase, nextStep };
-}
-
-// ---------------------------------------------------------------------------
-// Markdown-lite renderer — handles **bold**, newlines, numbered/bullet lists
-// ---------------------------------------------------------------------------
-function renderContent(text: string | null | undefined) {
- if (!text) return null;
- return text.split("\n").map((line, i) => {
- const parts = line.split(/(\*\*.*?\*\*)/g).map((seg, j) =>
- seg.startsWith("**") && seg.endsWith("**")
- ? {seg.slice(2, -2)}
- : {seg}
- );
- return {parts}
;
- });
-}
-
-// ---------------------------------------------------------------------------
-// Message row
-// ---------------------------------------------------------------------------
-function MessageRow({
- msg, projectId, workspace,
-}: {
- msg: ChatMessage;
- projectId: string;
- workspace: string;
-}) {
- const isAtlas = msg.role === "assistant";
- const { clean, phase, nextStep } = isAtlas
- ? extractMarkers(msg.content ?? "")
- : { clean: msg.content ?? "", phase: null, nextStep: null };
-
- // Phase save state
- const [saved, setSaved] = useState(false);
- const [saving, setSaving] = useState(false);
-
- // Architecture generation state
- const [archState, setArchState] = useState<"idle" | "loading" | "done" | "error">("idle");
- const [archError, setArchError] = useState(null);
-
- const handleSavePhase = async () => {
- if (!phase || saved || saving) return;
- setSaving(true);
- try {
- await fetch(`/api/projects/${projectId}/save-phase`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(phase),
- });
- setSaved(true);
- } catch { /* swallow — user can retry */ } finally {
- setSaving(false);
- }
- };
-
- const handleGenerateArchitecture = async () => {
- if (archState !== "idle") return;
- setArchState("loading");
- setArchError(null);
- try {
- const res = await fetch(`/api/projects/${projectId}/architecture`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
- const d = await res.json();
- if (!res.ok) throw new Error(d.error || "Generation failed");
- setArchState("done");
- } catch (e) {
- setArchError(e instanceof Error ? e.message : "Something went wrong");
- setArchState("error");
- }
- };
-
- if (!isAtlas) {
- return (
-
-
-
- {renderContent(clean)}
-
-
- You
-
-
-
- );
- }
-
- return (
-
-
- A
-
-
-
- Vibn
-
-
- {renderContent(clean)}
-
-
- {/* Phase save button */}
- {phase && (
-
-
- {saved ? "✓ Phase saved" : saving ? "Saving…" : `Save phase — ${phase.title}`}
-
- {!saved && (
-
- {phase.summary}
-
- )}
-
- )}
-
- {/* Next step — architecture generation */}
- {nextStep?.action === "generate_architecture" && (
-
- {archState === "done" ? (
-
-
- ✓ Architecture generated
-
-
- Review the recommended apps, services, and infrastructure — then confirm when you're ready.
-
-
- Review architecture →
-
-
- ) : archState === "error" ? (
-
-
- ⚠ {archError}
-
-
{ setArchState("idle"); setArchError(null); }}
- style={{
- padding: "7px 14px", borderRadius: 8, border: `1px solid ${JM.border}`,
- background: "none", fontSize: 12, color: JM.mid,
- fontFamily: JM.fontSans, cursor: "pointer",
- }}
- >
- Try again
-
-
- ) : (
-
-
- Next: Technical architecture
-
-
- The AI will read your PRD and recommend the apps, services, and infrastructure your product needs. Takes about 30 seconds.
-
-
- {archState === "loading" && (
-
- )}
- {archState === "loading" ? "Analysing PRD…" : nextStep.label}
-
-
- )}
-
- )}
-
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Typing indicator
-// ---------------------------------------------------------------------------
-function TypingIndicator() {
- return (
-
-
A
-
- {[0, 1, 2].map(d => (
-
- ))}
-
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Main component
-// ---------------------------------------------------------------------------
-export function AtlasChat({
- projectId,
- chatContextRefs = [],
- onRemoveChatContextRef,
- conversationScope = "overview",
- contextEmptyLabel = "Discovery",
- emptyStateHint,
-}: AtlasChatProps) {
- const params = useParams();
- const workspace = (params?.workspace as string) ?? "";
-
- const [messages, setMessages] = useState([]);
- const [input, setInput] = useState("");
- const [isStreaming, setIsStreaming] = useState(false);
- const [historyLoaded, setHistoryLoaded] = useState(false);
- const [showScrollFab, setShowScrollFab] = useState(false);
- const initTriggered = useRef(false);
- const endRef = useRef(null);
- const scrollRef = useRef(null);
- const textareaRef = useRef(null);
-
- const visibleMessages = messages.filter(msg => msg.content);
-
- const syncScrollFab = useCallback(() => {
- const el = scrollRef.current;
- if (!el) return;
- const dist = el.scrollHeight - el.scrollTop - el.clientHeight;
- setShowScrollFab(dist > 120 && visibleMessages.length > 0);
- }, [visibleMessages.length]);
-
- // Scroll to bottom whenever messages change
- useEffect(() => {
- endRef.current?.scrollIntoView({ behavior: "smooth" });
- requestAnimationFrame(syncScrollFab);
- }, [messages, isStreaming, syncScrollFab]);
-
- // Send a message to Atlas — optionally hidden from UI (for init trigger)
- const sendToAtlas = useCallback(async (text: string, hideUserMsg = false) => {
- if (!hideUserMsg) {
- setMessages(prev => [...prev, { role: "user", content: text }]);
- }
- setIsStreaming(true);
-
- const isInit = text.trim() === "__atlas_init__";
- const payload: { message: string; contextRefs?: ChatContextRef[] } = { message: text };
- if (!isInit && chatContextRefs.length > 0) {
- payload.contextRefs = chatContextRefs;
- }
-
- try {
- const res = await fetch(`/api/projects/${projectId}/atlas-chat`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ ...payload, scope: conversationScope }),
- });
-
- if (!res.ok) {
- const err = await res.json().catch(() => ({}));
- throw new Error(err.error || "Atlas is unavailable. Please try again.");
- }
-
- const data = await res.json();
-
- // alreadyStarted means the init was called but history already exists — ignore
- if (data.alreadyStarted) return;
-
- if (data.reply) {
- setMessages(prev => [...prev, { role: "assistant", content: data.reply }]);
- }
- } catch (e) {
- const msg = e instanceof Error ? e.message : "Something went wrong.";
- setMessages(prev => [...prev, { role: "assistant", content: msg }]);
- } finally {
- setIsStreaming(false);
- }
- }, [projectId, chatContextRefs, conversationScope]);
-
- // On mount: load stored history; if empty, trigger Atlas greeting exactly once
- useEffect(() => {
- let cancelled = false; // guard against unmount during fetch
-
- fetch(`/api/projects/${projectId}/atlas-chat?scope=${encodeURIComponent(conversationScope)}`)
- .then(r => r.json())
- .then((data: { messages: ChatMessage[] }) => {
- if (cancelled) return;
- const stored = data.messages ?? [];
- setMessages(stored);
- setHistoryLoaded(true);
-
- // Only greet if there is genuinely no history and we haven't triggered yet
- if (stored.length === 0 && !initTriggered.current) {
- initTriggered.current = true;
- sendToAtlas("__atlas_init__", true);
- }
- })
- .catch(() => {
- if (cancelled) return;
- setHistoryLoaded(true);
- });
-
- return () => { cancelled = true; };
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [projectId, conversationScope]);
-
- const handleReset = async () => {
- if (!confirm("Clear this conversation and start fresh?")) return;
- try {
- await fetch(
- `/api/projects/${projectId}/atlas-chat?scope=${encodeURIComponent(conversationScope)}`,
- { method: "DELETE" }
- );
- setMessages([]);
- setHistoryLoaded(false);
- initTriggered.current = false;
- // Trigger fresh greeting
- setTimeout(() => {
- initTriggered.current = true;
- sendToAtlas("__atlas_init__", true);
- }, 100);
- } catch {
- // swallow
- }
- };
-
- const handleSend = () => {
- const text = input.trim();
- if (!text || isStreaming) return;
- setInput("");
- sendToAtlas(text, false);
- };
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- handleSend();
- }
- };
-
- const isEmpty = visibleMessages.length === 0 && !isStreaming;
-
- const feedPad = { paddingLeft: 20, paddingRight: 20 } as const;
-
- return (
-
-
-
- {/* Empty state */}
- {isEmpty && (
-
-
-
-
A
-
-
-
Vibn
-
- {emptyStateHint ??
- "Your product strategist. Let\u2019s define what you\u2019re building."}
-
-
-
-
-
- )}
-
- {!isEmpty && (
-
-
-
- {visibleMessages.map((msg, i) => (
-
- ))}
- {isStreaming &&
}
-
-
-
- {showScrollFab && (
-
endRef.current?.scrollIntoView({ behavior: "smooth" })}
- style={{
- position: "absolute",
- right: `max(20px, calc((100% - ${JV.chatFeedMaxWidth}px) / 2 + 8px))`,
- bottom: 12,
- width: 36,
- height: 36,
- borderRadius: "50%",
- border: `1px solid ${JM.border}`,
- background: JV.composerSurface,
- boxShadow: "0 2px 12px rgba(30,27,75,0.1)",
- cursor: "pointer",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- color: JM.mid,
- }}
- >
-
-
- )}
-
- )}
-
- {isEmpty && isStreaming && (
-
- )}
-
- {!isEmpty && !isStreaming && (
-
-
- {[
- { label: "Give me suggestions", prompt: "Can you give me some examples or suggestions to help me think through this?" },
- { label: "What's most important?", prompt: "What's the most important thing for me to nail down right now?" },
- { label: "Move on", prompt: "That's enough detail for now — let's move to the next phase." },
- ].map(({ label, prompt }) => (
- sendToAtlas(prompt, false)}
- style={{
- padding: "6px 14px", borderRadius: 999,
- border: `1px solid ${JM.border}`,
- background: JV.composerSurface, color: JM.mid,
- fontSize: 12, fontFamily: JM.fontSans,
- cursor: "pointer", transition: "all 0.1s",
- whiteSpace: "nowrap",
- }}
- onMouseEnter={e => {
- (e.currentTarget as HTMLElement).style.background = JV.violetTint;
- (e.currentTarget as HTMLElement).style.borderColor = JV.bubbleAiBorder;
- (e.currentTarget as HTMLElement).style.color = JM.ink;
- }}
- onMouseLeave={e => {
- (e.currentTarget as HTMLElement).style.background = JV.composerSurface;
- (e.currentTarget as HTMLElement).style.borderColor = JM.border;
- (e.currentTarget as HTMLElement).style.color = JM.mid;
- }}
- >
- {label}
-
- ))}
-
-
- )}
-
-
-
- );
-}
diff --git a/components/project/project-stream-handler.tsx b/components/project/project-stream-handler.tsx
new file mode 100644
index 00000000..2a95e4f2
--- /dev/null
+++ b/components/project/project-stream-handler.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import { useEffect } from "react";
+import { useSWRConfig } from "swr";
+
+/**
+ * A headless component that maintains a Server-Sent Events (SSE) connection
+ * to the Postgres database. When the backend agent mutates this project,
+ * this component receives a ping and aggressively revalidates SWR.
+ */
+export function ProjectStreamHandler({ projectId }: { projectId: string }) {
+ const { mutate } = useSWRConfig();
+
+ useEffect(() => {
+ if (!projectId) return;
+
+ let eventSource: EventSource | null = null;
+ let retryTimeout: NodeJS.Timeout;
+
+ const connect = () => {
+ eventSource = new EventSource(`/api/projects/${projectId}/stream`);
+
+ eventSource.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+ if (data.event === "updated") {
+ // The database row for this project was mutated!
+ // Find all SWR cache keys that belong to this project and invalidate them.
+ // This instantly updates the Plan tab, Task Kanban, and Anatomy overview.
+ mutate(
+ (key) => typeof key === "string" && key.includes(projectId),
+ undefined,
+ { revalidate: true }
+ );
+ }
+ } catch (err) {
+ console.error("[SSE] Parse error", err);
+ }
+ };
+
+ eventSource.onerror = () => {
+ eventSource?.close();
+ // If the connection drops (e.g. server restart or network dip), back off and try again
+ retryTimeout = setTimeout(connect, 5000);
+ };
+ };
+
+ connect();
+
+ return () => {
+ eventSource?.close();
+ clearTimeout(retryTimeout);
+ };
+ }, [projectId, mutate]);
+
+ return null;
+}
diff --git a/components/vibn-chat/chat-panel.tsx b/components/vibn-chat/chat-panel.tsx
index 453b7768..bf1584f3 100644
--- a/components/vibn-chat/chat-panel.tsx
+++ b/components/vibn-chat/chat-panel.tsx
@@ -24,6 +24,8 @@ import {
Square,
MousePointerClick,
Sparkles,
+ Compass,
+ Cpu,
} from "lucide-react";
import { ProjectIconRail } from "@/components/project/project-icon-rail";
import {
@@ -71,12 +73,6 @@ type TimelineEntry =
// accumulation so multi-round turns render as separate bubbles.
| { kind: "text"; text: string };
-interface ToolEvent {
- name: string;
- status: "running" | "done";
- result?: string;
-}
-
// ── Helpers ───────────────────────────────────────────────────────────────────
function getFriendlyCategory(name: string): string {
@@ -167,11 +163,109 @@ function autoLinkBareUrls(s: string): string {
}
function renderMarkdown(text: string): string {
- let s = text
- .replace(/&/g, "&")
- .replace(//g, ">");
+ const codeBlocks: string[] = [];
+ let s = text;
+
+ // Extract triple backtick code blocks first to protect them from other formatting
+ s = s.replace(/```(\w*)[\s\n]([\s\S]*?)```/g, (match, lang, code) => {
+ const id = `___CODE_BLOCK_${codeBlocks.length}___`;
+ const escapedCode = code
+ .replace(/&/g, "&")
+ .replace(//g, ">");
+ const languageLabel = lang ? lang.toUpperCase() : "CODE";
+
+ const containerStyle = `
+ margin: 12px 0;
+ border: 1px solid #e8e4dc;
+ border-radius: 8px;
+ overflow: hidden;
+ background: #faf8f5;
+ box-shadow: 0 1px 3px rgba(1a,1a,1a,0.02);
+ font-family: var(--font-ibm-plex-mono), SFMono-Regular, Consolas, monospace;
+ `
+ .trim()
+ .replace(/\s+/g, " ");
+
+ const headerStyle = `
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 6px 12px;
+ background: #f0ede8;
+ border-bottom: 1px solid #e8e4dc;
+ font-size: 0.68rem;
+ font-weight: 600;
+ color: #6b6560;
+ `
+ .trim()
+ .replace(/\s+/g, " ");
+
+ const preStyle = `
+ display: block;
+ padding: 12px;
+ margin: 0;
+ overflow-x: auto;
+ font-size: 0.78rem;
+ line-height: 1.55;
+ color: #1a1a1a;
+ white-space: pre;
+ `
+ .trim()
+ .replace(/\s+/g, " ");
+
+ const buttonStyle = `
+ background: #ffffff;
+ border: 1px solid #e8e4dc;
+ border-radius: 4px;
+ padding: 2px 6px;
+ font-size: 0.65rem;
+ cursor: pointer;
+ color: #6b6560;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-family: var(--font-inter), ui-sans-serif, sans-serif;
+ transition: all 0.1s ease;
+ `
+ .trim()
+ .replace(/\s+/g, " ");
+
+ const copyId = `btn-copy-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
+ const textId = `txt-code-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
+
+ const copyScript = `
+ navigator.clipboard.writeText(document.getElementById('${textId}').innerText)
+ .then(() => {
+ const btn = document.getElementById('${copyId}');
+ btn.innerHTML = 'Copied ✓';
+ setTimeout(() => { btn.innerHTML = 'Copy'; }, 1500);
+ });
+ `
+ .trim()
+ .replace(/\s+/g, " ");
+
+ const blockHtml = `
+
+
+ ${languageLabel}
+ Copy
+
+
${escapedCode}
+
+ `
+ .trim()
+ .replace(/\s*\n\s*/g, "");
+
+ codeBlocks.push(blockHtml);
+ return id;
+ });
+
+ // Safe escape of remaining content
+ s = s.replace(/&/g, "&").replace(//g, ">");
+
s = markdownLinksToHtml(s);
+
s = s
.replace(/\*\*(.+?)\*\*/g, "$1 ")
.replace(
@@ -199,28 +293,21 @@ function renderMarkdown(text: string): string {
'',
)
.replace(/\n/g, " ");
+
s = autoLinkBareUrls(s);
+
+ // Restore the formatted code blocks
+ codeBlocks.forEach((html, index) => {
+ s = s.replace(`___CODE_BLOCK_${index}___`, html);
+ });
+
return s;
}
// ── Message bubble ────────────────────────────────────────────────────────────
-/**
- * Strip the markdown-bold "**Section Heading**" lines that Gemini
- * loves to start each thought with so the collapsed pill shows the
- * actual sentence rather than "**Examining the Target Server File**".
- * The full text is still available in the expanded view.
- */
-function thoughtPreview(thoughts: string): string {
- const stripped = thoughts
- .replace(/^\s*\*\*[^*]+\*\*\s*/gm, "")
- .replace(/\s+/g, " ")
- .trim();
- if (stripped.length <= 90) return stripped;
- return stripped.slice(0, 87) + "…";
-}
-
function ThinkingBubble({ thoughts }: { thoughts: string }) {
+ const [expanded, setExpanded] = useState(false);
if (!thoughts) return null;
// Split thoughts into phrases, take the last one as the "current" action
@@ -235,33 +322,66 @@ function ThinkingBubble({ thoughts }: { thoughts: string }) {
return (
-
setExpanded(!expanded)}
style={{
- position: "relative",
- width: 14,
- height: 14,
display: "flex",
alignItems: "center",
- justifyContent: "center",
+ width: "100%",
+ gap: 8,
+ padding: "6px 12px",
+ background: "none",
+ border: "none",
+ fontSize: "0.75rem",
+ color: "#8c8580",
+ cursor: "pointer",
+ textAlign: "left",
}}
>
-
-
-
{currentAction}
+
+
+
+
+ {expanded ? "Thinking Process" : `${currentAction}...`}
+
+
+
+
+
+
+ {expanded && (
+
+ {thoughts}
+
+ )}
);
}
@@ -570,35 +690,6 @@ function TimelineToolGroup({
);
}
-function ToolBubble({ event }: { event: ToolEvent }) {
- return (
-
- {event.status === "running" ? (
-
- ) : (
-
- )}
-
- {friendlyToolName(event.name)}
- {event.status === "running" ? "…" : " ✓"}
-
-
- );
-}
-
// ── Main panel ────────────────────────────────────────────────────────────────
interface ChatPanelProps {
@@ -757,7 +848,7 @@ export function ChatPanel({
structural = false,
artifactSlot,
}: ChatPanelProps = {}) {
- const { data: sessionData, status } = useSession();
+ const { status } = useSession();
const params = useParams();
const pathname = usePathname() ?? "";
const workspace = (params?.workspace as string) || "";
@@ -787,8 +878,10 @@ export function ChatPanel({
const [threadsLoaded, setThreadsLoaded] = useState(false);
const [activeThread, setActiveThread] = useState(null);
const [messages, setMessages] = useState([]);
- const [input, setInput] = useState("");
- const [chatMode, setChatMode] = useState<"collaborate" | "vibe" | "delegate">("vibe");
+ const [input, setInput] = useState("");
+ const [chatMode, setChatMode] = useState<"collaborate" | "vibe" | "delegate">(
+ "vibe",
+ );
const [sending, setSending] = useState(false);
const [showThreads, setShowThreads] = useState(false);
const [mcpToken, setMcpToken] = useState(null);
@@ -917,29 +1010,39 @@ export function ChatPanel({
// so a reloaded thread renders the same per-round bubbles the
// user saw during streaming. Older messages without
// textSegments fall back to the legacy single-bubble path.
- const hydrated = (data.messages || []).map((m: any) => {
- if (m.role !== "assistant") return m;
- const segs: string[] = Array.isArray(m.textSegments)
- ? m.textSegments
- : [];
- if (segs.length === 0) return m;
- const timeline: TimelineEntry[] = segs.map((t) => ({
- kind: "text",
- text: t,
- }));
- // We don't have round-level interleaving for tool calls in
- // the persisted shape (the schema flattens them), so we drop
- // the toolCalls into the timeline at the end. The streamed
- // shape preserves true ordering; this is just a reload
- // approximation. Good enough — what the user really cares
- // about is the text segments not run-on'ing into one blob.
- if (Array.isArray(m.toolCalls)) {
- for (const tc of m.toolCalls) {
- timeline.push({ kind: "tool", name: tc.name, status: "done" });
+ const hydrated = (data.messages || []).map(
+ (m: {
+ role: string;
+ textSegments?: string[];
+ toolCalls?: Array<{
+ name: string;
+ args: Record;
+ result?: string;
+ }>;
+ }) => {
+ if (m.role !== "assistant") return m as unknown as Message;
+ const segs: string[] = Array.isArray(m.textSegments)
+ ? m.textSegments
+ : [];
+ if (segs.length === 0) return m as unknown as Message;
+ const timeline: TimelineEntry[] = segs.map((t) => ({
+ kind: "text",
+ text: t,
+ }));
+ // We don't have round-level interleaving for tool calls in
+ // the persisted shape (the schema flattens them), so we drop
+ // the toolCalls into the timeline at the end. The streamed
+ // shape preserves true ordering; this is just a reload
+ // approximation. Good enough — what the user really cares
+ // about is the text segments not run-on'ing into one blob.
+ if (Array.isArray(m.toolCalls)) {
+ for (const tc of m.toolCalls) {
+ timeline.push({ kind: "tool", name: tc.name, status: "done" });
+ }
}
- }
- return { ...m, timeline, content: "" };
- });
+ return { ...m, timeline, content: "" } as unknown as Message;
+ },
+ );
setMessages(hydrated);
} catch {
/* silent */
@@ -1032,26 +1135,27 @@ export function ChatPanel({
try {
// If Delegate mode is selected, route to the background runner instead of streaming chat!
if (chatMode === "delegate") {
- const r = await fetch(
- `/api/projects/${projectId}/agent/sessions`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- appName: "frontend",
- appPath: ".",
- task: text,
- }),
- },
- );
+ const r = await fetch(`/api/projects/${projectId}/agent/sessions`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ appName: "frontend",
+ appPath: ".",
+ task: text,
+ }),
+ });
if (!r.ok) {
const err = await r.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${r.status}`);
}
-
+
setMessages((prev) => [
...prev,
- { role: "assistant", content: "I have started a background runner for this task. You can safely close this browser or work on something else. I will commit and ship the code when I am finished!" },
+ {
+ role: "assistant",
+ content:
+ "I have started a background runner for this task. You can safely close this browser or work on something else. I will commit and ship the code when I am finished!",
+ },
]);
setSending(false);
return;
@@ -1091,7 +1195,13 @@ export function ChatPanel({
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
- let ev: any;
+ let ev: {
+ type: string;
+ text?: string;
+ name?: string;
+ result?: string;
+ error?: string;
+ };
try {
ev = JSON.parse(line.slice(6));
} catch {
@@ -1277,6 +1387,8 @@ export function ChatPanel({
threads,
loadThreads,
unifiedProjectShell,
+ chatMode,
+ projectId,
],
);
@@ -1493,8 +1605,8 @@ export function ChatPanel({
}}
>
Welcome to {activeProjectName ? activeProjectName : "Vibn"}!
- Tell me what you want to build and I'll scaffold it, run it in a
- preview, and ship it when you say so.
+ Tell me what you want to build and I'll scaffold it, run it
+ in a preview, and ship it when you say so.