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:
5
.changeset/agent-log-tool-output-toggle.md
Normal file
5
.changeset/agent-log-tool-output-toggle.md
Normal 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`).
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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()];
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
Reference in New Issue
Block a user