feat(FN-2152): render collapsed tool call previews in chat

- Emit tool_start/tool_end SSE events from dashboard chat backend and parse them in streaming client helpers
- Track in-flight and completed tool calls in useChat/useQuickChat to preserve tool output summaries alongside assistant messages
- Render collapsed tool call preview blocks in ChatView and QuickChatFAB with dedicated tokenized styles for compact output summaries
- Expand frontend and backend test coverage for SSE tool events, hook state transitions, and collapsed preview rendering behavior
- Add a changeset for @gsxdsm/fusion documenting the new tool-call display behavior
This commit is contained in:
Fusion
2026-04-19 23:47:12 -07:00
committed by gsxdsm
parent d37da5b3db
commit fd3ef35e0d
12 changed files with 1021 additions and 84 deletions

View File

@@ -86,6 +86,8 @@ const MAX_REFERENCED_FILE_SIZE = 50 * 1024;
export type ChatStreamEvent =
| { type: "thinking"; data: string }
| { type: "text"; data: string }
| { type: "tool_start"; data: { toolName: string; args?: Record<string, unknown> } }
| { type: "tool_end"; data: { toolName: string; isError: boolean; result?: unknown } }
| { type: "done"; data: { messageId: string } }
| { type: "error"; data: string };
@@ -518,6 +520,14 @@ export class ChatManager {
let agentResult: AgentResult | undefined;
let accumulatedThinking = "";
let accumulatedText = "";
type ToolCallRecord = {
toolName: string;
args?: Record<string, unknown>;
isError: boolean;
result?: unknown;
};
const toolCallsAccum: ToolCallRecord[] = [];
const pendingToolStarts = new Map<string, Array<{ toolName: string; args?: Record<string, unknown> }>>();
try {
// Validate session exists
@@ -670,6 +680,35 @@ export class ChatManager {
data: delta,
});
},
onToolStart: (name: string, args?: Record<string, unknown>) => {
const pendingForTool = pendingToolStarts.get(name) ?? [];
pendingForTool.push({ toolName: name, args });
pendingToolStarts.set(name, pendingForTool);
chatStreamManager.broadcast(sessionId, {
type: "tool_start",
data: { toolName: name, args },
});
},
onToolEnd: (name: string, isError: boolean, result?: unknown) => {
const pendingForTool = pendingToolStarts.get(name);
const pendingStart = pendingForTool?.pop();
if (pendingForTool && pendingForTool.length === 0) {
pendingToolStarts.delete(name);
}
toolCallsAccum.push({
toolName: name,
args: pendingStart?.args,
isError,
result,
});
chatStreamManager.broadcast(sessionId, {
type: "tool_end",
data: { toolName: name, isError, result },
});
},
});
this.activeGenerations.set(sessionId, { abortController, agentResult });
@@ -714,6 +753,7 @@ export class ChatManager {
role: "assistant",
content: finalResponseText,
thinkingOutput: accumulatedThinking || undefined,
metadata: toolCallsAccum.length > 0 ? { toolCalls: toolCallsAccum } : undefined,
});
// Broadcast done event
@@ -733,13 +773,16 @@ export class ChatManager {
const errorMessage = err instanceof Error ? err.message : "AI processing failed";
console.error(`[chat] Error in sendMessage for session ${sessionId}:`, err);
if (accumulatedText || accumulatedThinking) {
if (accumulatedText || accumulatedThinking || toolCallsAccum.length > 0) {
try {
this.chatStore.addMessage(sessionId, {
role: "assistant",
content: accumulatedText || "(response interrupted before text generation)",
thinkingOutput: accumulatedThinking || undefined,
metadata: { interrupted: true },
metadata: {
interrupted: true,
...(toolCallsAccum.length > 0 ? { toolCalls: toolCallsAccum } : {}),
},
});
} catch (persistErr) {
console.error(`[chat] Failed to persist partial response for session ${sessionId}:`, persistErr);