From 5e07bbf39d56a7fe03fe70500235beeecd14c275 Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Mon, 27 Apr 2026 15:40:32 -0700 Subject: [PATCH] Add Vibn AI chat panel powered by Gemini 3.1 Pro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Right-docked chat panel on all workspace pages ([workspace]/layout.tsx) - Streaming SSE responses with Gemini 3.1 Pro preview via generativelanguage API - Full tool-calling loop (up to 6 rounds): deploys apps, lists projects, registers domains, fetches logs — all via existing MCP dispatcher - Persistent conversation history: fs_chat_threads + fs_chat_messages tables (Postgres) - Thread management: create, list, rename (auto-title from first message), delete - Panel collapses to a tab; open state persisted to localStorage - Read-only mode hint when no MCP token is present - Graceful content margin shift when panel is open Made-with: Cursor --- app/[workspace]/layout.tsx | 15 +- app/api/chat/route.ts | 218 ++++++++++ app/api/chat/threads/[id]/route.ts | 56 +++ app/api/chat/threads/route.ts | 55 +++ components/vibn-chat/chat-panel.tsx | 609 ++++++++++++++++++++++++++++ lib/ai/gemini-chat.ts | 192 +++++++++ lib/ai/vibn-tools.ts | 132 ++++++ 7 files changed, 1275 insertions(+), 2 deletions(-) create mode 100644 app/api/chat/route.ts create mode 100644 app/api/chat/threads/[id]/route.ts create mode 100644 app/api/chat/threads/route.ts create mode 100644 components/vibn-chat/chat-panel.tsx create mode 100644 lib/ai/gemini-chat.ts create mode 100644 lib/ai/vibn-tools.ts diff --git a/app/[workspace]/layout.tsx b/app/[workspace]/layout.tsx index b2fe40c6..629c5584 100644 --- a/app/[workspace]/layout.tsx +++ b/app/[workspace]/layout.tsx @@ -1,8 +1,19 @@ +"use client"; + +import { ChatPanel } from "@/components/vibn-chat/chat-panel"; + export default function WorkspaceLayout({ children, }: { children: React.ReactNode; }) { - return children; + return ( + <> + {/* Main content shifts left to make room for the open chat panel */} +
+ {children} +
+ + + ); } - diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 00000000..3dfbbeba --- /dev/null +++ b/app/api/chat/route.ts @@ -0,0 +1,218 @@ +/** + * POST /api/chat + * + * Streaming chat endpoint. Accepts a thread_id + user message, + * loads history, calls Gemini 3.1 Pro, runs the tool loop, + * persists messages, and streams SSE back to the client. + * + * SSE event shapes: + * data: {"type":"text","text":"..."} + * data: {"type":"tool_start","name":"...","args":{}} + * data: {"type":"tool_result","name":"...","result":"..."} + * data: {"type":"done"} + * data: {"type":"error","error":"..."} + */ +import { NextResponse } from 'next/server'; +import { authSession } from '@/lib/auth/session-server'; +import { query } from '@/lib/db-postgres'; +import { streamGeminiChat } from '@/lib/ai/gemini-chat'; +import { VIBN_TOOL_DEFINITIONS, executeMcpTool } from '@/lib/ai/vibn-tools'; +import type { ChatMessage, ToolCall } from '@/lib/ai/gemini-chat'; + +const MAX_TOOL_ROUNDS = 6; + +function buildSystemPrompt(projects: any[], workspace: string): string { + const projectsText = projects.length + ? projects + .map( + (p: any) => + `- "${p.productName || p.name}" (id: ${p.id}, status: ${p.status || 'defining'})${p.productVision ? ': ' + p.productVision.slice(0, 120) : ''}`, + ) + .join('\n') + : '(no projects yet)'; + + return `You are Vibn AI, an expert product and infrastructure assistant embedded in the Vibn platform. +You are talking to the owner of the "${workspace}" workspace. + +## Current workspace projects +${projectsText} + +## Your capabilities +You can take real actions by calling tools: +- List/inspect projects and deployed apps +- Deploy new applications from templates (n8n, WordPress, Twenty CRM, etc.) or Docker images +- Register and manage custom domains +- View application logs +- Search available app templates + +## Guidelines +- Be concise and action-oriented. If the user wants to deploy something, do it — don't just describe how. +- When deploying, always confirm the app name and domain with the user first unless they've been explicit. +- After a tool call, summarize what happened in plain language. +- If a deployment takes time, let the user know it may take a few minutes. +- Format code, URLs, and technical values in backticks. +- Today's date: ${new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}.`; +} + +export async function POST(request: Request) { + const session = await authSession(); + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + let body: { thread_id: string; message: string; workspace: string; mcp_token?: string }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const { thread_id, message, workspace, mcp_token } = body; + if (!thread_id || !message?.trim()) { + return NextResponse.json({ error: 'thread_id and message are required' }, { status: 400 }); + } + + const email = session.user.email; + + // Verify thread belongs to user + const threads = await query( + `SELECT id FROM fs_chat_threads WHERE id = $1 AND user_id = $2`, + [thread_id, email], + ); + if (!threads.length) { + return NextResponse.json({ error: 'Thread not found' }, { status: 404 }); + } + + // Load message history (last 40 messages) + const rows = await query( + `SELECT data FROM fs_chat_messages WHERE thread_id = $1 ORDER BY created_at DESC LIMIT 40`, + [thread_id], + ); + const history: ChatMessage[] = rows.reverse().map((r: any) => r.data); + + // Add user message + const userMsg: ChatMessage = { role: 'user', content: message.trim() }; + history.push(userMsg); + await query( + `INSERT INTO fs_chat_messages (thread_id, user_id, data) VALUES ($1, $2, $3)`, + [thread_id, email, JSON.stringify(userMsg)], + ); + + // Update thread updatedAt + await query( + `UPDATE fs_chat_threads SET updated_at = NOW(), data = data || $2 WHERE id = $1`, + [thread_id, JSON.stringify({ updatedAt: new Date().toISOString() })], + ); + + // Load projects for system prompt context + const projectRows = await query( + `SELECT p.data FROM fs_projects p + JOIN fs_users u ON u.id = p.user_id + WHERE u.data->>'email' = $1 + ORDER BY (p.data->>'updatedAt') DESC NULLS LAST LIMIT 20`, + [email], + ); + const projects = projectRows.map((r: any) => r.data); + const systemPrompt = buildSystemPrompt(projects, workspace); + + // Base URL for internal MCP calls + const host = request.headers.get('host') || 'vibnai.com'; + const proto = host.startsWith('localhost') ? 'http' : 'https'; + const baseUrl = `${proto}://${host}`; + + // Stream response + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + function emit(chunk: object) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + } + + let messages = [...history]; + let round = 0; + let assistantText = ''; + const assistantToolCalls: ToolCall[] = []; + + try { + while (round < MAX_TOOL_ROUNDS) { + round++; + let pendingToolCalls: ToolCall[] = []; + let roundText = ''; + + for await (const chunk of streamGeminiChat({ + systemPrompt, + messages, + tools: mcp_token ? VIBN_TOOL_DEFINITIONS : [], + temperature: 0.7, + })) { + if (chunk.type === 'text' && chunk.text) { + roundText += chunk.text; + assistantText += chunk.text; + emit({ type: 'text', text: chunk.text }); + } else if (chunk.type === 'tool_call' && chunk.toolCall) { + pendingToolCalls.push(chunk.toolCall); + assistantToolCalls.push(chunk.toolCall); + emit({ type: 'tool_start', name: chunk.toolCall.name, args: chunk.toolCall.args }); + } else if (chunk.type === 'error') { + emit({ type: 'error', error: chunk.error }); + controller.close(); + return; + } + } + + // Save assistant turn + const assistantMsg: ChatMessage = { + role: 'assistant', + content: roundText, + toolCalls: pendingToolCalls.length ? pendingToolCalls : undefined, + }; + messages.push(assistantMsg); + + if (!pendingToolCalls.length) break; + + // Execute tool calls + for (const tc of pendingToolCalls) { + const result = mcp_token + ? await executeMcpTool(tc.name, tc.args, mcp_token, baseUrl) + : JSON.stringify({ error: 'No MCP token — read-only mode.' }); + + emit({ type: 'tool_result', name: tc.name, result: result.slice(0, 500) }); + + const toolMsg: ChatMessage = { + role: 'tool', + content: result, + toolCallId: tc.id, + toolName: tc.name, + }; + messages.push(toolMsg); + } + } + + // Persist final assistant message + const finalMsg: ChatMessage = { + role: 'assistant', + content: assistantText, + toolCalls: assistantToolCalls.length ? assistantToolCalls : undefined, + }; + await query( + `INSERT INTO fs_chat_messages (thread_id, user_id, data) VALUES ($1, $2, $3)`, + [thread_id, email, JSON.stringify(finalMsg)], + ); + + emit({ type: 'done' }); + controller.close(); + } catch (e) { + emit({ type: 'error', error: e instanceof Error ? e.message : String(e) }); + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }); +} diff --git a/app/api/chat/threads/[id]/route.ts b/app/api/chat/threads/[id]/route.ts new file mode 100644 index 00000000..ce71fae4 --- /dev/null +++ b/app/api/chat/threads/[id]/route.ts @@ -0,0 +1,56 @@ +/** + * GET /api/chat/threads/[id] — load a thread + its messages + * PATCH /api/chat/threads/[id] — rename a thread + * DELETE /api/chat/threads/[id] — delete a thread + */ +import { NextResponse } from 'next/server'; +import { authSession } from '@/lib/auth/session-server'; +import { query } from '@/lib/db-postgres'; + +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const session = await authSession(); + if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { id } = await params; + + const threads = await query( + `SELECT id, data, created_at, updated_at FROM fs_chat_threads WHERE id = $1 AND user_id = $2`, + [id, session.user.email], + ); + if (!threads.length) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + + const messages = await query( + `SELECT id, data, created_at FROM fs_chat_messages WHERE thread_id = $1 ORDER BY created_at ASC`, + [id], + ); + + const t = threads[0]; + return NextResponse.json({ + thread: { id: t.id, title: t.data?.title || 'New conversation', createdAt: t.created_at, updatedAt: t.updated_at }, + messages: messages.map((m: any) => ({ id: m.id, ...m.data, createdAt: m.created_at })), + }); +} + +export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { + const session = await authSession(); + if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { id } = await params; + const { title } = await request.json().catch(() => ({})); + if (!title) return NextResponse.json({ error: 'title required' }, { status: 400 }); + + await query( + `UPDATE fs_chat_threads SET data = data || $3, updated_at = NOW() WHERE id = $1 AND user_id = $2`, + [id, session.user.email, JSON.stringify({ title })], + ); + return NextResponse.json({ ok: true }); +} + +export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { + const session = await authSession(); + if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { id } = await params; + await query(`DELETE FROM fs_chat_threads WHERE id = $1 AND user_id = $2`, [id, session.user.email]); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/chat/threads/route.ts b/app/api/chat/threads/route.ts new file mode 100644 index 00000000..bfd5a769 --- /dev/null +++ b/app/api/chat/threads/route.ts @@ -0,0 +1,55 @@ +/** + * GET /api/chat/threads — list user's threads + * POST /api/chat/threads — create a new thread + */ +import { NextResponse } from 'next/server'; +import { authSession } from '@/lib/auth/session-server'; +import { query } from '@/lib/db-postgres'; + +export async function GET(request: Request) { + const session = await authSession(); + if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { searchParams } = new URL(request.url); + const workspace = searchParams.get('workspace') || ''; + + const rows = await query( + `SELECT id, data, created_at, updated_at + FROM fs_chat_threads + WHERE user_id = $1 AND workspace = $2 + ORDER BY updated_at DESC + LIMIT 50`, + [session.user.email, workspace], + ); + + return NextResponse.json({ + threads: rows.map((r: any) => ({ + id: r.id, + title: r.data?.title || 'New conversation', + updatedAt: r.updated_at, + createdAt: r.created_at, + })), + }); +} + +export async function POST(request: Request) { + const session = await authSession(); + if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { workspace, title } = await request.json().catch(() => ({})); + if (!workspace) return NextResponse.json({ error: 'workspace required' }, { status: 400 }); + + const rows = await query( + `INSERT INTO fs_chat_threads (user_id, workspace, data) + VALUES ($1, $2, $3) + RETURNING id, data, created_at, updated_at`, + [ + session.user.email, + workspace, + JSON.stringify({ title: title || 'New conversation', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }), + ], + ); + + const r = rows[0]; + return NextResponse.json({ thread: { id: r.id, title: r.data?.title || 'New conversation', createdAt: r.created_at, updatedAt: r.updated_at } }); +} diff --git a/components/vibn-chat/chat-panel.tsx b/components/vibn-chat/chat-panel.tsx new file mode 100644 index 00000000..a463c637 --- /dev/null +++ b/components/vibn-chat/chat-panel.tsx @@ -0,0 +1,609 @@ +"use client"; + +import { useEffect, useRef, useState, useCallback } from "react"; +import { useSession } from "next-auth/react"; +import { useParams } from "next/navigation"; +import { + MessageSquare, + X, + ChevronRight, + Send, + Plus, + Loader2, + Wrench, + ChevronDown, + Trash2, +} from "lucide-react"; + +// ── 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; +} + +interface ToolEvent { + name: string; + status: "running" | "done"; + result?: string; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +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 renderMarkdown(text: string): string { + return text + .replace(/&/g, "&").replace(//g, ">") + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/`([^`]+)`/g, '$1') + .replace(/^### (.+)$/gm, '

$1

') + .replace(/^## (.+)$/gm, '

$1

') + .replace(/^- (.+)$/gm, '
  • $1
  • ') + .replace(/(]*>.*<\/li>\n?)+/g, (m) => `
      ${m}
    `) + .replace(/\n\n/g, '

    ') + .replace(/\n/g, "
    "); +} + +// ── Message bubble ──────────────────────────────────────────────────────────── + +function MessageBubble({ msg }: { msg: Message }) { + const isUser = msg.role === "user"; + return ( +

    + {!isUser && ( +
    + V +
    + )} +
    + {isUser ? ( + {msg.content} + ) : ( + + )} +
    +
    + ); +} + +function ToolBubble({ event }: { event: ToolEvent }) { + return ( +
    + {event.status === "running" ? ( + + ) : ( + + )} + {friendlyToolName(event.name)}{event.status === "running" ? "…" : " ✓"} +
    + ); +} + +// ── Main panel ──────────────────────────────────────────────────────────────── + +export function ChatPanel() { + const { data: sessionData, status } = useSession(); + const params = useParams(); + const workspace = (params?.workspace as string) || ""; + + const [open, setOpen] = useState(() => { + if (typeof window === "undefined") return false; + return localStorage.getItem("vibn-chat-open") !== "false"; + }); + const [threads, setThreads] = useState([]); + const [activeThread, setActiveThread] = useState(null); + const [messages, setMessages] = useState([]); + const [toolEvents, setToolEvents] = useState([]); + const [input, setInput] = useState(""); + const [sending, setSending] = useState(false); + const [showThreads, setShowThreads] = useState(false); + const [mcpToken, setMcpToken] = useState(null); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + + const scrollToBottom = useCallback(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, []); + + // Persist open state + adjust main content margin + useEffect(() => { + localStorage.setItem("vibn-chat-open", String(open)); + document.documentElement.style.setProperty("--chat-panel-width", open ? "380px" : "0px"); + }, [open]); + + // Load MCP token from localStorage (set at /settings) + useEffect(() => { + const t = localStorage.getItem(`vibn-mcp-token-${workspace}`); + if (t) setMcpToken(t); + }, [workspace]); + + // Load threads + const loadThreads = useCallback(async () => { + if (!workspace || status !== "authenticated") return; + try { + const res = await fetch(`/api/chat/threads?workspace=${encodeURIComponent(workspace)}`); + const data = await res.json(); + setThreads(data.threads || []); + } catch { /* silent */ } + }, [workspace, status]); + + useEffect(() => { + loadThreads(); + }, [loadThreads]); + + // Create and activate a new thread + const newThread = useCallback(async () => { + try { + const res = await fetch("/api/chat/threads", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ workspace }), + }); + const data = await res.json(); + if (data.thread) { + setThreads((prev) => [data.thread, ...prev]); + setActiveThread(data.thread.id); + setMessages([]); + setShowThreads(false); + } + } catch { /* silent */ } + }, [workspace]); + + // 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(); + setMessages(data.messages || []); + } catch { /* silent */ } + }, []); + + // Auto-create first thread + useEffect(() => { + if (open && status === "authenticated" && workspace && threads.length === 0 && !activeThread) { + newThread(); + } else if (open && threads.length > 0 && !activeThread) { + loadThread(threads[0].id); + } + }, [open, status, workspace, threads.length, activeThread, newThread, loadThread]); + + useEffect(() => { scrollToBottom(); }, [messages, toolEvents, 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 () => { + if (!input.trim() || sending || !activeThread) return; + const text = input.trim(); + setInput(""); + setSending(true); + setToolEvents([]); + + const userMsg: Message = { role: "user", content: text }; + setMessages((prev) => [...prev, userMsg]); + + let assistantContent = ""; + const assistantMsg: Message = { role: "assistant", content: "" }; + let msgIndex = -1; + + try { + 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 }), + }); + + 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: any; + try { ev = JSON.parse(line.slice(6)); } catch { continue; } + + if (ev.type === "text" && ev.text) { + assistantContent += ev.text; + setMessages((prev) => { + const next = [...prev]; + if (msgIndex >= 0 && next[msgIndex]) { + next[msgIndex] = { ...next[msgIndex], content: assistantContent }; + } + return next; + }); + } else if (ev.type === "tool_start") { + setToolEvents((prev) => [...prev, { name: ev.name, status: "running" }]); + } else if (ev.type === "tool_result") { + setToolEvents((prev) => + prev.map((t) => t.name === ev.name && t.status === "running" + ? { ...t, status: "done", result: ev.result } + : t + ) + ); + } else if (ev.type === "error") { + assistantContent += `\n\n⚠️ ${ev.error}`; + setMessages((prev) => { + const next = [...prev]; + if (msgIndex >= 0 && next[msgIndex]) { + next[msgIndex] = { ...next[msgIndex], content: assistantContent }; + } + 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) { + setMessages((prev) => { + const next = [...prev]; + if (msgIndex >= 0 && next[msgIndex]) { + next[msgIndex] = { ...next[msgIndex], content: "⚠️ Failed to get response. Please try again." }; + } + return next; + }); + } finally { + setSending(false); + } + }, [input, sending, activeThread, workspace, mcpToken, threads, loadThreads]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }; + + if (status !== "authenticated") return null; + + // ── Collapsed tab ────────────────────────────────────────────────────────── + if (!open) { + return ( + + ); + } + + // ── Open panel ───────────────────────────────────────────────────────────── + return ( +
    + {/* Header */} +
    + +
    + + +
    +
    + + {/* 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 +
    +

    + Vibn AI +

    +

    + Ask me to deploy an app, register a domain,
    or help plan your next product. +

    +
    + )} + + {messages.map((msg, i) => ( + + ))} + + {toolEvents.map((ev, i) => ( + + ))} + + {sending && messages[messages.length - 1]?.role !== "assistant" && ( +
    +
    + V +
    +
    + {[0, 1, 2].map((i) => ( + + ))} +
    +
    + )} + +
    +
    + + {/* Input */} +
    + {!mcpToken && ( +
    + Read-only mode — add your MCP token in Settings to enable actions. +
    + )} +
    +