Files
vibn-agent-runner/vibn-frontend/components/vibn-chat/chat-panel.tsx

2810 lines
93 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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,
} 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<string, unknown> }[];
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<string, unknown>;
}
// 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<string, string> = {
// 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<string, unknown> | null = null;
try {
const p = JSON.parse(raw);
if (p && typeof p === "object") parsed = p as Record<string, unknown>;
} 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, "&amp;").replace(/"/g, "&quot;");
}
const LINK_STYLE =
"color:#4338ca;text-decoration:underline;text-underline-offset:2px;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 `<a href="${escapeHtmlAttr(url)}" target="_blank" rel="noopener noreferrer" style="${LINK_STYLE}">${label}</a>`;
},
);
}
/** 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}<a href="${escapeHtmlAttr(url)}" target="_blank" rel="noopener noreferrer" style="${LINK_STYLE}">${url}</a>`,
);
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
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 = `
<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(
/`([^`]+)`/g,
'<code style="background:#f4f4f5;padding:1px 5px;border-radius:3px;font-family:var(--font-ibm-plex-mono),monospace;font-size:0.85em;overflow-wrap:anywhere;word-break:break-word">$1</code>',
)
.replace(
/^### (.+)$/gm,
'<h3 style="font-weight:600;margin:12px 0 4px;font-size:0.88rem;overflow-wrap:anywhere;word-break:break-word">$1</h3>',
)
.replace(
/^## (.+)$/gm,
'<h2 style="font-weight:600;margin:14px 0 4px;font-size:0.9rem;overflow-wrap:anywhere;word-break:break-word">$1</h2>',
)
.replace(
/^- (.+)$/gm,
'<li style="margin-left:16px;list-style:disc;overflow-wrap:anywhere;word-break:break-word">$1</li>',
)
.replace(
/(<li[^>]*>.*<\/li>\n?)+/g,
(m) => `<ul style="margin:6px 0">${m}</ul>`,
)
.replace(
/\n\n/g,
'</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 ─────────────────────────────────────────────────────────────
// 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,
}: {
msg: Message;
}) {
const isUser = msg.role === "user";
const proseWrap: React.CSSProperties = {
overflowWrap: "anywhere",
wordBreak: "break-word",
minWidth: 0,
};
return (
<div
style={{
display: "flex",
justifyContent: isUser ? "flex-end" : "flex-start",
marginBottom: 12,
minWidth: 0,
}}
>
{!isUser && (
<div
style={{
width: 24,
height: 24,
borderRadius: "50%",
background: "#f4f4f5", // Zinc-100 instead of black
display: "flex",
alignItems: "center",
justifyContent: "center",
marginRight: 8,
flexShrink: 0,
marginTop: 2,
border: "1px solid #e4e4e7",
}}
>
<span
style={{
color: "#18181b", // Dark gray instead of white
fontSize: "0.6rem",
fontWeight: 700,
fontFamily: "var(--font-lora),serif",
fontStyle: "italic",
}}
>
V.
</span>
</div>
)}
<div
style={{
maxWidth: "82%",
minWidth: 0,
display: "flex",
flexDirection: "column",
}}
>
{!isUser && msg.timeline && msg.timeline.length > 0 && (
<Timeline entries={msg.timeline} />
)}
{/*
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))) && (
<div
style={{
padding: isUser ? "9px 14px" : "4px 0px",
borderRadius: isUser ? "14px 14px 4px 14px" : "0",
background: isUser ? "#111827" : "transparent",
color: isUser ? "#fff" : "#111827",
fontSize: "0.84rem",
lineHeight: 1.6,
letterSpacing: "-0.01em",
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
...proseWrap,
}}
>
{isUser ? (
<span style={{ whiteSpace: "pre-wrap", ...proseWrap }}>
{msg.content}
</span>
) : (
<span
style={proseWrap}
dangerouslySetInnerHTML={{
__html: renderMarkdown(stripRawToolLogs(msg.content)),
}}
/>
)}
</div>
)}
</div>
</div>
);
});
/**
* 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 }: { entries: TimelineEntry[] }) {
// 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<Extract<TimelineEntry, { kind: "tool" }>>;
};
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 (
<div style={{ marginBottom: 6 }}>
{items.map((item, i) => {
if (item.kind === "thought") {
// Reasoning/thought bubbles are intentionally not rendered — they're
// internal and add noise to the chat.
return null;
}
if (item.kind === "text") {
return <TimelineText key={i} text={item.text} />;
}
if (item.kind === "toolGroup") {
return (
<TimelineToolGroup
key={i}
category={item.category}
entries={item.entries}
/>
);
}
return null;
})}
</div>
);
}
/**
* 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 TimelineText({ text }: { text: string }) {
const proseWrap: React.CSSProperties = {
overflowWrap: "anywhere",
wordBreak: "break-word",
minWidth: 0,
};
return (
<div
style={{
padding: "4px 0px",
borderRadius: "0",
background: "transparent",
color: "#111827",
fontSize: "0.84rem",
lineHeight: 1.6,
letterSpacing: "-0.01em",
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
marginBottom: 6,
...proseWrap,
}}
>
<span
style={proseWrap}
dangerouslySetInnerHTML={{
__html: renderMarkdown(stripRawToolLogs(text)),
}}
/>
</div>
);
}
function TimelineToolGroup({
entries,
}: {
category: string;
entries: Array<Extract<TimelineEntry, { kind: "tool" }>>;
}) {
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 6,
margin: "8px 0",
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
}}
>
{entries.map((e, i) => {
const isError = e.status === "error";
const isDone = e.status === "done";
let argSummary = "";
if (
e.args &&
!e.name.includes("dev_server_start") &&
!e.name.includes("dev.server.start") &&
!e.name.includes("shell_exec") &&
!e.name.includes("shell.exec")
) {
if (e.args.path) argSummary = String(e.args.path);
else if (e.args.url)
argSummary = String(e.args.url).replace(/^https?:\/\//, "");
else if (e.args.name) argSummary = String(e.args.name);
}
return (
<div
key={i}
style={{
fontSize: "0.75rem",
color: isError ? "#ef4444" : "#52525b",
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<span style={{ display: "flex", alignItems: "center", width: 14 }}>
{isError ? (
"✗"
) : !isDone ? (
<Loader2
style={{ width: 12, height: 12, color: "#8b5cf6" }}
className="animate-spin"
/>
) : (
<span style={{ color: "#a1a1aa" }}></span>
)}
</span>
<span
style={{
fontFamily: "var(--font-mono), monospace",
fontWeight: 500,
}}
>
{friendlyToolName(e.name)}
</span>
{argSummary && (
<span
style={{
background: "#f4f4f5",
padding: "2px 6px",
borderRadius: 4,
color: "#71717a",
maxWidth: 200,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{argSummary}
</span>
)}
{(() => {
const summary = summarizeToolResult(e.result);
if (!summary) return null;
return (
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: 220,
color: summary.ok ? "#a1a1aa" : "#ef4444",
}}
title={summary.label}
>
{summary.label}
</span>
);
})()}
</div>
);
})}
</div>
);
}
// ── Main panel ────────────────────────────────────────────────────────────────
interface ChatPanelProps {
/**
* When true, the panel renders inline as a flex child of its parent
* (a structural left column on project pages). Skips the fixed-position
* slide-out treatment, the collapsed-tab affordance, and the
* --chat-panel-width side-effect. Always "open" — there's no close
* button because the panel IS the column.
*
* When false / omitted: legacy behavior — fixed slide-out on the
* right, collapsible, sets --chat-panel-width so the workspace
* content shifts left to make room.
*/
structural?: boolean;
/**
* When set with `structural` on a project route, renders a unified shell:
* full-width top bar (chat controls | section icons) and a split row
* below (chat column | artifact slot). Omit on slide-out chat.
*/
artifactSlot?: ReactNode;
}
/** Shared dimensions for preview-select + send icon buttons in the composer. */
const COMPOSER_ACTION_BTN_BASE: CSSProperties = {
flexShrink: 0,
width: 32,
height: 32,
boxSizing: "border-box",
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 0,
};
/** Preview pick chip + select-mode toggle for unified project shell chat composer. */
function ProjectPreviewChatInputWrap({
unifiedShell,
children,
}: {
unifiedShell: boolean;
children: (selectToggle: React.ReactNode) => React.ReactNode;
}) {
const bridge = usePreviewBridge();
if (!unifiedShell || !bridge) {
return <>{children(null)}</>;
}
const { selectMode, setSelectMode, picked, clearPick } = bridge;
const chip = picked ? (
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 8,
marginBottom: 8,
padding: "8px 10px",
borderRadius: 8,
background: "#eef2ff",
border: "1px solid #c7d2fe",
fontSize: "0.72rem",
lineHeight: 1.45,
color: "#312e81",
}}
>
<MousePointerClick
style={{
width: 14,
height: 14,
flexShrink: 0,
marginTop: 2,
opacity: 0.85,
}}
aria-hidden
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, marginBottom: 2 }}>
Preview selection
</div>
<div style={{ opacity: 0.92 }}>
<span
style={{
fontFamily: "var(--font-ibm-plex-mono),monospace",
fontSize: "0.68rem",
}}
>
{picked.tagName}
</span>
{" · "}
<span style={{ wordBreak: "break-word" }}>{picked.selector}</span>
</div>
{picked.textSnippet ? (
<div style={{ marginTop: 4, opacity: 0.88, wordBreak: "break-word" }}>
{`"${picked.textSnippet.slice(0, 140)}${picked.textSnippet.length > 140 ? "..." : ""}"`}
</div>
) : null}
</div>
<button
type="button"
onClick={clearPick}
aria-label="Clear preview selection"
style={{
flexShrink: 0,
background: "none",
border: "none",
padding: 2,
cursor: "pointer",
borderRadius: 4,
color: "#4338ca",
display: "flex",
alignItems: "center",
}}
>
<X style={{ width: 13, height: 13 }} />
</button>
</div>
) : null;
const selectToggle = (
<button
type="button"
aria-pressed={selectMode}
aria-label={
selectMode ? "Exit preview select mode" : "Select element from preview"
}
title={
selectMode
? "Click preview to choose an element"
: "Choose element from preview"
}
onClick={() => setSelectMode(!selectMode)}
style={{
...COMPOSER_ACTION_BTN_BASE,
border: selectMode ? "1px solid #6366f1" : "1px solid #e8e4dc",
background: selectMode ? "#eef2ff" : "#faf8f5",
color: selectMode ? "#4338ca" : "#6b6560",
cursor: "pointer",
}}
>
<MousePointerClick style={{ width: 15, height: 15 }} strokeWidth={2} />
</button>
);
return (
<>
{chip}
{children(selectToggle)}
</>
);
}
const suggestionChipStyle: React.CSSProperties = {
background: "#f4f4f5",
border: "1px solid #e4e4e7",
borderRadius: 16,
padding: "4px 10px",
fontSize: "0.72rem",
color: "#52525b",
cursor: "pointer",
whiteSpace: "nowrap",
transition: "all 0.1s ease",
};
export function ChatPanel({
structural = false,
artifactSlot,
}: ChatPanelProps = {}) {
const { status } = useSession();
const params = useParams();
const pathname = usePathname() ?? "";
const workspace = (params?.workspace as string) || "";
// When the user is on a /project/<id>/* route, scope the chat to
// that project. The threads list, the new-thread create call, and
// the system prompt all branch on this; the chat header surfaces it
// so the user knows the AI is "talking about" the right thing.
const projectId = (params?.projectId as string) || "";
/** Full project shell (chat | artifact); must render even while auth is loading or signed out. */
const unifiedProjectShell =
structural && Boolean(projectId) && artifactSlot !== undefined;
const [activeProjectName, setActiveProjectName] = useState<string | null>(
null,
);
const [open, setOpen] = useState(() => {
// Structural mode is always-open by definition — the panel IS the
// column, there's no "closed" state to persist.
if (structural) return true;
if (typeof window === "undefined") return false;
return localStorage.getItem("vibn-chat-open") !== "false";
});
const [threads, setThreads] = useState<Thread[]>([]);
// threadsLoaded flips to true after the FIRST loadThreads() resolves.
// Used to gate the auto-create effect — without it we race the fetch
// and spawn an empty thread before history loads.
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 [projectFiles, setProjectFiles] = useState<string[]>([]);
const [attachedFiles, setAttachedFiles] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [suggestionsFilter, setSuggestionsFilter] = useState("");
const [suggestionIndex, setSuggestionIndex] = useState(0);
// Fetch codebase files list inside the scoped project
useEffect(() => {
if (!projectId || !workspace || status !== "authenticated") return;
fetch(`/api/projects/${projectId}/files?workspace=${workspace}`)
.then((res) => res.json())
.then((data) => {
if (Array.isArray(data.files)) {
setProjectFiles(data.files);
}
})
.catch(() => {});
}, [projectId, workspace, status]);
const [sending, setSending] = useState(false);
const [currentPhaseLabel, setCurrentPhaseLabel] = useState<string | null>(
null,
);
const [showThreads, setShowThreads] = useState(false);
const [mcpToken, setMcpToken] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
// AbortController for the in-flight /api/chat fetch. Lives in a ref
// so the Stop button can reach it without re-rendering on every
// streaming chunk.
const abortRef = useRef<AbortController | null>(null);
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
// Persist open state + adjust main content margin
useEffect(() => {
if (structural) return;
localStorage.setItem("vibn-chat-open", String(open));
document.documentElement.style.setProperty(
"--chat-panel-width",
open ? "380px" : "0px",
);
}, [open, structural]);
// Load MCP token — fetch fresh from API on mount to avoid stale, revoked tokens.
useEffect(() => {
if (!workspace || status !== "authenticated") return;
fetch("/api/workspaces?include_default_token=true")
.then((r) => (r.ok ? r.json() : null))
.then((d) => {
if (d?.defaultToken) {
setMcpToken(d.defaultToken);
}
});
}, [workspace, status]);
// Load threads (scoped to the current project when one is in the URL).
// Reset the loaded flag when projectId changes so the resume effect
// re-runs against the correct list and doesn't restore a thread from
// the previous project.
const loadThreads = useCallback(async () => {
if (!workspace || status !== "authenticated") return;
try {
const qs = new URLSearchParams({ workspace });
if (projectId) qs.set("projectId", projectId);
const res = await fetch(`/api/chat/threads?${qs.toString()}`);
const data = await res.json();
setThreads(data.threads || []);
} catch {
/* silent */
} finally {
setThreadsLoaded(true);
}
}, [workspace, projectId, status]);
useEffect(() => {
setThreadsLoaded(false);
setActiveThread(null);
setMessages([]);
// Clear the threads array immediately so the resume effect doesn't
// race the loadThreads() fetch and resume a stale project-scoped
// thread when the user navigates from /project/X back to /projects.
setThreads([]);
loadThreads();
}, [loadThreads, projectId]);
// Look up the active project's display name once we have a projectId,
// so the chat header can show "Talking about: <name>".
useEffect(() => {
if (!projectId) {
setActiveProjectName(null);
return;
}
let cancelled = false;
fetch(`/api/projects/${projectId}/anatomy`, { credentials: "include" })
.then((r) => (r.ok ? r.json() : null))
.then((d) => {
if (cancelled) return;
const name = d?.project?.name;
if (name) setActiveProjectName(name);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [projectId]);
// Create and activate a new thread (tagged to the active project, if any).
const newThread = useCallback(async () => {
try {
const res = await fetch("/api/chat/threads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspace, projectId: projectId || undefined }),
});
const data = await res.json();
if (data.thread) {
setThreads((prev) => [data.thread, ...prev]);
setActiveThread(data.thread.id);
setMessages([]);
setShowThreads(false);
}
} catch {
/* silent */
}
}, [workspace, projectId]);
// Load thread messages
const loadThread = useCallback(async (id: string) => {
setActiveThread(id);
setShowThreads(false);
setMessages([]);
try {
const res = await fetch(`/api/chat/threads/${id}`);
const data = await res.json();
// Hydrate the timeline from persisted textSegments + toolCalls
// 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: {
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: "" } as unknown as Message;
},
);
setMessages(hydrated);
} catch {
/* silent */
}
}, []);
// Auto-resume previous thread (or create a fresh one if the user has
// never chatted in this workspace). We MUST wait for threadsLoaded
// before deciding — otherwise we race the fetch and spawn an empty
// thread before history arrives. Last-active thread is restored from
// localStorage so a page reload (deploy, refresh) lands the user back
// in the conversation they were in.
useEffect(() => {
if (!open || status !== "authenticated" || !workspace) return;
if (!threadsLoaded) return;
if (activeThread) return;
if (threads.length === 0) {
newThread();
return;
}
const scopeKey = projectId ? `${workspace}:${projectId}` : workspace;
const savedKey = `vibn-chat-active-thread:${scopeKey}`;
const saved =
typeof window !== "undefined" ? localStorage.getItem(savedKey) : null;
const target =
saved && threads.some((t) => t.id === saved) ? saved : threads[0].id;
loadThread(target);
}, [
open,
status,
workspace,
projectId,
threadsLoaded,
threads,
activeThread,
newThread,
loadThread,
]);
// Persist active thread so reload re-opens the same conversation,
// keyed per-project so each project has its own "last conversation".
useEffect(() => {
if (typeof window === "undefined" || !workspace) return;
const scopeKey = projectId ? `${workspace}:${projectId}` : workspace;
const savedKey = `vibn-chat-active-thread:${scopeKey}`;
if (activeThread) localStorage.setItem(savedKey, activeThread);
}, [activeThread, workspace, projectId]);
useEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);
const deleteThread = useCallback(
async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
await fetch(`/api/chat/threads/${id}`, { method: "DELETE" });
setThreads((prev) => prev.filter((t) => t.id !== id));
if (activeThread === id) {
setActiveThread(null);
setMessages([]);
}
loadThreads();
},
[activeThread, loadThreads],
);
const sendMessage = useCallback(
async (override?: string) => {
let raw = (override ?? input).trim();
if (!raw || sending || !activeThread) return;
if (unifiedProjectShell && previewMessagePrepRef.current) {
raw = previewMessagePrepRef.current(raw);
}
const text = raw;
if (!override) setInput("");
setSending(true);
setCurrentPhaseLabel("Starting...");
const userMsg: Message = { role: "user", content: text };
setMessages((prev) => [...prev, userMsg]);
let assistantContent = "";
const assistantMsg: Message = { role: "assistant", content: "" };
let msgIndex = -1;
const controller = new AbortController();
abortRef.current = controller;
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,
}),
});
if (!r.ok) {
const err = await r.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${r.status}`);
}
setAttachedFiles([]);
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!",
},
]);
setSending(false);
return;
}
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
thread_id: activeThread,
message: text,
workspace,
mcp_token: mcpToken,
chatMode,
attachedFiles,
}),
signal: controller.signal,
});
setAttachedFiles([]);
if (!res.ok || !res.body) throw new Error("Stream failed");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
setMessages((prev) => {
msgIndex = prev.length;
return [...prev, { ...assistantMsg }];
});
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
let ev: {
type: string;
text?: string;
name?: string;
result?: string;
error?: string;
phase?: string;
label?: string;
goal?: string;
findings?: string;
args?: Record<string, unknown>;
};
try {
ev = JSON.parse(line.slice(6));
} catch {
continue;
}
if (ev.type === "phase" && ev.phase && ev.label) {
setCurrentPhaseLabel(ev.label);
// Legacy phase entries in history are ignored
} else if (ev.type === "text" && ev.text) {
// Each text SSE event = one round of the model's text
// output. Push a new "text" timeline entry so the
// renderer can show multi-round turns as separate
// bubbles instead of one run-on paragraph. We still
// maintain `assistantContent` (joined with blank lines)
// so the legacy single-bubble fallback path and any
// post-stream consumers still work.
assistantContent += (assistantContent ? "\n\n" : "") + ev.text;
setMessages((prev) => {
const next = [...prev];
if (msgIndex >= 0 && next[msgIndex]) {
const tl = next[msgIndex].timeline ?? [];
next[msgIndex] = {
...next[msgIndex],
// Don't write to msg.content during streaming —
// the timeline is the source of truth. Setting
// content on every text event re-renders one
// giant bubble in the bottom slot AND the
// segmented timeline above it, duplicating the
// same prose. Persisted messages pick up
// content via the final flush below.
timeline: [...tl, { kind: "text", text: ev.text }],
};
}
return next;
});
} else if (ev.type === "thinking" && ev.text) {
// Each thinking event from the server is one round of the
// model's reasoning. Push as a separate timeline entry so
// the renderer can show it as its own collapsed pill —
// 12 rounds become 12 small pills the user can each
// expand independently, not one giant blob.
setMessages((prev) => {
const next = [...prev];
if (msgIndex >= 0 && next[msgIndex]) {
const tl = next[msgIndex].timeline ?? [];
next[msgIndex] = {
...next[msgIndex],
timeline: [...tl, { kind: "thought", text: ev.text }],
};
}
return next;
});
} else if (ev.type === "tool_start") {
// Update bottom status label dynamically based on tool name
const toolLabel =
friendlyToolName(ev.name ?? "")
.replace(/^[^\w\s]+/, "")
.trim() + "...";
setCurrentPhaseLabel(toolLabel);
setMessages((prev) => {
const next = [...prev];
if (msgIndex >= 0 && next[msgIndex]) {
const tl = next[msgIndex].timeline ?? [];
next[msgIndex] = {
...next[msgIndex],
timeline: [
...tl,
{
kind: "tool",
name: ev.name,
args: ev.args,
status: "running",
},
],
};
}
return next;
});
} else if (ev.type === "tool_result") {
setMessages((prev) => {
const next = [...prev];
if (msgIndex >= 0 && next[msgIndex]) {
const tl = next[msgIndex].timeline ?? [];
// Walk backward to the most recent matching running
// tool entry and mark it done. Avoids cross-matching
// earlier same-named entries.
let updated = false;
const newTl: TimelineEntry[] = [];
let isToolErr = false;
if (typeof ev.result === "string") {
try {
// 1. Try to parse as JSON first (e.g. {"ok": true, "errors": []})
const parsed = JSON.parse(ev.result);
if (parsed && typeof parsed === "object") {
if (parsed.ok === true) {
isToolErr = false;
} else if (
parsed.ok === false ||
parsed.error ||
parsed.errors?.length > 0
) {
isToolErr = true;
}
}
} catch {
// 2. If it's a raw string (like a bash crash), scan for fatal keywords
const lower = ev.result.toLowerCase();
if (
lower.includes("error") ||
lower.includes("failed") ||
lower.includes("unexpected") ||
lower.includes("not found")
) {
isToolErr = true;
}
}
}
for (let i = tl.length - 1; i >= 0; i--) {
const e = tl[i];
if (
!updated &&
e.kind === "tool" &&
e.name === ev.name &&
e.status === "running"
) {
newTl.unshift({
...e,
status: isToolErr ? "error" : "done",
result: ev.result,
});
updated = true;
} else {
newTl.unshift(e);
}
}
next[msgIndex] = { ...next[msgIndex], timeline: newTl };
}
return next;
});
} else if (ev.type === "error") {
const errText = ev.error || "Unknown error";
const isToolErr = /tool|mcp|coolify|gitea/i.test(errText);
const errBubble = isToolErr
? `⚠️ **Tool error:** ${errText}`
: `⚠️ ${errText}`;
assistantContent += (assistantContent ? "\n\n" : "") + errBubble;
setMessages((prev) => {
const next = [...prev];
if (msgIndex >= 0 && next[msgIndex]) {
const tl = next[msgIndex].timeline ?? [];
next[msgIndex] = {
...next[msgIndex],
timeline: [...tl, { kind: "text", text: errBubble }],
};
}
return next;
});
}
}
}
// Auto-title thread from first message
const thisThread = threads.find((t) => t.id === activeThread);
if (thisThread?.title === "New conversation") {
const title = text.slice(0, 50);
await fetch(`/api/chat/threads/${activeThread}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
setThreads((prev) =>
prev.map((t) => (t.id === activeThread ? { ...t, title } : t)),
);
}
loadThreads();
} catch (e) {
const isAbort = e instanceof DOMException && e.name === "AbortError";
if (isAbort) {
// Server-side will have appended "(stopped by user)" to the
// partial response and persisted it. We just need to make
// sure the local UI reflects whatever streamed in before the
// user clicked Stop — which it already does, because we've
// been mutating `messages[msgIndex]` chunk-by-chunk above.
setMessages((prev) => {
const next = [...prev];
if (
msgIndex >= 0 &&
next[msgIndex] &&
!next[msgIndex].content.includes("(stopped by user)")
) {
next[msgIndex] = {
...next[msgIndex],
content:
(next[msgIndex].content || "") + "\n\n_(stopped by user)_",
};
}
return next;
});
} else {
const errMsg = e instanceof Error ? e.message : String(e);
const isNetwork = /fetch|network|failed to fetch/i.test(errMsg);
const friendlyError = isNetwork
? "⚠️ Network error — check your connection and try again."
: `⚠️ Something went wrong: ${errMsg.slice(0, 200)}\n\nYou can try again or start a new message.`;
setMessages((prev) => {
const next = [...prev];
if (msgIndex >= 0 && next[msgIndex]) {
next[msgIndex] = { ...next[msgIndex], content: friendlyError };
}
return next;
});
}
} finally {
abortRef.current = null;
setSending(false);
setCurrentPhaseLabel(null);
}
},
[
input,
sending,
activeThread,
workspace,
mcpToken,
threads,
loadThreads,
unifiedProjectShell,
chatMode,
projectId,
attachedFiles,
],
);
const cancelMessage = useCallback(() => {
abortRef.current?.abort();
}, []);
// External components (e.g. ProjectHeaderUrls' "Start preview" button)
// can ask the chat to send a canned prompt without prop-drilling. Open
// the panel if collapsed, then fire the prompt as if the user typed it.
useEffect(() => {
function onPrompt(e: Event) {
const ce = e as CustomEvent<{ prompt?: string; scopeProjectId?: string }>;
const prompt = ce.detail?.prompt;
if (!prompt) return;
// If the dispatcher scopes the prompt to a specific project, only
// accept it when the chat panel is currently bound to that project.
// Prevents a "Start preview on Manifest" click from accidentally
// landing in a chat that's scoped to a different project.
if (ce.detail?.scopeProjectId && ce.detail.scopeProjectId !== projectId) {
return;
}
setOpen(true);
void sendMessage(prompt);
}
window.addEventListener("vibn:chat-prompt", onPrompt as EventListener);
return () =>
window.removeEventListener("vibn:chat-prompt", onPrompt as EventListener);
}, [sendMessage, projectId]);
const handleInputChange = (val: string) => {
setInput(val);
const match = val.match(/\/(\S*)$/);
if (match) {
setShowSuggestions(true);
setSuggestionsFilter(match[1] || "");
setSuggestionIndex(0);
} else {
setShowSuggestions(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (showSuggestions) {
const filtered = projectFiles
.filter((f) =>
f.toLowerCase().includes(suggestionsFilter.toLowerCase()),
)
.slice(0, 8);
if (e.key === "ArrowDown") {
e.preventDefault();
setSuggestionIndex((prev) => (prev + 1) % Math.max(1, filtered.length));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSuggestionIndex(
(prev) => (prev - 1 + filtered.length) % Math.max(1, filtered.length),
);
} else if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
if (filtered[suggestionIndex]) {
const selectedFile = filtered[suggestionIndex];
const before = input.slice(0, input.lastIndexOf("/"));
setInput(before + " ");
if (!attachedFiles.includes(selectedFile)) {
setAttachedFiles((prev) => [...prev, selectedFile]);
}
setShowSuggestions(false);
}
} else if (e.key === "Escape") {
e.preventDefault();
setShowSuggestions(false);
}
} else {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
} else if (e.key === "Escape" && sending) {
e.preventDefault();
cancelMessage();
}
}
};
// Slide-out chat hidden until signed in. Structural project shell always
// mounts so Preview / Product / Plan pages render; chat column shows loading
// or sign-in instead of wiping the whole viewport (blank page).
if (!unifiedProjectShell && status !== "authenticated") return null;
// ── Collapsed tab ──────────────────────────────────────────────────────────
// Structural mode is always-open; skip the collapsed-tab branch entirely.
if (!open && !structural) {
return (
<button
onClick={() => setOpen(true)}
style={{
position: "fixed",
right: 0,
top: "50%",
transform: "translateY(-50%)",
zIndex: 1000,
background: "#1a1a1a",
color: "#fff",
border: "none",
borderRadius: "8px 0 0 8px",
padding: "14px 10px",
cursor: "pointer",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 6,
boxShadow: "-2px 0 12px #1a1a1a14",
}}
title="Open Vibn AI"
>
<MessageSquare style={{ width: 16, height: 16 }} />
<span
style={{
writingMode: "vertical-rl",
textOrientation: "mixed",
fontSize: "0.65rem",
fontWeight: 600,
letterSpacing: "0.08em",
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
transform: "rotate(180deg)",
}}
>
VIBN AI
</span>
</button>
);
}
const conversationColumn = (
<>
{/* Thread list dropdown */}
{showThreads && (
<div
style={{
borderBottom: "1px solid #e8e4dc",
background: "#faf8f5",
maxHeight: 200,
overflowY: "auto",
flexShrink: 0,
}}
>
{threads.length === 0 && (
<div
style={{
padding: "12px 16px",
fontSize: "0.78rem",
color: "#a09a90",
}}
>
No conversations yet
</div>
)}
{threads.map((t) => (
<div
key={t.id}
onClick={() => loadThread(t.id)}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "9px 16px",
background: activeThread === t.id ? "#f0ede8" : "transparent",
cursor: "pointer",
borderBottom: "1px solid #f0ede8",
}}
onMouseEnter={(e) => {
if (activeThread !== t.id)
e.currentTarget.style.background = "#f7f4ef";
}}
onMouseLeave={(e) => {
if (activeThread !== t.id)
e.currentTarget.style.background = "transparent";
}}
>
<div style={{ minWidth: 0 }}>
<div
style={{
fontSize: "0.8rem",
fontWeight: 500,
color: "#1a1a1a",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{t.title}
</div>
<div style={{ fontSize: "0.7rem", color: "#a09a90" }}>
{timeAgo(t.updatedAt)}
</div>
</div>
<button
onClick={(e) => deleteThread(t.id, e)}
style={{
background: "none",
border: "none",
cursor: "pointer",
padding: "2px 4px",
color: "#c0bab2",
flexShrink: 0,
}}
>
<Trash2 style={{ width: 12, height: 12 }} />
</button>
</div>
))}
</div>
)}
{/* Messages */}
<div
style={{
flex: 1,
minWidth: 0,
overflowY: "auto",
overflowX: "hidden",
padding: "16px 14px",
}}
>
{messages.length === 0 && !sending && (
<div
style={{ padding: "24px 14px", maxWidth: 640, margin: "0 auto" }}
>
<div style={{ display: "flex", gap: 12 }}>
<div
style={{
width: 28,
height: 28,
borderRadius: 6,
background: "#1a1a1a",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
color: "#fff",
fontSize: "0.85rem",
fontFamily: "var(--font-lora),serif",
}}
>
V
</div>
<div
style={{
background: "#fff",
border: "1px solid #e8e4dc",
padding: "14px 18px",
borderRadius: "0 12px 12px 12px",
fontSize: "0.9rem",
color: "#1a1a1a",
lineHeight: 1.6,
boxShadow: "0 1px 2px #1a1a1a05",
maxWidth: "85%",
}}
>
Welcome to {activeProjectName ? activeProjectName : "Vibn"}!
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>
)}
{messages.map((msg, i) => (
<MessageBubble key={msg.id || i} msg={msg} />
))}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div
style={{
padding: "12px 14px",
borderTop: "1px solid #e8e4dc",
background: "#faf8f5",
flexShrink: 0,
position: "relative",
}}
>
{!mcpToken && (
<div
style={{
fontSize: "0.7rem",
color: "#9a7b3a",
background: "#d4a04a12",
border: "1px solid #d4a04a30",
borderRadius: 6,
padding: "5px 10px",
marginBottom: 8,
lineHeight: 1.4,
}}
>
Read-only mode add your MCP token in Settings to enable actions.
</div>
)}
{/* File Autocomplete Suggestions */}
{showSuggestions &&
(() => {
const filtered = projectFiles
.filter((f) =>
f.toLowerCase().includes(suggestionsFilter.toLowerCase()),
)
.slice(0, 8);
if (filtered.length === 0) return null;
return (
<div
style={{
position: "absolute",
bottom: "calc(100% - 10px)",
left: 14,
right: 14,
background: "#fff",
border: "1px solid #e8e4dc",
borderRadius: 8,
boxShadow:
"0 -4px 12px rgba(0,0,0,0.08), 0 2px 4px rgba(0,0,0,0.02)",
zIndex: 1000,
maxHeight: 200,
overflowY: "auto",
padding: "4px 0",
}}
>
<div
style={{
fontSize: "0.65rem",
fontWeight: 600,
color: "#a09a90",
padding: "6px 12px 4px",
borderBottom: "1px solid #f0ede8",
marginBottom: 4,
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
Codebase Files
</div>
{filtered.map((file, idx) => (
<div
key={file}
onClick={() => {
const before = input.slice(0, input.lastIndexOf("/"));
setInput(before + " ");
if (!attachedFiles.includes(file)) {
setAttachedFiles((prev) => [...prev, file]);
}
setShowSuggestions(false);
}}
onMouseEnter={() => setSuggestionIndex(idx)}
style={{
padding: "6px 12px",
fontSize: "0.76rem",
fontFamily: "var(--font-mono), monospace",
color: idx === suggestionIndex ? "#3d5afe" : "#1a1a1a",
background:
idx === suggestionIndex ? "#3d5afe0c" : "transparent",
cursor: "pointer",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
📄 {file}
</span>
{idx === suggestionIndex && (
<span
style={{
fontSize: "0.6rem",
color: "#3d5afe",
opacity: 0.7,
fontFamily: "var(--font-inter), sans-serif",
}}
>
press Enter
</span>
)}
</div>
))}
</div>
);
})()}
{/* Attached Files Chips */}
{attachedFiles.length > 0 && (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 6,
marginBottom: 8,
}}
>
{attachedFiles.map((file) => (
<div
key={file}
style={{
display: "flex",
alignItems: "center",
gap: 6,
background: "#eef2ff",
border: "1px solid #c7d2fe",
borderRadius: 6,
padding: "4px 8px",
fontSize: "0.7rem",
color: "#312e81",
fontFamily: "var(--font-mono), monospace",
}}
>
<span>📄 {file.split("/").pop()}</span>
<button
type="button"
onClick={() =>
setAttachedFiles((prev) => prev.filter((f) => f !== file))
}
style={{
background: "none",
border: "none",
padding: 0,
cursor: "pointer",
color: "#4338ca",
display: "flex",
alignItems: "center",
}}
title="Remove file"
>
<X style={{ width: 12, height: 12 }} />
</button>
</div>
))}
</div>
)}
{/* Action Status Bar anchored above composer */}
{sending && currentPhaseLabel && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "4px 8px 10px",
marginLeft: 4,
}}
>
<Loader2
className="animate-spin"
style={{ width: 14, height: 14, color: "#8b5cf6" }}
/>
<span
style={{
fontSize: "0.75rem",
fontWeight: 500,
color: "#71717a",
}}
>
{currentPhaseLabel}
</span>
</div>
)}
<ProjectPreviewChatInputWrap unifiedShell={unifiedProjectShell}>
{(selectToggle) => (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 8,
background: "#fff",
borderRadius: 12,
border: "1px solid #e4e4e7",
padding: "10px 12px",
boxShadow: "0 2px 6px rgba(0,0,0,0.03)",
}}
>
{/* Fake Suggested Next Steps Chips (for design review) */}
{!sending &&
messages.length > 0 &&
messages[messages.length - 1].role === "assistant" && (
<div
style={{
display: "flex",
gap: 6,
marginBottom: 4,
overflowX: "auto",
paddingBottom: 4,
}}
>
<button style={suggestionChipStyle}>
Implement bulk checkout
</button>
<button style={suggestionChipStyle}>
Make mobile responsive
</button>
<button style={suggestionChipStyle}>Connect Stripe</button>
</div>
)}
<textarea
ref={inputRef}
value={input}
onChange={(e) => handleInputChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
sending ? "Esc to stop generating…" : "Ask Vibn AI anything…"
}
rows={3}
disabled={!activeThread}
style={{
width: "100%",
border: "none",
outline: "none",
background: "transparent",
fontSize: "0.86rem",
lineHeight: 1.55,
resize: "none",
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
color: "#1a1a1a",
maxHeight: 240,
overflowY: "auto",
}}
onInput={(e) => {
const el = e.currentTarget;
const newlines = (el.value.match(/\n/g) || []).length;
if ((el as any).lastNewlines !== newlines) {
(el as any).lastNewlines = newlines;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 240) + "px";
}
}}
/>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 8,
paddingTop: 4,
}}
>
<div style={{ display: "flex", gap: 6, flex: 1 }}>
{/* Mode Toggles */}
<select
value={chatMode}
onChange={(e) => setChatMode(e.target.value as any)}
style={{
appearance: "none",
background: "transparent",
border: "none",
fontSize: "0.75rem",
fontWeight: 500,
color: "#71717a",
cursor: "pointer",
outline: "none",
padding: "4px 8px",
}}
>
<option value="vibe">Discuss</option>
<option value="collaborate">Plan</option>
</select>
</div>
<div style={{ display: "flex", gap: 6 }}>
<button
type="button"
onClick={() => {
const fname = window.prompt(
"Enter exact file path to attach (e.g. src/app/page.tsx):",
);
if (fname && fname.trim()) {
setAttachedFiles((prev) => [...prev, fname.trim()]);
}
}}
style={{
...COMPOSER_ACTION_BTN_BASE,
background: "transparent",
color: "#a1a1aa",
border: "1px solid transparent",
cursor: "pointer",
transition: "all 0.1s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "#f4f4f5";
e.currentTarget.style.color = "#18181b";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
e.currentTarget.style.color = "#a1a1aa";
}}
title="Attach file to context"
>
<Paperclip style={{ width: 14, height: 14 }} />
</button>
</div>
{(() => {
// While the AI is streaming or running tools, the button
// turns into a Stop control. Click → AbortController fires,
// server bails between rounds, partial text gets persisted.
const isActive = sending;
const canSend = !sending && input.trim() && activeThread;
return (
<button
type="button"
onClick={isActive ? cancelMessage : () => sendMessage()}
disabled={!isActive && !canSend}
aria-label={isActive ? "Stop generating" : "Send message"}
title={isActive ? "Stop generating (Esc)" : "Send"}
style={{
...COMPOSER_ACTION_BTN_BASE,
background: isActive
? "#111827"
: canSend
? "#111827"
: "#e4e4e7",
color: isActive || canSend ? "#fff" : "#a1a1aa",
border: "none",
cursor: isActive || canSend ? "pointer" : "not-allowed",
transition: "all 0.15s ease",
}}
>
{isActive ? (
<>
<Square
style={{
width: 11,
height: 11,
fill: "currentColor",
strokeWidth: 3,
}}
/>
</>
) : (
<Send style={{ width: 14, height: 14 }} />
)}
</button>
);
})()}
</div>
</div>
)}
</ProjectPreviewChatInputWrap>
</div>
</>
);
const structuralChatLoadingColumn = (
<div
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: 24,
gap: 14,
}}
>
<Loader2
className="animate-spin"
style={{ width: 22, height: 22, color: "#9c9590" }}
/>
<p style={{ fontSize: "0.84rem", color: "#6b6560", margin: 0 }}>
Signing you in
</p>
</div>
);
const authHref = pathname
? `/signin?callbackUrl=${encodeURIComponent(pathname)}`
: "/signin";
const structuralChatSignedOutColumn = (
<div
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: 24,
gap: 12,
}}
>
<p
style={{
fontSize: "0.88rem",
fontWeight: 500,
color: "#1a1a1a",
margin: 0,
textAlign: "center",
}}
>
Sign in to use Vibn AI
</p>
<p
style={{
fontSize: "0.76rem",
color: "#a09a90",
margin: 0,
textAlign: "center",
maxWidth: 260,
lineHeight: 1.5,
}}
>
Preview and tabs still work here. Chat needs an account.
</p>
<Link
href={authHref}
style={{
marginTop: 4,
padding: "10px 18px",
borderRadius: 10,
background: "#1a1a1a",
color: "#fff",
fontSize: "0.82rem",
fontWeight: 500,
textDecoration: "none",
}}
>
Sign in
</Link>
</div>
);
const structuralChatBody =
status === "loading"
? structuralChatLoadingColumn
: status !== "authenticated"
? structuralChatSignedOutColumn
: conversationColumn;
if (unifiedProjectShell) {
return (
<>
<PreviewBridgeProvider>
<div
style={{
flex: 1,
minHeight: 0,
minWidth: 0,
display: "flex",
flexDirection: "column",
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
background: "#fff",
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "stretch",
flexShrink: 0,
height: 48,
borderBottom: "1px solid #e8e4dc",
background: "#faf8f5",
boxSizing: "border-box",
}}
>
<div
style={{
width: 380,
flexShrink: 0,
height: "100%",
display: "flex",
alignItems: "center",
padding: "0 12px",
gap: 6,
boxSizing: "border-box",
borderRight: "1px solid #e8e4dc",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
gap: 6,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
minWidth: 0,
}}
>
{workspace ? (
<Link
href={`/${workspace}/projects`}
title="All projects"
style={{ flexShrink: 0, display: "flex" }}
>
<img
src="/vibn-black-circle-logo.png"
alt="VIBN"
width={26}
height={26}
style={{
borderRadius: 7,
objectFit: "cover",
display: "block",
}}
/>
</Link>
) : null}
<button
type="button"
onClick={() => setShowThreads((v) => !v)}
aria-label="Conversations"
style={{
display: "flex",
alignItems: "center",
gap: 4,
background: "none",
border: "none",
cursor: "pointer",
padding: "2px 4px",
borderRadius: 6,
minWidth: 0,
}}
>
<ChevronDown
style={{
width: 12,
height: 12,
color: "#a09a90",
transition: "transform 0.15s",
flexShrink: 0,
transform: showThreads ? "rotate(180deg)" : "none",
}}
/>
{projectId && (
<span
title={`Chat is scoped to ${activeProjectName ?? "this project"}. Tool calls assume projectId=${projectId}.`}
style={{
marginLeft: 4,
padding: "1px 6px",
fontSize: "0.6rem",
fontWeight: 600,
letterSpacing: "0.02em",
color: "#3d5afe",
background: "#3d5afe14",
borderRadius: 999,
maxWidth: 140,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{activeProjectName ?? "this project"}
</span>
)}
</button>
</div>
<button
type="button"
onClick={newThread}
style={{
background: "none",
border: "none",
cursor: "pointer",
padding: "4px 5px",
borderRadius: 6,
color: "#6b6560",
display: "flex",
alignItems: "center",
}}
title="New conversation"
>
<Plus style={{ width: 14, height: 14 }} />
</button>
</div>
</div>
<div
style={{
flex: 1,
minWidth: 0,
minHeight: 0,
display: "flex",
alignItems: "stretch",
}}
>
<ProjectIconRail workspace={workspace} projectId={projectId} />
</div>
</div>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "row",
minHeight: 0,
minWidth: 0,
}}
>
<div
style={{
width: 380,
flexShrink: 0,
borderRight: "1px solid #e8e4dc",
background: "#fff",
display: "flex",
flexDirection: "column",
minHeight: 0,
minWidth: 0,
}}
>
{structuralChatBody}
</div>
<div
key={pathname}
style={{
flex: 1,
minWidth: 0,
minHeight: 0,
overflow: "auto",
background: "#faf8f5",
display: "flex",
flexDirection: "column",
}}
>
{artifactSlot}
</div>
</div>
</div>
</PreviewBridgeProvider>
<style>{`
@keyframes vibn-bounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-4px); }
}
`}</style>
</>
);
}
// ── Open panel ─────────────────────────────────────────────────────────────
// Structural mode: fill the parent column. Default mode: fixed slide-out
// anchored to the right edge of the viewport (legacy behavior).
return (
<div
style={
structural
? {
flex: 1,
minHeight: 0,
background: "#fff",
display: "flex",
flexDirection: "column",
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
}
: {
position: "fixed",
right: 0,
top: 0,
bottom: 0,
zIndex: 999,
width: 380,
background: "#fff",
borderLeft: "1px solid #e8e4dc",
display: "flex",
flexDirection: "column",
boxShadow: "-4px 0 24px #1a1a1a08",
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
}
}
>
{/* Header — structural: circle logo + threads; slide-out: wordmark */}
<div
style={
structural
? {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
height: 48,
padding: "0 12px",
boxSizing: "border-box",
borderBottom: "1px solid #e8e4dc",
background: "#faf8f5",
flexShrink: 0,
gap: 6,
}
: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "14px 16px",
borderBottom: "1px solid #e8e4dc",
background: "#faf8f5",
flexShrink: 0,
}
}
>
{structural ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
minWidth: 0,
}}
>
{workspace ? (
<Link
href={`/${workspace}/projects`}
title="All projects"
style={{ flexShrink: 0, display: "flex" }}
>
<img
src="/vibn-black-circle-logo.png"
alt="VIBN"
width={26}
height={26}
style={{
borderRadius: 7,
objectFit: "cover",
display: "block",
}}
/>
</Link>
) : null}
<button
type="button"
onClick={() => setShowThreads((v) => !v)}
aria-label="Conversations"
style={{
display: "flex",
alignItems: "center",
gap: 4,
background: "none",
border: "none",
cursor: "pointer",
padding: "2px 4px",
borderRadius: 6,
minWidth: 0,
}}
>
<ChevronDown
style={{
width: 12,
height: 12,
color: "#a09a90",
transition: "transform 0.15s",
flexShrink: 0,
transform: showThreads ? "rotate(180deg)" : "none",
}}
/>
{projectId && (
<span
title={`Chat is scoped to ${activeProjectName ?? "this project"}. Tool calls assume projectId=${projectId}.`}
style={{
marginLeft: 4,
padding: "1px 6px",
fontSize: "0.6rem",
fontWeight: 600,
letterSpacing: "0.02em",
color: "#3d5afe",
background: "#3d5afe14",
borderRadius: 999,
maxWidth: 140,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{activeProjectName ?? "this project"}
</span>
)}
</button>
</div>
) : (
<button
type="button"
onClick={() => setShowThreads((v) => !v)}
style={{
display: "flex",
alignItems: "center",
gap: 6,
background: "none",
border: "none",
cursor: "pointer",
padding: "4px 6px",
borderRadius: 6,
minWidth: 0,
}}
>
<span
style={{
fontFamily: "var(--font-lora),serif",
fontSize: "0.95rem",
fontWeight: 400,
color: "#1a1a1a",
letterSpacing: "-0.01em",
whiteSpace: "nowrap",
}}
>
Vibn AI
</span>
<ChevronDown
style={{
width: 13,
height: 13,
color: "#a09a90",
transition: "transform 0.15s",
flexShrink: 0,
transform: showThreads ? "rotate(180deg)" : "none",
}}
/>
{projectId && (
<span
title={`Chat is scoped to ${activeProjectName ?? "this project"}. Tool calls assume projectId=${projectId}.`}
style={{
marginLeft: 6,
padding: "2px 8px",
fontSize: "0.65rem",
fontWeight: 600,
letterSpacing: "0.02em",
color: "#3d5afe",
background: "#3d5afe14",
borderRadius: 999,
maxWidth: 160,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{activeProjectName ?? "this project"}
</span>
)}
</button>
)}
<div style={{ display: "flex", gap: 4 }}>
{/* Top-Level Publish Button */}
<button
type="button"
onClick={() => {
if (activeThread && !sending) {
sendMessage("ship it");
}
}}
disabled={!activeThread || sending}
style={{
background: "#18181b",
border: "none",
cursor: activeThread && !sending ? "pointer" : "not-allowed",
padding: structural ? "4px 12px" : "5px 14px",
borderRadius: 6,
color: "#fff",
display: "flex",
alignItems: "center",
fontSize: "0.75rem",
fontWeight: 600,
opacity: activeThread && !sending ? 1 : 0.5,
transition: "opacity 0.15s ease",
}}
title="Ship to production"
>
Publish
</button>
<button
type="button"
onClick={newThread}
style={{
background: "none",
border: "none",
cursor: "pointer",
padding: structural ? "4px 5px" : "5px 6px",
borderRadius: 6,
color: "#6b6560",
display: "flex",
alignItems: "center",
}}
title="New conversation"
>
<Plus
style={{
width: structural ? 14 : 15,
height: structural ? 14 : 15,
}}
/>
</button>
{!structural && (
<button
type="button"
onClick={() => setOpen(false)}
style={{
background: "none",
border: "none",
cursor: "pointer",
padding: "5px 6px",
borderRadius: 6,
color: "#6b6560",
display: "flex",
alignItems: "center",
}}
title="Close"
>
<ChevronRight style={{ width: 15, height: 15 }} />
</button>
)}
</div>
</div>
{conversationColumn}
<style>{`
@keyframes vibn-bounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-4px); }
}
`}</style>
</div>
);
}