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 { Maximize2, Minimize2, Loader2, ChevronDown, ChevronRight } from "lucide-react";
|
||||||
import "./AgentLogViewer.css";
|
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 {
|
function formatTimestamp(iso: string): string {
|
||||||
const date = new Date(iso);
|
const date = new Date(iso);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -240,19 +263,37 @@ export function AgentLogViewer({
|
|||||||
const previousScrollHeightRef = useRef<number>(0);
|
const previousScrollHeightRef = useRef<number>(0);
|
||||||
const previousOldestEntryKeyRef = useRef<string | null>(null);
|
const previousOldestEntryKeyRef = useRef<string | null>(null);
|
||||||
const previousNewestEntryKeyRef = 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 [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [modelHeaderExpanded, setModelHeaderExpanded] = useState(false);
|
const [modelHeaderExpanded, setModelHeaderExpanded] = useState(false);
|
||||||
const [isFollowing, setIsFollowing] = useState(true);
|
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(
|
const chronologicalEntryKeys = useMemo(
|
||||||
() => buildEntryRenderKeys(entries),
|
() => buildEntryRenderKeys(visibleEntries),
|
||||||
[entries],
|
[visibleEntries],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderGroups = useMemo(
|
const renderGroups = useMemo(
|
||||||
() => buildRenderGroups(entries, chronologicalEntryKeys),
|
() => buildRenderGroups(visibleEntries, chronologicalEntryKeys),
|
||||||
[entries, chronologicalEntryKeys],
|
[visibleEntries, chronologicalEntryKeys],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keep live-follow pinned to the bottom when new streamed entries append.
|
// Keep live-follow pinned to the bottom when new streamed entries append.
|
||||||
@@ -420,6 +461,16 @@ export function AgentLogViewer({
|
|||||||
>
|
>
|
||||||
{renderMarkdown ? "Markdown" : "Plain"}
|
{renderMarkdown ? "Markdown" : "Plain"}
|
||||||
</button>
|
</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
|
<button
|
||||||
className="agent-log-mode-toggle"
|
className="agent-log-mode-toggle"
|
||||||
onClick={() => setIsFullscreen((prev) => !prev)}
|
onClick={() => setIsFullscreen((prev) => !prev)}
|
||||||
@@ -478,7 +529,10 @@ export function AgentLogViewer({
|
|||||||
{/* Pagination summary */}
|
{/* Pagination summary */}
|
||||||
{totalCount !== null && (
|
{totalCount !== null && (
|
||||||
<div className="agent-log-summary" data-testid="agent-log-summary">
|
<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>
|
</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 { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { AgentLogViewer } from "../AgentLogViewer";
|
import { AgentLogViewer } from "../AgentLogViewer";
|
||||||
import type { AgentLogEntry } from "@fusion/core";
|
import type { AgentLogEntry } from "@fusion/core";
|
||||||
@@ -30,6 +30,10 @@ function getScrollContainer(container: HTMLElement): HTMLDivElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("AgentLogViewer", () => {
|
describe("AgentLogViewer", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
it("shows loading message when loading with no entries", () => {
|
it("shows loading message when loading with no entries", () => {
|
||||||
render(<AgentLogViewer entries={[]} loading={true} />);
|
render(<AgentLogViewer entries={[]} loading={true} />);
|
||||||
expect(screen.getByText("Loading agent logs…")).toBeTruthy();
|
expect(screen.getByText("Loading agent logs…")).toBeTruthy();
|
||||||
@@ -1524,7 +1528,7 @@ describe("AgentLogViewer", () => {
|
|||||||
makeEntry({ text: "done", type: "tool_result" }),
|
makeEntry({ text: "done", type: "tool_result" }),
|
||||||
makeEntry({ text: "fail", type: "tool_error" }),
|
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;
|
const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement;
|
||||||
|
|
||||||
// Tool entries in markdown mode
|
// 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", () => {
|
describe("fullscreen toggle", () => {
|
||||||
it("applies matching min dimensions to markdown and fullscreen header toggles", () => {
|
it("applies matching min dimensions to markdown and fullscreen header toggles", () => {
|
||||||
const entries = [makeEntry()];
|
const entries = [makeEntry()];
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export const GLOBAL_STORAGE_KEYS: string[] = [
|
|||||||
"kb-dashboard-view-mode",
|
"kb-dashboard-view-mode",
|
||||||
"kb-dashboard-current-project",
|
"kb-dashboard-current-project",
|
||||||
"kb-dashboard-recent-projects",
|
"kb-dashboard-recent-projects",
|
||||||
|
"fn-agent-log-markdown",
|
||||||
|
"fn-agent-log-tool-output",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const PROJECT_STORAGE_KEYS: string[] = [
|
export const PROJECT_STORAGE_KEYS: string[] = [
|
||||||
|
|||||||
Reference in New Issue
Block a user