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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" />);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user