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

@@ -9,8 +9,9 @@ import {
ChevronLeft,
Bot,
Square,
Wrench,
} from "lucide-react";
import { useChat } from "../hooks/useChat";
import { useChat, type ToolCallInfo } from "../hooks/useChat";
import { useViewportMode } from "./Header";
import { fetchAgents, fetchDiscoveredSkills, fetchModels } from "../api";
import type { Agent } from "@fusion/core";
@@ -108,6 +109,111 @@ function formatModelTag(provider?: string | null, modelId?: string | null): stri
return formatted.length > 30 ? formatted.slice(0, 30) + "…" : formatted;
}
function truncateValue(value: string, maxLength: number): string {
return value.length > maxLength ? `${value.slice(0, maxLength)}` : value;
}
function formatToolArgsSummary(args?: Record<string, unknown>): string | null {
if (!args) return null;
const entries = Object.entries(args);
if (entries.length === 0) return null;
return entries
.map(([key, value]) => {
let stringValue = "";
if (typeof value === "string") {
stringValue = value;
} else {
try {
stringValue = JSON.stringify(value);
} catch {
stringValue = String(value);
}
}
return `${key}=${truncateValue(stringValue, 50)}`;
})
.join(", ");
}
function formatToolResultSummary(result: unknown): string | null {
if (result === undefined) {
return null;
}
if (typeof result === "string") {
return truncateValue(result, 200);
}
try {
return truncateValue(JSON.stringify(result), 200);
} catch {
return truncateValue(String(result), 200);
}
}
function renderToolCalls(toolCalls?: ToolCallInfo[]): ReactNode {
if (!toolCalls || toolCalls.length === 0) {
return null;
}
return (
<div className="chat-tool-calls" data-testid="chat-tool-calls">
<div className="chat-tool-calls-header">
<Wrench size={12} aria-hidden="true" />
<span>Tool calls</span>
</div>
{toolCalls.map((toolCall, index) => {
const isRunning = toolCall.status === "running";
const isError = toolCall.status === "completed" && toolCall.isError;
const argsSummary = formatToolArgsSummary(toolCall.args);
const resultSummary = formatToolResultSummary(toolCall.result);
const summaryPreview = isRunning
? argsSummary
: resultSummary
? `result: ${resultSummary}`
: argsSummary
? `args: ${argsSummary}`
: null;
const statusLabel = isRunning ? "running" : isError ? "error" : "completed";
return (
<details
key={`${toolCall.toolName}-${index}`}
className={`chat-tool-call${isRunning ? " chat-tool-call--running" : ""}${isError ? " chat-tool-call--error" : ""}`}
open={isRunning}
>
<summary>
<span className="chat-tool-call-status-dot" aria-hidden="true" />
<span className="chat-tool-call-name">{toolCall.toolName}</span>
{summaryPreview && (
<span className="chat-tool-call-preview" title={summaryPreview}>
{summaryPreview}
</span>
)}
<span className="chat-tool-call-status-text">{statusLabel}</span>
</summary>
<div className="chat-tool-call-content">
{argsSummary && (
<div className="chat-tool-call-row">
<span className="chat-tool-call-label">args</span>
<span className="chat-tool-call-value">{argsSummary}</span>
</div>
)}
{resultSummary && (
<div className={`chat-tool-call-row${isError ? " chat-tool-call-row--error" : ""}`}>
<span className="chat-tool-call-label">result</span>
<span className="chat-tool-call-value">{resultSummary}</span>
</div>
)}
</div>
</details>
);
})}
</div>
);
}
/**
* Constant agent ID for the built-in fn agent.
* The chat system always uses createFnAgent with CHAT_SYSTEM_PROMPT regardless
@@ -316,6 +422,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
isStreaming,
streamingText,
streamingThinking,
streamingToolCalls,
selectSession,
createSession,
archiveSession,
@@ -1056,6 +1163,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
</div>
)}
<div className="chat-message-content">{renderMessageContent(message.content)}</div>
{renderToolCalls(message.toolCalls)}
{message.thinkingOutput && (
<details className="chat-message-thinking">
<summary>Thinking</summary>
@@ -1079,6 +1187,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
{streamingThinking ? "Thinking…" : "Connecting…"}
</div>
)}
{renderToolCalls(streamingToolCalls)}
{streamingThinking && (
<details className="chat-message-thinking">
<summary>Thinking</summary>

View File

@@ -7,11 +7,11 @@ import {
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from "react";
import { MessageSquare, Send, Square, X } from "lucide-react";
import { MessageSquare, Send, Square, Wrench, X } from "lucide-react";
import { fetchModels, type Agent, type ModelInfo } from "../api";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { AgentMentionPopup } from "./AgentMentionPopup";
import { FN_AGENT_ID, useQuickChat, type ChatMessageInfo } from "../hooks/useQuickChat";
import { FN_AGENT_ID, useQuickChat, type ChatMessageInfo, type ToolCallInfo } from "../hooks/useQuickChat";
import { useAgents } from "../hooks/useAgents";
import { FileMentionPopup } from "./FileMentionPopup";
import { useFileMention } from "../hooks/useFileMention";
@@ -98,6 +98,111 @@ function formatModelTagName(modelInfo: ModelInfo | null, parsedSelection: Parsed
.trim();
}
function truncateValue(value: string, maxLength: number): string {
return value.length > maxLength ? `${value.slice(0, maxLength)}` : value;
}
function formatToolArgsSummary(args?: Record<string, unknown>): string | null {
if (!args) return null;
const entries = Object.entries(args);
if (entries.length === 0) return null;
return entries
.map(([key, value]) => {
let stringValue = "";
if (typeof value === "string") {
stringValue = value;
} else {
try {
stringValue = JSON.stringify(value);
} catch {
stringValue = String(value);
}
}
return `${key}=${truncateValue(stringValue, 50)}`;
})
.join(", ");
}
function formatToolResultSummary(result: unknown): string | null {
if (result === undefined) {
return null;
}
if (typeof result === "string") {
return truncateValue(result, 200);
}
try {
return truncateValue(JSON.stringify(result), 200);
} catch {
return truncateValue(String(result), 200);
}
}
function renderToolCalls(toolCalls?: ToolCallInfo[], compact = false): ReactNode {
if (!toolCalls || toolCalls.length === 0) {
return null;
}
return (
<div className={`chat-tool-calls${compact ? " chat-tool-calls--compact" : ""}`} data-testid="chat-tool-calls">
<div className="chat-tool-calls-header">
<Wrench size={12} aria-hidden="true" />
<span>Tool calls</span>
</div>
{toolCalls.map((toolCall, index) => {
const isRunning = toolCall.status === "running";
const isError = toolCall.status === "completed" && toolCall.isError;
const argsSummary = formatToolArgsSummary(toolCall.args);
const resultSummary = formatToolResultSummary(toolCall.result);
const summaryPreview = isRunning
? argsSummary
: resultSummary
? `result: ${resultSummary}`
: argsSummary
? `args: ${argsSummary}`
: null;
const statusLabel = isRunning ? "running" : isError ? "error" : "completed";
return (
<details
key={`${toolCall.toolName}-${index}`}
className={`chat-tool-call${isRunning ? " chat-tool-call--running" : ""}${isError ? " chat-tool-call--error" : ""}`}
open={isRunning}
>
<summary>
<span className="chat-tool-call-status-dot" aria-hidden="true" />
<span className="chat-tool-call-name">{toolCall.toolName}</span>
{summaryPreview && (
<span className="chat-tool-call-preview" title={summaryPreview}>
{summaryPreview}
</span>
)}
<span className="chat-tool-call-status-text">{statusLabel}</span>
</summary>
<div className="chat-tool-call-content">
{argsSummary && (
<div className="chat-tool-call-row">
<span className="chat-tool-call-label">args</span>
<span className="chat-tool-call-value">{argsSummary}</span>
</div>
)}
{resultSummary && (
<div className={`chat-tool-call-row${isError ? " chat-tool-call-row--error" : ""}`}>
<span className="chat-tool-call-label">result</span>
<span className="chat-tool-call-value">{resultSummary}</span>
</div>
)}
</div>
</details>
);
})}
</div>
);
}
function getMentionTriggerMatch(
value: string,
cursorPos: number,
@@ -374,6 +479,7 @@ export function QuickChatFAB({
isStreaming,
streamingText,
streamingThinking,
streamingToolCalls,
sessionsLoading,
messagesLoading,
sendMessage,
@@ -973,6 +1079,7 @@ export function QuickChatFAB({
data-testid={`quick-chat-message-${message.id}`}
>
<p>{renderMessageContent(message.content)}</p>
{renderToolCalls(message.toolCalls, true)}
</div>
);
})}
@@ -989,6 +1096,7 @@ export function QuickChatFAB({
{streamingThinking ? "Thinking…" : "Connecting…"}
</p>
)}
{renderToolCalls(streamingToolCalls, true)}
{streamingThinking && (
<details className="chat-message-thinking" data-testid="quick-chat-streaming-thinking">
<summary>Thinking</summary>

View File

@@ -94,6 +94,7 @@ const defaultChatState = {
isStreaming: false,
streamingText: "",
streamingThinking: "",
streamingToolCalls: [],
selectSession: vi.fn(),
createSession: vi.fn().mockResolvedValue({ id: "session-new", agentId: "__fn_agent__" }),
archiveSession: vi.fn(),
@@ -398,6 +399,143 @@ describe("ChatView", () => {
expect(screen.getByText("Hi there!")).toBeInTheDocument();
});
it("renders tool calls from persisted messages", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{
id: "msg-002",
sessionId: "session-001",
role: "assistant",
content: "I used a tool",
toolCalls: [
{
toolName: "read",
args: { path: "foo.ts" },
isError: false,
result: "contents",
status: "completed",
},
],
createdAt: "2026-04-08T00:01:00.000Z",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByText("read")).toBeInTheDocument();
const preview = document.querySelector(".chat-tool-call-preview");
expect(preview).toHaveTextContent("result: contents");
});
it("renders streaming tool calls", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [{ id: "msg-001", sessionId: "session-001", role: "user", content: "Use tools", createdAt: "2026-04-08T00:00:00.000Z" }],
isStreaming: true,
streamingText: "Working...",
streamingToolCalls: [
{
toolName: "read",
args: { path: "foo.ts" },
isError: false,
status: "running",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const streamingBubble = document.querySelector(".chat-message--streaming");
expect(streamingBubble).toBeInTheDocument();
expect(within(streamingBubble as HTMLElement).getByText("read")).toBeInTheDocument();
const preview = (streamingBubble as HTMLElement).querySelector(".chat-tool-call-preview");
expect(preview).toHaveTextContent("path=foo.ts");
});
it("completed tool calls are collapsed by default", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{
id: "msg-002",
sessionId: "session-001",
role: "assistant",
content: "Done",
toolCalls: [
{
toolName: "read",
isError: false,
result: "contents",
status: "completed",
},
],
createdAt: "2026-04-08T00:01:00.000Z",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const details = document.querySelector(".chat-tool-call") as HTMLDetailsElement | null;
expect(details).toBeInTheDocument();
expect(details?.open).toBe(false);
});
it("running tool calls show running indicator", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{
id: "msg-002",
sessionId: "session-001",
role: "assistant",
content: "Running",
toolCalls: [
{
toolName: "read",
isError: false,
status: "running",
},
],
createdAt: "2026-04-08T00:01:00.000Z",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(document.querySelector(".chat-tool-call--running")).toBeInTheDocument();
});
it("error tool calls show error styling", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{
id: "msg-002",
sessionId: "session-001",
role: "assistant",
content: "Error",
toolCalls: [
{
toolName: "read",
isError: true,
result: "failed",
status: "completed",
},
],
createdAt: "2026-04-08T00:01:00.000Z",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(document.querySelector(".chat-tool-call--error")).toBeInTheDocument();
});
it("shows resolved agent name in assistant message avatar", async () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Agent Chat", updatedAt: "2026-04-08T00:00:00.000Z" },

View File

@@ -88,6 +88,8 @@ function createMockStreamResponse() {
const handlers: {
onThinking?: (data: string) => void;
onText?: (data: string) => void;
onToolStart?: (data: { toolName: string; args?: Record<string, unknown> }) => void;
onToolEnd?: (data: { toolName: string; isError: boolean; result?: unknown }) => void;
onDone?: (data: { messageId: string }) => void;
onError?: (data: string) => void;
onConnectionStateChange?: (state: string) => void;
@@ -454,6 +456,8 @@ describe("QuickChatFAB", () => {
expect.objectContaining({
onThinking: expect.any(Function),
onText: expect.any(Function),
onToolStart: expect.any(Function),
onToolEnd: expect.any(Function),
onDone: expect.any(Function),
onError: expect.any(Function),
}),
@@ -611,6 +615,71 @@ describe("QuickChatFAB", () => {
});
});
it("renders tool calls in quick chat messages", async () => {
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
setTimeout(() => {
handlers.onText?.("Used read tool");
handlers.onToolStart?.({ toolName: "read", args: { path: "foo.ts" } });
handlers.onToolEnd?.({ toolName: "read", isError: false, result: "contents" });
handlers.onDone?.({ messageId: "msg-tool" });
}, 0);
return {
close: vi.fn(),
isConnected: vi.fn(() => true),
};
});
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "Show tools" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
await waitFor(() => {
expect(screen.getByText("read")).toBeInTheDocument();
expect(screen.getByText("Tool calls")).toBeInTheDocument();
});
const preview = document.querySelector(".chat-tool-call-preview");
expect(preview).toHaveTextContent("result: contents");
});
it("shows streaming tool calls during generation", async () => {
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
handlers.onText?.("Still working");
handlers.onToolStart?.({ toolName: "read", args: { path: "foo.ts" } });
return {
close: vi.fn(),
isConnected: vi.fn(() => true),
};
});
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "Stream tools" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
await waitFor(() => {
const streamingMessage = screen.getByTestId("quick-chat-streaming-message");
expect(within(streamingMessage).getByText("read")).toBeInTheDocument();
expect(streamingMessage.querySelector(".chat-tool-call--running")).toBeTruthy();
expect(streamingMessage.querySelector(".chat-tool-call-preview")).toHaveTextContent("path=foo.ts");
});
});
it("preserves user message after assistant reply completes", async () => {
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);