"use client"; import { 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, Sparkles, Compass, Cpu, } 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: "tool"; name: string; status: "running" | "done"; result?: string } // 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 { if ( name.includes("fs.edit") || name.includes("fs.write") || name.includes("fs_edit") || name.includes("fs_write") ) return "Writing 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 "Reading codebase"; if (name.includes("shell.exec") || name.includes("shell_exec")) return "Running terminal commands"; if (name.includes("dev_server.start") || name.includes("dev_server_start")) return "Starting dev server"; if (name.includes("dev_server.logs") || name.includes("dev_server_logs")) return "Checking server logs"; if ( name.includes("browser.navigate") || name.includes("browser.console") || name.includes("browser_navigate") || name.includes("browser_console") ) return "Checking browser preview"; if (name.includes("ship")) return "Shipping code to production"; 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 { return name .replace(/_/g, ".") .replace("projects.list", "listing projects") .replace("apps.list", "listing apps") .replace("apps.create", "deploying app") .replace("apps.templates.list", "listing templates") .replace("apps.templates.search", "searching templates") .replace("domains.register", "registering domain") .replace("domains.list", "listing domains") .replace("apps.logs", "fetching logs"); } // ── Markdown-lite renderer ──────────────────────────────────────────────────── function escapeHtmlAttr(s: string): string { return s.replace(/&/g, "&").replace(/"/g, """); } 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 `${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 #e8e4dc; border-radius: 8px; overflow: hidden; background: #faf8f5; box-shadow: 0 1px 3px rgba(1a,1a,1a,0.02); font-family: var(--font-ibm-plex-mono), SFMono-Regular, Consolas, monospace; ` .trim() .replace(/\s+/g, " "); const headerStyle = ` display: flex; justify-content: space-between; align-items: center; padding: 6px 12px; background: #f0ede8; border-bottom: 1px solid #e8e4dc; font-size: 0.68rem; font-weight: 600; color: #6b6560; ` .trim() .replace(/\s+/g, " "); const preStyle = ` display: block; padding: 12px; margin: 0; overflow-x: auto; font-size: 0.78rem; line-height: 1.55; color: #1a1a1a; white-space: pre; ` .trim() .replace(/\s+/g, " "); const buttonStyle = ` background: #ffffff; border: 1px solid #e8e4dc; border-radius: 4px; padding: 2px 6px; font-size: 0.65rem; cursor: pointer; color: #6b6560; display: flex; align-items: center; gap: 4px; font-family: var(--font-inter), ui-sans-serif, sans-serif; transition: all 0.1s ease; ` .trim() .replace(/\s+/g, " "); const copyId = `btn-copy-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const textId = `txt-code-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const copyScript = ` navigator.clipboard.writeText(document.getElementById('${textId}').innerText) .then(() => { const btn = document.getElementById('${copyId}'); btn.innerHTML = 'Copied ✓'; setTimeout(() => { btn.innerHTML = 'Copy'; }, 1500); }); ` .trim() .replace(/\s+/g, " "); const blockHtml = `
${languageLabel}
${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) => ``, ) .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 ──────────────────────────────────────────────────────────── 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 const lines = thoughts .split(/[.!?\n]/) .map((l) => l.trim()) .filter(Boolean); const currentAction = lines[lines.length - 1]; if (!currentAction) return null; return (

    {expanded && (
    {thoughts}
    )}
    ); } function MessageBubble({ msg }: { msg: Message }) { 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 }: { 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: "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 { 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) => { if (item.kind === "thought") { return ; } if (item.kind === "text") { return ; } return ( ); })}
    ); } /** * 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 (
    ); } function TimelineToolGroup({ category, entries, }: { category: string; entries: Array>; }) { const [expanded, setExpanded] = useState(false); const count = entries.length; const allDone = entries.every((e) => e.status === "done"); return (
    {expanded && (
    {entries.map((e, i) => (
    {friendlyToolName(e.name)} {!e.result && e.status === "running" && ( ... )} {e.result && ( — {e.result} )}
    ))}
    )}
    ); } // ── 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 ? (
    Preview selection
    {picked.tagName} {" · "} {picked.selector}
    {picked.textSnippet ? (
    {`"${picked.textSnippet.slice(0, 140)}${picked.textSnippet.length > 140 ? "..." : ""}"`}
    ) : null}
    ) : null; const selectToggle = ( ); return ( <> {chip} {children(selectToggle)} ); } 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//* 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( 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([]); // 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(null); const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [chatMode, setChatMode] = useState<"collaborate" | "vibe" | "delegate">( "vibe", ); const [projectFiles, setProjectFiles] = useState([]); const [attachedFiles, setAttachedFiles] = useState([]); 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 [showThreads, setShowThreads] = useState(false); const [mcpToken, setMcpToken] = useState(null); const messagesEndRef = useRef(null); const inputRef = useRef(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(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 — prefer localStorage cache, fetch from API if missing. // We use /api/workspaces (not the URL param) because the URL slug // (e.g. "mark-account") differs from the actual workspace slug ("mark"). useEffect(() => { if (!workspace || status !== "authenticated") return; const cached = localStorage.getItem(`vibn-mcp-token-${workspace}`); if (cached) { setMcpToken(cached); return; } fetch("/api/workspaces?include_default_token=true") .then((r) => (r.ok ? r.json() : null)) .then((d) => { if (d?.defaultToken) { localStorage.setItem(`vibn-mcp-token-${workspace}`, d.defaultToken); setMcpToken(d.defaultToken); } }) .catch(() => {}); }, [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: ". 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; 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); 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; }; try { ev = JSON.parse(line.slice(6)); } catch { continue; } 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") { 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, 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[] = []; 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: "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); } }, [ 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) => { 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 ( ); } const conversationColumn = ( <> {/* Thread list dropdown */} {showThreads && (
    {threads.length === 0 && (
    No conversations yet
    )} {threads.map((t) => (
    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"; }} >
    {t.title}
    {timeAgo(t.updatedAt)}
    ))}
    )} {/* Messages */}
    {messages.length === 0 && !sending && (
    V
    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) => ( ))} {sending && messages[messages.length - 1]?.role !== "assistant" && (
    V
    {[0, 1, 2].map((i) => ( ))}
    )}
    {/* Input */}
    {/* Chat Mode Toggle */}
    {!mcpToken && (
    Read-only mode — add your MCP token in Settings to enable actions.
    )} {/* File Autocomplete Suggestions */} {showSuggestions && (() => { const filtered = projectFiles .filter((f) => f.toLowerCase().includes(suggestionsFilter.toLowerCase()), ) .slice(0, 8); if (filtered.length === 0) return null; return (
    Codebase Files
    {filtered.map((file, idx) => (
    { 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", }} > 📄 {file} {idx === suggestionIndex && ( press Enter )}
    ))}
    ); })()} {/* Attached Files Chips */} {attachedFiles.length > 0 && (
    {attachedFiles.map((file) => (
    📄 {file.split("/").pop()}
    ))}
    )} {(selectToggle) => (