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