feat(dashboard): add tool-output toggle to agent log viewer with persisted markdown/tools prefs

Adds a "Tools: On/Off" toggle next to the existing markdown toggle in
AgentLogViewer (used by both agent logs and task agent logs). When tool
output is off, tool/tool_result/tool_error entries are filtered before
grouping so only agent text and thinking render. Both toggles persist
globally across sessions via localStorage (fn-agent-log-markdown,
fn-agent-log-tool-output).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-05 21:22:07 -07:00
parent e658e8ee76
commit bb32765438
4 changed files with 204 additions and 8 deletions

View File

@@ -0,0 +1,5 @@
---
"@fusion/dashboard": minor
---
Add a "Tools: On/Off" toggle next to the existing "Markdown/Plain" toggle in the agent log viewer (used by both agent logs and task agent logs). When tool output is off, entries of type `tool` / `tool_result` / `tool_error` are hidden — only agent text and thinking are shown. Both toggles now persist globally across sessions via `localStorage` (`fn-agent-log-markdown`, `fn-agent-log-tool-output`).

View File

@@ -7,6 +7,29 @@ import type { Components } from "react-markdown";
import { Maximize2, Minimize2, Loader2, ChevronDown, ChevronRight } from "lucide-react";
import "./AgentLogViewer.css";
const MARKDOWN_TOGGLE_STORAGE_KEY = "fn-agent-log-markdown";
const TOOL_OUTPUT_TOGGLE_STORAGE_KEY = "fn-agent-log-tool-output";
function readBooleanPref(key: string, defaultValue: boolean): boolean {
if (typeof window === "undefined") return defaultValue;
try {
const raw = window.localStorage.getItem(key);
if (raw === null) return defaultValue;
return raw === "true";
} catch {
return defaultValue;
}
}
function writeBooleanPref(key: string, value: boolean): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, value ? "true" : "false");
} catch {
// ignore storage failures (quota, private mode, etc.)
}
}
function formatTimestamp(iso: string): string {
const date = new Date(iso);
const now = new Date();
@@ -240,19 +263,37 @@ export function AgentLogViewer({
const previousScrollHeightRef = useRef<number>(0);
const previousOldestEntryKeyRef = useRef<string | null>(null);
const previousNewestEntryKeyRef = useRef<string | null>(null);
const [renderMarkdown, setRenderMarkdown] = useState(true);
const [renderMarkdown, setRenderMarkdown] = useState(() =>
readBooleanPref(MARKDOWN_TOGGLE_STORAGE_KEY, true),
);
const [showToolOutput, setShowToolOutput] = useState(() =>
readBooleanPref(TOOL_OUTPUT_TOGGLE_STORAGE_KEY, true),
);
const [isFullscreen, setIsFullscreen] = useState(false);
const [modelHeaderExpanded, setModelHeaderExpanded] = useState(false);
const [isFollowing, setIsFollowing] = useState(true);
useEffect(() => {
writeBooleanPref(MARKDOWN_TOGGLE_STORAGE_KEY, renderMarkdown);
}, [renderMarkdown]);
useEffect(() => {
writeBooleanPref(TOOL_OUTPUT_TOGGLE_STORAGE_KEY, showToolOutput);
}, [showToolOutput]);
const visibleEntries = useMemo(
() => (showToolOutput ? entries : entries.filter((e) => !isToolLikeType(e.type))),
[entries, showToolOutput],
);
const chronologicalEntryKeys = useMemo(
() => buildEntryRenderKeys(entries),
[entries],
() => buildEntryRenderKeys(visibleEntries),
[visibleEntries],
);
const renderGroups = useMemo(
() => buildRenderGroups(entries, chronologicalEntryKeys),
[entries, chronologicalEntryKeys],
() => buildRenderGroups(visibleEntries, chronologicalEntryKeys),
[visibleEntries, chronologicalEntryKeys],
);
// Keep live-follow pinned to the bottom when new streamed entries append.
@@ -420,6 +461,16 @@ export function AgentLogViewer({
>
{renderMarkdown ? "Markdown" : "Plain"}
</button>
<button
className="agent-log-mode-toggle"
onClick={() => setShowToolOutput((prev) => !prev)}
aria-label={showToolOutput ? "Hide tool output" : "Show tool output"}
aria-pressed={showToolOutput}
data-testid="agent-log-tool-output-toggle"
title={showToolOutput ? "Hide tool calls and results" : "Show tool calls and results"}
>
{showToolOutput ? "Tools: On" : "Tools: Off"}
</button>
<button
className="agent-log-mode-toggle"
onClick={() => setIsFullscreen((prev) => !prev)}
@@ -478,7 +529,10 @@ export function AgentLogViewer({
{/* Pagination summary */}
{totalCount !== null && (
<div className="agent-log-summary" data-testid="agent-log-summary">
Showing {entries.length} of {totalCount} entries
Showing {visibleEntries.length} of {totalCount} entries
{!showToolOutput && entries.length !== visibleEntries.length
? ` (${entries.length - visibleEntries.length} tool entries hidden)`
: ""}
</div>
)}

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { AgentLogViewer } from "../AgentLogViewer";
import type { AgentLogEntry } from "@fusion/core";
@@ -30,6 +30,10 @@ function getScrollContainer(container: HTMLElement): HTMLDivElement {
}
describe("AgentLogViewer", () => {
beforeEach(() => {
window.localStorage.clear();
});
it("shows loading message when loading with no entries", () => {
render(<AgentLogViewer entries={[]} loading={true} />);
expect(screen.getByText("Loading agent logs…")).toBeTruthy();
@@ -1524,7 +1528,7 @@ describe("AgentLogViewer", () => {
makeEntry({ text: "done", type: "tool_result" }),
makeEntry({ text: "fail", type: "tool_error" }),
];
const { container, rerender } = render(<AgentLogViewer entries={entries} loading={false} />);
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement;
// Tool entries in markdown mode
@@ -1575,6 +1579,137 @@ describe("AgentLogViewer", () => {
});
});
describe("tool output toggle", () => {
it("renders the tool output toggle defaulting to On", () => {
const entries = [makeEntry()];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const toggle = container.querySelector("[data-testid='agent-log-tool-output-toggle']") as HTMLButtonElement;
expect(toggle).toBeTruthy();
expect(toggle.textContent).toBe("Tools: On");
expect(toggle.getAttribute("aria-pressed")).toBe("true");
});
it("hides tool entries when toggled off and shows them again when toggled back on", () => {
const entries = [
makeEntry({ text: "before tool", type: "text", agent: "executor" }),
makeEntry({ text: "Read", type: "tool", agent: "executor" }),
makeEntry({ text: "done", type: "tool_result", agent: "executor" }),
makeEntry({ text: "fail", type: "tool_error", agent: "executor" }),
makeEntry({ text: "after tool", type: "text", agent: "executor" }),
];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const toggle = container.querySelector("[data-testid='agent-log-tool-output-toggle']") as HTMLButtonElement;
expect(container.querySelector(".agent-log-tool")).toBeTruthy();
expect(container.querySelector(".agent-log-tool-result")).toBeTruthy();
expect(container.querySelector(".agent-log-tool-error")).toBeTruthy();
fireEvent.click(toggle);
expect(toggle.textContent).toBe("Tools: Off");
expect(toggle.getAttribute("aria-pressed")).toBe("false");
expect(container.querySelector(".agent-log-tool")).toBeNull();
expect(container.querySelector(".agent-log-tool-result")).toBeNull();
expect(container.querySelector(".agent-log-tool-error")).toBeNull();
const textRows = container.querySelectorAll(".agent-log-text");
const combined = Array.from(textRows).map((r) => r.textContent).join(" ");
expect(combined).toContain("before tool");
expect(combined).toContain("after tool");
fireEvent.click(toggle);
expect(toggle.textContent).toBe("Tools: On");
expect(container.querySelector(".agent-log-tool")).toBeTruthy();
expect(container.querySelector(".agent-log-tool-result")).toBeTruthy();
expect(container.querySelector(".agent-log-tool-error")).toBeTruthy();
});
it("does not render any tool log entries when off (only agent text)", () => {
const entries = [
makeEntry({ text: "Read", type: "tool", agent: "executor", detail: "some/path" }),
makeEntry({ text: "thinking out loud", type: "thinking", agent: "executor" }),
];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const toggle = container.querySelector("[data-testid='agent-log-tool-output-toggle']") as HTMLButtonElement;
fireEvent.click(toggle);
expect(container.querySelector(".agent-log-tool")).toBeNull();
expect(container.querySelector("[data-testid='tool-detail-toggle']")).toBeNull();
expect(container.querySelector(".agent-log-thinking")).toBeTruthy();
});
it("reflects hidden tool entries in the pagination summary", () => {
const entries = [
makeEntry({ text: "hi", type: "text" }),
makeEntry({ text: "Read", type: "tool" }),
makeEntry({ text: "done", type: "tool_result" }),
];
const { container } = render(
<AgentLogViewer entries={entries} loading={false} totalCount={3} />,
);
const toggle = container.querySelector("[data-testid='agent-log-tool-output-toggle']") as HTMLButtonElement;
fireEvent.click(toggle);
const summary = container.querySelector("[data-testid='agent-log-summary']") as HTMLElement;
expect(summary).toBeTruthy();
expect(summary.textContent).toContain("Showing 1 of 3 entries");
expect(summary.textContent).toContain("2 tool entries hidden");
});
});
describe("toggle persistence across remounts", () => {
it("persists the markdown toggle state in localStorage", () => {
const entries = [makeEntry()];
const first = render(<AgentLogViewer entries={entries} loading={false} />);
const toggle = first.container.querySelector(
"[data-testid='agent-log-mode-toggle']",
) as HTMLButtonElement;
fireEvent.click(toggle);
expect(window.localStorage.getItem("fn-agent-log-markdown")).toBe("false");
first.unmount();
const second = render(<AgentLogViewer entries={entries} loading={false} />);
const restoredToggle = second.container.querySelector(
"[data-testid='agent-log-mode-toggle']",
) as HTMLButtonElement;
expect(restoredToggle.textContent).toBe("Plain");
expect(restoredToggle.getAttribute("aria-pressed")).toBe("false");
});
it("persists the tool output toggle state in localStorage", () => {
const entries = [
makeEntry({ text: "Read", type: "tool" }),
makeEntry({ text: "hi", type: "text" }),
];
const first = render(<AgentLogViewer entries={entries} loading={false} />);
const toggle = first.container.querySelector(
"[data-testid='agent-log-tool-output-toggle']",
) as HTMLButtonElement;
fireEvent.click(toggle);
expect(window.localStorage.getItem("fn-agent-log-tool-output")).toBe("false");
first.unmount();
const second = render(<AgentLogViewer entries={entries} loading={false} />);
const restoredToggle = second.container.querySelector(
"[data-testid='agent-log-tool-output-toggle']",
) as HTMLButtonElement;
expect(restoredToggle.textContent).toBe("Tools: Off");
expect(second.container.querySelector(".agent-log-tool")).toBeNull();
});
it("uses default true values when no preference is stored", () => {
const entries = [makeEntry({ text: "Read", type: "tool" })];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const markdown = container.querySelector(
"[data-testid='agent-log-mode-toggle']",
) as HTMLButtonElement;
const tools = container.querySelector(
"[data-testid='agent-log-tool-output-toggle']",
) as HTMLButtonElement;
expect(markdown.textContent).toBe("Markdown");
expect(tools.textContent).toBe("Tools: On");
});
});
describe("fullscreen toggle", () => {
it("applies matching min dimensions to markdown and fullscreen header toggles", () => {
const entries = [makeEntry()];

View File

@@ -4,6 +4,8 @@ export const GLOBAL_STORAGE_KEYS: string[] = [
"kb-dashboard-view-mode",
"kb-dashboard-current-project",
"kb-dashboard-recent-projects",
"fn-agent-log-markdown",
"fn-agent-log-tool-output",
];
export const PROJECT_STORAGE_KEYS: string[] = [