From c77f3fbc7fbe169c9ec2713ccb073a54b5feeb7a Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Thu, 30 Apr 2026 23:21:31 -0700 Subject: [PATCH] fix(chat-ui): render tool tray inside the bubble, collapse repeats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two UX wins from this change: 1. Tool pills now render INSIDE the assistant message bubble, ABOVE the streaming text. Previously: messages list rendered first, tool tray rendered after — so the user saw the summary text mid-screen and the tool pills below it, having to scroll UP to read the summary they actually cared about. Now the visual flow is: [user question] → [tool pills, chronological] → [summary text] and the user's eye lands on the summary at the bottom. 2. Adjacent runs of the same tool name collapse into a single pill with ×N counter. Click to expand. Kills the "8× shell.exec ✓" wall of identical pills the user saw in prod (Dr Dave thread). Tool events are now scoped to their owning assistant message (message.toolEvents) rather than a global state — also means stop/restart can't bleed events across turns. Made-with: Cursor --- components/vibn-chat/chat-panel.tsx | 120 ++++++++++++++++++++++++---- 1 file changed, 106 insertions(+), 14 deletions(-) diff --git a/components/vibn-chat/chat-panel.tsx b/components/vibn-chat/chat-panel.tsx index 6b236992..e38c9c82 100644 --- a/components/vibn-chat/chat-panel.tsx +++ b/components/vibn-chat/chat-panel.tsx @@ -39,6 +39,14 @@ interface Message { * worth keeping in PG). */ thoughts?: string; + /** + * Tool calls executed during this assistant turn, in chronological + * order. Rendered as pills INSIDE the bubble above the text content + * so the final summary lands where the user's eye naturally goes + * (the bottom of the bubble) instead of being buried above the + * tool tray. + */ + toolEvents?: ToolEvent[]; } interface ToolEvent { @@ -173,6 +181,9 @@ function MessageBubble({ msg }: { msg: Message }) { flexDirection: "column", }}> {!isUser && msg.thoughts && } + {!isUser && msg.toolEvents && msg.toolEvents.length > 0 && ( + + )} {(msg.content || isUser) && (
= []; + for (const ev of events) { + const last = groups[groups.length - 1]; + if (last && last.name === ev.name) last.events.push(ev); + else groups.push({ name: ev.name, events: [ev] }); + } + return ( +
+ {groups.map((g, i) => )} +
+ ); +} + +function ToolGroup({ group }: { group: { name: string; events: ToolEvent[] } }) { + const [expanded, setExpanded] = useState(false); + const count = group.events.length; + const allDone = group.events.every(e => e.status === "done"); + if (count === 1 || expanded) { + return ( + <> + {group.events.map((ev, i) => )} + {expanded && count > 1 && ( + + )} + + ); + } + return ( + + ); +} + function ToolBubble({ event }: { event: ToolEvent }) { return (
(null); const [messages, setMessages] = useState([]); - const [toolEvents, setToolEvents] = useState([]); const [input, setInput] = useState(""); const [sending, setSending] = useState(false); const [showThreads, setShowThreads] = useState(false); @@ -384,7 +461,7 @@ export function ChatPanel() { if (activeThread) localStorage.setItem(savedKey, activeThread); }, [activeThread, workspace, projectId]); - useEffect(() => { scrollToBottom(); }, [messages, toolEvents, scrollToBottom]); + useEffect(() => { scrollToBottom(); }, [messages, scrollToBottom]); const deleteThread = useCallback(async (id: string, e: React.MouseEvent) => { e.stopPropagation(); @@ -402,7 +479,6 @@ export function ChatPanel() { const text = input.trim(); setInput(""); setSending(true); - setToolEvents([]); const userMsg: Message = { role: "user", content: text }; setMessages((prev) => [...prev, userMsg]); @@ -471,14 +547,34 @@ export function ChatPanel() { return next; }); } else if (ev.type === "tool_start") { - setToolEvents((prev) => [...prev, { name: ev.name, status: "running" }]); + setMessages((prev) => { + const next = [...prev]; + if (msgIndex >= 0 && next[msgIndex]) { + const existing = next[msgIndex].toolEvents ?? []; + next[msgIndex] = { + ...next[msgIndex], + toolEvents: [...existing, { name: ev.name, status: "running" }], + }; + } + return next; + }); } 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 - ) - ); + setMessages((prev) => { + const next = [...prev]; + if (msgIndex >= 0 && next[msgIndex]) { + const events = next[msgIndex].toolEvents ?? []; + let updated = false; + const newEvents = [...events].reverse().map((t) => { + if (!updated && t.name === ev.name && t.status === "running") { + updated = true; + return { ...t, status: "done" as const, result: ev.result }; + } + return t; + }).reverse(); + next[msgIndex] = { ...next[msgIndex], toolEvents: newEvents }; + } + return next; + }); } else if (ev.type === "error") { const errText = ev.error || "Unknown error"; const isToolErr = /tool|mcp|coolify|gitea/i.test(errText); @@ -739,10 +835,6 @@ export function ChatPanel() { ))} - {toolEvents.map((ev, i) => ( - - ))} - {sending && messages[messages.length - 1]?.role !== "assistant" && (