feat(refactor): premium zed-style chat UI, collapsible reasoning, and comprehensive strict type sweeps

This commit is contained in:
2026-05-21 17:05:42 -07:00
parent 8bf8da088c
commit 50f65e337d
23 changed files with 5001 additions and 4279 deletions

View File

@@ -6,6 +6,7 @@
import { ReactNode } from "react";
import { Toaster } from "sonner";
import { ChatPanel } from "@/components/vibn-chat/chat-panel";
import { ProjectStreamHandler } from "@/components/project/project-stream-handler";
export default async function ProjectShell({
children,
@@ -14,10 +15,11 @@ export default async function ProjectShell({
children: ReactNode;
params: Promise<{ workspace: string; projectId: string }>;
}) {
const { workspace } = await params;
const { workspace, projectId } = await params;
return (
<>
<ProjectStreamHandler projectId={projectId} />
<div style={pageWrap}>
<ChatPanel structural artifactSlot={children} />
</div>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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<string> {
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<any>(
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(/<tool_calls>[\s\S]*?<\/tool_calls>/g, "")
.replace(/<think>[\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(/<tool_calls>[\s\S]*?<\/tool_calls>/g, "")
.replace(/<think>[\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<any>(
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<string, unknown>;
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) {

View File

@@ -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<string, any>,
) {
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<string, any>) {
{ 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<string, any>) {
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<string, any>) {
@@ -5784,17 +5868,20 @@ async function toolPlanTaskComplete(
});
}
async function toolPlanDecisionLog(
async function toolPlanDocumentUpdate(
principal: Principal,
params: Record<string, any>,
) {
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<string, any>) {
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

View File

@@ -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 });

View File

@@ -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<Partial<Task>>) : [];
const tasksIn = Array.isArray(raw.tasks)
? (raw.tasks as Array<Partial<Task>>)
: [];
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 });

View File

@@ -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",
},
});
}

View File

@@ -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;
}
}

View File

@@ -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<string, unknown>;
}
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("**")
? <strong key={j} style={{ fontWeight: 600, color: JM.ink }}>{seg.slice(2, -2)}</strong>
: <span key={j}>{seg}</span>
);
return <div key={i} style={{ minHeight: line.length ? undefined : "0.75em" }}>{parts}</div>;
});
}
// ---------------------------------------------------------------------------
// 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<string | null>(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 (
<div style={{
marginBottom: 20,
marginTop: -4,
animation: "enter 0.3s ease both",
display: "flex",
justifyContent: "flex-end",
}}>
<div style={{
maxWidth: "min(85%, 480px)",
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
gap: 4,
}}>
<div style={{
background: JV.userBubbleBg,
border: `1px solid ${JV.userBubbleBorder}`,
borderRadius: 18,
padding: "12px 16px",
fontSize: 14,
color: JM.ink,
lineHeight: 1.65,
fontFamily: JM.fontSans,
whiteSpace: "pre-wrap",
}}>
{renderContent(clean)}
</div>
<div style={{
fontSize: 10, fontWeight: 600, color: JM.muted,
textTransform: "uppercase", letterSpacing: "0.06em",
fontFamily: JM.fontSans,
paddingRight: 2,
}}>
You
</div>
</div>
</div>
);
}
return (
<div style={{ display: "flex", gap: 12, marginBottom: 26, animation: "enter 0.3s ease both" }}>
<div style={{
width: 28, height: 28, borderRadius: 8, flexShrink: 0, marginTop: 2,
background: JM.primaryGradient,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 11, fontWeight: 700,
color: "#fff",
fontFamily: JM.fontSans,
boxShadow: JM.primaryShadow,
}}>
A
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 10, fontWeight: 600, color: JM.muted,
marginBottom: 6, textTransform: "uppercase", letterSpacing: "0.05em",
fontFamily: JM.fontSans,
}}>
Vibn
</div>
<div style={{
fontSize: 15, color: JM.ink, lineHeight: 1.75,
fontFamily: JM.fontSans,
whiteSpace: "normal",
}}>
{renderContent(clean)}
</div>
{/* Phase save button */}
{phase && (
<div style={{ marginTop: 14 }}>
<button
type="button"
onClick={handleSavePhase}
disabled={saved || saving}
style={{
display: "inline-flex", alignItems: "center", gap: 7,
padding: "8px 16px", borderRadius: 8,
background: saved ? "#e8f5e9" : JM.primaryGradient,
color: saved ? "#2e7d32" : "#fff",
border: saved ? "1px solid #a5d6a7" : "none",
fontSize: 12, fontWeight: 600,
fontFamily: JM.fontSans,
cursor: saved || saving ? "default" : "pointer",
transition: "all 0.15s",
opacity: saving ? 0.7 : 1,
boxShadow: saved ? "none" : JM.primaryShadow,
}}
>
{saved ? "✓ Phase saved" : saving ? "Saving…" : `Save phase — ${phase.title}`}
</button>
{!saved && (
<div style={{
marginTop: 6, fontSize: 11, color: JM.muted,
fontFamily: JM.fontSans, lineHeight: 1.4,
}}>
{phase.summary}
</div>
)}
</div>
)}
{/* Next step — architecture generation */}
{nextStep?.action === "generate_architecture" && (
<div style={{
marginTop: 16,
padding: "16px 18px",
background: JV.composerSurface,
border: `1px solid ${JM.border}`,
borderRadius: 14,
borderLeft: `3px solid ${JM.indigo}`,
boxShadow: "0 1px 8px rgba(30,27,75,0.04)",
}}>
{archState === "done" ? (
<div>
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#2e7d32", marginBottom: 6 }}>
Architecture generated
</div>
<p style={{ fontSize: "0.76rem", color: "#6b6560", margin: "0 0 12px", lineHeight: 1.5 }}>
Review the recommended apps, services, and infrastructure then confirm when you&apos;re ready.
</p>
<Link
href={`/${workspace}/project/${projectId}/build`}
style={{
display: "inline-block", padding: "8px 16px", borderRadius: 8,
background: JM.primaryGradient, color: "#fff",
fontSize: 12, fontWeight: 600,
fontFamily: JM.fontSans, textDecoration: "none",
boxShadow: JM.primaryShadow,
}}
>
Review architecture
</Link>
</div>
) : archState === "error" ? (
<div>
<div style={{ fontSize: "0.78rem", color: "#c62828", marginBottom: 8 }}>
{archError}
</div>
<button
type="button"
onClick={() => { 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
</button>
</div>
) : (
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: JM.ink, marginBottom: 5, fontFamily: JM.fontSans }}>
Next: Technical architecture
</div>
<p style={{ fontSize: 12, color: JM.mid, margin: "0 0 14px", lineHeight: 1.55, fontFamily: JM.fontSans }}>
The AI will read your PRD and recommend the apps, services, and infrastructure your product needs. Takes about 30 seconds.
</p>
<button
type="button"
onClick={handleGenerateArchitecture}
disabled={archState === "loading"}
style={{
display: "inline-flex", alignItems: "center", gap: 8,
padding: "9px 18px", borderRadius: 8, border: "none",
background: archState === "loading" ? JM.muted : JM.primaryGradient,
color: "#fff", fontSize: 12, fontWeight: 600,
fontFamily: JM.fontSans,
cursor: archState === "loading" ? "default" : "pointer",
transition: "background 0.15s",
boxShadow: archState === "loading" ? "none" : JM.primaryShadow,
}}
>
{archState === "loading" && (
<span style={{
width: 12, height: 12, borderRadius: "50%",
border: "2px solid #ffffff40", borderTopColor: "#fff",
animation: "spin 0.7s linear infinite", display: "inline-block",
}} />
)}
{archState === "loading" ? "Analysing PRD…" : nextStep.label}
</button>
</div>
)}
</div>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Typing indicator
// ---------------------------------------------------------------------------
function TypingIndicator() {
return (
<div style={{ display: "flex", gap: 12, marginBottom: 26, animation: "enter 0.2s ease", alignItems: "center" }}>
<div style={{
width: 28, height: 28, borderRadius: 8, flexShrink: 0,
background: JM.primaryGradient,
boxShadow: JM.primaryShadow,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 11, fontWeight: 700, color: "#fff", fontFamily: JM.fontSans,
}}>A</div>
<div style={{ display: "flex", gap: 5, alignItems: "center", paddingTop: 2 }}>
{[0, 1, 2].map(d => (
<div key={d} style={{
width: 5, height: 5, borderRadius: "50%", background: JM.muted,
animation: `blink 1s ease ${d * 0.15}s infinite`,
}} />
))}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// 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<ChatMessage[]>([]);
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<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const isEmpty = visibleMessages.length === 0 && !isStreaming;
const feedPad = { paddingLeft: 20, paddingRight: 20 } as const;
return (
<div style={{
display: "flex", flexDirection: "column", height: "100%",
background: JV.chatColumnBg,
fontFamily: JM.fontSans,
}}>
<style>{`
@keyframes blink { 0%,100%{opacity:.2} 50%{opacity:.8} }
@keyframes enter { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:translateY(0); } }
@keyframes spin { to { transform:rotate(360deg); } }
`}</style>
{/* Empty state */}
{isEmpty && (
<div style={{
flex: 1, display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "center",
gap: 12, padding: "40px 20px",
}}>
<div style={{ width: "100%", maxWidth: JV.chatFeedMaxWidth, margin: "0 auto" }}>
<div style={{
display: "flex", flexDirection: "column", alignItems: "center", gap: 12,
}}>
<div style={{
width: 44, height: 44, borderRadius: 14, background: JM.primaryGradient,
boxShadow: JM.primaryShadow,
display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: JM.fontSans, fontSize: 18, fontWeight: 600, color: "#fff",
animation: "breathe 2.5s ease infinite",
}}>A</div>
<style>{`@keyframes breathe { 0%,100%{transform:scale(1)} 50%{transform:scale(1.08)} }`}</style>
<div style={{ textAlign: "center" }}>
<p style={{ fontSize: 15, fontWeight: 600, color: JM.ink, marginBottom: 4, fontFamily: JM.fontDisplay }}>Vibn</p>
<p style={{ fontSize: 13, color: JM.muted, maxWidth: 320, lineHeight: 1.55, margin: 0 }}>
{emptyStateHint ??
"Your product strategist. Let\u2019s define what you\u2019re building."}
</p>
</div>
</div>
</div>
</div>
)}
{!isEmpty && (
<div style={{
flex: 1, minHeight: 0, position: "relative", display: "flex", flexDirection: "column",
}}>
<div
ref={scrollRef}
onScroll={syncScrollFab}
style={{
flex: 1, overflowY: "auto", paddingTop: 24, paddingBottom: 16,
...feedPad,
}}
>
<div style={{ maxWidth: JV.chatFeedMaxWidth, margin: "0 auto", width: "100%" }}>
{visibleMessages.map((msg, i) => (
<MessageRow key={i} msg={msg} projectId={projectId} workspace={workspace} />
))}
{isStreaming && <TypingIndicator />}
<div ref={endRef} />
</div>
</div>
{showScrollFab && (
<button
type="button"
title="Scroll to latest"
onClick={() => 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,
}}
>
<ChevronDown size={18} strokeWidth={2} />
</button>
)}
</div>
)}
{isEmpty && isStreaming && (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", ...feedPad }}>
<div style={{ maxWidth: JV.chatFeedMaxWidth, width: "100%" }}>
<TypingIndicator />
</div>
</div>
)}
{!isEmpty && !isStreaming && (
<div style={{
padding: "0 0 10px",
...feedPad,
display: "flex",
justifyContent: "center",
}}>
<div style={{
maxWidth: JV.chatFeedMaxWidth,
width: "100%",
display: "flex",
gap: 8,
flexWrap: "wrap",
}}>
{[
{ 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 }) => (
<button
type="button"
key={label}
onClick={() => 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}
</button>
))}
</div>
</div>
)}
<div style={{
padding: `10px 20px max(20px, env(safe-area-inset-bottom))`,
flexShrink: 0,
display: "flex",
justifyContent: "center",
}}>
<div style={{ width: "100%", maxWidth: JV.chatFeedMaxWidth }}>
<div style={{
background: JV.composerSurface,
border: `1px solid ${JM.border}`,
borderRadius: JV.composerRadius,
boxShadow: JV.composerShadow,
overflow: "hidden",
}}>
<textarea
ref={textareaRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Reply…"
rows={2}
disabled={isStreaming}
style={{
display: "block",
width: "100%",
border: "none",
background: "transparent",
fontSize: 15,
fontFamily: JM.fontSans,
color: JM.ink,
padding: "16px 18px 10px",
resize: "none",
outline: "none",
minHeight: 52,
maxHeight: 200,
lineHeight: 1.5,
boxSizing: "border-box",
}}
/>
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
padding: "8px 10px 10px 12px",
borderTop: `1px solid ${JM.border}`,
background: "rgba(249,250,251,0.6)",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
<button
type="button"
title="Focus composer"
onClick={() => textareaRef.current?.focus()}
style={{
width: 36,
height: 36,
borderRadius: 10,
border: "none",
background: "transparent",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: JM.mid,
}}
>
<Plus size={20} strokeWidth={1.75} />
</button>
<button
type="button"
title="Clear conversation"
onClick={handleReset}
style={{
width: 36,
height: 36,
borderRadius: 10,
border: "none",
background: "transparent",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: JM.muted,
}}
>
<Trash2 size={18} strokeWidth={1.75} />
</button>
</div>
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 8,
flex: 1,
minWidth: 0,
flexWrap: "wrap",
}}>
{chatContextRefs.length > 0 && onRemoveChatContextRef && (
<div style={{
display: "flex",
flexWrap: "wrap",
gap: 6,
alignItems: "center",
justifyContent: "flex-end",
minWidth: 0,
flex: "1 1 auto",
maxWidth: "100%",
}}>
{chatContextRefs.map(ref => {
const key = contextRefKey(ref);
const prefix =
ref.kind === "section" ? "Section" : ref.kind === "phase" ? "Phase" : "App";
return (
<span
key={key}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
maxWidth: 200,
padding: "4px 6px 4px 10px",
borderRadius: 999,
border: `1px solid ${JV.bubbleAiBorder}`,
background: JV.violetTint,
fontSize: 11,
fontWeight: 600,
color: JM.indigo,
fontFamily: JM.fontSans,
flexShrink: 1,
}}
>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", minWidth: 0 }}>
{prefix}: {ref.label}
</span>
<button
type="button"
title="Remove reference"
onClick={() => onRemoveChatContextRef(key)}
style={{
border: "none",
background: "rgba(255,255,255,0.7)",
borderRadius: "50%",
width: 20,
height: 20,
padding: 0,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: JM.mid,
flexShrink: 0,
}}
>
<X size={12} strokeWidth={2.5} />
</button>
</span>
);
})}
</div>
)}
{chatContextRefs.length === 0 && (
<span style={{
fontSize: 11,
fontWeight: 600,
color: JM.mid,
fontFamily: JM.fontSans,
padding: "5px 10px",
borderRadius: 999,
border: `1px solid ${JM.border}`,
background: JV.violetTint,
whiteSpace: "nowrap",
flexShrink: 0,
}}>
{contextEmptyLabel}
</span>
)}
{isStreaming ? (
<button
type="button"
onClick={() => setIsStreaming(false)}
style={{
padding: "9px 18px", borderRadius: 10, border: "none",
background: JV.violetTint, color: JM.mid,
fontSize: 12, fontWeight: 600, fontFamily: JM.fontSans,
cursor: "pointer",
display: "flex", alignItems: "center", gap: 6,
flexShrink: 0,
}}
>
<span style={{ width: 8, height: 8, background: JM.indigo, borderRadius: 2, display: "inline-block" }} />
Stop
</button>
) : (
<button
type="button"
onClick={handleSend}
disabled={!input.trim()}
style={{
padding: "9px 20px", borderRadius: 10, border: "none",
background: input.trim() ? JM.primaryGradient : JV.violetTint,
color: input.trim() ? "#fff" : JM.muted,
fontSize: 12, fontWeight: 600, fontFamily: JM.fontSans,
cursor: input.trim() ? "pointer" : "default",
boxShadow: input.trim() ? JM.primaryShadow : "none",
transition: "opacity 0.15s",
flexShrink: 0,
}}
onMouseEnter={e => { if (input.trim()) (e.currentTarget.style.opacity = "0.92"); }}
onMouseLeave={e => { (e.currentTarget.style.opacity = "1"); }}
>
Send
</button>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -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;
}

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
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 = `
<div style="${containerStyle}">
<div style="${headerStyle}">
<span>${languageLabel}</span>
<button id="${copyId}" onclick="${copyScript}" style="${buttonStyle}">Copy</button>
</div>
<pre id="${textId}" style="${preStyle}"><code>${escapedCode}</code></pre>
</div>
`
.trim()
.replace(/\s*\n\s*/g, "");
codeBlocks.push(blockHtml);
return id;
});
// Safe escape of remaining content
s = s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
s = markdownLinksToHtml(s);
s = s
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(
@@ -199,28 +293,21 @@ function renderMarkdown(text: string): string {
'</p><p style="margin:0 0 8px;overflow-wrap:anywhere;word-break:break-word">',
)
.replace(/\n/g, "<br>");
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 (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 12px",
margin: "4px 0",
fontSize: "0.75rem",
color: "#8c8580",
background: "#faf8f5",
border: "1px dashed #e8e4dc",
borderRadius: 8,
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
fontStyle: "italic",
}}
>
<div
<button
type="button"
onClick={() => 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",
}}
>
<Sparkles
style={{ width: 12, height: 12, opacity: 0.7 }}
className="animate-pulse"
/>
</div>
<span className="animate-pulse">{currentAction}</span>
<span style={{ width: 14, display: "flex", justifyContent: "center" }}>
<Sparkles
style={{ width: 12, height: 12, color: "#d4a04a" }}
className="animate-pulse"
/>
</span>
<span style={{ flex: 1, fontStyle: "italic" }}>
{expanded ? "Thinking Process" : `${currentAction}...`}
</span>
<span
style={{
transform: expanded ? "rotate(180deg)" : "none",
transition: "transform 0.15s ease",
opacity: 0.5,
}}
>
<ChevronDown style={{ width: 12, height: 12 }} />
</span>
</button>
{expanded && (
<div
style={{
padding: "0 12px 10px 34px",
fontSize: "0.74rem",
color: "#6b6560",
lineHeight: 1.55,
whiteSpace: "pre-wrap",
borderTop: "1px solid #f0ede8",
marginTop: 4,
paddingTop: 8,
}}
>
{thoughts}
</div>
)}
</div>
);
}
@@ -570,35 +690,6 @@ function TimelineToolGroup({
);
}
function ToolBubble({ event }: { event: ToolEvent }) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 12px",
margin: "4px 0",
background: "#f0ede8",
borderRadius: 8,
fontSize: "0.75rem",
color: "#6b6560",
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
}}
>
{event.status === "running" ? (
<Loader2 style={{ width: 12, height: 12 }} className="animate-spin" />
) : (
<Wrench style={{ width: 12, height: 12, color: "#2e7d32" }} />
)}
<span>
{friendlyToolName(event.name)}
{event.status === "running" ? "…" : " ✓"}
</span>
</div>
);
}
// ── 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<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
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<string | null>(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<string, unknown>;
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&apos;ll scaffold it, run it
in a preview, and ship it when you say so.
</div>
</div>
</div>
@@ -1566,6 +1678,118 @@ export function ChatPanel({
flexShrink: 0,
}}
>
{/* Chat Mode Toggle */}
<div
style={{
display: "flex",
gap: 4,
marginBottom: 8,
padding: "2px",
background: "#f0ede8",
borderRadius: 8,
border: "1px solid #e8e4dc",
}}
>
<button
type="button"
onClick={() => setChatMode("vibe")}
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 6,
padding: "6px 0",
fontSize: "0.72rem",
fontWeight: chatMode === "vibe" ? 600 : 500,
borderRadius: 6,
border: "none",
background: chatMode === "vibe" ? "#fff" : "transparent",
color: chatMode === "vibe" ? "#3d5afe" : "#6b6560",
cursor: "pointer",
boxShadow:
chatMode === "vibe" ? "0 1px 2px rgba(0,0,0,0.05)" : "none",
transition: "all 0.1s ease",
}}
title="Vibe Code: Fast, iterative coding with immediate live previews."
>
<Sparkles
style={{
width: 12,
height: 12,
color: chatMode === "vibe" ? "#3d5afe" : "#8c8580",
}}
/>
<span>Vibe Code</span>
</button>
<button
type="button"
onClick={() => setChatMode("collaborate")}
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 6,
padding: "6px 0",
fontSize: "0.72rem",
fontWeight: chatMode === "collaborate" ? 600 : 500,
borderRadius: 6,
border: "none",
background: chatMode === "collaborate" ? "#fff" : "transparent",
color: chatMode === "collaborate" ? "#d4a04a" : "#6b6560",
cursor: "pointer",
boxShadow:
chatMode === "collaborate"
? "0 1px 2px rgba(0,0,0,0.05)"
: "none",
transition: "all 0.1s ease",
}}
title="Architect: Brainstorm, spec out features, and plan architecture without writing code."
>
<Compass
style={{
width: 12,
height: 12,
color: chatMode === "collaborate" ? "#d4a04a" : "#8c8580",
}}
/>
<span>Architect</span>
</button>
<button
type="button"
onClick={() => setChatMode("delegate")}
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 6,
padding: "6px 0",
fontSize: "0.72rem",
fontWeight: chatMode === "delegate" ? 600 : 500,
borderRadius: 6,
border: "none",
background: chatMode === "delegate" ? "#fff" : "transparent",
color: chatMode === "delegate" ? "#2e7d32" : "#6b6560",
cursor: "pointer",
boxShadow:
chatMode === "delegate" ? "0 1px 2px rgba(0,0,0,0.05)" : "none",
transition: "all 0.1s ease",
}}
title="Delegate Offline: Send this task to the background runner to build automatically while you step away."
>
<Cpu
style={{
width: 12,
height: 12,
color: chatMode === "delegate" ? "#2e7d32" : "#8c8580",
}}
/>
<span>Delegate</span>
</button>
</div>
{!mcpToken && (
<div
style={{

View File

@@ -0,0 +1,103 @@
# Postal Email Infrastructure & AI Integration Scope
## Overview
Vibn requires a scalable, multi-tenant email infrastructure that allows both the core platform and user-generated AI agents to send emails. To support unlimited custom domains without incurring high per-domain or per-email costs, we will self-host **Postal**, an open-source mail delivery platform, on Coolify.
This document outlines the end-to-end implementation plan, covering infrastructure, backend integration, database modifications, and the AI MCP tool.
---
## Phase 1: Infrastructure (Self-Hosted Postal)
*Status: Pending*
Postal requires its own dedicated environment with MariaDB and RabbitMQ. We will deploy this via Coolify's Docker Compose feature.
### 1. Server & DNS Prerequisites
- **Clean IP:** Ensure the Coolify host (or dedicated VPS) IP is not on any DNSBL (Blacklists).
- **Subdomain:** Dedicate a subdomain (e.g., `postal.vibn.com` or `mail.vibn.com`).
- **Reverse DNS (rDNS/PTR):** Configure the hosting provider to map the server's IP back to the Postal subdomain.
### 2. Coolify Deployment
- Create a new **Docker Compose** application in Coolify.
- Use the official Postal docker-compose configuration, which includes:
- `postal` (The Ruby-based core app)
- `mariadb` (Relational database for routing/config)
- `rabbitmq` (Message queue for email spooling)
- Configure environment variables within Coolify (signing keys, DNS hostnames).
- Expose ports: `80/443` (Web UI/API) and `25` (SMTP inbound/outbound).
### 3. Deliverability Foundations
- Generate and configure the global **SPF**, **DKIM**, and **DMARC** records for the Vibn root domain.
- Set up a master "Organization" and "Vibn System" Mail Server inside the Postal UI to generate the first API key.
---
## Phase 2: Backend Integration & Database
*Status: Pending*
We need to securely store Postal credentials and allow individual Vibn projects to configure their own sender identities.
### 1. Database Schema Update (`lib/db-postgres.ts`)
Add an `email_config` JSONB column to the `projects` table to handle multi-tenancy.
```typescript
// Proposed structure for email_config
{
provider: "postal",
postalServerKey: "api_key_for_this_specific_project",
verifiedDomains: ["userapp.com"],
defaultFrom: "hello@userapp.com"
}
```
### 2. Environment Variables (`.env.local` & Coolify)
Add global fallback variables for Vibn system emails:
- `VIBN_POSTAL_API_URL` (e.g., `https://postal.vibn.com/api/v1`)
- `VIBN_POSTAL_API_KEY`
### 3. Email Utility Client (`lib/email/postal.ts`)
Create a robust TypeScript wrapper for the Postal REST API.
- **Methods:** `sendEmail()`, `createServer()`, `addDomain()`.
- **Logic:** Must support passing a custom API key (for project-specific routing) or falling back to the global key.
---
## Phase 3: AI Agent Integration (MCP Tool)
*Status: Pending*
Expose the ability to send emails to the AI via the Model Context Protocol (MCP).
### 1. Register `send_email` Tool (`vibn-agent-runner/src/tools/vibn-tools.ts`)
Define the JSON Schema for the tool:
- `to`: Array of email addresses.
- `subject`: String.
- `body`: Markdown or Plain Text string.
- *(Optional)* `from`: Override sender address (must match a verified domain).
### 2. Handle MCP Execution (`vibn-frontend/app/api/mcp/route.ts`)
When the AI invokes `send_email`:
1. Extract the `projectId` from the authenticated MCP session.
2. Query the database for the project's `email_config`.
3. Validate that the requested `from` address is authorized for this project.
4. Call the `lib/email/postal.ts` utility.
5. Return the Postal Message ID to the AI so it can confirm delivery in the chat.
---
## Phase 4: User Interface (Optional but Recommended)
*Status: Pending*
To achieve true multi-tenancy, users need a way to connect their custom domains to Postal via the Vibn UI.
### 1. Project Settings -> Email Tab
- A UI where users can click "Add Domain".
- Vibn calls the Postal API to register the domain and retrieve the required DNS records (DKIM, SPF).
- Display these DNS records to the user.
- Add a "Verify DNS" button that queries Postal to confirm the records are active.
---
## Next Steps
To begin, we should tackle **Phase 1: Infrastructure**.
I can write the exact `docker-compose.yml` you need to paste into Coolify right now. Do you have a specific subdomain in mind for Postal (e.g., `postal.yourdomain.com`)?

View File

@@ -0,0 +1,57 @@
# Vibn Real-Time Updates Architecture
## The Problem
Currently, the Vibn frontend relies on static data fetching and manual browser refreshes. Because autonomous AI agents (like the background Coder) operate headlessly via `vibn-agent-runner`, they mutate the Postgres database directly. The Next.js frontend has no mechanism to know when these mutations occur, leading to stale data in the UI (e.g., the Plan tab not updating when a task is checked off, or the Chat UI not streaming in new messages instantly).
## The Solution: Real-Time SWR + Server-Sent Events (SSE)
To solve this holistically without introducing the overhead of WebSockets or a third-party service (like Pusher), we will implement a lightweight **Server-Sent Events (SSE)** architecture combined with **SWR mutation**.
### 1. The Real-Time Event Bus (Postgres LISTEN/NOTIFY)
Postgres has built-in pub/sub capabilities. When an AI agent makes a change to a project (e.g., completes a task or sends a message), we will fire a simple trigger in Postgres.
```sql
-- Example Postgres Trigger (To be created)
CREATE OR REPLACE FUNCTION notify_project_update() RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('project_updates', json_build_object('project_id', NEW.id, 'updated_at', NEW.data->>'updatedAt')::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
```
### 2. The SSE API Route (`/api/projects/[projectId]/stream`)
We will create a Next.js API route that keeps an open HTTP connection to the client. This route will subscribe to the Postgres `pg_notify` channel.
When the database emits an event for `projectId`, this route pushes a small payload to the browser: `data: {"event": "plan_updated"}`.
### 3. The Frontend Hook (`useProjectStream`)
We will write a global React hook that wraps SWR.
When the SSE connection receives an event, it simply tells SWR to re-fetch the data in the background and update the UI instantly.
```tsx
// Example Hook Concept
export function useProjectStream(projectId: string) {
useEffect(() => {
const eventSource = new EventSource(`/api/projects/${projectId}/stream`);
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'plan_updated') mutate(`/api/projects/${projectId}/plan`);
if (data.type === 'chat_updated') mutate(`/api/projects/${projectId}/chat`);
};
return () => eventSource.close();
}, [projectId]);
}
```
### Why this is the best approach for Vibn:
1. **No New Infrastructure:** It uses your existing Postgres database and Next.js server.
2. **Zero Dependencies:** SSE is native to HTTP and browsers. We don't need `socket.io`.
3. **Works perfectly with SWR:** Instead of trying to push massive JSON payloads over the socket, the socket just acts as a "ping" to tell the client, *"Hey, the data is stale, fetch the new state via your existing API routes."*
---
## Next Steps for Implementation
1. Add the generic SSE API route.
2. Build the `useProjectStream` hook and drop it into the `app/[workspace]/project/[projectId]/layout.tsx` file so every tab gets real-time capabilities.
3. Update the backend API tools (like `plan_task_complete` and the new `email_send`) to trigger the update events.

View File

@@ -22,8 +22,6 @@ if (!VIBNDEV_API_KEY) {
// ── JSON-RPC helpers ─────────────────────────────────────────────────
let requestId = 0;
function sendJson(obj) {
process.stdout.write(JSON.stringify(obj) + "\n");
}

21
docs/todo/DIAGNOSIS.md Normal file
View File

@@ -0,0 +1,21 @@
# Diagnosis: Why the Plan / PRD Didn't Update in the UI
I investigated why the PRD and Tasks did not appear in your Plan UI after the AI said it updated them.
Here is exactly what happened:
### 1. Why the PRD text didn't update
- The UI's "Product Spec" tab specifically looks for a single document stored in your database under a hardcoded ID called `prd_spec`.
- Because the AI doesn't have a tool specifically named `update_prd_spec`, it used the generic `plan_vision_set` tool. It saved your new PRD text into the `plan.vision` field (the "Objective" box) instead of the `prd_spec` document.
- **The Fix:** I can either add a new AI tool specifically for updating the PRD document, or we can adjust the UI to read from the correct field.
### 2. Why the Tasks didn't show up immediately
- The AI correctly executed `plan_task_add` for both tasks (`[US1]` and `[US2]`). I queried the Postgres database, and **both tasks are successfully saved in your project's plan.**
- However, the Plan UI uses aggressive caching (`revalidateOnFocus: false`). When the AI finishes running its background tools, the UI doesn't know the database changed.
- You simply need to **refresh the page**, and then click on the **Delegate** tab. You will see both `[US1]` and `[US2]` waiting in the queue!
### How we can fix this permanently when you return:
1. **Real-time UI:** We can add a simple `router.refresh()` or WebSocket trigger so the Plan tab instantly updates when the AI adds a task.
2. **Dedicated PRD Tool:** I can add an `update_prd_document` tool to the AI so it knows exactly where to save Product Specifications.
Let me know which you want to tackle first when you're back!

46
docs/todo/NEXT_STEPS.md Normal file
View File

@@ -0,0 +1,46 @@
# Vibn Product Roadmap & To-Do List
This document tracks the immediate next steps for the Vibn UI and project management features.
## 1. Complete the "Plan" UI
*Status: Pending*
The Plan UI needs to be the central hub where founders and AI agents collaborate on the product roadmap, user stories, and architecture.
**Key Tasks:**
- **Visual Design:** Implement the UI for viewing and editing the PRD (Product Requirements Document).
- **Agent Integration:** Ensure the `Atlas` (PRD) and `PM` (Product Manager) agents can seamlessly update the plan and reflect changes in the UI in real-time.
- **Task Management:** Build out the interface for tracking actionable tasks (e.g., KanBan or List view) that the `Coder` agent can pick up and execute.
- **Database Sync:** Ensure all UI interactions correctly read/write to the `phase_data` or `plan` JSON structure in the Postgres database.
## 2. Build the "Messages/Inbox" UI
*Status: Pending*
Projects need a dedicated "Messages" tab to act as a unified inbox for all external communications, specifically emails sent by the AI or the platform.
**Key Tasks:**
- **Email History Table:** Create a Postgres table (e.g., `project_messages`) to log every email sent via the new Mailgun integration. The schema should include: `project_id`, `to`, `subject`, `body_text`, `body_html`, `sent_at`, and `status`.
- **Backend Logging:** Update the `app/api/mcp/route.ts` `email.send` tool to insert a record into this new table immediately after a successful Mailgun dispatch.
- **UI Implementation:** Build a clean Inbox UI within the Project Dashboard.
- List view of all sent/received messages.
- Detail view to read the actual email content (rendering the compiled HTML/MJML safely).
- *(Future)* Webhook integration with Mailgun to receive replies and log them in this same UI.
## 3. Implement System-Wide Real-Time UI Updates
*Status: Pending*
**Key Tasks:**
- Implement the Postgres `LISTEN/NOTIFY` event bus (as defined in `REALTIME_UPDATES.md`).
- Build the Next.js Server-Sent Events (SSE) `/api/projects/[projectId]/stream` route.
- Wrap the Project layout with a `useProjectStream` hook so SWR automatically re-fetches when the background AI modifies the database (e.g., checking off tasks, editing the PRD).
## 4. Integrate Open-Source Analytics (Umami)
*Status: Pending*
Every project deployed by Vibn should automatically have privacy-first analytics injected and visible to the founder.
**Key Tasks:**
- **Infrastructure:** Deploy Umami via Coolify using Docker Compose (backed by the existing Postgres DB).
- **Automation:** Build a backend integration so when an AI ships a project, Vibn programmatically calls the Umami API to generate a unique `Website ID` for that project.
- **Code Injection:** Ensure the AI agent automatically injects the Umami `<script>` tag with the correct ID into the project's root `layout.tsx`.
- **UI Dashboard:** Add an "Analytics" tab to the project workspace that surfaces the Umami traffic metrics natively inside Vibn.

View File

@@ -1,10 +1,15 @@
import { GoogleGenAI } from '@google/genai';
import { GoogleGenAI } from "@google/genai";
const GEMINI_API_KEY = process.env.GOOGLE_API_KEY || "";
const GEMINI_MODEL = process.env.VIBN_CHAT_MODEL || "gemini-3.1-pro-preview";
// Add a clear visual log so we always know exactly which model is active in the terminal
console.log(`[GeminiChat] Initialized — Model: ${GEMINI_MODEL}`);
if (!GEMINI_API_KEY) {
console.warn(`[GeminiChat] WARNING: GOOGLE_API_KEY is not set. Chat stream will fail with 403 Forbidden.`);
console.warn(
`[GeminiChat] WARNING: GOOGLE_API_KEY is not set. Chat stream will fail with 403 Forbidden.`,
);
}
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
@@ -38,18 +43,24 @@ export interface ChatChunk {
error?: string;
}
type GeminiPart = Record<string, unknown>;
type GeminiContent = {
role: string;
parts: GeminiPart[];
};
function toGeminiContents(messages: ChatMessage[]) {
const contents: any[] = [];
const contents: GeminiContent[] = [];
for (const msg of messages) {
if (msg.role === "user") {
contents.push({ role: "user", parts: [{ text: msg.content }] });
} else if (msg.role === "assistant") {
const parts: any[] = [];
const parts: GeminiPart[] = [];
if (msg.content) parts.push({ text: msg.content });
if (msg.toolCalls?.length) {
for (const tc of msg.toolCalls) {
const part: any = {
const part: GeminiPart = {
functionCall: { name: tc.name, args: tc.args },
};
if (tc.thoughtSignature) {
@@ -84,7 +95,7 @@ function toGeminiFunctions(tools: ToolDefinition[]) {
functionDeclarations: tools.map((t) => ({
name: t.name,
description: t.description,
parameters: t.parameters as any,
parameters: t.parameters as Record<string, unknown>,
})),
},
];
@@ -104,16 +115,14 @@ export async function callGeminiChat(opts: {
error?: string;
}> {
try {
const config: any = {
const config: Record<string, unknown> = {
temperature: opts.temperature ?? 0.7,
maxOutputTokens: 8192,
};
if (opts.systemPrompt) {
config.systemInstruction = opts.systemPrompt;
}
if (opts.systemPrompt) {
config.systemInstruction = opts.systemPrompt;
}
const fns = toGeminiFunctions(opts.tools ?? []);
if (fns) config.tools = fns;
@@ -121,37 +130,37 @@ export async function callGeminiChat(opts: {
const response = await ai.models.generateContent({
model: GEMINI_MODEL,
contents: toGeminiContents(opts.messages),
config
config,
});
let text = "";
let thoughts = "";
const toolCalls: ToolCall[] = [];
const parts = response.candidates?.[0]?.content?.parts ?? [];
for (const part of parts) {
if (part.text) {
if ((part as any).thought) thoughts += part.text;
if ((part as { thought?: unknown }).thought) thoughts += part.text;
else text += part.text;
}
if (part.functionCall) {
toolCalls.push({
id: `tc-${Date.now()}-${Math.random().toString(36).slice(2)}`,
name: part.functionCall.name,
args: part.functionCall.args as Record<string, unknown> ?? {},
thoughtSignature: (part as any).thoughtSignature,
args: (part.functionCall.args as Record<string, unknown>) ?? {},
thoughtSignature: (part as { thoughtSignature?: string })
.thoughtSignature,
});
}
}
return {
text,
thoughts,
toolCalls,
finishReason: response.candidates?.[0]?.finishReason
return {
text,
thoughts,
toolCalls,
finishReason: response.candidates?.[0]?.finishReason,
};
} catch (error) {
return {
text: "",
@@ -169,14 +178,13 @@ export async function* streamGeminiChat(opts: {
temperature?: number;
}): AsyncGenerator<ChatChunk> {
try {
const config: any = {
const config: Record<string, unknown> = {
temperature: opts.temperature ?? 0.7,
maxOutputTokens: 8192,
};
if (opts.systemPrompt) {
config.systemInstruction = opts.systemPrompt;
config.systemInstruction = opts.systemPrompt;
}
const fns = toGeminiFunctions(opts.tools ?? []);
@@ -185,14 +193,14 @@ export async function* streamGeminiChat(opts: {
const streamResult = await ai.models.generateContentStream({
model: GEMINI_MODEL,
contents: toGeminiContents(opts.messages),
config
config,
});
for await (const chunk of streamResult) {
const parts = chunk.candidates?.[0]?.content?.parts ?? [];
for (const part of parts) {
if (part.text) {
yield (part as any).thought
yield (part as { thought?: unknown }).thought
? { type: "thinking", text: part.text }
: { type: "text", text: part.text };
}
@@ -200,7 +208,6 @@ export async function* streamGeminiChat(opts: {
}
yield { type: "done" };
} catch (error) {
yield {
type: "error",

View File

@@ -289,17 +289,24 @@ export async function callOpenAiCompatibleChat(opts: {
if (tools?.length) body.tools = tools;
// ── Request logging (DeepSeek 400 debug) ──────────────────────────────
const msgSummary = oaiMessages.map((m: any) => ({
role: m.role,
has_tool_calls:
m.role === "assistant" ? Boolean(m.tool_calls?.length) : undefined,
tool_calls_ids:
m.role === "assistant" && m.tool_calls?.length
? m.tool_calls.map((tc: any) => tc.id)
: undefined,
tool_call_id: m.role === "tool" ? m.tool_call_id : undefined,
content_len: typeof m.content === "string" ? m.content.length : 0,
}));
const msgSummary = oaiMessages.map(
(m: {
role: string;
content?: unknown;
tool_calls?: Array<{ id: string }>;
tool_call_id?: string;
}) => ({
role: m.role,
has_tool_calls:
m.role === "assistant" ? Boolean(m.tool_calls?.length) : undefined,
tool_calls_ids:
m.role === "assistant" && m.tool_calls?.length
? m.tool_calls.map((tc: { id: string }) => tc.id)
: undefined,
tool_call_id: m.role === "tool" ? m.tool_call_id : undefined,
content_len: typeof m.content === "string" ? m.content.length : 0,
}),
);
console.error(
"[deepseek] request",
JSON.stringify({
@@ -309,7 +316,7 @@ export async function callOpenAiCompatibleChat(opts: {
has_tools: Boolean(tools?.length),
tool_count: tools?.length ?? 0,
msg_summary: msgSummary,
last_5_roles: msgSummary.slice(-5).map((m: any) => m.role),
last_5_roles: msgSummary.slice(-5).map((m: { role: string }) => m.role),
}),
);
// ───────────────────────────────────────────────────────────────────────

View File

@@ -1675,28 +1675,35 @@ After this returns, ALWAYS call apps_deploy { uuid } to regenerate the live Trae
},
},
{
name: "plan_decision_log",
name: "plan_document_update",
description:
"Log a decision the user has made. Call this PROACTIVELY whenever a non-trivial choice gets settled in conversation (database engine, auth approach, framework, pricing model, copy, branding…) — so it shows up in the Plan tab and you stop re-asking it next session. Don't ask permission; log it and move on.",
"Overwrite the content of one of the Blueprint documents in the Plan tab. These documents define the specifications for the product. ALWAYS use this instead of `fs_write` when a user asks you to update the PRD, Spec, or Architecture plan.",
parameters: {
type: "OBJECT",
properties: {
projectId: { type: "STRING", description: "The Vibn project ID." },
title: {
projectId: { type: "STRING" },
docId: {
type: "STRING",
description: "The specific document to update.",
enum: [
"stories",
"acceptance",
"success",
"ui_design",
"tech_context",
"data_model",
"file_structure",
"tasks",
"checklist",
],
},
content: {
type: "STRING",
description:
'Short topic of the decision (e.g. "Database engine", "Auth provider").',
},
choice: {
type: "STRING",
description: 'What was chosen (e.g. "Postgres", "Stripe Checkout").',
},
why: {
type: "STRING",
description: "Optional 1-2 sentence reasoning. Strongly recommended.",
"The full markdown content for the document. This will completely overwrite the existing document.",
},
},
required: ["projectId", "title", "choice"],
required: ["projectId", "docId", "content"],
},
},
{

81
lib/email/mailgun.ts Normal file
View File

@@ -0,0 +1,81 @@
import Mailgun from "mailgun.js";
import formData from "form-data";
import mjml2html from "mjml";
const mailgun = new Mailgun(formData);
const API_KEY = process.env.MAILGUN_API_KEY || "";
const DOMAIN = process.env.MAILGUN_DOMAIN || "";
const API_URL = process.env.MAILGUN_API_URL || "https://api.mailgun.net";
// Initialize the Mailgun client
const mg = mailgun.client({
username: "api",
key: API_KEY,
url: API_URL,
});
export interface SendEmailOptions {
to: string | string[];
subject: string;
text: string;
html?: string;
mjml?: string; // Optional: If provided, this will be compiled to HTML and override the `html` field.
from?: string; // Optional: If not provided, defaults to the Mailgun system domain
}
/**
* Sends an email using the Mailgun API.
* If `mjml` is provided, it compiles it to responsive HTML before sending.
*/
export async function sendEmail(options: SendEmailOptions) {
// Use the provided from address, or fallback to a default on the live domain
const fromAddress = options.from || `Vibn AI <mailgun@${DOMAIN}>`;
// Format the 'to' address
const toAddresses = Array.isArray(options.to)
? options.to.join(",")
: options.to;
// Process MJML if it exists
let finalHtml = options.html;
if (options.mjml) {
try {
const compiled = mjml2html(options.mjml, {
validationLevel: "soft", // Warn on errors but try to compile anyway
minify: true,
});
if (compiled.errors && compiled.errors.length > 0) {
console.warn("[MJML Compiler Warnings]:", compiled.errors);
}
finalHtml = compiled.html;
} catch (e) {
console.error("[MJML Compiler Error]:", e);
// Fallback to plain text if MJML fails catastrophically
finalHtml = undefined;
}
}
try {
const response = await mg.messages.create(DOMAIN, {
from: fromAddress,
to: toAddresses,
subject: options.subject,
text: options.text,
html: finalHtml,
});
return {
success: true,
id: response.id,
message: response.message,
};
} catch (error: any) {
console.error("[Mailgun Error]:", error);
return {
success: false,
error: error.message || "Failed to send email via Mailgun",
status: error.status,
};
}
}

2300
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -54,8 +54,11 @@
"daisyui": "^5.5.1-beta.2",
"dotenv": "^17.2.3",
"firebase": "^12.5.0",
"form-data": "^4.0.5",
"google-auth-library": "^10.5.0",
"lucide-react": "^0.553.0",
"mailgun.js": "^13.0.1",
"mjml": "^5.2.2",
"next": "16.0.1",
"next-auth": "^4.24.13",
"next-themes": "^0.4.6",
@@ -78,6 +81,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/mjml": "^5.0.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",