feat(FN-833): add markdown render toggle for Agent Log viewer
- Add toggle button to switch between raw text and rendered markdown in AgentLogViewer - Support markdown rendering with proper code syntax highlighting and line breaks - Add CSS styles for rendered markdown content in agent log - Add comprehensive tests for toggle behavior, markdown rendering, and edge cases - Document the markdown render toggle feature in dashboard README
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type { AgentLogEntry } from "@fusion/core";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { useRef, useEffect } from "react";
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Components } from "react-markdown";
|
||||
@@ -49,10 +49,12 @@ interface AgentLogViewerProps {
|
||||
* Renders agent log entries in a scrollable, monospace container.
|
||||
* Displays entries in reverse chronological order (newest first).
|
||||
* Auto-scrolls to keep latest entries visible when streaming.
|
||||
* Supports toggling between markdown-formatted and plain-text rendering.
|
||||
*/
|
||||
export function AgentLogViewer({ entries, loading, executorModel, validatorModel }: AgentLogViewerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const previousEntryCountRef = useRef<number>(0);
|
||||
const [renderMarkdown, setRenderMarkdown] = useState(true);
|
||||
|
||||
// Auto-scroll to top when new entries arrive (since newest are first)
|
||||
useEffect(() => {
|
||||
@@ -131,6 +133,7 @@ export function AgentLogViewer({ entries, loading, executorModel, validatorModel
|
||||
color: "var(--text-muted, #888)",
|
||||
overflow: "hidden",
|
||||
minWidth: 0,
|
||||
alignItems: "center",
|
||||
}}
|
||||
data-testid="agent-log-model-header"
|
||||
>
|
||||
@@ -160,6 +163,19 @@ export function AgentLogViewer({ entries, loading, executorModel, validatorModel
|
||||
<span className="model-badge-default">Using default</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Markdown render toggle */}
|
||||
<div style={{ marginLeft: "auto" }}>
|
||||
<button
|
||||
className="agent-log-mode-toggle"
|
||||
onClick={() => setRenderMarkdown((prev) => !prev)}
|
||||
aria-label={renderMarkdown ? "Switch to plain text mode" : "Switch to markdown mode"}
|
||||
aria-pressed={renderMarkdown}
|
||||
data-testid="agent-log-mode-toggle"
|
||||
title={renderMarkdown ? "Show raw text" : "Show formatted markdown"}
|
||||
>
|
||||
{renderMarkdown ? "Markdown" : "Plain"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{reversedEntries.map((entry, i) => {
|
||||
// Look at previous entry in reversed array (= next chronologically) for deduplication
|
||||
@@ -226,9 +242,13 @@ export function AgentLogViewer({ entries, loading, executorModel, validatorModel
|
||||
}}
|
||||
>
|
||||
{agentBadge}
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{entry.text}
|
||||
</ReactMarkdown>
|
||||
{renderMarkdown ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{entry.text}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
entry.text
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -297,9 +317,13 @@ export function AgentLogViewer({ entries, loading, executorModel, validatorModel
|
||||
return (
|
||||
<span key={i} className="agent-log-text">
|
||||
{agentBadge}
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{entry.text}
|
||||
</ReactMarkdown>
|
||||
{renderMarkdown ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{entry.text}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
entry.text
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { AgentLogViewer } from "../AgentLogViewer";
|
||||
import type { AgentLogEntry } from "@fusion/core";
|
||||
|
||||
@@ -598,4 +598,174 @@ describe("AgentLogViewer", () => {
|
||||
expect(textSpans[0].textContent).toContain("works!");
|
||||
});
|
||||
});
|
||||
|
||||
describe("markdown render toggle", () => {
|
||||
it("renders the toggle button in the model info header", () => {
|
||||
const entries = [makeEntry()];
|
||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']");
|
||||
expect(toggle).toBeTruthy();
|
||||
expect(toggle!.textContent).toBe("Markdown");
|
||||
});
|
||||
|
||||
it("defaults to markdown mode", () => {
|
||||
const entries = [makeEntry({ text: "**bold** text" })];
|
||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
// In markdown mode, bold should be rendered as <strong>
|
||||
const textSpans = container.querySelectorAll(".agent-log-text");
|
||||
const strong = textSpans[0].querySelector("strong");
|
||||
expect(strong).toBeTruthy();
|
||||
expect(strong!.textContent).toBe("bold");
|
||||
});
|
||||
|
||||
it("has correct aria attributes on the toggle", () => {
|
||||
const entries = [makeEntry()];
|
||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement;
|
||||
expect(toggle).toBeTruthy();
|
||||
expect(toggle.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(toggle.getAttribute("aria-label")).toBe("Switch to plain text mode");
|
||||
});
|
||||
|
||||
it("switches to plain text mode when clicked", () => {
|
||||
const entries = [makeEntry({ text: "**bold** and *italic*" })];
|
||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement;
|
||||
|
||||
// Click to switch to plain text mode
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Button should update
|
||||
expect(toggle.textContent).toBe("Plain");
|
||||
expect(toggle.getAttribute("aria-pressed")).toBe("false");
|
||||
expect(toggle.getAttribute("aria-label")).toBe("Switch to markdown mode");
|
||||
|
||||
// Text should now show raw markdown syntax literally
|
||||
const textSpans = container.querySelectorAll(".agent-log-text");
|
||||
expect(textSpans).toHaveLength(1);
|
||||
expect(textSpans[0].textContent).toBe("**bold** and *italic*");
|
||||
// No markdown elements should be present
|
||||
expect(textSpans[0].querySelector("strong")).toBeNull();
|
||||
expect(textSpans[0].querySelector("em")).toBeNull();
|
||||
});
|
||||
|
||||
it("toggles back to markdown mode from plain text", () => {
|
||||
const entries = [makeEntry({ text: "**bold** text" })];
|
||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement;
|
||||
|
||||
// Switch to plain text
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle.textContent).toBe("Plain");
|
||||
|
||||
// Switch back to markdown
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle.textContent).toBe("Markdown");
|
||||
|
||||
// Markdown elements should be present again
|
||||
const textSpans = container.querySelectorAll(".agent-log-text");
|
||||
const strong = textSpans[0].querySelector("strong");
|
||||
expect(strong).toBeTruthy();
|
||||
expect(strong!.textContent).toBe("bold");
|
||||
});
|
||||
|
||||
it("shows raw markdown syntax literally in plain text mode for text entries", () => {
|
||||
const entries = [
|
||||
makeEntry({ text: "## Heading\n\n- item 1\n- item 2\n\n`code` and **bold**" }),
|
||||
];
|
||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement;
|
||||
|
||||
// Switch to plain text
|
||||
fireEvent.click(toggle);
|
||||
|
||||
const textSpans = container.querySelectorAll(".agent-log-text");
|
||||
expect(textSpans).toHaveLength(1);
|
||||
// Raw markdown syntax should appear literally
|
||||
expect(textSpans[0].textContent).toContain("## Heading");
|
||||
expect(textSpans[0].textContent).toContain("- item 1");
|
||||
expect(textSpans[0].textContent).toContain("`code`");
|
||||
expect(textSpans[0].textContent).toContain("**bold**");
|
||||
// No rendered markdown elements
|
||||
expect(textSpans[0].querySelector("h2")).toBeNull();
|
||||
expect(textSpans[0].querySelector("ul")).toBeNull();
|
||||
expect(textSpans[0].querySelector("code")).toBeNull();
|
||||
expect(textSpans[0].querySelector("strong")).toBeNull();
|
||||
});
|
||||
|
||||
it("respects toggle for thinking entries", () => {
|
||||
const entries = [
|
||||
makeEntry({ text: "Thinking about **this**", type: "thinking" }),
|
||||
];
|
||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement;
|
||||
|
||||
// In markdown mode, bold is rendered
|
||||
const thinkingSpans = container.querySelectorAll(".agent-log-thinking");
|
||||
expect(thinkingSpans[0].querySelector("strong")).toBeTruthy();
|
||||
|
||||
// Switch to plain text
|
||||
fireEvent.click(toggle);
|
||||
|
||||
const thinkingSpansUpdated = container.querySelectorAll(".agent-log-thinking");
|
||||
expect(thinkingSpansUpdated[0].textContent).toContain("Thinking about **this**");
|
||||
expect(thinkingSpansUpdated[0].querySelector("strong")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not affect tool entries in either mode", () => {
|
||||
const entries = [
|
||||
makeEntry({ text: "Read", type: "tool" }),
|
||||
makeEntry({ text: "done", type: "tool_result" }),
|
||||
makeEntry({ text: "fail", type: "tool_error" }),
|
||||
];
|
||||
const { container, rerender } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement;
|
||||
|
||||
// Tool entries in markdown mode
|
||||
expect(container.querySelector(".agent-log-tool")!.textContent).toContain("Read");
|
||||
expect(container.querySelector(".agent-log-tool-result")!.textContent).toContain("done");
|
||||
expect(container.querySelector(".agent-log-tool-error")!.textContent).toContain("fail");
|
||||
|
||||
// Switch to plain text - tool entries should be unchanged
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(container.querySelector(".agent-log-tool")!.textContent).toContain("Read");
|
||||
expect(container.querySelector(".agent-log-tool-result")!.textContent).toContain("done");
|
||||
expect(container.querySelector(".agent-log-tool-error")!.textContent).toContain("fail");
|
||||
});
|
||||
|
||||
it("safely renders HTML tags as text in plain text mode (no XSS)", () => {
|
||||
const entries = [
|
||||
makeEntry({ text: '<script>alert("xss")</script> and <b>bold</b>' }),
|
||||
];
|
||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement;
|
||||
|
||||
// Switch to plain text
|
||||
fireEvent.click(toggle);
|
||||
|
||||
const textSpans = container.querySelectorAll(".agent-log-text");
|
||||
// The text content should contain the literal HTML tags
|
||||
expect(textSpans[0].textContent).toContain('<script>alert("xss")</script>');
|
||||
expect(textSpans[0].textContent).toContain("<b>bold</b>");
|
||||
// No actual script or bold HTML elements should be rendered
|
||||
expect(textSpans[0].querySelector("script")).toBeNull();
|
||||
expect(textSpans[0].querySelector("b")).toBeNull();
|
||||
});
|
||||
|
||||
it("safely renders HTML in markdown mode via react-markdown sanitization", () => {
|
||||
const entries = [
|
||||
makeEntry({ text: "**safe** text here" }),
|
||||
];
|
||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
// In markdown mode, react-markdown sanitizes HTML (no script execution)
|
||||
const textSpans = container.querySelectorAll(".agent-log-text");
|
||||
// Markdown formatting should work
|
||||
const strong = textSpans[0].querySelector("strong");
|
||||
expect(strong).toBeTruthy();
|
||||
expect(strong!.textContent).toBe("safe");
|
||||
// No script elements are rendered for any HTML content in markdown
|
||||
expect(textSpans[0].querySelector("script")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user