"use client";
import React, {
useEffect,
useRef,
useState,
useCallback,
type ReactNode,
type CSSProperties,
} from "react";
import Link from "next/link";
import { useSession } from "next-auth/react";
import { useParams, usePathname } from "next/navigation";
import {
MessageSquare,
X,
ChevronRight,
Send,
Plus,
Loader2,
Wrench,
ChevronDown,
Trash2,
Square,
MousePointerClick,
Paperclip,
ChevronLeft,
} from "lucide-react";
import { ProjectIconRail } from "@/components/project/project-icon-rail";
import {
PreviewBridgeProvider,
previewMessagePrepRef,
usePreviewBridge,
} from "@/components/project/preview-bridge-context";
// ── Types ─────────────────────────────────────────────────────────────────────
interface Thread {
id: string;
title: string;
updatedAt: string;
}
interface Message {
id?: string;
role: "user" | "assistant" | "tool";
content: string;
toolCalls?: { id: string; name: string; args: Record }[];
toolName?: string;
createdAt?: string;
/**
* Chronological turn timeline interleaving the model's thinking
* narration and the tool calls it fired. Rendered as a stack of
* pills INSIDE the bubble above the final text content, so the
* user sees the actual flow:
* [thought] [tool ×N] [thought] [tool] ... [summary]
* Each thought is its own collapsed pill (click to expand);
* adjacent runs of the same tool name collapse into one pill
* with a ×N counter. The final assistant text is rendered
* separately, below the timeline.
*/
timeline?: TimelineEntry[];
}
type TimelineEntry =
| { kind: "thought"; text: string }
| { kind: "phase"; phase: string; label: string }
| {
kind: "tool";
name: string;
status: "running" | "done" | "error";
result?: string;
args?: Record;
}
// A text segment from one round of the assistant's tool loop.
// Each text SSE event from the server starts a new entry; subsequent
// streaming chunks for that same round append to the most-recent
// text entry. Tool/thought entries between text segments break the
// accumulation so multi-round turns render as separate bubbles.
| { kind: "text"; text: string };
// ── Helpers ───────────────────────────────────────────────────────────────────
function getFriendlyCategory(name: string): string {
const dotted = name.replace(/_/g, ".");
if (dotted.startsWith("databases.")) return "Provisioning database service";
if (dotted.startsWith("domains."))
return "Registering & attaching custom domains";
if (dotted.startsWith("apps.envs."))
return "Updating configuration environment";
if (dotted.startsWith("apps.")) return "Deploying web application container";
if (dotted.startsWith("gitea.")) return "Configuring Git repositories";
if (dotted.startsWith("devcontainer."))
return "Spinning up secure Alpine environment";
if (dotted.startsWith("plan."))
return "Structuring development roadmap & specs";
if (
name.includes("fs.edit") ||
name.includes("fs.write") ||
name.includes("fs_edit") ||
name.includes("fs_write")
)
return "Surgically writing & editing code";
if (
name.includes("fs.read") ||
name.includes("fs.list") ||
name.includes("fs.grep") ||
name.includes("fs.tree") ||
name.includes("fs_read") ||
name.includes("fs_list") ||
name.includes("fs_grep") ||
name.includes("fs_tree")
)
return "Scanning & reading project files";
if (name.includes("shell.exec") || name.includes("shell_exec"))
return "Executing sandbox terminal scripts";
if (name.includes("dev_server.start") || name.includes("dev_server_start"))
return "Initializing local dev-server";
if (name.includes("dev_server.logs") || name.includes("dev_server_logs"))
return "Analyzing compilation build logs";
if (
name.includes("browser.navigate") ||
name.includes("browser.console") ||
name.includes("browser_navigate") ||
name.includes("browser_console")
)
return "Performing live design QA & console audits";
if (name.includes("request_visual_qa") || name.includes("request.visual.qa"))
return "Running world-class visual design QA";
if (name.includes("ship")) return "Bundling & shipping live release";
return name;
}
function timeAgo(dateStr?: string): string {
if (!dateStr) return "";
const diff = (Date.now() - new Date(dateStr).getTime()) / 1000;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
function friendlyToolName(name: string): string {
const dotted = name.replace(/_/g, ".");
const map: Record = {
// Filesystem Tools
"fs.read": "Reading file",
"fs.write": "Writing file",
"fs.edit": "Editing code",
"fs.list": "Listing folder",
"fs.tree": "Mapping structure",
"fs.delete": "Deleting file",
"fs.glob": "Finding files",
"fs.grep": "Searching code",
// Server & Browser Tools
"dev.server.start": "Starting server",
"dev.server.stop": "Stopping server",
"dev.server.list": "Checking servers",
"dev.server.logs": "Reading logs",
"browser.console": "Checking console",
"browser.navigate": "Testing page",
"request.visual.qa": "Running visual QA",
// Core Platform Tools
"projects.list": "Listing workspace projects",
"projects.get": "Retrieving project spec sheets",
"workspace.describe": "Fetching workspace details",
"gitea.credentials": "Resolving repository Git credentials",
"shell.exec": "Running command",
ship: "Shipping",
"generate.media": "Generating visual media assets",
"get.design.template": "Retrieving design templates",
"apps.templates.scaffold": "Scaffolding bento-grid layouts",
// App & Database Deployment Tools
"apps.list": "Listing deployed web services",
"apps.get": "Checking application build status",
"apps.create": "Creating app",
"apps.update": "Updating application settings",
"apps.delete": "Removing deployed application",
"apps.deploy": "Deploying app",
"apps.deployments": "Fetching recent deployment history",
"apps.envs.list": "Loading environment variables",
"apps.envs.upsert": "Injecting environment variables",
"apps.envs.delete": "Removing environment variables",
"apps.domains.list": "Checking application domain routing",
"apps.domains.set": "Binding custom domains",
"apps.logs": "Fetching application logs",
"apps.exec": "Running command inside container",
"databases.list": "Listing database clusters",
"databases.create": "Provisioning database service",
"databases.get": "Retrieving database connection credentials",
"databases.update": "Updating database configuration",
"databases.delete": "Removing database service",
// Domain & Git Tools
"domains.search": "Searching open domain names",
"domains.list": "Listing registered domains",
"domains.get": "Retrieving domain details",
"domains.register": "Registering domain name",
"domains.attach": "Attaching domain reverse-proxy rules",
"gitea.repos.list": "Listing Gitea repositories",
"gitea.repo.get": "Loading Gitea repository info",
"gitea.repo.create": "Initializing Gitea repository",
"gitea.file.read": "Reading file from Gitea",
"gitea.file.write": "Saving file to Gitea",
"gitea.file.delete": "Deleting file from Gitea",
"gitea.branches.list": "Checking repository branches",
"gitea.branch.create": "Creating Git branch",
"devcontainer.ensure": "Spinning up secure Alpine dev container",
"devcontainer.status": "Probing dev container liveness",
"devcontainer.suspend": "Suspending dev container",
// Planning / Specs Tools
"plan.get": "Loading specifications checklist",
"plan.vision.set": "Saving feature product specification",
"plan.idea.add": "Adding planning ideation",
"plan.task.add": "Adding task to development roadmap",
"plan.task.edit": "Updating development roadmap task",
"plan.task.complete": "Toggling checklist milestone as completed",
"plan.document.update": "Updating specs documentation",
};
return map[dotted] || dotted;
}
/**
* Turn a raw tool result string (often a JSON blob like
* `{ "code": 1, "stdout": "...", "stderr": "..." }`) into a short,
* human-readable status. We NEVER show raw JSON in the chat — only a
* clean verb + outcome (e.g. "Failed — exit 1: connection refused").
*/
function summarizeToolResult(result?: string): {
ok: boolean;
label: string;
} | null {
if (!result) return null;
const raw = String(result).trim();
if (!raw) return null;
let parsed: Record | null = null;
try {
const p = JSON.parse(raw);
if (p && typeof p === "object") parsed = p as Record;
} catch {
// not JSON — fall through to text heuristics
}
const firstLine = (s: string) => s.replace(/\s+/g, " ").trim().slice(0, 80);
if (parsed) {
const code = parsed.code;
const stderr = typeof parsed.stderr === "string" ? parsed.stderr : "";
const stdout = typeof parsed.stdout === "string" ? parsed.stdout : "";
const errMsg = typeof parsed.error === "string" ? parsed.error : "";
// Explicit non-zero exit code → failure
if (typeof code === "number" && code !== 0) {
const detail = firstLine(stderr || errMsg || stdout);
return {
ok: false,
label: detail
? `Failed (exit ${code}) — ${detail}`
: `Failed (exit ${code})`,
};
}
if (errMsg && !/^null$/i.test(errMsg)) {
return { ok: false, label: `Failed — ${firstLine(errMsg)}` };
}
if (parsed.ok === false) {
return {
ok: false,
label: `Failed${stderr ? " — " + firstLine(stderr) : ""}`,
};
}
// Success-ish: surface a tiny hint when available, else just "Done"
if (typeof code === "number" && code === 0)
return { ok: true, label: "Done" };
return { ok: true, label: "Done" };
}
// Plain-text heuristics
const lower = raw.toLowerCase();
if (
/(econnrefused|enoent|error|failed|traceback|exception|not found|permission denied|cannot)/.test(
lower,
)
) {
return { ok: false, label: `Failed — ${firstLine(raw)}` };
}
return { ok: true, label: "Done" };
}
// ── Markdown-lite renderer ────────────────────────────────────────────────────
function escapeHtmlAttr(s: string): string {
return s.replace(/&/g, "&").replace(/"/g, """);
}
const LINK_STYLE =
"color:#4f46e5;text-decoration:none;font-weight:500;border-bottom:1px solid #c7d2fe;transition:all 0.2s ease;overflow-wrap:anywhere;word-break:break-all";
/** [label](https://...) — href restricted to http(s) */
function markdownLinksToHtml(s: string): string {
return s.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)<>]+)\)/gi,
(_m, label: string, url: string) => {
return `${label}`;
},
);
}
/** Bare https:// in prose (skips when prefix is `>` so href=/code aren't touched) */
function autoLinkBareUrls(s: string): string {
return s.replace(
/(^|[\s\-—:(\[{])(https?:\/\/[^\s<>"']+)/gi,
(match, pre: string, url: string) =>
`${pre}${url}`,
);
}
function renderMarkdown(text: string): string {
const codeBlocks: string[] = [];
let s = text;
// Extract triple backtick code blocks first to protect them from other formatting
s = s.replace(/```(\w*)[\s\n]([\s\S]*?)```/g, (match, lang, code) => {
const id = `___CODE_BLOCK_${codeBlocks.length}___`;
const escapedCode = code
.replace(/&/g, "&")
.replace(//g, ">");
const languageLabel = lang ? lang.toUpperCase() : "CODE";
const containerStyle = `
margin: 12px 0;
border: 1px solid #e4e4e7;
border-radius: 8px;
overflow: hidden;
background: #fafafa;
box-shadow: 0 1px 3px rgba(0,0,0,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: #f4f4f5;
border-bottom: 1px solid #e4e4e7;
font-size: 0.68rem;
font-weight: 600;
color: #52525b;
`
.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 #e4e4e7;
border-radius: 4px;
padding: 2px 6px;
font-size: 0.65rem;
cursor: pointer;
color: #52525b;
display: flex;
align-items: center;
gap: 4px;
font-family: var(--font-inter), ui-sans-serif, sans-serif;
transition: all 0.1s ease;
`
.trim()
.replace(/\s+/g, " ");
const copyId = `btn-copy-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const textId = `txt-code-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const copyScript = `
navigator.clipboard.writeText(document.getElementById('${textId}').innerText)
.then(() => {
const btn = document.getElementById('${copyId}');
btn.innerHTML = 'Copied ✓';
setTimeout(() => { btn.innerHTML = 'Copy'; }, 1500);
});
`
.trim()
.replace(/\s+/g, " ");
const blockHtml = `
${languageLabel}
${escapedCode}
`
.trim()
.replace(/\s*\n\s*/g, "");
codeBlocks.push(blockHtml);
return id;
});
// Safe escape of remaining content
s = s.replace(/&/g, "&").replace(//g, ">");
s = markdownLinksToHtml(s);
s = s
.replace(/\*\*(.+?)\*\*/g, "$1")
.replace(
/`([^`]+)`/g,
'$1',
)
.replace(
/^### (.+)$/gm,
'
$1
',
)
.replace(
/^## (.+)$/gm,
'
$1
',
)
.replace(
/^- (.+)$/gm,
'
$1
',
)
.replace(
/(
]*>.*<\/li>\n?)+/g,
(m) => `
${m}
`,
)
.replace(
/\n\n/g,
'
',
)
.replace(/\n/g, " ");
s = autoLinkBareUrls(s);
// Restore the formatted code blocks
codeBlocks.forEach((html, index) => {
s = s.replace(`___CODE_BLOCK_${index}___`, html);
});
return s;
}
// ── Message bubble ─────────────────────────────────────────────────────────────
// NOTE: the model's raw reasoning ("thinking") is intentionally NOT rendered in
// the chat — `thought` timeline entries are dropped at render time. We keep the
// per-round narration (the model's plain "I am editing X…" text), which is
// shown as normal text. The old collapsible ThinkingBubble was removed when we
// stopped surfacing reasoning walls.
function stripRawToolLogs(text: string): string {
if (!text) return text;
let out = text.replace(
/(?:\r?\n)*\[tools executed this turn:[\s\S]*?\]/g,
"",
);
// Safety net: strip the internal "Phase Checkpoint" planning block
// (Goal / Current Findings / Suspected Cause / Verification Plan) if it
// ever reaches a user-facing message. This is loop-control machinery, not
// something the end user should read. We drop from the heading to the end
// of that block (until a blank line followed by non-bulleted prose, or EOF).
out = out.replace(/(?:^|\n)\s*#{0,3}\s*Phase Checkpoint[\s\S]*$/i, "").trim();
return out.trim();
}
const MessageBubble = React.memo(function MessageBubble({
msg,
isLast,
sending,
}: {
msg: Message;
isLast: boolean;
sending: boolean;
}) {
const isUser = msg.role === "user";
const proseWrap: React.CSSProperties = {
overflowWrap: "anywhere",
wordBreak: "break-word",
minWidth: 0,
};
return (
{!isUser && (
V.
)}
{!isUser && msg.timeline && msg.timeline.length > 0 && (
)}
{/*
Render the legacy bottom content bubble ONLY when:
- the message is from the user (their bubble is always the
content slot), OR
- the assistant message has no timeline at all (very old
messages from before timeline existed).
When the timeline contains text entries the prose is already
rendered there, and showing it again here would duplicate
every paragraph below the timeline.
*/}
{((msg.content && isUser) ||
(msg.content &&
!isUser &&
(!msg.timeline || msg.timeline.length === 0))) && (
{isUser ? (
{msg.content}
) : (
)}
)}
);
});
/**
* Renders the chronological turn timeline: thoughts as their own
* collapsed pills, tool calls grouped by adjacent runs of the same
* name with a ×N counter. The flow visually mirrors what actually
* happened: thought → tools → thought → tools → ... → final summary.
*/
function Timeline({ entries, isActiveStream }: { entries: TimelineEntry[], isActiveStream?: boolean }) {
// Walk the entries and emit a renderable list. Adjacent same-category
// tool entries get bundled into a TimelineToolGroup; thought and
// text entries pass through as-is.
type Item =
| { kind: "thought"; text: string }
| { kind: "text"; text: string }
| { kind: "phase"; phase: string; label: string }
| {
kind: "toolGroup";
category: string;
entries: Array>;
};
const items: Item[] = [];
for (const e of entries) {
if (e.kind === "thought") {
items.push({ kind: "thought", text: e.text });
} else if (e.kind === "text") {
items.push({ kind: "text", text: e.text });
} else if (e.kind === "phase") {
// ignore
} else {
const last = items[items.length - 1];
const category = getFriendlyCategory(e.name);
if (last && last.kind === "toolGroup" && last.category === category) {
last.entries.push(e);
} else {
items.push({ kind: "toolGroup", category, entries: [e] });
}
}
}
return (
{items.map((item, i) => {
const isLast = i === items.length - 1;
if (item.kind === "thought") {
return ;
}
if (item.kind === "text") {
return ;
}
if (item.kind === "toolGroup") {
return (
);
}
return null;
})}
);
}
/**
* One text segment in the assistant's timeline. Rendered as its own
* bubble so each round of multi-tool-loop output reads as a discrete
* step instead of concatenating into a wall of text.
*/
function TimelineThought({ text, isStreaming }: { text: string; isStreaming?: boolean }) {
const [expanded, setExpanded] = React.useState(true);
const textLenRef = React.useRef(text.length);
React.useEffect(() => {
// If not streaming, auto-collapse after a short delay so the user isn't stuck with huge thinking blocks
if (!isStreaming) {
const t = setTimeout(() => setExpanded(false), 500);
return () => clearTimeout(t);
}
}, [isStreaming]);
const proseWrap: React.CSSProperties = {
overflowWrap: "anywhere",
wordBreak: "break-word",
minWidth: 0,
};
return (
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.
)}
{messages.map((msg, i) => (
))}
{/* Input */}
{!mcpToken && (
Read-only mode — add your MCP token in Settings to enable actions.
>
);
}
// ── Open panel ─────────────────────────────────────────────────────────────
// Structural mode: fill the parent column. Default mode: fixed slide-out
// anchored to the right edge of the viewport (legacy behavior).
return (
{/* Header — structural: circle logo + threads; slide-out: wordmark */}