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 eaf99e6279
commit 7b8bbaaa0a
12 changed files with 1021 additions and 84 deletions

View File

@@ -12,7 +12,7 @@ import {
} from "../api";
import { subscribeSse } from "../sse-bus";
import { getScopedItem, setScopedItem, removeScopedItem } from "../utils/projectStorage";
import type { Agent } from "@fusion/core";
import type { Agent, ChatMessage } from "@fusion/core";
const ACTIVE_SESSION_STORAGE_KEY = "kb-chat-active-session";
@@ -29,12 +29,21 @@ export interface ChatSessionInfo {
lastMessageAt?: string;
}
export interface ToolCallInfo {
toolName: string;
args?: Record<string, unknown>;
isError: boolean;
result?: unknown;
status: "running" | "completed";
}
export interface ChatMessageInfo {
id: string;
sessionId: string;
role: "user" | "assistant" | "system";
content: string;
thinkingOutput?: string | null;
toolCalls?: ToolCallInfo[];
createdAt: string;
}
@@ -50,6 +59,7 @@ export interface UseChatReturn {
isStreaming: boolean;
streamingText: string;
streamingThinking: string;
streamingToolCalls: ToolCallInfo[];
pendingMessage: string;
// Session operations
@@ -79,6 +89,51 @@ export interface UseChatReturn {
agentsMap: Map<string, Agent>;
}
function extractCompletedToolCalls(metadata: Record<string, unknown> | null | undefined): ToolCallInfo[] | undefined {
const rawToolCalls = metadata?.toolCalls;
if (!Array.isArray(rawToolCalls)) {
return undefined;
}
const parsed = rawToolCalls
.map((toolCall): ToolCallInfo | null => {
if (!toolCall || typeof toolCall !== "object") {
return null;
}
const record = toolCall as Record<string, unknown>;
const toolName = typeof record.toolName === "string" ? record.toolName : "";
if (!toolName) {
return null;
}
const args = record.args;
return {
toolName,
...(args && typeof args === "object" ? { args: args as Record<string, unknown> } : {}),
isError: Boolean(record.isError),
result: record.result,
status: "completed" as const,
};
})
.filter((toolCall): toolCall is ToolCallInfo => toolCall !== null);
return parsed.length > 0 ? parsed : undefined;
}
function mapChatMessageToInfo(message: ChatMessage): ChatMessageInfo {
return {
id: message.id,
sessionId: message.sessionId,
role: message.role,
content: message.content,
thinkingOutput: message.thinkingOutput,
toolCalls: extractCompletedToolCalls(message.metadata),
createdAt: message.createdAt,
};
}
export function useChat(projectId?: string): UseChatReturn {
// Session state
const [sessions, setSessions] = useState<ChatSessionInfo[]>([]);
@@ -91,6 +146,7 @@ export function useChat(projectId?: string): UseChatReturn {
const [isStreaming, setIsStreaming] = useState(false);
const [streamingText, setStreamingText] = useState("");
const [streamingThinking, setStreamingThinking] = useState("");
const [streamingToolCalls, setStreamingToolCalls] = useState<ToolCallInfo[]>([]);
const [pendingMessage, setPendingMessage] = useState("");
// Search/filter
@@ -196,11 +252,12 @@ export function useChat(projectId?: string): UseChatReturn {
setMessagesLoading(true);
try {
const data = await fetchChatMessages(sessionId, { limit: 50, ...opts }, projectId);
const mappedMessages = data.messages.map(mapChatMessageToInfo);
if (opts?.offset && opts.offset > 0) {
// Prepend older messages
setMessages((prev) => [...data.messages, ...prev]);
setMessages((prev) => [...mappedMessages, ...prev]);
} else {
setMessages(data.messages);
setMessages(mappedMessages);
}
setHasMoreMessages(data.messages.length >= 50);
} catch {
@@ -228,6 +285,7 @@ export function useChat(projectId?: string): UseChatReturn {
// Reset streaming state
setStreamingText("");
setStreamingThinking("");
setStreamingToolCalls([]);
setIsStreaming(false);
setHasMoreMessages(true);
@@ -275,6 +333,7 @@ export function useChat(projectId?: string): UseChatReturn {
setMessages([]);
setStreamingText("");
setStreamingThinking("");
setStreamingToolCalls([]);
setIsStreaming(false);
setHasMoreMessages(true);
@@ -339,6 +398,7 @@ export function useChat(projectId?: string): UseChatReturn {
setIsStreaming(false);
setStreamingText("");
setStreamingThinking("");
setStreamingToolCalls([]);
}, [activeSession, projectId]);
const clearPendingMessage = useCallback(() => {
@@ -379,11 +439,13 @@ export function useChat(projectId?: string): UseChatReturn {
// Clear streaming state
setStreamingText("");
setStreamingThinking("");
setStreamingToolCalls([]);
setIsStreaming(true);
// Accumulate streaming text in local variables
// Accumulate streaming text and tool calls in local variables
let capturedText = "";
let capturedThinking = "";
let capturedToolCalls: ToolCallInfo[] = [];
const textHandlers = {
onThinking: (data: string) => {
@@ -394,6 +456,46 @@ export function useChat(projectId?: string): UseChatReturn {
capturedText += data;
setStreamingText(capturedText);
},
onToolStart: (data: { toolName: string; args?: Record<string, unknown> }) => {
capturedToolCalls = [
...capturedToolCalls,
{
toolName: data.toolName,
args: data.args,
isError: false,
status: "running",
},
];
setStreamingToolCalls(capturedToolCalls);
},
onToolEnd: (data: { toolName: string; isError: boolean; result?: unknown }) => {
const nextToolCalls = [...capturedToolCalls];
for (let i = nextToolCalls.length - 1; i >= 0; i--) {
const candidate = nextToolCalls[i];
if (candidate?.toolName === data.toolName && candidate.status === "running") {
nextToolCalls[i] = {
...candidate,
status: "completed",
isError: data.isError,
result: data.result,
};
capturedToolCalls = nextToolCalls;
setStreamingToolCalls(nextToolCalls);
return;
}
}
capturedToolCalls = [
...nextToolCalls,
{
toolName: data.toolName,
isError: data.isError,
result: data.result,
status: "completed",
},
];
setStreamingToolCalls(capturedToolCalls);
},
onDone: (data: { messageId: string }) => {
const assistantMessage: ChatMessageInfo = {
id: data.messageId || `msg-${Date.now()}`,
@@ -401,6 +503,7 @@ export function useChat(projectId?: string): UseChatReturn {
role: "assistant",
content: capturedText,
thinkingOutput: capturedThinking,
toolCalls: capturedToolCalls.length > 0 ? capturedToolCalls : undefined,
createdAt: new Date().toISOString(),
};
@@ -412,6 +515,7 @@ export function useChat(projectId?: string): UseChatReturn {
setStreamingText("");
setStreamingThinking("");
setStreamingToolCalls([]);
setIsStreaming(false);
streamRef.current = null;
@@ -433,6 +537,7 @@ export function useChat(projectId?: string): UseChatReturn {
setMessages((prev) => prev.filter((m) => m.id !== tempId));
setStreamingText("");
setStreamingThinking("");
setStreamingToolCalls([]);
setIsStreaming(false);
streamRef.current = null;
console.error("[useChat] Stream error:", data);
@@ -506,7 +611,8 @@ export function useChat(projectId?: string): UseChatReturn {
const handleChatMessageAdded = (e: MessageEvent) => {
if (isStale()) return;
const message: ChatMessageInfo = JSON.parse(e.data);
const rawMessage = JSON.parse(e.data) as ChatMessage;
const message = mapChatMessageToInfo(rawMessage);
// Skip if this message was already added via streaming completion
// (SSE event may arrive before streaming state clears)
@@ -564,6 +670,7 @@ export function useChat(projectId?: string): UseChatReturn {
isStreaming,
streamingText,
streamingThinking,
streamingToolCalls,
pendingMessage,
selectSession,
createSession,

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ChatSession } from "@fusion/core";
import type { ChatMessage, ChatSession } from "@fusion/core";
import {
fetchChatSessions,
createChatSession,
@@ -10,12 +10,21 @@ import {
export const FN_AGENT_ID = "__fn_agent__";
export interface ToolCallInfo {
toolName: string;
args?: Record<string, unknown>;
isError: boolean;
result?: unknown;
status: "running" | "completed";
}
export interface ChatMessageInfo {
id: string;
sessionId: string;
role: "user" | "assistant" | "system";
content: string;
thinkingOutput?: string | null;
toolCalls?: ToolCallInfo[];
createdAt: string;
}
@@ -41,6 +50,7 @@ export interface UseQuickChatReturn {
isStreaming: boolean;
streamingText: string;
streamingThinking: string;
streamingToolCalls: ToolCallInfo[];
pendingMessage: string;
// Operations
@@ -104,6 +114,51 @@ function findMatchingSession(sessions: ChatSession[], target: SessionTarget): Ch
return candidateSessions.find((session) => !session.modelProvider && !session.modelId) ?? candidateSessions[0];
}
function extractCompletedToolCalls(metadata: Record<string, unknown> | null | undefined): ToolCallInfo[] | undefined {
const rawToolCalls = metadata?.toolCalls;
if (!Array.isArray(rawToolCalls)) {
return undefined;
}
const parsed = rawToolCalls
.map((toolCall): ToolCallInfo | null => {
if (!toolCall || typeof toolCall !== "object") {
return null;
}
const record = toolCall as Record<string, unknown>;
const toolName = typeof record.toolName === "string" ? record.toolName : "";
if (!toolName) {
return null;
}
const args = record.args;
return {
toolName,
...(args && typeof args === "object" ? { args: args as Record<string, unknown> } : {}),
isError: Boolean(record.isError),
result: record.result,
status: "completed" as const,
};
})
.filter((toolCall): toolCall is ToolCallInfo => toolCall !== null);
return parsed.length > 0 ? parsed : undefined;
}
function mapChatMessageToInfo(message: ChatMessage): ChatMessageInfo {
return {
id: message.id,
sessionId: message.sessionId,
role: message.role,
content: message.content,
thinkingOutput: message.thinkingOutput,
toolCalls: extractCompletedToolCalls(message.metadata),
createdAt: message.createdAt,
};
}
/**
* Hook for the QuickChatFAB component.
* Provides chat session management and SSE streaming for real-time AI responses.
@@ -122,6 +177,7 @@ export function useQuickChat(
const [isStreaming, setIsStreaming] = useState(false);
const [streamingText, setStreamingText] = useState("");
const [streamingThinking, setStreamingThinking] = useState("");
const [streamingToolCalls, setStreamingToolCalls] = useState<ToolCallInfo[]>([]);
const [pendingMessage, setPendingMessage] = useState("");
// Stream connection ref for cleanup
@@ -183,7 +239,7 @@ export function useQuickChat(
setMessagesLoading(true);
try {
const data = await fetchChatMessages(activeSession.id, { limit: 50 }, projectId);
setMessages(data.messages);
setMessages(data.messages.map(mapChatMessageToInfo));
} catch (err) {
console.error("[useQuickChat] Failed to load messages:", err);
} finally {
@@ -206,7 +262,7 @@ export function useQuickChat(
setMessagesLoading(true);
try {
const data = await fetchChatMessages(activeSession.id, { limit: 50 }, projectId);
setMessages(data.messages);
setMessages(data.messages.map(mapChatMessageToInfo));
} catch (err) {
console.error("[useQuickChat] Failed to reload messages:", err);
} finally {
@@ -231,6 +287,7 @@ export function useQuickChat(
// Reset streaming state
setStreamingText("");
setStreamingThinking("");
setStreamingToolCalls([]);
setIsStreaming(false);
if (targetSessionKey === currentSessionKeyRef.current && activeSession) {
@@ -271,6 +328,7 @@ export function useQuickChat(
setIsStreaming(false);
setStreamingText("");
setStreamingThinking("");
setStreamingToolCalls([]);
}, [activeSession, projectId]);
const clearPendingMessage = useCallback(() => {
@@ -311,11 +369,13 @@ export function useQuickChat(
// Clear streaming state
setStreamingText("");
setStreamingThinking("");
setStreamingToolCalls([]);
setIsStreaming(true);
// Accumulate streaming text in local variables
// Accumulate streaming text and tool calls in local variables
let capturedText = "";
let capturedThinking = "";
let capturedToolCalls: ToolCallInfo[] = [];
const textHandlers = {
onThinking: (data: string) => {
@@ -326,6 +386,46 @@ export function useQuickChat(
capturedText += data;
setStreamingText(capturedText);
},
onToolStart: (data: { toolName: string; args?: Record<string, unknown> }) => {
capturedToolCalls = [
...capturedToolCalls,
{
toolName: data.toolName,
args: data.args,
isError: false,
status: "running",
},
];
setStreamingToolCalls(capturedToolCalls);
},
onToolEnd: (data: { toolName: string; isError: boolean; result?: unknown }) => {
const nextToolCalls = [...capturedToolCalls];
for (let i = nextToolCalls.length - 1; i >= 0; i--) {
const candidate = nextToolCalls[i];
if (candidate?.toolName === data.toolName && candidate.status === "running") {
nextToolCalls[i] = {
...candidate,
status: "completed",
isError: data.isError,
result: data.result,
};
capturedToolCalls = nextToolCalls;
setStreamingToolCalls(nextToolCalls);
return;
}
}
capturedToolCalls = [
...nextToolCalls,
{
toolName: data.toolName,
isError: data.isError,
result: data.result,
status: "completed",
},
];
setStreamingToolCalls(capturedToolCalls);
},
onDone: (data: { messageId: string }) => {
const assistantMessage: ChatMessageInfo = {
id: data.messageId || `msg-${Date.now()}`,
@@ -333,6 +433,7 @@ export function useQuickChat(
role: "assistant",
content: capturedText,
thinkingOutput: capturedThinking || undefined,
toolCalls: capturedToolCalls.length > 0 ? capturedToolCalls : undefined,
createdAt: new Date().toISOString(),
};
@@ -341,6 +442,7 @@ export function useQuickChat(
setStreamingText("");
setStreamingThinking("");
setStreamingToolCalls([]);
setIsStreaming(false);
streamRef.current = null;
@@ -354,6 +456,7 @@ export function useQuickChat(
onError: (data: string) => {
setStreamingText("");
setStreamingThinking("");
setStreamingToolCalls([]);
setIsStreaming(false);
streamRef.current = null;
console.error("[useQuickChat] Stream error:", data);
@@ -395,6 +498,7 @@ export function useQuickChat(
isStreaming,
streamingText,
streamingThinking,
streamingToolCalls,
pendingMessage,
sendMessage,
stopStreaming,
@@ -411,6 +515,7 @@ export function useQuickChat(
isStreaming,
streamingText,
streamingThinking,
streamingToolCalls,
pendingMessage,
sendMessage,
stopStreaming,