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:
@@ -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={{
|
||||
|
||||
Reference in New Issue
Block a user