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:
@@ -47,7 +47,7 @@ AI-guided interactive planning for creating well-specified tasks from high-level
|
||||
- **AI-Assisted Creation Controls**: Plan, Subtask, and Refine buttons appear directly below the description textarea in all task creation surfaces (quick entry box, inline create card, and task form modal). These description-adjacent controls make AI-assisted creation and refinement feel directly associated with the text being edited. Deps, Models, and Save actions remain in the expanded controls footer.
|
||||
- **Layered Model Dropdowns**: Shared model combobox menus render in a top-level portal attached to `document.body`, so they stay above board columns and scrollable modal content instead of being clipped behind surrounding dashboard surfaces.
|
||||
- **Bulk Model Editing**: Update AI model configuration for multiple tasks at once in the list view. Select tasks via checkboxes (archived tasks excluded), then use the "Bulk Edit Models" toolbar to apply executor and/or validator model changes to all selected tasks. Selection persists in localStorage across page reloads.
|
||||
- **Task Details**: View full task specifications, agent logs, and attachments. The Agent Log tab expands to fill the full modal body height above the action bar, providing maximum vertical space for watching live agent output. The tab header shows the effective executor and validator model names resolved from task-level overrides or project/global settings fallbacks, matching the same resolution order the engine uses at runtime. The refinement modal positions the "Create Refinement Task" button adjacent to the feedback textarea alongside the character count, creating a tight input group that connects the submit action directly to the text being edited. The **Changes** tab for done tasks loads the diff from the recorded merge commit (`mergeDetails.commitSha`) via `fetchCommitDiff` rather than requiring a live worktree — changes remain visible even after the worktree is cleaned up. The tab shows commit metadata (short SHA, merge commit message, merged timestamp) alongside the file-level diff with addition/deletion totals. In-progress and in-review tasks continue to use the worktree-based diff path.
|
||||
- **Task Details**: View full task specifications, agent logs, and attachments. The Agent Log tab expands to fill the full modal body height above the action bar, providing maximum vertical space for watching live agent output. The tab header shows the effective executor and validator model names resolved from task-level overrides or project/global settings fallbacks, matching the same resolution order the engine uses at runtime. A **Markdown/Plain toggle** in the header bar switches between formatted markdown rendering (default) and literal plain-text display — useful for debugging raw agent output, checking escaped markdown syntax, or inspecting exactly what the agent emitted without formatting. The toggle applies to `text` and `thinking` entries only; tool entries always render as plain text. React-markdown handles sanitization in markdown mode (no raw HTML is executed); plain-text mode uses React's built-in text escaping for safe literal output. The refinement modal positions the "Create Refinement Task" button adjacent to the feedback textarea alongside the character count, creating a tight input group that connects the submit action directly to the text being edited. The **Changes** tab for done tasks loads the diff from the recorded merge commit (`mergeDetails.commitSha`) via `fetchCommitDiff` rather than requiring a live worktree — changes remain visible even after the worktree is cleaned up. The tab shows commit metadata (short SHA, merge commit message, merged timestamp) alongside the file-level diff with addition/deletion totals. In-progress and in-review tasks continue to use the worktree-based diff path.
|
||||
- **Changed Files Viewer**: Click a task card's "files changed" button to open a dedicated diff viewer showing only files changed in that task worktree, with per-file statuses and sidebar navigation. On mobile (≤768px), the viewer switches to a single-pane flow: the file list and diff are shown one at a time with a back button for navigation between them. The viewer always opens to the file list on mobile, and only switches to the diff view when the user taps a specific file. Pressing Escape on the diff view returns to the file list first; pressing Escape again closes the modal. Loading, error, and empty states use theme-aware styling (including light mode). Diff syntax highlighting (additions, deletions, hunks) adapts to the active theme for correct contrast. The board card file count and the changed-files viewer always agree — both use the same merge-base diff strategy, so the card never advertises files that the viewer cannot inspect
|
||||
- **GitHub Import**: Import issues directly from GitHub repositories
|
||||
- **PR Management**: Create, monitor, and merge pull requests for in-review tasks
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3122,6 +3122,31 @@ body {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Agent Log markdown/plain-text mode toggle */
|
||||
.agent-log-mode-toggle {
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.agent-log-mode-toggle:hover {
|
||||
background: var(--card-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.agent-log-mode-toggle[aria-pressed="true"] {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-accent, #fff);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.detail-spec-edit-trigger {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user