fix(chat-ui): render tool tray inside the bubble, collapse repeats

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
This commit is contained in:
2026-04-30 23:21:31 -07:00
parent b395546529
commit c77f3fbc7f

View File

@@ -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 && <ThinkingBubble thoughts={msg.thoughts} />}
{!isUser && msg.toolEvents && msg.toolEvents.length > 0 && (
<ToolEventList events={msg.toolEvents} />
)}
{(msg.content || isUser) && (
<div style={{
padding: isUser ? "9px 14px" : "10px 14px",
@@ -195,6 +206,73 @@ function MessageBubble({ msg }: { msg: Message }) {
);
}
/**
* Renders the chronological tool tray for an assistant turn. Adjacent
* runs of the same tool name collapse into a single pill with a "×N"
* counter, so a 12-call shell.exec debugging streak doesn't fill the
* viewport. Click to expand the run into individual pills.
*/
function ToolEventList({ events }: { events: ToolEvent[] }) {
const groups: Array<{ name: string; events: ToolEvent[] }> = [];
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 (
<div style={{ marginBottom: 6 }}>
{groups.map((g, i) => <ToolGroup key={i} group={g} />)}
</div>
);
}
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) => <ToolBubble key={i} event={ev} />)}
{expanded && count > 1 && (
<button
type="button"
onClick={() => setExpanded(false)}
style={{
fontSize: "0.7rem", color: "#a09a90", background: "none",
border: "none", padding: "2px 12px", cursor: "pointer",
}}
>
collapse
</button>
)}
</>
);
}
return (
<button
type="button"
onClick={() => setExpanded(true)}
style={{
display: "flex", alignItems: "center", gap: 8,
padding: "6px 12px", margin: "4px 0",
background: "#f0ede8", borderRadius: 8,
fontSize: "0.75rem", color: "#6b6560",
border: "none", cursor: "pointer", textAlign: "left",
width: "auto",
}}
>
<span style={{ fontFamily: "ui-monospace, monospace" }}>
{group.name}
</span>
<span style={{ color: "#a09a90" }}>×{count}</span>
<span style={{ marginLeft: "auto", color: allDone ? "#2e7d32" : "#a09a90" }}>
{allDone ? "✓" : "…"}
</span>
</button>
);
}
function ToolBubble({ event }: { event: ToolEvent }) {
return (
<div style={{
@@ -238,7 +316,6 @@ export function ChatPanel() {
const [threadsLoaded, setThreadsLoaded] = useState(false);
const [activeThread, setActiveThread] = useState<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [toolEvents, setToolEvents] = useState<ToolEvent[]>([]);
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() {
<MessageBubble key={msg.id || i} msg={msg} />
))}
{toolEvents.map((ev, i) => (
<ToolBubble key={i} event={ev} />
))}
{sending && messages[messages.length - 1]?.role !== "assistant" && (
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 0" }}>
<div style={{