From 01848ba6827208777cf98273f9cead6d699679d2 Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Mon, 9 Mar 2026 15:34:41 -0700 Subject: [PATCH] feat: add persistent COO/Assist chat as left-side primary AI interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New CooChat component: streaming Gemini-backed advisor chat, message bubbles, typing cursor animation, Shift+Enter for newlines - New /api/projects/[projectId]/advisor streaming endpoint: builds a COO system prompt from project context (name, description, vision, repo), proxies Gemini SSE stream back to the client - Restructured BuildHubInner layout: Left (340px): CooChat — persistent across all Build sections Inner nav (200px): Build pills + contextual items (apps, tree, surfaces) Main area: File viewer for Code, Layouts content, Infra content - AgentMode removed from main view — execution surfaces via COO delegation Made-with: Cursor --- .../project/[projectId]/build/page.tsx | 400 +++++++++++++----- app/api/projects/[projectId]/advisor/route.ts | 152 +++++++ 2 files changed, 435 insertions(+), 117 deletions(-) create mode 100644 app/api/projects/[projectId]/advisor/route.ts diff --git a/app/[workspace]/project/[projectId]/build/page.tsx b/app/[workspace]/project/[projectId]/build/page.tsx index 0be40a3..9c5a456 100644 --- a/app/[workspace]/project/[projectId]/build/page.tsx +++ b/app/[workspace]/project/[projectId]/build/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { Suspense, useState, useEffect, useCallback } from "react"; +import { Suspense, useState, useEffect, useCallback, useRef } from "react"; import { useParams, useSearchParams, useRouter } from "next/navigation"; import { useSession } from "next-auth/react"; import Link from "next/link"; @@ -812,6 +812,181 @@ function TerminalPanel({ appName }: { appName: string }) { ); } +// ── COO / Assist chat — persistent left-side advisor ───────────────────────── + +interface CooMessage { + id: string; + role: "user" | "assistant"; + content: string; + streaming?: boolean; +} + +const WELCOME: CooMessage = { + id: "welcome", + role: "assistant", + content: "Hi. I'm your product COO — I know your codebase, your goals, and what's been built. What do you need?", +}; + +function CooChat({ projectId, projectName }: { projectId: string; projectName: string }) { + const [messages, setMessages] = useState([WELCOME]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const bottomRef = useRef(null); + const textareaRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + const send = async () => { + const text = input.trim(); + if (!text || loading) return; + setInput(""); + + const userMsg: CooMessage = { id: Date.now().toString(), role: "user", content: text }; + const assistantId = (Date.now() + 1).toString(); + const assistantMsg: CooMessage = { id: assistantId, role: "assistant", content: "", streaming: true }; + + setMessages(prev => [...prev, userMsg, assistantMsg]); + setLoading(true); + + // Build history for the API (exclude welcome message, exclude the new blank assistant placeholder) + const history = messages + .filter(m => m.id !== "welcome" && m.content) + .map(m => ({ role: m.role === "assistant" ? "model" as const : "user" as const, content: m.content })); + + try { + const res = await fetch(`/api/projects/${projectId}/advisor`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: text, history }), + }); + + if (!res.ok || !res.body) { + setMessages(prev => prev.map(m => m.id === assistantId + ? { ...m, content: "Something went wrong. Please try again.", streaming: false } + : m)); + return; + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = decoder.decode(value, { stream: true }); + setMessages(prev => prev.map(m => m.id === assistantId + ? { ...m, content: m.content + chunk } + : m)); + } + + setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, streaming: false } : m)); + } catch { + setMessages(prev => prev.map(m => m.id === assistantId + ? { ...m, content: "Connection error. Please try again.", streaming: false } + : m)); + } finally { + setLoading(false); + textareaRef.current?.focus(); + } + }; + + return ( +
+ {/* Messages */} +
+ {messages.map(msg => ( +
+ {msg.role === "assistant" && ( + + )} +
+ {msg.content || (msg.streaming ? "" : "")} + {msg.streaming && msg.content === "" && ( + + {[0, 1, 2].map(i => ( + + ))} + + )} + {msg.streaming && msg.content !== "" && ( + + )} +
+
+ ))} +
+
+ + {/* Input */} +
+
+