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" && (