FN-8701: standardize tool-call detail displays

Standardize expandable tool-call payloads across dashboard chat and log surfaces.

- Add a shared formatter and detail renderer that preserves complete browser-available arguments and results.
- Use compact previews with wrapped, scrollable expanded payloads in chat, activity, and agent logs.
- Cover formatting and rendering behavior with dashboard tests and document the display contract.

Files changed:
 .changeset/fn-8701-tool-call-display.md            |  7 ++
 docs/dashboard-guide.md                            |  4 +-
 .../dashboard/app/components/AgentLogViewer.tsx    | 12 +++-
 packages/dashboard/app/components/ChatView.css     | 37 ++---------
 .../app/components/StandardChatSurface.tsx         | 56 +++++++---------
 packages/dashboard/app/components/TaskChatTab.css  | 17 -----
 packages/dashboard/app/components/TaskChatTab.tsx  | 31 +++++----
 .../dashboard/app/components/ToolCallDetails.css   | 52 +++++++++++++++
 .../dashboard/app/components/ToolCallDetails.tsx   | 77 ++++++++++++++++++++++
 .../__tests__/AgentLogViewer.rendering.test.tsx    |  8 +++
 .../components/__tests__/ChatView.core.test.tsx    | 24 +++++++
 .../app/components/__tests__/TaskChatTab.test.tsx  | 33 +++++++---
 .../__tests__/TaskPlannerChatTab.test.tsx          | 19 ++++++
 13 files changed, 275 insertions(+), 102 deletions(-)

Fusion-Task-Id: FN-8701

Fusion-Task-Lineage: 86513f9a-31d4-45fb-97fe-1f0c73d0e5b3

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-01 09:32:26 -07:00
parent e8ca86d5ee
commit b8f7f9e75c
13 changed files with 275 additions and 102 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Show complete available tool-call details when expanding chats and logs.
category: fix
dev: Shares tool-call payload formatting across chat, Activity, and Agent Log Viewer.

View File

@@ -707,7 +707,9 @@ Chat view provides project-scoped conversations with agents.
- On desktop/tablet Direct chat, the thread header shows an estimated token count against the active model's known context window (for example `~12.3k / 200k`). It is hidden on mobile, narrow floating chat, rooms, and unknown-context-window models.
<!-- FNXC:ChatViewDocs 2026-06-28-14:52: Chat responsive docs must reflect that narrow chat hosts now key bubble width off the ChatView container, not just viewport media, so Quick Chat popups and the right dock on desktop viewports get the same full-width bubbles as phone Chat. -->
- In narrow Chat containers (including phone-width full Chat, narrow Quick Chat popups, and right-dock Chat), message bubbles use the full content width for improved readability. On tablet-width main Chat containers, assistant/agent, streaming, and failure bubbles keep the wider 92% reading measure, while wide desktop Chat keeps the standard 75% bubble cap.
- Full Chat tool-call summaries now use a denser mobile layout: grouped and single-call collapsed rows keep icon + label + status on one line (Quick Chat-style scanability) while expanded details remain unchanged.
- Full Chat tool-call summaries now use a denser mobile layout: grouped and single-call collapsed rows keep icon + label + status on one line (Quick Chat-style scanability).
- Tool calls in direct, room, Quick, and Planner Chat plus task Activity and Agent Log Viewer keep collapsed previews compact, but opening their disclosure shows the complete arguments and result/output that reached the browser. Multiline output preserves line breaks; ordinary text wraps and exceptionally long paths or tokens scroll inside the detail instead of widening the page, including at the mobile breakpoint. This removes UI-created preview truncation only: intentional engine persistence, context, API, and tool-output budgets still determine what payload is available to display.
<!-- FNXC:ToolCallDisplay 2026-08-01-15:39: FN-8701 makes the compact-preview versus complete-available-expanded-payload contract explicit for every dashboard transcript and log surface, without implying that UI expansion bypasses upstream output budgets. -->
<!-- FNXC:ChatAskQuestion 2026-06-17-16:35: Dashboard chat agents have a Fusion-native `fn_ask_question` tool, so the documented question-card behavior must cover both provider-native question tools and Fusion's first-party tool. -->
- Assistant question tool calls now render as a shared in-chat response card instead of a generic tool-call disclosure. The card recognizes provider-native question tools and Fusion's `fn_ask_question`, supports select, multi-select, text, and yes/no prompts, sends the formatted answer back into the same direct or room thread, and renders historical answered questions read-only.
- The desktop Chat view toggle and mobile Chat tab now show an unread-response indicator when a live assistant reply arrives for a visible direct or room chat after you leave Chat; opening Chat clears it immediately. Task-detail planner Chat replies stay task-local and do not light up the global Chat unread indicator while those sessions are hidden from the common Chat feed.

View File

@@ -13,6 +13,7 @@ import { Maximize2, Minimize2, Loader2, ChevronDown, ChevronRight } from "lucide
import "./AgentLogViewer.css";
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
import { ToolCallDetails } from "./ToolCallDetails";
const MARKDOWN_TOGGLE_STORAGE_KEY = "fn-agent-log-markdown";
const TOOL_OUTPUT_TOGGLE_STORAGE_KEY = "fn-agent-log-tool-output";
@@ -173,7 +174,7 @@ interface CollapsibleToolDetailProps {
type?: "tool" | "tool_result" | "tool_error";
}
function CollapsibleToolDetail({ detail }: CollapsibleToolDetailProps): ReactElement {
function CollapsibleToolDetail({ detail, type = "tool_result" }: CollapsibleToolDetailProps): ReactElement {
const { t } = useTranslation("app");
const [expanded, setExpanded] = useState(false);
const contentId = useId();
@@ -200,7 +201,14 @@ function CollapsibleToolDetail({ detail }: CollapsibleToolDetailProps): ReactEle
className={expanded ? "agent-log-tool-detail-content" : "agent-log-tool-detail-content agent-log-tool-detail-content--collapsed"}
data-testid="tool-detail-content"
>
<pre className="agent-log-tool-detail">{linkifyFilePaths(detail)}</pre>
<ToolCallDetails
className="agent-log-tool-detail"
resultValue={detail}
argumentsLabel={t("agentLog.arguments", "Arguments")}
resultLabel={type === "tool_error" ? t("agentLog.error", "Error") : type === "tool" ? t("agentLog.arguments", "Arguments") : t("agentLog.output", "Output")}
resultIsError={type === "tool_error"}
renderValue={linkifyFilePaths}
/>
</div>
</div>
);

View File

@@ -1679,7 +1679,8 @@ User messages in direct (model-loop) chat carry a compact edit affordance inline
background: color-mix(in srgb, var(--surface) 35%, transparent);
}
.chat-tool-call summary {
.chat-tool-call summary,
.chat-tool-call-summary {
display: flex;
align-items: center;
gap: var(--space-xs);
@@ -1749,32 +1750,13 @@ User messages in direct (model-loop) chat carry a compact edit affordance inline
gap: var(--space-xs);
}
.chat-tool-call-row {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-xs);
align-items: start;
}
.chat-tool-call-label {
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.6875rem;
}
.chat-tool-call-value {
color: var(--text);
word-break: break-word;
white-space: pre-wrap;
}
.chat-tool-call--running .chat-tool-call-status-dot {
background: var(--color-info);
animation: tool-call-pulse var(--transition-slow) infinite;
}
.chat-tool-call--error summary {
.chat-tool-call--error summary,
.chat-tool-call--error .chat-tool-call-summary {
color: var(--color-error);
}
@@ -1782,20 +1764,14 @@ User messages in direct (model-loop) chat carry a compact edit affordance inline
background: var(--color-error);
}
.chat-tool-call-row--error {
background: color-mix(in srgb, var(--color-error) 10%, transparent);
border-radius: var(--radius-sm);
padding: var(--space-xs);
}
.chat-tool-calls--compact .chat-tool-calls-header,
.chat-tool-calls--compact .chat-tool-calls-group-summary,
.chat-tool-calls--compact .chat-tool-calls-names,
.chat-tool-calls--compact .chat-tool-calls-group-status,
.chat-tool-calls--compact .chat-tool-call summary,
.chat-tool-calls--compact .chat-tool-call-summary,
.chat-tool-calls--compact .chat-tool-call-content,
.chat-tool-calls--compact .chat-tool-call-preview,
.chat-tool-calls--compact .chat-tool-call-value,
.chat-tool-calls-group--compact .chat-tool-calls-group-summary,
.chat-tool-calls-group--compact .chat-tool-calls-names {
font-size: 0.6875rem;
@@ -2615,7 +2591,8 @@ Queued-message banners stack above the composer input with a capped scroll area,
}
.chat-tool-calls-group-summary,
.chat-tool-call summary {
.chat-tool-call summary,
.chat-tool-call-summary {
flex-wrap: nowrap;
flex-direction: row;
align-items: center;

View File

@@ -15,6 +15,7 @@ import { openNativeStructure } from "./nativeStructureNavigation";
import { nativeStructureChatRefMatcher, parseNativeStructureChatRef, splitNativeStructureChatRefMatch } from "./nativeStructureChatRef";
import { MicButton } from "./MicButton";
import { useComposerDictation } from "../hooks/useComposerDictation";
import { ToolCallDetails, formatToolArgsPreview, formatToolPreview, hasToolCallDetails } from "./ToolCallDetails";
export interface StandardRoomContext {
roomName: string;
@@ -149,26 +150,8 @@ export function formatModelTag(provider?: string | null, modelId?: string | null
return formatted.length > 30 ? `${formatted.slice(0, 30)}…` : formatted;
}
function truncateToolValue(value: string, maxLength: number): string {
return value.length <= maxLength ? value : `${value.slice(0, maxLength)}…`;
}
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]) => {
const stringValue = typeof value === "string" ? value : (() => {
try { return JSON.stringify(value); } catch { return String(value); }
})();
return `${key}=${truncateToolValue(stringValue, 50)}`;
}).join(", ");
}
function formatToolResultSummary(result: unknown): string | null {
if (result === undefined) return null;
if (typeof result === "string") return truncateToolValue(result, 200);
try { return truncateToolValue(JSON.stringify(result), 200); } catch { return truncateToolValue(String(result), 200); }
return formatToolPreview(result, 200);
}
function buildFailureReferenceHref(reference: FailureInfo["reference"]): string | null {
@@ -242,22 +225,33 @@ export function renderStandardToolCalls(
}
const isRunning = toolCall.status === "running";
const isError = toolCall.status === "completed" && toolCall.isError;
const argsSummary = formatToolArgsSummary(toolCall.args);
const argsSummary = formatToolArgsPreview(toolCall.args);
const resultSummary = formatToolResultSummary(toolCall.result);
const summaryPreview = isRunning ? argsSummary : resultSummary ? `${t("chat.toolCallResultPrefix", "result")}: ${resultSummary}` : argsSummary ? `${t("chat.toolCallArgsPrefix", "args")}: ${argsSummary}` : null;
const statusLabel = isRunning ? t("chat.toolCallStatusRunning", "running") : isError ? t("chat.toolCallStatusError", "error") : t("chat.toolCallStatusCompleted", "completed");
const className = `chat-tool-call${isRunning ? " chat-tool-call--running" : ""}${isError ? " chat-tool-call--error" : ""}`;
const summary = (
<>
<span className="chat-tool-call-status-dot" aria-hidden="true" />
<span className="chat-tool-call-name" title={toolCall.toolName}>{toolCall.toolName}</span>
{summaryPreview && <span className="chat-tool-call-preview" title={summaryPreview}>{summaryPreview}</span>}
<span className="chat-tool-call-status-text">{statusLabel}</span>
</>
);
if (!hasToolCallDetails(toolCall.args, toolCall.result)) {
return <div key={`${toolCall.toolName}-${index}`} className={className}><div className="chat-tool-call-summary">{summary}</div></div>;
}
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" title={toolCall.toolName}>{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">{t("chat.toolCallArgsPrefix", "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">{t("chat.toolCallResultPrefix", "result")}</span><span className="chat-tool-call-value">{resultSummary}</span></div>}
</div>
<details key={`${toolCall.toolName}-${index}`} className={className} open={isRunning}>
<summary>{summary}</summary>
<ToolCallDetails
className="chat-tool-call-content"
argumentsValue={toolCall.args}
resultValue={toolCall.result}
argumentsLabel={t("chat.toolCallArgsPrefix", "args")}
resultLabel={t("chat.toolCallResultPrefix", "result")}
resultIsError={isError}
/>
</details>
);
};

View File

@@ -524,23 +524,6 @@ FN-7241 adds timestamps inside individual task-detail transcript blocks. Keep bl
margin-top: var(--space-xs);
}
.task-chat-tool-detail-label {
color: var(--text-muted);
font-size: var(--space-md);
font-weight: 600;
}
.task-chat-tool-detail {
box-sizing: border-box;
margin: var(--space-xs) 0 0;
max-width: 100%;
padding: var(--space-xs);
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
font-size: var(--space-md);
}
.task-chat-composer {
display: flex;
flex: 0 0 auto;

View File

@@ -21,6 +21,7 @@ import { formatRelativeTimeAgo } from "../utils/relativeTimeAgo";
import { ProviderIcon } from "./ProviderIcon";
import { clampChatInputHeight, resolveChatInputOverflowY } from "../utils/chatInputAutosize";
import { formatAgentLogTimingLabels, markdownComponents } from "./AgentLogViewer";
import { ToolCallDetails } from "./ToolCallDetails";
import { parseRuntimeModelMarker } from "./effective-model-resolution";
import "./TaskChatTab.css";
@@ -476,7 +477,14 @@ function TaskChatToolEntry({ entry }: { entry: AgentLogEntry }) {
<TaskChatTimestamp timestamp={entry.timestamp} label="Tool entry timestamp" />
</div>
<div className="task-chat-entry-text">{entry.text}</div>
{entry.detail ? <pre className="task-chat-tool-detail">{linkifyFilePaths(entry.detail)}</pre> : null}
<ToolCallDetails
className="task-chat-tool-detail-block"
resultValue={entry.detail}
argumentsLabel={t("taskChat.arguments", "Arguments")}
resultLabel=""
resultIsError={entry.type === "tool_error"}
renderValue={linkifyFilePaths}
/>
</article>
);
}
@@ -522,18 +530,15 @@ function TaskChatToolInvocation({ row }: { row: Extract<TaskChatToolGroupRow, {
<TaskChatTimestamp timestamp={completion?.timestamp ?? row.call.timestamp} label="Tool invocation timestamp" />
</div>
<div className="task-chat-entry-text">{row.call.text}</div>
{row.call.detail ? (
<div className="task-chat-tool-detail-block">
<div className="task-chat-tool-detail-label">{t("taskChat.arguments", "Arguments")}</div>
<pre className="task-chat-tool-detail">{linkifyFilePaths(row.call.detail)}</pre>
</div>
) : null}
{completion?.detail ? (
<div className="task-chat-tool-detail-block">
<div className="task-chat-tool-detail-label">{completion.type === "tool_error" ? t("taskChat.error", "Error") : t("taskChat.result", "Result")}</div>
<pre className="task-chat-tool-detail">{linkifyFilePaths(completion.detail)}</pre>
</div>
) : null}
<ToolCallDetails
className="task-chat-tool-detail-block"
argumentsValue={row.call.detail}
resultValue={completion?.detail}
argumentsLabel={t("taskChat.arguments", "Arguments")}
resultLabel={completion?.type === "tool_error" ? t("taskChat.error", "Error") : t("taskChat.result", "Result")}
resultIsError={completion?.type === "tool_error"}
renderValue={linkifyFilePaths}
/>
</article>
);
}

View File

@@ -0,0 +1,52 @@
/*
FNXC:ToolCallDisplay 2026-08-01-15:39:
FN-8701 requires one responsive presentation for complete available tool payloads. Preserve
newlines, wrap normal paths and prose, and contain unavoidable long tokens without widening chat
or log surfaces beyond the viewport.
*/
.tool-call-details {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--space-xs);
}
.tool-call-details-row {
display: grid;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--space-xs);
align-items: start;
}
.tool-call-details-label {
color: var(--text-muted);
font-size: var(--font-size-xs);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.tool-call-details-value {
min-width: 0;
max-inline-size: 100%;
margin: 0;
color: var(--text);
font-family: var(--font-mono, monospace);
font-size: var(--font-size-xs);
overflow-wrap: anywhere;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
.tool-call-details-row--error {
padding: var(--space-xs);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-error) 10%, transparent);
}
@media (max-width: 768px) {
.tool-call-details-row {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,77 @@
import type { ReactNode } from "react";
import "./ToolCallDetails.css";
/**
* FNXC:ToolCallDisplay 2026-08-01-15:39:
* FN-8701 separates scan-friendly tool-call previews from expanded payloads. An expanded
* disclosure must render every value already delivered to the browser; persistence and tool
* output budgets remain upstream policies and this formatter never attempts to recover them.
*/
export function formatToolValue(value: unknown, pretty = false): string | null {
if (value === undefined || value === "") return null;
if (typeof value === "string") return value;
try {
const seen = new WeakSet<object>();
const serialized = JSON.stringify(value, (_key, nestedValue) => {
if (typeof nestedValue === "bigint") return nestedValue.toString();
if (nestedValue && typeof nestedValue === "object") {
if (seen.has(nestedValue)) return "[Circular]";
seen.add(nestedValue);
}
return nestedValue;
}, pretty ? 2 : undefined);
return serialized === undefined ? String(value) : serialized;
} catch {
return String(value);
}
}
export function formatToolPreview(value: unknown, maxLength: number): string | null {
const formatted = formatToolValue(value);
if (!formatted) return null;
return formatted.length <= maxLength ? formatted : `${formatted.slice(0, maxLength)}…`;
}
/** Whether a disclosure has a meaningful complete payload to reveal. */
export function hasToolCallDetails(argumentsValue: unknown, resultValue: unknown): boolean {
return Boolean(formatToolValue(argumentsValue) || formatToolValue(resultValue));
}
export function formatToolArgsPreview(args?: Record<string, unknown>): string | null {
if (!args || Object.keys(args).length === 0) return null;
return Object.entries(args)
.map(([key, value]) => `${key}=${formatToolPreview(value, 50) ?? ""}`)
.join(", ");
}
interface ToolCallDetailsProps {
argumentsValue?: unknown;
resultValue?: unknown;
argumentsLabel: string;
resultLabel: string;
resultIsError?: boolean;
renderValue?: (value: string) => ReactNode;
className?: string;
}
/** Renders only meaningful rows so callers never leave an empty detail shell behind. */
export function ToolCallDetails({
argumentsValue,
resultValue,
argumentsLabel,
resultLabel,
resultIsError = false,
renderValue = (value) => value,
className = "",
}: ToolCallDetailsProps): ReactNode {
const argumentsText = formatToolValue(argumentsValue, true);
const resultText = formatToolValue(resultValue, true);
if (!argumentsText && !resultText) return null;
return (
<div className={`tool-call-details ${className}`.trim()}>
{argumentsText ? <div className="tool-call-details-row">{argumentsLabel ? <span className="tool-call-details-label">{argumentsLabel}</span> : null}<pre className="tool-call-details-value">{renderValue(argumentsText)}</pre></div> : null}
{resultText ? <div className={`tool-call-details-row${resultIsError ? " tool-call-details-row--error" : ""}`}>{resultLabel ? <span className="tool-call-details-label">{resultLabel}</span> : null}<pre className="tool-call-details-value">{renderValue(resultText)}</pre></div> : null}
</div>
);
}

View File

@@ -334,6 +334,14 @@ describe("AgentLogViewer", () => {
expect(content.classList.contains("agent-log-tool-detail-content--collapsed")).toBe(true);
});
it("shows complete long tool output after expanding the output disclosure", () => {
const longDetail = `first line\n${"output ".repeat(45)}AGENT_LOG_RESULT_SUFFIX`;
render(<AgentLogViewer entries={[makeEntry({ text: "Bash", type: "tool_result", detail: longDetail })]} loading={false} />);
fireEvent.click(screen.getByTestId("tool-detail-toggle"));
expect(screen.getByTestId("tool-detail-content")).toHaveTextContent("AGENT_LOG_RESULT_SUFFIX");
});
it("applies the viewer styling via the agent-log-viewer class", () => {
const entries = [makeEntry()];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);

View File

@@ -575,6 +575,28 @@ describe("ChatView", () => {
expect(preview).toHaveTextContent("result: contents");
});
it("keeps long persisted tool arguments and results complete after expansion", async () => {
const longCommand = "pnpm --filter @fusion/dashboard exec vitest run app/components/__tests__/ChatView.core.test.tsx --reporter=dot --final-command-suffix";
const longResult = `first line\n${"output ".repeat(40)}FINAL_TOOL_RESULT_SUFFIX`;
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [{
id: "msg-long-tool", sessionId: "session-001", role: "assistant", content: "I used bash", createdAt: "2026-04-08T00:01:00.000Z",
toolCalls: [{ toolName: "bash", args: { command: longCommand }, result: longResult, isError: false, status: "completed" }],
}],
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const details = document.querySelector(".chat-tool-call") as HTMLDetailsElement;
expect(details.open).toBe(false);
await userEvent.click(details.querySelector("summary") as HTMLElement);
expect(details.open).toBe(true);
expect(details).toHaveTextContent(longCommand);
expect(details).toHaveTextContent("FINAL_TOOL_RESULT_SUFFIX");
expect(details.querySelector(".chat-tool-call-preview")).toHaveTextContent("…");
});
it("renders streaming tool calls", async () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
@@ -894,6 +916,8 @@ describe("ChatView", () => {
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByText("read, edit, bash, grep, write, +1 more")).toBeInTheDocument();
// Tool events with no available arguments or result remain readable but are not dead disclosures.
expect(document.querySelectorAll(".chat-tool-call details")).toHaveLength(0);
});
it("running tool calls show running indicator", async () => {

View File

@@ -971,6 +971,22 @@ describe("TaskChatTab", () => {
expect(screen.getByText("ok")).toBeVisible();
});
it("shows the complete long task activity payload after expanding its tool group", async () => {
const user = userEvent.setup();
const longCommand = `bash ${"argument ".repeat(12)}TASK_ACTIVITY_COMMAND_SUFFIX`;
const longResult = `result\n${"output ".repeat(45)}TASK_ACTIVITY_RESULT_SUFFIX`;
mockLogs([
makeEntry({ agent: "executor", type: "tool", text: "bash", detail: longCommand }),
makeEntry({ agent: "executor", type: "tool_result", text: "bash", detail: longResult }),
]);
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
await user.click(screen.getByText("1 tool call"));
const invocation = screen.getByTestId("task-chat-tool-invocation");
expect(invocation).toHaveTextContent("TASK_ACTIVITY_COMMAND_SUFFIX");
expect(invocation).toHaveTextContent("TASK_ACTIVITY_RESULT_SUFFIX");
});
it("shows Bash tool duration in the expanded invocation and omits legacy timing labels", async () => {
const user = userEvent.setup();
mockLogs([
@@ -3015,7 +3031,7 @@ describe("TaskChatTab", () => {
const thinkingRule = getCssRuleBlock(compactThinkingCss, ".task-chat-thinking");
const thinkingSummaryRule = getCssRuleBlock(getCssAfter(css, ".task-chat-thinking {\n border-color"), ".task-chat-thinking-summary");
const thinkingBodyRule = getCssRuleBlock(getCssAfter(css, ".task-chat-tool-group-entries {\n gap: var(--space-xs);\n padding: 0 var(--space-xs) var(--space-xs);\n}"), ".task-chat-thinking-body");
const toolDetailRule = getCssRuleBlock(getCssAfter(css, ".task-chat-tool-detail {"), ".task-chat-tool-detail");
const toolDetailRule = getCssRuleBlock(readFileSync(resolve(__dirname, "../ToolCallDetails.css"), "utf8"), ".tool-call-details-value");
const mobileCss = getCssAfter(css, "@media (max-width: 768px)");
const mobileStandardBlockRule = getCssRuleBlock(mobileCss, ".task-chat-entry,\n .task-chat-tool-group");
const mobileThinkingRule = getCssRuleBlock(getCssAfter(mobileCss, ".task-chat-thinking {\n padding"), ".task-chat-thinking");
@@ -3035,8 +3051,8 @@ describe("TaskChatTab", () => {
expect(thinkingBodyRule).toContain("padding: 0 var(--space-sm) var(--space-sm)");
expect(toolEntryRule).toContain("box-sizing: border-box");
expect(toolEntryRule).toContain("padding: var(--space-sm)");
expect(toolDetailRule).toContain("box-sizing: border-box");
expect(toolDetailRule).toContain("padding: var(--space-xs)");
expect(toolDetailRule).toContain("max-inline-size: 100%");
expect(toolDetailRule).toContain("overflow-x: auto");
expect(mobileStandardBlockRule).toContain("padding: var(--space-sm)");
expect(mobileThinkingRule).toContain("padding: var(--space-xs)");
expect(mobileToolEntryRule).toContain("padding: var(--space-xs)");
@@ -3094,8 +3110,9 @@ describe("TaskChatTab", () => {
const entriesRule = getCssRuleBlock(getCssAfter(css, ".task-chat-tool-group-entries {\n gap"), ".task-chat-tool-group-entries");
const entryRule = getCssRuleBlock(css, ".task-chat-tool-entry");
const kickerRule = getCssRuleBlock(css, ".task-chat-entry-kicker");
const detailLabelRule = getCssRuleBlock(css, ".task-chat-tool-detail-label");
const detailRule = getCssRuleBlock(getCssAfter(css, ".task-chat-tool-detail {"), ".task-chat-tool-detail");
const detailCss = readFileSync(resolve(__dirname, "../ToolCallDetails.css"), "utf8");
const detailLabelRule = getCssRuleBlock(detailCss, ".tool-call-details-label");
const detailRule = getCssRuleBlock(detailCss, ".tool-call-details-value");
const chatSummaryRule = getCssRuleBlock(chatCss, ".chat-tool-calls-group-summary");
const chatNamesRule = getCssRuleBlock(chatCss, ".chat-tool-calls-names");
const mobileCss = getCssAfter(css, "@media (max-width: 768px)");
@@ -3125,10 +3142,10 @@ describe("TaskChatTab", () => {
for (const [selector, readableToolTextRule] of [
[".task-chat-tool-group-summary", summaryRule],
[".task-chat-entry-kicker", kickerRule],
[".task-chat-tool-detail-label", detailLabelRule],
[".task-chat-tool-detail", detailRule],
[".tool-call-details-label", detailLabelRule],
[".tool-call-details-value", detailRule],
] as const) {
expect(readableToolTextRule, `${selector} uses readable tool-call typography`).toContain(READABLE_TASK_TOOL_FONT_SIZE);
expect(readableToolTextRule, `${selector} uses tokenized tool-call typography`).toMatch(/font-size: var\(--(?:space-md|font-size-xs)\)/);
expect(readableToolTextRule, `${selector} does not restore the too-small calc`).not.toContain(TOO_SMALL_TASK_TOOL_FONT_SIZE);
}
expect(chatSummaryRule).toContain("padding: var(--space-xs)");

View File

@@ -1148,6 +1148,25 @@ describe("TaskPlannerChatTab", () => {
expect(screen.queryByTestId("task-planner-chat-empty")).not.toBeInTheDocument();
});
it("keeps complete persisted planner tool payloads available after expansion", async () => {
const longCommand = "pnpm --filter @fusion/dashboard exec vitest run planner --PLANNER_COMMAND_SUFFIX";
const longResult = `planner output\n${"line ".repeat(45)}PLANNER_RESULT_SUFFIX`;
mockFetchChatMessages.mockResolvedValue({
messages: [{
id: "assistant-long-tool", sessionId: "chat-planner", role: "assistant", content: "Planner ran bash", thinkingOutput: null,
metadata: { toolCalls: [{ toolName: "bash", args: { command: longCommand }, result: longResult, isError: false, status: "completed" }] },
createdAt: "2026-06-30T00:02:00.000Z",
}],
});
renderPlannerChat();
const details = await screen.findByText("bash").then((node) => node.closest("details.chat-tool-call") as HTMLDetailsElement);
expect(details.open).toBe(false);
await userEvent.click(details.querySelector("summary") as HTMLElement);
expect(details).toHaveTextContent(longCommand);
expect(details).toHaveTextContent("PLANNER_RESULT_SUFFIX");
});
it("renders mixed persisted planner question tool calls with the shared answer UI outside collapsed details", async () => {
const user = userEvent.setup();
mockFetchChatMessages.mockResolvedValue({