diff --git a/packages/dashboard/app/components/__tests__/AgentLogViewer.header.test.tsx b/packages/dashboard/app/components/__tests__/AgentLogViewer.header.test.tsx new file mode 100644 index 0000000000..fdb7c14be4 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/AgentLogViewer.header.test.tsx @@ -0,0 +1,490 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { AgentLogViewer } from "../AgentLogViewer"; +import { makeEntry, getScrollContainer } from "./AgentLogViewer.test-helpers"; +import "../../styles.css"; +import "../TaskDetailModal.css"; + +// Mock lucide-react icons used by AgentLogViewer and ProviderIcon +vi.mock("lucide-react", () => ({ + Maximize2: () => null, + Minimize2: () => null, + Loader2: () => null, + Cpu: () => null, + ChevronDown: () => null, + ChevronRight: () => null, +})); + +describe("AgentLogViewer", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + describe("model info header", () => { + it("renders model info header with executor model when set", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy(); + expect(header!.textContent).not.toContain("Executor:"); + + fireEvent.click(screen.getByTestId("agent-log-model-expand")); + expect(header!.textContent).toContain("Executor:"); + expect(header!.textContent).toContain("anthropic/claude-sonnet-4-5"); + }); + + it("renders 'Using default' when no executor model override is set", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(header!.textContent).not.toContain("Using default"); + }); + + it("renders 'Using default' when executorModel is undefined", () => { + const entries = [makeEntry()]; + const { container } = render(); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(header!.textContent).not.toContain("Using default"); + }); + + it("renders model info header with validator model when set", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(container.querySelector('[data-provider="openai"]')).toBeTruthy(); + expect(header!.textContent).not.toContain("Reviewer:"); + + fireEvent.click(screen.getByTestId("agent-log-model-expand")); + expect(header!.textContent).toContain("Reviewer:"); + expect(header!.textContent).toContain("openai/gpt-4o"); + }); + + it("renders 'Using default' when no validator model override is set", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(header!.textContent).not.toContain("Using default"); + }); + + it("renders both models when both are configured", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy(); + expect(container.querySelector('[data-provider="openai"]')).toBeTruthy(); + expect(header!.textContent).not.toContain("anthropic/claude-opus-4"); + expect(header!.textContent).not.toContain("openai/gpt-4o"); + + fireEvent.click(screen.getByTestId("agent-log-model-expand")); + expect(header!.textContent).toContain("anthropic/claude-opus-4"); + expect(header!.textContent).toContain("openai/gpt-4o"); + }); + + it("renders header with 'Using default' for both models when both are null/undefined", () => { + const entries = [makeEntry()]; + const { container } = render(); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(header!.textContent).not.toContain("Using default"); + }); + + it("shows 'Using default' when executorModel has only provider but no modelId", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(header!.textContent).not.toContain("Using default"); + }); + + it("shows 'Using default' when executorModel has only modelId but no provider", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(header!.textContent).not.toContain("Using default"); + }); + + it("renders model info header with planning model when set", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy(); + expect(header!.textContent).not.toContain("Planning:"); + + fireEvent.click(screen.getByTestId("agent-log-model-expand")); + expect(header!.textContent).toContain("Planning:"); + expect(header!.textContent).toContain("anthropic/claude-opus-4"); + }); + + it("renders 'Using default' for planning when no planning model is set", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(header!.textContent).not.toContain("Using default"); + }); + + it("renders 'Using default' for planning when planningModel is undefined", () => { + const entries = [makeEntry()]; + const { container } = render(); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(header!.textContent).not.toContain("Using default"); + }); + + it("renders all three models when all are configured", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy(); + expect(container.querySelector('[data-provider="openai"]')).toBeTruthy(); + expect(container.querySelector('[data-provider="google"]')).toBeTruthy(); + expect(header!.textContent).not.toContain("anthropic/claude-opus-4"); + expect(header!.textContent).not.toContain("openai/gpt-4o"); + expect(header!.textContent).not.toContain("google/gemini-pro"); + + fireEvent.click(screen.getByTestId("agent-log-model-expand")); + expect(header!.textContent).toContain("anthropic/claude-opus-4"); + expect(header!.textContent).toContain("openai/gpt-4o"); + expect(header!.textContent).toContain("google/gemini-pro"); + }); + + it("shows 'Using default' for planning when planningModel has only provider but no modelId", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + const header = container.querySelector("[data-testid='agent-log-model-header']"); + expect(header).toBeTruthy(); + expect(header!.textContent).not.toContain("Using default"); + }); + }); + + describe("model header expand/collapse", () => { + it("shows only provider icons in collapsed state, hides model text", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + + expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy(); + expect(screen.getByTestId("agent-log-model-expand")).toBeTruthy(); + expect(container.textContent).not.toContain("Executor:"); + expect(container.textContent).not.toContain("claude-sonnet-4-5"); + }); + + it("shows model details when expand button is clicked", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + + fireEvent.click(screen.getByTestId("agent-log-model-expand")); + expect(container.textContent).toContain("Executor:"); + expect(container.textContent).toContain("anthropic/claude-sonnet-4-5"); + }); + + it("collapses model details when expand button is clicked again", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + + const button = screen.getByTestId("agent-log-model-expand"); + fireEvent.click(button); + expect(container.textContent).toContain("Executor:"); + fireEvent.click(button); + expect(container.textContent).not.toContain("Executor:"); + }); + + it("shows no provider icons when no model overrides are set", () => { + const entries = [makeEntry()]; + const { container } = render(); + + expect(container.querySelector("[data-provider]")).toBeNull(); + }); + + it("has aria-expanded=false when collapsed and aria-expanded=true when expanded", () => { + const entries = [makeEntry()]; + render( + , + ); + + const button = screen.getByTestId("agent-log-model-expand"); + expect(button.getAttribute("aria-expanded")).toBe("false"); + fireEvent.click(button); + expect(button.getAttribute("aria-expanded")).toBe("true"); + }); + + it("renders multiple provider icons for multiple overrides", () => { + const entries = [makeEntry()]; + const { container } = render( + , + ); + + expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy(); + expect(container.querySelector('[data-provider="openai"]')).toBeTruthy(); + expect(container.querySelector('[data-provider="google"]')).toBeTruthy(); + }); + }); + + describe("timestamp display", () => { + it("renders no timestamps for entries without agent field", () => { + const entries = [ + makeEntry({ text: "legacy 1", type: "text" }), + makeEntry({ text: "legacy 2", type: "text" }), + ]; + const { container } = render(); + const timestamps = container.querySelectorAll(".agent-log-timestamp"); + expect(timestamps).toHaveLength(0); + }); + + it("renders relative timestamps for recent entries next to the badge", () => { + const recentTimestamp = new Date(Date.now() - 5 * 60 * 1000).toISOString(); + const entries = [ + makeEntry({ text: "hello", type: "text", agent: "executor", timestamp: recentTimestamp }), + ]; + const { container } = render(); + const timestamp = container.querySelector(".agent-log-timestamp"); + expect(timestamp).toBeTruthy(); + expect(timestamp!.textContent).toBe("5m ago"); + + const badge = container.querySelector(".agent-log-agent-badge") as HTMLElement; + expect(badge).toBeTruthy(); + expect(badge.parentElement?.querySelector(".agent-log-timestamp")).toBeTruthy(); + }); + + it("renders 'just now' for entries less than a minute old", () => { + const recentTimestamp = new Date(Date.now() - 30 * 1000).toISOString(); + const entries = [ + makeEntry({ text: "hello", type: "text", agent: "executor", timestamp: recentTimestamp }), + ]; + const { container } = render(); + const timestamp = container.querySelector(".agent-log-timestamp"); + expect(timestamp!.textContent).toBe("just now"); + }); + + it("renders hours ago for older entries", () => { + const olderTimestamp = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(); + const entries = [ + makeEntry({ text: "hello", type: "text", agent: "executor", timestamp: olderTimestamp }), + ]; + const { container } = render(); + const timestamp = container.querySelector(".agent-log-timestamp"); + expect(timestamp!.textContent).toBe("3h ago"); + }); + + it("renders days ago for entries older than a day", () => { + const oldTimestamp = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(); + const entries = [ + makeEntry({ text: "hello", type: "text", agent: "executor", timestamp: oldTimestamp }), + ]; + const { container } = render(); + const timestamp = container.querySelector(".agent-log-timestamp"); + expect(timestamp!.textContent).toBe("2d ago"); + }); + + it("renders locale date for entries older than 7 days", () => { + const veryOldTimestamp = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); + const entries = [ + makeEntry({ text: "hello", type: "text", agent: "executor", timestamp: veryOldTimestamp }), + ]; + const { container } = render(); + const timestamp = container.querySelector(".agent-log-timestamp"); + // Should be a locale date string, not a relative time + expect(timestamp!.textContent).not.toContain("ago"); + expect(timestamp!.textContent).not.toBe("just now"); + }); + + it("uses the timestamp class inside the badge row", () => { + const entries = [makeEntry({ text: "hello", type: "text", agent: "executor" })]; + const { container } = render(); + const badge = container.querySelector(".agent-log-agent-badge") as HTMLElement; + expect(badge).toBeTruthy(); + const timestamp = badge.parentElement?.querySelector(".agent-log-timestamp") as HTMLElement; + expect(timestamp).toBeTruthy(); + expect(timestamp.classList.contains("agent-log-timestamp")).toBe(true); + // Theme styles are class-based now, not inline. + expect(timestamp.style.fontSize).toBe(""); + expect(timestamp.style.opacity).toBe(""); + }); + + it("renders the agent badge as a sticky overlay on a full-width text block", () => { + const entries = [makeEntry({ text: "long executor output", type: "text", agent: "executor" })]; + const { container } = render(); + const block = container.querySelector(".agent-log-text") as HTMLElement; + const badgeRow = container.querySelector(".agent-log-badge-row") as HTMLElement; + + expect(block).toBeTruthy(); + expect(badgeRow).toBeTruthy(); + expect(getComputedStyle(block).width).toBe("100%"); + expect(getComputedStyle(badgeRow).position).toBe("sticky"); + expect(getComputedStyle(badgeRow).left).not.toBe(""); + expect(getComputedStyle(badgeRow).pointerEvents).toBe("none"); + }); + + it("includes timestamp in the badge container for tool entries", () => { + const entries = [makeEntry({ text: "Bash", type: "tool", agent: "executor" })]; + const { container } = render(); + const toolDiv = container.querySelector(".agent-log-tool"); + expect(toolDiv).toBeTruthy(); + const badge = toolDiv!.querySelector(".agent-log-agent-badge") as HTMLElement; + expect(badge).toBeTruthy(); + expect(badge.parentElement?.querySelector(".agent-log-timestamp")).toBeTruthy(); + }); + + it("includes timestamp in the badge container for tool_result entries", () => { + const entries = [makeEntry({ text: "ok", type: "tool_result", agent: "executor" })]; + const { container } = render(); + const resultDiv = container.querySelector(".agent-log-tool-result"); + expect(resultDiv).toBeTruthy(); + const badge = resultDiv!.querySelector(".agent-log-agent-badge") as HTMLElement; + expect(badge).toBeTruthy(); + expect(badge.parentElement?.querySelector(".agent-log-timestamp")).toBeTruthy(); + }); + + it("includes timestamp in the badge container for tool_error entries", () => { + const entries = [makeEntry({ text: "fail", type: "tool_error", agent: "executor" })]; + const { container } = render(); + const errorDiv = container.querySelector(".agent-log-tool-error"); + expect(errorDiv).toBeTruthy(); + const badge = errorDiv!.querySelector(".agent-log-agent-badge") as HTMLElement; + expect(badge).toBeTruthy(); + expect(badge.parentElement?.querySelector(".agent-log-timestamp")).toBeTruthy(); + }); + + it("includes timestamp in the badge container for thinking entries", () => { + const entries = [makeEntry({ text: "hmm", type: "thinking", agent: "executor" })]; + const { container } = render(); + const thinkingSpan = container.querySelector(".agent-log-thinking"); + expect(thinkingSpan).toBeTruthy(); + const badge = thinkingSpan!.querySelector(".agent-log-agent-badge") as HTMLElement; + expect(badge).toBeTruthy(); + expect(badge.parentElement?.querySelector(".agent-log-timestamp")).toBeTruthy(); + }); + + it("shows exactly one timestamp for consecutive text entries from the same agent", () => { + const entries = [ + makeEntry({ text: "chunk 1", type: "text", agent: "executor" }), + makeEntry({ text: "chunk 2", type: "text", agent: "executor" }), + ]; + const { container } = render(); + const timestamps = container.querySelectorAll(".agent-log-timestamp"); + expect(timestamps).toHaveLength(1); + }); + + it("renders timestamps at each agent transition", () => { + const entries = [ + makeEntry({ text: "triage output", type: "text", agent: "triage" }), + makeEntry({ text: "executor output", type: "text", agent: "executor" }), + makeEntry({ text: "review notes", type: "text", agent: "reviewer" }), + ]; + const { container } = render(); + const badges = Array.from(container.querySelectorAll(".agent-log-agent-badge")); + const timestamps = container.querySelectorAll(".agent-log-timestamp"); + + expect(badges).toHaveLength(3); + expect(timestamps).toHaveLength(3); + expect(badges.map((badge) => badge.textContent)).toEqual(["[Plan]", "[executor]", "[reviewer]"]); + }); + + it("badge container includes both badge text and timestamp text", () => { + const recentTimestamp = new Date(Date.now() - 5 * 60 * 1000).toISOString(); + const entries = [ + makeEntry({ text: "hello", type: "text", agent: "executor", timestamp: recentTimestamp }), + ]; + const { container } = render(); + const badge = container.querySelector(".agent-log-agent-badge") as HTMLElement; + expect(badge).toBeTruthy(); + + const badgeContainer = badge.parentElement as HTMLElement; + expect(badgeContainer.textContent).toContain("[executor]"); + expect(badgeContainer.textContent).toContain("5m ago"); + }); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/AgentLogViewer.layout.test.tsx b/packages/dashboard/app/components/__tests__/AgentLogViewer.layout.test.tsx new file mode 100644 index 0000000000..db7cf13b66 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/AgentLogViewer.layout.test.tsx @@ -0,0 +1,367 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { AgentLogViewer } from "../AgentLogViewer"; +import { makeEntry, getScrollContainer } from "./AgentLogViewer.test-helpers"; +import "../../styles.css"; +import "../TaskDetailModal.css"; + +// Mock lucide-react icons used by AgentLogViewer and ProviderIcon +vi.mock("lucide-react", () => ({ + Maximize2: () => null, + Minimize2: () => null, + Loader2: () => null, + Cpu: () => null, + ChevronDown: () => null, + ChevronRight: () => null, +})); + +describe("AgentLogViewer", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + describe("horizontal overflow prevention", () => { + it("uses the scroll container class for overflow-x handling", () => { + const longString = "A".repeat(300); + const entries = [makeEntry({ text: longString })]; + const { container } = render(); + const scrollContainer = getScrollContainer(container); + expect(scrollContainer.classList.contains("agent-log-viewer-scroll")).toBe(true); + expect(scrollContainer.style.overflowX).toBe(""); + }); + + it("uses the scroll container class for overflow-wrap handling", () => { + const entries = [makeEntry({ text: "x".repeat(250) })]; + const { container } = render(); + const scrollContainer = getScrollContainer(container); + expect(scrollContainer.classList.contains("agent-log-viewer-scroll")).toBe(true); + expect(scrollContainer.style.overflowWrap).toBe(""); + }); + + it("renders pre elements with overflow-x auto for internal scrolling", () => { + const longLine = "const x = " + "'a'.repeat(500)"; + const entries = [makeEntry({ text: "```\n" + longLine + "\n```" })]; + const { container } = render(); + const pre = container.querySelector("pre") as HTMLElement; + expect(pre).toBeTruthy(); + expect(pre.style.overflowX).toBe("auto"); + expect(pre.style.maxWidth).toBe("100%"); + }); + + it("applies model-header wrapping via class", () => { + const entries = [makeEntry()]; + const { container } = render(); + const header = container.querySelector("[data-testid='agent-log-model-header']") as HTMLElement; + expect(header.classList.contains("agent-log-model-header")).toBe(true); + expect(header.style.flexWrap).toBe(""); + }); + }); + + describe("full-height layout", () => { + it("does not have a fixed maxHeight constraint", () => { + const entries = [makeEntry()]; + const { container } = render(); + const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; + // The viewer should NOT have a maxHeight of 500px (the old fixed constraint) + expect(viewer.style.maxHeight).not.toBe("500px"); + // maxHeight should be empty (unset) so the viewer can grow to fill available space + expect(viewer.style.maxHeight).toBe(""); + }); + + it("uses class-based overflow-y scrolling on the entries container", () => { + const entries = [makeEntry()]; + const { container } = render(); + const scrollContainer = getScrollContainer(container); + // Scrolling behavior is now defined in CSS. + expect(scrollContainer.classList.contains("agent-log-viewer-scroll")).toBe(true); + expect(scrollContainer.style.overflowY).toBe(""); + }); + + it("uses agent-log-viewer--streaming class when entries are present", () => { + const entries = [makeEntry()]; + const { container } = render(); + const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; + expect(viewer.classList.contains("agent-log-viewer")).toBe(true); + expect(viewer.classList.contains("agent-log-viewer--streaming")).toBe(true); + }); + + it("does not use streaming class on loading state", () => { + const { container } = render(); + const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; + expect(viewer.classList.contains("agent-log-viewer")).toBe(true); + expect(viewer.classList.contains("agent-log-viewer--streaming")).toBe(false); + }); + }); + + describe("sticky header layout", () => { + it("renders the model header as a sibling of the scroll container", () => { + const entries = [makeEntry()]; + const { container } = render(); + const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; + const header = screen.getByTestId("agent-log-model-header"); + const scrollContainer = getScrollContainer(container); + + expect(header.parentElement).toBe(viewer); + expect(scrollContainer.parentElement).toBe(viewer); + expect(scrollContainer.contains(header)).toBe(false); + }); + + it("renders log entry rows inside the scroll container", () => { + const entries = [ + makeEntry({ type: "text", text: "hello" }), + makeEntry({ type: "tool", text: "Bash" }), + ]; + const { container } = render(); + const scrollContainer = getScrollContainer(container); + + expect(scrollContainer.querySelector(".agent-log-text")).toBeTruthy(); + expect(scrollContainer.querySelector(".agent-log-tool")).toBeTruthy(); + }); + + it("renders pagination summary and load-more controls inside the scroll container", () => { + const entries = [makeEntry({ text: "hello" })]; + const { container } = render( + {}} + />, + ); + const scrollContainer = getScrollContainer(container); + + expect(scrollContainer.querySelector("[data-testid='agent-log-summary']")).toBeTruthy(); + expect(scrollContainer.querySelector("[data-testid='agent-log-load-more']")).toBeTruthy(); + }); + + it("renders the return-to-live button inside the scroll container", () => { + const entries = [ + makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), + makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), + ]; + const { container } = render(); + const scrollContainer = getScrollContainer(container); + + Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1000 }); + Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 200 }); + + scrollContainer.scrollTop = 300; + fireEvent.scroll(scrollContainer); + + const returnToLive = screen.getByTestId("agent-log-return-to-live"); + expect(returnToLive.parentElement).toBe(scrollContainer); + }); + }); + + describe("auto-scroll behavior", () => { + it("scrolls to bottom when streaming updates arrive and user is near the bottom", () => { + const initialEntries = [ + makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), + ]; + const streamedEntries = [ + ...initialEntries, + makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), + ]; + + const { rerender, container } = render(); + const viewer = getScrollContainer(container); + + let scrollHeight = 600; + Object.defineProperty(viewer, "scrollHeight", { + configurable: true, + get: () => scrollHeight, + }); + + viewer.scrollTop = 560; + rerender(); + + scrollHeight = 720; + rerender(); + + expect(viewer.scrollTop).toBe(720); + }); + + it("does not auto-scroll when streaming updates arrive and user is reading older output", () => { + const initialEntries = [ + makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), + ]; + const streamedEntries = [ + ...initialEntries, + makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), + ]; + + const { rerender, container } = render(); + const viewer = getScrollContainer(container); + + let scrollHeight = 1000; + Object.defineProperty(viewer, "scrollHeight", { + configurable: true, + get: () => scrollHeight, + }); + + viewer.scrollTop = 220; + rerender(); + + scrollHeight = 1120; + rerender(); + + expect(viewer.scrollTop).toBe(220); + }); + + it("keeps viewport anchored when older history is prepended", () => { + const initialEntries = [ + makeEntry({ text: "recent", timestamp: "2026-01-01T00:00:00Z" }), + ]; + const olderLoadedEntries = [ + makeEntry({ text: "older", timestamp: "2025-12-31T23:59:00Z" }), + ...initialEntries, + ]; + + const { rerender, container } = render(); + const viewer = getScrollContainer(container); + + let scrollHeight = 900; + Object.defineProperty(viewer, "scrollHeight", { + configurable: true, + get: () => scrollHeight, + }); + + viewer.scrollTop = 260; + rerender(); + + scrollHeight = 1030; + rerender(); + + // Anchored by delta (1030 - 900): 260 + 130 + expect(viewer.scrollTop).toBe(390); + }); + + it("shows return-to-live button when user scrolls away from bottom", () => { + const entries = [ + makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), + makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), + ]; + const { container } = render(); + const viewer = getScrollContainer(container); + + Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1000 }); + Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 }); + + viewer.scrollTop = 300; + fireEvent.scroll(viewer); + + expect(screen.getByTestId("agent-log-return-to-live")).toBeTruthy(); + }); + + it("hides return-to-live button when user is following live output", () => { + const entries = [ + makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), + makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), + ]; + const { container } = render(); + const viewer = getScrollContainer(container); + + Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1000 }); + Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 }); + + viewer.scrollTop = 760; + fireEvent.scroll(viewer); + + expect(screen.queryByTestId("agent-log-return-to-live")).toBeNull(); + }); + + it("returns to bottom and resumes following when return-to-live is clicked", () => { + const entries = [ + makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), + makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), + ]; + const { container } = render(); + const viewer = getScrollContainer(container); + + Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1000 }); + Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 }); + + viewer.scrollTop = 280; + fireEvent.scroll(viewer); + + const returnButton = screen.getByTestId("agent-log-return-to-live"); + fireEvent.click(returnButton); + + expect(viewer.scrollTop).toBe(1000); + expect(screen.queryByTestId("agent-log-return-to-live")).toBeNull(); + }); + + it("re-pins to bottom on resize while following", () => { + const resizeCallbacks: Array<() => void> = []; + const originalResizeObserver = globalThis.ResizeObserver; + + class ResizeObserverMock { + constructor(callback: ResizeObserverCallback) { + resizeCallbacks.push(() => callback([], this as unknown as ResizeObserver)); + } + + observe() {} + unobserve() {} + disconnect() {} + } + + Object.defineProperty(globalThis, "ResizeObserver", { + configurable: true, + value: ResizeObserverMock, + }); + + try { + const entries = [ + makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), + makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), + ]; + const { container } = render(); + const viewer = getScrollContainer(container); + + Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1200 }); + Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 }); + viewer.scrollTop = 980; + fireEvent.scroll(viewer); + + viewer.scrollTop = 640; + resizeCallbacks.forEach((callback) => callback()); + + expect(viewer.scrollTop).toBe(1200); + } finally { + if (originalResizeObserver) { + Object.defineProperty(globalThis, "ResizeObserver", { + configurable: true, + value: originalResizeObserver, + }); + } else { + delete (globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver; + } + } + }); + }); + + describe("pagination placement", () => { + it("renders the load-more control above the first log entry", () => { + const entries = [ + makeEntry({ text: "oldest", timestamp: "2026-01-01T00:00:00Z" }), + makeEntry({ text: "newest", timestamp: "2026-01-01T00:00:01Z" }), + ]; + + const { container } = render( + {}} + />, + ); + + const loadMore = screen.getByTestId("agent-log-load-more"); + const firstRow = container.querySelector(".agent-log-text") as HTMLElement; + expect(firstRow).toBeTruthy(); + + expect(loadMore.compareDocumentPosition(firstRow) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/AgentLogViewer.markdown.test.tsx b/packages/dashboard/app/components/__tests__/AgentLogViewer.markdown.test.tsx new file mode 100644 index 0000000000..9bdec741de --- /dev/null +++ b/packages/dashboard/app/components/__tests__/AgentLogViewer.markdown.test.tsx @@ -0,0 +1,797 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { AgentLogViewer } from "../AgentLogViewer"; +import { FileBrowserProvider } from "../../context/FileBrowserContext"; +import { makeEntry, getScrollContainer } from "./AgentLogViewer.test-helpers"; +import "../../styles.css"; +import "../TaskDetailModal.css"; + +// Mock lucide-react icons used by AgentLogViewer and ProviderIcon +vi.mock("lucide-react", () => ({ + Maximize2: () => null, + Minimize2: () => null, + Loader2: () => null, + Cpu: () => null, + ChevronDown: () => null, + ChevronRight: () => null, +})); + +describe("AgentLogViewer", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + describe("long content preservation", () => { + it("renders very long text entries without truncation", () => { + const longText = "A".repeat(5000); + const entries = [makeEntry({ text: longText })]; + const { container } = render(); + const textSpans = container.querySelectorAll(".agent-log-text"); + expect(textSpans).toHaveLength(1); + expect(textSpans[0].textContent).toContain(longText); + }); + + it("renders very long detail text without truncation", () => { + const longDetail = "B".repeat(5000); + const entries = [makeEntry({ text: "Read", type: "tool", detail: longDetail })]; + const { container } = render(); + fireEvent.click(screen.getByTestId("tool-detail-toggle")); + const detail = container.querySelector(".agent-log-tool-detail"); + expect(detail).toBeTruthy(); + expect(detail!.textContent).toContain(longDetail); + expect(detail!.textContent!.length).toBe(5000); + }); + + it("renders multiline text content without truncation", () => { + const multilineText = [ + "## Analysis", + "", + "After reviewing the codebase:", + "", + "1. First issue found", + "2. Second issue found", + "", + "```typescript", + "const x = 1;", + "```", + "", + "Line " + "C".repeat(2000) + " end", + ].join("\n"); + const entries = [makeEntry({ text: multilineText })]; + const { container } = render(); + const textSpans = container.querySelectorAll(".agent-log-text"); + expect(textSpans).toHaveLength(1); + // The markdown-rendered content should still contain the essential parts + expect(textSpans[0].textContent).toContain("Analysis"); + expect(textSpans[0].textContent).toContain("First issue found"); + expect(textSpans[0].textContent).toContain("const x = 1"); + }); + + it("renders long tool_result detail without truncation", () => { + const longDetail = "D".repeat(5000); + const entries = [makeEntry({ text: "Bash", type: "tool_result", detail: longDetail })]; + const { container } = render(); + fireEvent.click(screen.getByTestId("tool-detail-toggle")); + const detail = container.querySelector(".agent-log-tool-detail"); + expect(detail).toBeTruthy(); + expect(detail!.textContent).toContain(longDetail); + }); + + it("renders long tool_error detail without truncation", () => { + const longDetail = "E".repeat(5000); + const entries = [makeEntry({ text: "Write", type: "tool_error", detail: longDetail })]; + const { container } = render(); + fireEvent.click(screen.getByTestId("tool-detail-toggle")); + const detail = container.querySelector(".agent-log-tool-detail"); + expect(detail).toBeTruthy(); + expect(detail!.textContent).toContain(longDetail); + }); + + it("preserves raw whitespace in tool detail blocks", () => { + const detailText = "stdout:\n line one\n indented line two\n"; + const entries = [makeEntry({ text: "Bash", type: "tool_result", detail: detailText })]; + const { container } = render(); + fireEvent.click(screen.getByTestId("tool-detail-toggle")); + const detail = container.querySelector(".agent-log-tool-detail") as HTMLElement; + expect(detail).toBeTruthy(); + expect(detail.tagName).toBe("PRE"); + expect(detail.textContent).toBe(detailText); + }); + }); + + describe("markdown rendering", () => { + it("renders plain text without markdown correctly", () => { + const entries = [ + makeEntry({ text: "Hello world, this is plain text." }), + ]; + const { container } = render(); + const textSpans = container.querySelectorAll(".agent-log-text"); + expect(textSpans).toHaveLength(1); + expect(textSpans[0].textContent).toContain("Hello world, this is plain text."); + }); + + it("renders text entries inside markdown-body in markdown mode", () => { + const entries = [ + makeEntry({ text: "Paragraph one\n\nParagraph two" }), + ]; + const { container } = render(); + const textRow = container.querySelector(".agent-log-text") as HTMLElement; + expect(textRow).toBeTruthy(); + + const proseContainer = textRow.querySelector(".markdown-body") as HTMLElement; + expect(proseContainer).toBeTruthy(); + expect(proseContainer.querySelectorAll("p")).toHaveLength(2); + }); + + it("renders thinking entries inside markdown-body in markdown mode", () => { + const entries = [ + makeEntry({ text: "Considering:\n\n- option A\n- option B", type: "thinking" }), + ]; + const { container } = render(); + const thinkingRow = container.querySelector(".agent-log-thinking") as HTMLElement; + expect(thinkingRow).toBeTruthy(); + + const proseContainer = thinkingRow.querySelector(".markdown-body") as HTMLElement; + expect(proseContainer).toBeTruthy(); + expect(proseContainer.querySelector("ul")).toBeTruthy(); + }); + + it("renders inline markdown elements (bold, italic, inline code)", () => { + const entries = [ + makeEntry({ text: "This is **bold** and *italic* with `inline code`." }), + ]; + const { container } = render(); + const textSpans = container.querySelectorAll(".agent-log-text"); + expect(textSpans).toHaveLength(1); + // Check that the markdown elements are rendered + const strong = textSpans[0].querySelector("strong"); + expect(strong).toBeTruthy(); + expect(strong!.textContent).toBe("bold"); + const em = textSpans[0].querySelector("em"); + expect(em).toBeTruthy(); + expect(em!.textContent).toBe("italic"); + const code = textSpans[0].querySelector("code"); + expect(code).toBeTruthy(); + expect(code!.textContent).toBe("inline code"); + }); + + it("renders file links inside inline code with the code wrapper preserved", () => { + const openFile = vi.fn(); + const entries = [ + makeEntry({ text: "Check `packages/engine/src/scheduler.ts:7` now." }), + ]; + const { container } = render( + + + , + ); + + const fileLink = screen.getByRole("button", { name: "packages/engine/src/scheduler.ts:7" }); + const code = fileLink.closest("code"); + expect(code).toBeTruthy(); + expect(code?.querySelector("button.file-path-link")).toBe(fileLink); + + fireEvent.click(fileLink); + expect(openFile).toHaveBeenCalledWith("packages/engine/src/scheduler.ts", { line: 7, col: undefined }); + expect(container.querySelectorAll("code button.file-path-link")).toHaveLength(1); + }); + + it("renders code blocks with GFM support", () => { + const entries = [ + makeEntry({ text: "```typescript\nconst x = 1;\n```" }), + ]; + const { container } = render(); + const textSpans = container.querySelectorAll(".agent-log-text"); + expect(textSpans).toHaveLength(1); + // Check that code block is rendered + const pre = textSpans[0].querySelector("pre"); + expect(pre).toBeTruthy(); + const code = pre!.querySelector("code"); + expect(code).toBeTruthy(); + expect(code!.textContent).toContain("const x = 1"); + }); + + it("renders GFM task lists", () => { + const entries = [ + makeEntry({ text: "- [x] Completed task\n- [ ] Pending task" }), + ]; + const { container } = render(); + const textSpans = container.querySelectorAll(".agent-log-text"); + expect(textSpans).toHaveLength(1); + // Check that task list is rendered + const ul = textSpans[0].querySelector("ul"); + expect(ul).toBeTruthy(); + const taskListItems = ul!.querySelectorAll("li"); + expect(taskListItems).toHaveLength(2); + // Check checkboxes + const checkboxes = ul!.querySelectorAll('input[type="checkbox"]'); + expect(checkboxes).toHaveLength(2); + expect((checkboxes[0] as HTMLInputElement).checked).toBe(true); + expect((checkboxes[1] as HTMLInputElement).checked).toBe(false); + }); + + it("renders blockquotes", () => { + const entries = [ + makeEntry({ text: "> This is a blockquote\n> with multiple lines" }), + ]; + const { container } = render(); + const textSpans = container.querySelectorAll(".agent-log-text"); + expect(textSpans).toHaveLength(1); + // Check that blockquote is rendered + const blockquote = textSpans[0].querySelector("blockquote"); + expect(blockquote).toBeTruthy(); + expect(blockquote!.textContent).toContain("This is a blockquote"); + }); + + it("renders markdown in thinking entries", () => { + const entries = [ + makeEntry({ text: "Let me think about **this problem**...", type: "thinking" }), + ]; + const { container } = render(); + const thinkingSpans = container.querySelectorAll(".agent-log-thinking"); + expect(thinkingSpans).toHaveLength(1); + const strong = thinkingSpans[0].querySelector("strong"); + expect(strong).toBeTruthy(); + expect(strong!.textContent).toBe("this problem"); + }); + + it("renders mixed content with markdown and plain text", () => { + const entries = [ + makeEntry({ text: "The code:\n\n```js\nconsole.log('hello');\n```\n\nworks!" }), + ]; + const { container } = render(); + const textSpans = container.querySelectorAll(".agent-log-text"); + expect(textSpans).toHaveLength(1); + const pre = textSpans[0].querySelector("pre"); + expect(pre).toBeTruthy(); + // Plain text before and after should be preserved + expect(textSpans[0].textContent).toContain("The code:"); + 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(); + 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(); + // In markdown mode, bold should be rendered as + 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(); + 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("FN-3847: uses accent text color for pressed markdown/tools toggles", () => { + window.localStorage.setItem("fn-agent-log-markdown", "true"); + window.localStorage.setItem("fn-agent-log-tool-output", "true"); + const entries = [makeEntry({ text: "hello" })]; + const { container } = render(); + + const markdownToggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; + const toolsToggle = container.querySelector("[data-testid='agent-log-tool-output-toggle']") as HTMLButtonElement; + const fullscreenToggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; + + expect(markdownToggle.getAttribute("aria-pressed")).toBe("true"); + expect(toolsToggle.getAttribute("aria-pressed")).toBe("true"); + + const markdownColor = getComputedStyle(markdownToggle).color; + const toolsColor = getComputedStyle(toolsToggle).color; + const unpressedColor = getComputedStyle(fullscreenToggle).color; + + // Contract: unpressed toggles keep the shared muted button color, pressed toggles switch to accent foreground. + expect(markdownColor.length).toBeGreaterThan(0); + expect(toolsColor.length).toBeGreaterThan(0); + expect(markdownColor).toBe(toolsColor); + expect(markdownColor).not.toBe(unpressedColor); + }); + + it("switches to plain text mode when clicked", () => { + const entries = [makeEntry({ text: "**bold** and *italic*" })]; + const { container } = render(); + const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; + + // Markdown mode starts with prose container + rendered markdown + const markdownModeTextRow = container.querySelector(".agent-log-text") as HTMLElement; + expect(markdownModeTextRow.querySelector(".markdown-body")).toBeTruthy(); + expect(markdownModeTextRow.querySelector("strong")?.textContent).toBe("bold"); + + // 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).toContain("**bold** and *italic*"); + const plainBlock = textSpans[0].querySelector(".agent-log-plain-block") as HTMLElement; + expect(plainBlock).toBeTruthy(); + // Plain mode should remove markdown rendering/prose container + expect(textSpans[0].querySelector(".markdown-body")).toBeNull(); + expect(textSpans[0].querySelector("strong")).toBeNull(); + expect(textSpans[0].querySelector("em")).toBeNull(); + }); + + it("concatenates grouped text into a single markdown render", () => { + const entries = [ + makeEntry({ text: "**bold", type: "text", agent: "executor" }), + makeEntry({ text: "** text", type: "text", agent: "executor" }), + ]; + const { container } = render(); + + const textRows = container.querySelectorAll(".agent-log-text"); + expect(textRows).toHaveLength(1); + expect(textRows[0].querySelectorAll(".markdown-body")).toHaveLength(1); + const strong = textRows[0].querySelector("strong"); + expect(strong).toBeTruthy(); + expect(strong!.textContent).toBe("bold"); + }); + + it("joins grouped chunks inline in plain text mode", () => { + const entries = [ + makeEntry({ text: "hello", type: "text", agent: "executor" }), + makeEntry({ text: " world", type: "text", agent: "executor" }), + ]; + const { container } = render(); + const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; + + fireEvent.click(toggle); + + const textRows = container.querySelectorAll(".agent-log-text"); + expect(textRows).toHaveLength(1); + expect(textRows[0].querySelector(".markdown-body")).toBeNull(); + expect(textRows[0].textContent).toContain("hello world"); + }); + + it("toggles back to markdown mode from plain text", () => { + const entries = [makeEntry({ text: "**bold** text" })]; + const { container } = render(); + 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("preserves line breaks in plain text mode for thinking entries", () => { + const entries = [makeEntry({ text: "line1\nline2\nline3", type: "thinking", agent: "executor" })]; + const { container } = render(); + const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; + + fireEvent.click(toggle); + + const plainThinking = container.querySelector(".agent-log-thinking .agent-log-plain-block") as HTMLElement; + expect(plainThinking).toBeTruthy(); + expect(plainThinking.textContent).toContain("line1\nline2\nline3"); + }); + + 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(); + 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(); + 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 } = render(); + 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: ' and bold' }), + ]; + const { container } = render(); + 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(''); + expect(textSpans[0].textContent).toContain("bold"); + // 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(); + // 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(); + }); + }); + + describe("tool output toggle", () => { + it("renders the tool output toggle defaulting to On", () => { + const entries = [makeEntry()]; + const { container } = render(); + 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(); + 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("keeps the latest non-tool message visible as its own row when tools are hidden", () => { + const entries = [ + makeEntry({ text: "Starting plan", type: "text", agent: "executor", timestamp: "2026-01-01T00:00:00Z" }), + makeEntry({ text: "read file", type: "tool", agent: "executor", timestamp: "2026-01-01T00:00:01Z" }), + makeEntry({ text: "Final answer", type: "text", agent: "executor", timestamp: "2026-01-01T00:00:02Z" }), + ]; + const { container } = render(); + const toggle = container.querySelector("[data-testid='agent-log-tool-output-toggle']") as HTMLButtonElement; + + fireEvent.click(toggle); + + const textRows = container.querySelectorAll(".agent-log-text"); + expect(textRows).toHaveLength(2); + expect(textRows[0].textContent).toContain("Starting plan"); + expect(textRows[1].textContent).toContain("Final answer"); + expect(container.querySelectorAll(".agent-log-agent-badge")).toHaveLength(2); + expect(container.querySelectorAll(".agent-log-timestamp")).toHaveLength(2); + }); + + 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(); + 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( + , + ); + 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(); + 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(); + 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(); + 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(); + 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(); + 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()]; + const { container } = render(); + const markdownToggle = container.querySelector( + "[data-testid='agent-log-mode-toggle']", + ) as HTMLButtonElement; + const fullscreenToggle = container.querySelector( + "[data-testid='agent-log-fullscreen-toggle']", + ) as HTMLButtonElement; + + const markdownStyle = getComputedStyle(markdownToggle); + const fullscreenStyle = getComputedStyle(fullscreenToggle); + + expect(markdownStyle.minWidth).toBe(fullscreenStyle.minWidth); + expect(markdownStyle.minHeight).toBe(fullscreenStyle.minHeight); + expect(markdownStyle.minWidth).not.toBe("0px"); + expect(markdownStyle.minHeight).not.toBe("0px"); + }); + + it("adds visible gap spacing between header toggle buttons", () => { + const entries = [makeEntry()]; + const { container } = render(); + const toggleGroup = container.querySelector(".agent-log-model-header-toggle") as HTMLElement; + const toggleGroupStyle = getComputedStyle(toggleGroup); + + expect(toggleGroupStyle.gap).not.toBe(""); + expect(toggleGroupStyle.gap).not.toBe("normal"); + }); + + it("renders the fullscreen toggle button in the model info header", () => { + const entries = [makeEntry()]; + const { container } = render(); + const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']"); + expect(toggle).toBeTruthy(); + }); + + it("has correct aria attributes on the fullscreen toggle", () => { + const entries = [makeEntry()]; + const { container } = render(); + const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; + expect(toggle).toBeTruthy(); + expect(toggle.getAttribute("aria-label")).toBe("Expand agent log to full screen"); + expect(toggle.getAttribute("title")).toBe("Expand agent log to full screen"); + }); + + it("adds fullscreen class when toggle is clicked", () => { + const entries = [makeEntry()]; + const { container } = render(); + const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; + const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; + + // Initially not fullscreen + expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(false); + + // Click to enter fullscreen + fireEvent.click(toggle); + + // Should have fullscreen class + expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(true); + }); + + it("removes fullscreen class when toggle is clicked while in fullscreen", () => { + const entries = [makeEntry()]; + const { container } = render(); + const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; + const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; + + // Enter fullscreen + fireEvent.click(toggle); + expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(true); + + // Exit fullscreen + fireEvent.click(toggle); + expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(false); + }); + + it("updates aria label when toggling fullscreen", () => { + const entries = [makeEntry()]; + const { container } = render(); + const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; + + // Initially shows expand label + expect(toggle.getAttribute("aria-label")).toBe("Expand agent log to full screen"); + + // Enter fullscreen + fireEvent.click(toggle); + expect(toggle.getAttribute("aria-label")).toBe("Exit full screen"); + + // Exit fullscreen + fireEvent.click(toggle); + expect(toggle.getAttribute("aria-label")).toBe("Expand agent log to full screen"); + }); + + it("exits fullscreen when Escape key is pressed", () => { + const entries = [makeEntry()]; + const { container } = render(); + const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; + const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; + + // Enter fullscreen + fireEvent.click(toggle); + expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(true); + + // Press Escape to exit + fireEvent.keyDown(document, { key: "Escape" }); + + expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(false); + }); + + it("does nothing when Escape key is pressed while not in fullscreen", () => { + const entries = [makeEntry()]; + const { container } = render(); + const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; + const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; + + // Initially not fullscreen + expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(false); + + // Press Escape - should do nothing + fireEvent.keyDown(document, { key: "Escape" }); + + expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(false); + + // Toggle should still work normally + fireEvent.click(toggle); + expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(true); + }); + + it("only responds to Escape key when in fullscreen mode", () => { + const entries = [makeEntry()]; + const { container, unmount } = render(); + const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; + const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; + + // Press Escape when not fullscreen - no effect + fireEvent.keyDown(document, { key: "Escape" }); + expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(false); + + // Enter fullscreen + fireEvent.click(toggle); + expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(true); + + // Clean up to remove the keydown listener + unmount(); + + // Verify the listener was removed (no errors should occur when Escape is pressed after unmount) + }); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/AgentLogViewer.rendering.test.tsx b/packages/dashboard/app/components/__tests__/AgentLogViewer.rendering.test.tsx new file mode 100644 index 0000000000..ab74044203 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/AgentLogViewer.rendering.test.tsx @@ -0,0 +1,410 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { AgentLogViewer } from "../AgentLogViewer"; +import { FileBrowserProvider } from "../../context/FileBrowserContext"; +import { makeEntry, getScrollContainer } from "./AgentLogViewer.test-helpers"; +import "../../styles.css"; +import "../TaskDetailModal.css"; + +// Mock lucide-react icons used by AgentLogViewer and ProviderIcon +vi.mock("lucide-react", () => ({ + Maximize2: () => null, + Minimize2: () => null, + Loader2: () => null, + Cpu: () => null, + ChevronDown: () => null, + ChevronRight: () => null, +})); + +describe("AgentLogViewer", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("shows loading message when loading with no entries", () => { + render(); + expect(screen.getByRole("status")).toHaveTextContent("Loading agent logs…"); + expect(screen.queryByText("No agent output yet.")).toBeNull(); + }); + + it("shows empty message when no entries and not loading", () => { + render(); + expect(screen.getByText("No agent output yet.")).toBeTruthy(); + }); + + it("rerenders from empty state to populated logs without changing hook order", () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const entry = makeEntry({ text: "streamed chunk" }); + const { rerender } = render(); + + expect(() => { + rerender(); + }).not.toThrow(); + + expect(screen.getByText("streamed chunk")).toBeTruthy(); + consoleErrorSpy.mockRestore(); + }); + + it("renders grouped text entries in chronological order (oldest first)", () => { + const entries = [ + makeEntry({ text: "first chunk", agent: "executor" }), + makeEntry({ text: " second chunk", agent: "executor" }), + ]; + const { container } = render(); + const textSpans = container.querySelectorAll(".agent-log-text"); + expect(textSpans).toHaveLength(1); + expect(textSpans[0].textContent).toContain("first chunk second chunk"); + }); + + it("preserves just now output for future timestamps", () => { + const futureTimestamp = new Date(Date.now() + 30_000).toISOString(); + + render(); + + expect(screen.getByTestId("agent-log-timestamp")).toHaveTextContent("just now"); + }); + + it("keeps existing DOM rows stable when a new live entry appears at the bottom", () => { + const initialEntries = [ + makeEntry({ text: "first chunk", timestamp: "2026-01-01T00:00:00Z", agent: "triage" }), + makeEntry({ text: "second chunk", timestamp: "2026-01-01T00:00:01Z", agent: "executor" }), + ]; + + const { container, rerender } = render( + , + ); + + const initialTextRows = container.querySelectorAll(".agent-log-text"); + const firstChunkNode = initialTextRows[0] as HTMLElement; + const secondChunkNode = initialTextRows[1] as HTMLElement; + expect(firstChunkNode.textContent).toContain("first chunk"); + expect(secondChunkNode.textContent).toContain("second chunk"); + + const withLiveUpdate = [ + ...initialEntries, + makeEntry({ text: "third chunk", timestamp: "2026-01-01T00:00:02Z", agent: "reviewer" }), + ]; + + rerender(); + + const updatedTextRows = container.querySelectorAll(".agent-log-text"); + expect(updatedTextRows).toHaveLength(3); + expect(updatedTextRows[0].textContent).toContain("first chunk"); + expect(updatedTextRows[1].textContent).toContain("second chunk"); + expect(updatedTextRows[2].textContent).toContain("third chunk"); + expect(updatedTextRows[0]).toBe(firstChunkNode); + expect(updatedTextRows[1]).toBe(secondChunkNode); + }); + + it("avoids duplicate-key collisions when entries are exact duplicates", () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const duplicateEntry = makeEntry({ + timestamp: "2026-01-01T00:00:00Z", + taskId: "FN-001", + text: "same chunk", + type: "text", + agent: "executor", + detail: "same detail", + }); + + const { container, rerender } = render( + , + ); + + rerender( + , + ); + + expect(container.querySelectorAll(".agent-log-text")).toHaveLength(1); + expect( + consoleErrorSpy.mock.calls.some((call) => + String(call[0]).includes("Encountered two children with the same key"), + ), + ).toBe(false); + + consoleErrorSpy.mockRestore(); + }); + + it("renders file paths in plain log lines as clickable file-browser links", async () => { + const openFile = vi.fn(); + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "packages/engine/src/scheduler.ts" })); + expect(openFile).toHaveBeenCalledWith("packages/engine/src/scheduler.ts", { line: undefined, col: undefined }); + }); + + it("renders tool entries with distinct styling", () => { + const entries = [ + makeEntry({ text: "Read", type: "tool" }), + ]; + const { container } = render(); + const toolDiv = container.querySelector(".agent-log-tool"); + expect(toolDiv).toBeTruthy(); + expect(toolDiv!.textContent).toContain("Read"); + }); + + it("renders a mix of text and tool entries in chronological order", () => { + const entries = [ + makeEntry({ text: "Starting...", type: "text" }), + makeEntry({ text: "Bash", type: "tool" }), + makeEntry({ text: "Done!", type: "text" }), + ]; + const { container } = render(); + const textSpans = container.querySelectorAll(".agent-log-text"); + expect(textSpans).toHaveLength(2); + expect(textSpans[0].textContent).toContain("Starting..."); + expect(textSpans[1].textContent).toContain("Done!"); + + const toolDivs = container.querySelectorAll(".agent-log-tool"); + expect(toolDivs).toHaveLength(1); + }); + + describe("entry grouping", () => { + it("groups consecutive text entries from the same agent into one container", () => { + const entries = [ + makeEntry({ text: "hello", agent: "executor" }), + makeEntry({ text: " world", agent: "executor" }), + makeEntry({ text: "!", agent: "executor" }), + ]; + + const { container } = render(); + const textRows = container.querySelectorAll(".agent-log-text"); + expect(textRows).toHaveLength(1); + expect(textRows[0].textContent).toContain("hello world!"); + }); + + it("groups consecutive thinking entries from the same agent into one container", () => { + const entries = [ + makeEntry({ text: "think", type: "thinking", agent: "triage" }), + makeEntry({ text: "ing", type: "thinking", agent: "triage" }), + ]; + + const { container } = render(); + const thinkingRows = container.querySelectorAll(".agent-log-thinking"); + expect(thinkingRows).toHaveLength(1); + expect(thinkingRows[0].textContent).toContain("thinking"); + }); + + it("does not group text across tool entries", () => { + const entries = [ + makeEntry({ text: "part 1", type: "text", agent: "executor" }), + makeEntry({ text: "Read", type: "tool", agent: "executor" }), + makeEntry({ text: " part 2", type: "text", agent: "executor" }), + ]; + + const { container } = render(); + expect(container.querySelectorAll(".agent-log-text")).toHaveLength(2); + expect(container.querySelectorAll(".agent-log-tool")).toHaveLength(1); + }); + + it("does not group text entries from different agents", () => { + const entries = [ + makeEntry({ text: "triage", agent: "triage" }), + makeEntry({ text: "executor", agent: "executor" }), + ]; + + const { container } = render(); + expect(container.querySelectorAll(".agent-log-text")).toHaveLength(2); + }); + + it("does not group entries across text and thinking type boundaries", () => { + const entries = [ + makeEntry({ text: "text", type: "text", agent: "executor" }), + makeEntry({ text: "thought", type: "thinking", agent: "executor" }), + ]; + + const { container } = render(); + expect(container.querySelectorAll(".agent-log-text")).toHaveLength(1); + expect(container.querySelectorAll(".agent-log-thinking")).toHaveLength(1); + }); + + it("shows badge and timestamp only once at the start of a grouped text run", () => { + const entries = [ + makeEntry({ text: "a", type: "text", agent: "executor", timestamp: "2026-01-01T00:00:00Z" }), + makeEntry({ text: "b", type: "text", agent: "executor", timestamp: "2026-01-01T00:00:01Z" }), + ]; + + const { container } = render(); + expect(container.querySelectorAll(".agent-log-agent-badge")).toHaveLength(1); + expect(container.querySelectorAll(".agent-log-timestamp")).toHaveLength(1); + }); + }); + + it("renders tool entry detail toggle collapsed by default when detail is present", () => { + const entries = [ + makeEntry({ text: "Bash", type: "tool", detail: "ls -la packages/" }), + ]; + render(); + + const toggle = screen.getByTestId("tool-detail-toggle"); + expect(toggle).toBeTruthy(); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + const content = screen.getByTestId("tool-detail-content"); + expect(content.classList.contains("agent-log-tool-detail-content--collapsed")).toBe(true); + }); + + it("does not render detail toggle when detail is absent", () => { + const entries = [ + makeEntry({ text: "Bash", type: "tool" }), + makeEntry({ text: "Bash", type: "tool_result" }), + makeEntry({ text: "Bash", type: "tool_error" }), + ]; + render(); + expect(screen.queryByTestId("tool-detail-toggle")).toBeNull(); + }); + + it("renders long detail text without breaking layout", () => { + const longDetail = "a/very/long/path/".repeat(10) + "file.ts"; + const entries = [ + makeEntry({ text: "Read", type: "tool", detail: longDetail }), + ]; + const { container } = render(); + fireEvent.click(screen.getByTestId("tool-detail-toggle")); + const detail = container.querySelector(".agent-log-tool-detail"); + expect(detail).toBeTruthy(); + expect(detail!.textContent).toContain(longDetail); + // Verify the tool div still renders correctly + const toolDiv = container.querySelector(".agent-log-tool"); + expect(toolDiv).toBeTruthy(); + }); + + it("collapses tool-like detail by default across tool, tool_result, and tool_error", () => { + const entries = [ + makeEntry({ text: "Read", type: "tool", detail: "tool output" }), + makeEntry({ text: "Done", type: "tool_result", detail: "result output" }), + makeEntry({ text: "Oops", type: "tool_error", detail: "error output" }), + ]; + render(); + + const toggles = screen.getAllByTestId("tool-detail-toggle"); + expect(toggles).toHaveLength(3); + const contents = screen.getAllByTestId("tool-detail-content"); + expect(contents).toHaveLength(3); + for (const content of contents) { + expect(content.classList.contains("agent-log-tool-detail-content--collapsed")).toBe(true); + } + }); + + it("expands and collapses tool detail on toggle click", () => { + const entries = [ + makeEntry({ text: "Bash", type: "tool", detail: "line 1\nline 2" }), + ]; + render(); + + const toggle = screen.getByTestId("tool-detail-toggle"); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + fireEvent.click(toggle); + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + const content = screen.getByTestId("tool-detail-content"); + expect(content.textContent).toContain("line 1"); + expect(content.classList.contains("agent-log-tool-detail-content--collapsed")).toBe(false); + fireEvent.click(toggle); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + expect(content.classList.contains("agent-log-tool-detail-content--collapsed")).toBe(true); + }); + + it("applies the viewer styling via the agent-log-viewer class", () => { + const entries = [makeEntry()]; + const { container } = render(); + const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; + expect(viewer.classList.contains("agent-log-viewer")).toBe(true); + // Theme/layout styles come from CSS classes, not inline style attributes. + expect(viewer.style.fontFamily).toBe(""); + }); + + describe("agent badge deduplication", () => { + it("shows badge only on the first (oldest) of consecutive text entries from the same agent", () => { + const entries = [ + makeEntry({ text: "chunk 1", type: "text", agent: "executor" }), + makeEntry({ text: "chunk 2", type: "text", agent: "executor" }), + makeEntry({ text: "chunk 3", type: "text", agent: "executor" }), + ]; + const { container } = render(); + const badges = container.querySelectorAll(".agent-log-agent-badge"); + expect(badges).toHaveLength(1); + // In chronological order, the oldest (chunk 1) gets the badge + expect(badges[0].textContent).toBe("[executor]"); + }); + + it("shows badge on each agent transition in chronological order", () => { + const entries = [ + makeEntry({ text: "hello", type: "text", agent: "triage" }), + makeEntry({ text: "world", type: "text", agent: "triage" }), + makeEntry({ text: "starting", type: "text", agent: "executor" }), + makeEntry({ text: "done", type: "text", agent: "executor" }), + ]; + const { container } = render(); + const badges = container.querySelectorAll(".agent-log-agent-badge"); + expect(badges).toHaveLength(2); + expect(badges[0].textContent).toBe("[Plan]"); + expect(badges[1].textContent).toBe("[executor]"); + }); + + it("shows badge on text, tool, and text-after-tool (same agent, type change) in chronological order", () => { + const entries = [ + makeEntry({ text: "reading...", type: "text", agent: "executor" }), + makeEntry({ text: "Read", type: "tool", agent: "executor" }), + makeEntry({ text: "got it", type: "text", agent: "executor" }), + ]; + const { container } = render(); + const badges = container.querySelectorAll(".agent-log-agent-badge"); + // Chronological: reading... (text), Read (tool), got it (text) + // Badge on reading... (i=0), Read (always block-level), got it (type changed from tool) + expect(badges).toHaveLength(3); + }); + + it("shows badge only on the first (oldest) of consecutive thinking entries from the same agent", () => { + const entries = [ + makeEntry({ text: "hmm", type: "thinking", agent: "triage" }), + makeEntry({ text: "let me think", type: "thinking", agent: "triage" }), + makeEntry({ text: "ok", type: "thinking", agent: "triage" }), + ]; + const { container } = render(); + const badges = container.querySelectorAll(".agent-log-agent-badge"); + expect(badges).toHaveLength(1); + // In chronological order, the oldest (hmm) gets the badge + expect(badges[0].textContent).toBe("[Plan]"); + }); + + it("always shows badge on tool entries regardless of surrounding entries", () => { + const entries = [ + makeEntry({ text: "Bash", type: "tool", agent: "executor" }), + makeEntry({ text: "Read", type: "tool", agent: "executor" }), + makeEntry({ text: "Write", type: "tool", agent: "executor" }), + ]; + const { container } = render(); + const badges = container.querySelectorAll(".agent-log-agent-badge"); + expect(badges).toHaveLength(3); + }); + + it("always shows badge on tool_result and tool_error entries", () => { + const entries = [ + makeEntry({ text: "Bash", type: "tool", agent: "executor" }), + makeEntry({ text: "ok", type: "tool_result", agent: "executor" }), + makeEntry({ text: "Read", type: "tool", agent: "executor" }), + makeEntry({ text: "not found", type: "tool_error", agent: "executor" }), + ]; + const { container } = render(); + const badges = container.querySelectorAll(".agent-log-agent-badge"); + expect(badges).toHaveLength(4); + }); + + it("produces no badges when entries have no agent field", () => { + const entries = [ + makeEntry({ text: "legacy chunk 1", type: "text" }), + makeEntry({ text: "legacy chunk 2", type: "text" }), + ]; + const { container } = render(); + const badges = container.querySelectorAll(".agent-log-agent-badge"); + expect(badges).toHaveLength(0); + }); + }); + +}); diff --git a/packages/dashboard/app/components/__tests__/AgentLogViewer.test-helpers.ts b/packages/dashboard/app/components/__tests__/AgentLogViewer.test-helpers.ts new file mode 100644 index 0000000000..558d15fe50 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/AgentLogViewer.test-helpers.ts @@ -0,0 +1,16 @@ +// FNXC:DashboardTests 2026-06-25-19:31: Shared fixtures for AgentLogViewer.*.test.tsx — split from AgentLogViewer.test.tsx to drop under the 2000-line guard (FN-7028, see FN-7013). +import type { AgentLogEntry } from "@fusion/core"; + +export function makeEntry(overrides: Partial = {}): AgentLogEntry { + return { + timestamp: "2026-01-01T00:00:00Z", + taskId: "FN-001", + text: "Hello world", + type: "text", + ...overrides, + }; +} + +export function getScrollContainer(container: HTMLElement): HTMLDivElement { + return container.querySelector(".agent-log-viewer-scroll") as HTMLDivElement; +} diff --git a/packages/dashboard/app/components/__tests__/AgentLogViewer.test.tsx b/packages/dashboard/app/components/__tests__/AgentLogViewer.test.tsx deleted file mode 100644 index e23d491228..0000000000 --- a/packages/dashboard/app/components/__tests__/AgentLogViewer.test.tsx +++ /dev/null @@ -1,2010 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { AgentLogViewer } from "../AgentLogViewer"; -import { FileBrowserProvider } from "../../context/FileBrowserContext"; -import type { AgentLogEntry } from "@fusion/core"; -import "../../styles.css"; -import "../TaskDetailModal.css"; - -// Mock lucide-react icons used by AgentLogViewer and ProviderIcon -vi.mock("lucide-react", () => ({ - Maximize2: () => null, - Minimize2: () => null, - Loader2: () => null, - Cpu: () => null, - ChevronDown: () => null, - ChevronRight: () => null, -})); - -function makeEntry(overrides: Partial = {}): AgentLogEntry { - return { - timestamp: "2026-01-01T00:00:00Z", - taskId: "FN-001", - text: "Hello world", - type: "text", - ...overrides, - }; -} - -function getScrollContainer(container: HTMLElement): HTMLDivElement { - return container.querySelector(".agent-log-viewer-scroll") as HTMLDivElement; -} - -describe("AgentLogViewer", () => { - beforeEach(() => { - window.localStorage.clear(); - }); - - it("shows loading message when loading with no entries", () => { - render(); - expect(screen.getByRole("status")).toHaveTextContent("Loading agent logs…"); - expect(screen.queryByText("No agent output yet.")).toBeNull(); - }); - - it("shows empty message when no entries and not loading", () => { - render(); - expect(screen.getByText("No agent output yet.")).toBeTruthy(); - }); - - it("rerenders from empty state to populated logs without changing hook order", () => { - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const entry = makeEntry({ text: "streamed chunk" }); - const { rerender } = render(); - - expect(() => { - rerender(); - }).not.toThrow(); - - expect(screen.getByText("streamed chunk")).toBeTruthy(); - consoleErrorSpy.mockRestore(); - }); - - it("renders grouped text entries in chronological order (oldest first)", () => { - const entries = [ - makeEntry({ text: "first chunk", agent: "executor" }), - makeEntry({ text: " second chunk", agent: "executor" }), - ]; - const { container } = render(); - const textSpans = container.querySelectorAll(".agent-log-text"); - expect(textSpans).toHaveLength(1); - expect(textSpans[0].textContent).toContain("first chunk second chunk"); - }); - - it("preserves just now output for future timestamps", () => { - const futureTimestamp = new Date(Date.now() + 30_000).toISOString(); - - render(); - - expect(screen.getByTestId("agent-log-timestamp")).toHaveTextContent("just now"); - }); - - it("keeps existing DOM rows stable when a new live entry appears at the bottom", () => { - const initialEntries = [ - makeEntry({ text: "first chunk", timestamp: "2026-01-01T00:00:00Z", agent: "triage" }), - makeEntry({ text: "second chunk", timestamp: "2026-01-01T00:00:01Z", agent: "executor" }), - ]; - - const { container, rerender } = render( - , - ); - - const initialTextRows = container.querySelectorAll(".agent-log-text"); - const firstChunkNode = initialTextRows[0] as HTMLElement; - const secondChunkNode = initialTextRows[1] as HTMLElement; - expect(firstChunkNode.textContent).toContain("first chunk"); - expect(secondChunkNode.textContent).toContain("second chunk"); - - const withLiveUpdate = [ - ...initialEntries, - makeEntry({ text: "third chunk", timestamp: "2026-01-01T00:00:02Z", agent: "reviewer" }), - ]; - - rerender(); - - const updatedTextRows = container.querySelectorAll(".agent-log-text"); - expect(updatedTextRows).toHaveLength(3); - expect(updatedTextRows[0].textContent).toContain("first chunk"); - expect(updatedTextRows[1].textContent).toContain("second chunk"); - expect(updatedTextRows[2].textContent).toContain("third chunk"); - expect(updatedTextRows[0]).toBe(firstChunkNode); - expect(updatedTextRows[1]).toBe(secondChunkNode); - }); - - it("avoids duplicate-key collisions when entries are exact duplicates", () => { - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const duplicateEntry = makeEntry({ - timestamp: "2026-01-01T00:00:00Z", - taskId: "FN-001", - text: "same chunk", - type: "text", - agent: "executor", - detail: "same detail", - }); - - const { container, rerender } = render( - , - ); - - rerender( - , - ); - - expect(container.querySelectorAll(".agent-log-text")).toHaveLength(1); - expect( - consoleErrorSpy.mock.calls.some((call) => - String(call[0]).includes("Encountered two children with the same key"), - ), - ).toBe(false); - - consoleErrorSpy.mockRestore(); - }); - - it("renders file paths in plain log lines as clickable file-browser links", async () => { - const openFile = vi.fn(); - render( - - - , - ); - - fireEvent.click(screen.getByRole("button", { name: "packages/engine/src/scheduler.ts" })); - expect(openFile).toHaveBeenCalledWith("packages/engine/src/scheduler.ts", { line: undefined, col: undefined }); - }); - - it("renders tool entries with distinct styling", () => { - const entries = [ - makeEntry({ text: "Read", type: "tool" }), - ]; - const { container } = render(); - const toolDiv = container.querySelector(".agent-log-tool"); - expect(toolDiv).toBeTruthy(); - expect(toolDiv!.textContent).toContain("Read"); - }); - - it("renders a mix of text and tool entries in chronological order", () => { - const entries = [ - makeEntry({ text: "Starting...", type: "text" }), - makeEntry({ text: "Bash", type: "tool" }), - makeEntry({ text: "Done!", type: "text" }), - ]; - const { container } = render(); - const textSpans = container.querySelectorAll(".agent-log-text"); - expect(textSpans).toHaveLength(2); - expect(textSpans[0].textContent).toContain("Starting..."); - expect(textSpans[1].textContent).toContain("Done!"); - - const toolDivs = container.querySelectorAll(".agent-log-tool"); - expect(toolDivs).toHaveLength(1); - }); - - describe("entry grouping", () => { - it("groups consecutive text entries from the same agent into one container", () => { - const entries = [ - makeEntry({ text: "hello", agent: "executor" }), - makeEntry({ text: " world", agent: "executor" }), - makeEntry({ text: "!", agent: "executor" }), - ]; - - const { container } = render(); - const textRows = container.querySelectorAll(".agent-log-text"); - expect(textRows).toHaveLength(1); - expect(textRows[0].textContent).toContain("hello world!"); - }); - - it("groups consecutive thinking entries from the same agent into one container", () => { - const entries = [ - makeEntry({ text: "think", type: "thinking", agent: "triage" }), - makeEntry({ text: "ing", type: "thinking", agent: "triage" }), - ]; - - const { container } = render(); - const thinkingRows = container.querySelectorAll(".agent-log-thinking"); - expect(thinkingRows).toHaveLength(1); - expect(thinkingRows[0].textContent).toContain("thinking"); - }); - - it("does not group text across tool entries", () => { - const entries = [ - makeEntry({ text: "part 1", type: "text", agent: "executor" }), - makeEntry({ text: "Read", type: "tool", agent: "executor" }), - makeEntry({ text: " part 2", type: "text", agent: "executor" }), - ]; - - const { container } = render(); - expect(container.querySelectorAll(".agent-log-text")).toHaveLength(2); - expect(container.querySelectorAll(".agent-log-tool")).toHaveLength(1); - }); - - it("does not group text entries from different agents", () => { - const entries = [ - makeEntry({ text: "triage", agent: "triage" }), - makeEntry({ text: "executor", agent: "executor" }), - ]; - - const { container } = render(); - expect(container.querySelectorAll(".agent-log-text")).toHaveLength(2); - }); - - it("does not group entries across text and thinking type boundaries", () => { - const entries = [ - makeEntry({ text: "text", type: "text", agent: "executor" }), - makeEntry({ text: "thought", type: "thinking", agent: "executor" }), - ]; - - const { container } = render(); - expect(container.querySelectorAll(".agent-log-text")).toHaveLength(1); - expect(container.querySelectorAll(".agent-log-thinking")).toHaveLength(1); - }); - - it("shows badge and timestamp only once at the start of a grouped text run", () => { - const entries = [ - makeEntry({ text: "a", type: "text", agent: "executor", timestamp: "2026-01-01T00:00:00Z" }), - makeEntry({ text: "b", type: "text", agent: "executor", timestamp: "2026-01-01T00:00:01Z" }), - ]; - - const { container } = render(); - expect(container.querySelectorAll(".agent-log-agent-badge")).toHaveLength(1); - expect(container.querySelectorAll(".agent-log-timestamp")).toHaveLength(1); - }); - }); - - it("renders tool entry detail toggle collapsed by default when detail is present", () => { - const entries = [ - makeEntry({ text: "Bash", type: "tool", detail: "ls -la packages/" }), - ]; - render(); - - const toggle = screen.getByTestId("tool-detail-toggle"); - expect(toggle).toBeTruthy(); - expect(toggle.getAttribute("aria-expanded")).toBe("false"); - const content = screen.getByTestId("tool-detail-content"); - expect(content.classList.contains("agent-log-tool-detail-content--collapsed")).toBe(true); - }); - - it("does not render detail toggle when detail is absent", () => { - const entries = [ - makeEntry({ text: "Bash", type: "tool" }), - makeEntry({ text: "Bash", type: "tool_result" }), - makeEntry({ text: "Bash", type: "tool_error" }), - ]; - render(); - expect(screen.queryByTestId("tool-detail-toggle")).toBeNull(); - }); - - it("renders long detail text without breaking layout", () => { - const longDetail = "a/very/long/path/".repeat(10) + "file.ts"; - const entries = [ - makeEntry({ text: "Read", type: "tool", detail: longDetail }), - ]; - const { container } = render(); - fireEvent.click(screen.getByTestId("tool-detail-toggle")); - const detail = container.querySelector(".agent-log-tool-detail"); - expect(detail).toBeTruthy(); - expect(detail!.textContent).toContain(longDetail); - // Verify the tool div still renders correctly - const toolDiv = container.querySelector(".agent-log-tool"); - expect(toolDiv).toBeTruthy(); - }); - - it("collapses tool-like detail by default across tool, tool_result, and tool_error", () => { - const entries = [ - makeEntry({ text: "Read", type: "tool", detail: "tool output" }), - makeEntry({ text: "Done", type: "tool_result", detail: "result output" }), - makeEntry({ text: "Oops", type: "tool_error", detail: "error output" }), - ]; - render(); - - const toggles = screen.getAllByTestId("tool-detail-toggle"); - expect(toggles).toHaveLength(3); - const contents = screen.getAllByTestId("tool-detail-content"); - expect(contents).toHaveLength(3); - for (const content of contents) { - expect(content.classList.contains("agent-log-tool-detail-content--collapsed")).toBe(true); - } - }); - - it("expands and collapses tool detail on toggle click", () => { - const entries = [ - makeEntry({ text: "Bash", type: "tool", detail: "line 1\nline 2" }), - ]; - render(); - - const toggle = screen.getByTestId("tool-detail-toggle"); - expect(toggle.getAttribute("aria-expanded")).toBe("false"); - fireEvent.click(toggle); - expect(toggle.getAttribute("aria-expanded")).toBe("true"); - const content = screen.getByTestId("tool-detail-content"); - expect(content.textContent).toContain("line 1"); - expect(content.classList.contains("agent-log-tool-detail-content--collapsed")).toBe(false); - fireEvent.click(toggle); - expect(toggle.getAttribute("aria-expanded")).toBe("false"); - expect(content.classList.contains("agent-log-tool-detail-content--collapsed")).toBe(true); - }); - - it("applies the viewer styling via the agent-log-viewer class", () => { - const entries = [makeEntry()]; - const { container } = render(); - const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; - expect(viewer.classList.contains("agent-log-viewer")).toBe(true); - // Theme/layout styles come from CSS classes, not inline style attributes. - expect(viewer.style.fontFamily).toBe(""); - }); - - describe("agent badge deduplication", () => { - it("shows badge only on the first (oldest) of consecutive text entries from the same agent", () => { - const entries = [ - makeEntry({ text: "chunk 1", type: "text", agent: "executor" }), - makeEntry({ text: "chunk 2", type: "text", agent: "executor" }), - makeEntry({ text: "chunk 3", type: "text", agent: "executor" }), - ]; - const { container } = render(); - const badges = container.querySelectorAll(".agent-log-agent-badge"); - expect(badges).toHaveLength(1); - // In chronological order, the oldest (chunk 1) gets the badge - expect(badges[0].textContent).toBe("[executor]"); - }); - - it("shows badge on each agent transition in chronological order", () => { - const entries = [ - makeEntry({ text: "hello", type: "text", agent: "triage" }), - makeEntry({ text: "world", type: "text", agent: "triage" }), - makeEntry({ text: "starting", type: "text", agent: "executor" }), - makeEntry({ text: "done", type: "text", agent: "executor" }), - ]; - const { container } = render(); - const badges = container.querySelectorAll(".agent-log-agent-badge"); - expect(badges).toHaveLength(2); - expect(badges[0].textContent).toBe("[Plan]"); - expect(badges[1].textContent).toBe("[executor]"); - }); - - it("shows badge on text, tool, and text-after-tool (same agent, type change) in chronological order", () => { - const entries = [ - makeEntry({ text: "reading...", type: "text", agent: "executor" }), - makeEntry({ text: "Read", type: "tool", agent: "executor" }), - makeEntry({ text: "got it", type: "text", agent: "executor" }), - ]; - const { container } = render(); - const badges = container.querySelectorAll(".agent-log-agent-badge"); - // Chronological: reading... (text), Read (tool), got it (text) - // Badge on reading... (i=0), Read (always block-level), got it (type changed from tool) - expect(badges).toHaveLength(3); - }); - - it("shows badge only on the first (oldest) of consecutive thinking entries from the same agent", () => { - const entries = [ - makeEntry({ text: "hmm", type: "thinking", agent: "triage" }), - makeEntry({ text: "let me think", type: "thinking", agent: "triage" }), - makeEntry({ text: "ok", type: "thinking", agent: "triage" }), - ]; - const { container } = render(); - const badges = container.querySelectorAll(".agent-log-agent-badge"); - expect(badges).toHaveLength(1); - // In chronological order, the oldest (hmm) gets the badge - expect(badges[0].textContent).toBe("[Plan]"); - }); - - it("always shows badge on tool entries regardless of surrounding entries", () => { - const entries = [ - makeEntry({ text: "Bash", type: "tool", agent: "executor" }), - makeEntry({ text: "Read", type: "tool", agent: "executor" }), - makeEntry({ text: "Write", type: "tool", agent: "executor" }), - ]; - const { container } = render(); - const badges = container.querySelectorAll(".agent-log-agent-badge"); - expect(badges).toHaveLength(3); - }); - - it("always shows badge on tool_result and tool_error entries", () => { - const entries = [ - makeEntry({ text: "Bash", type: "tool", agent: "executor" }), - makeEntry({ text: "ok", type: "tool_result", agent: "executor" }), - makeEntry({ text: "Read", type: "tool", agent: "executor" }), - makeEntry({ text: "not found", type: "tool_error", agent: "executor" }), - ]; - const { container } = render(); - const badges = container.querySelectorAll(".agent-log-agent-badge"); - expect(badges).toHaveLength(4); - }); - - it("produces no badges when entries have no agent field", () => { - const entries = [ - makeEntry({ text: "legacy chunk 1", type: "text" }), - makeEntry({ text: "legacy chunk 2", type: "text" }), - ]; - const { container } = render(); - const badges = container.querySelectorAll(".agent-log-agent-badge"); - expect(badges).toHaveLength(0); - }); - }); - - describe("model info header", () => { - it("renders model info header with executor model when set", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy(); - expect(header!.textContent).not.toContain("Executor:"); - - fireEvent.click(screen.getByTestId("agent-log-model-expand")); - expect(header!.textContent).toContain("Executor:"); - expect(header!.textContent).toContain("anthropic/claude-sonnet-4-5"); - }); - - it("renders 'Using default' when no executor model override is set", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(header!.textContent).not.toContain("Using default"); - }); - - it("renders 'Using default' when executorModel is undefined", () => { - const entries = [makeEntry()]; - const { container } = render(); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(header!.textContent).not.toContain("Using default"); - }); - - it("renders model info header with validator model when set", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(container.querySelector('[data-provider="openai"]')).toBeTruthy(); - expect(header!.textContent).not.toContain("Reviewer:"); - - fireEvent.click(screen.getByTestId("agent-log-model-expand")); - expect(header!.textContent).toContain("Reviewer:"); - expect(header!.textContent).toContain("openai/gpt-4o"); - }); - - it("renders 'Using default' when no validator model override is set", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(header!.textContent).not.toContain("Using default"); - }); - - it("renders both models when both are configured", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy(); - expect(container.querySelector('[data-provider="openai"]')).toBeTruthy(); - expect(header!.textContent).not.toContain("anthropic/claude-opus-4"); - expect(header!.textContent).not.toContain("openai/gpt-4o"); - - fireEvent.click(screen.getByTestId("agent-log-model-expand")); - expect(header!.textContent).toContain("anthropic/claude-opus-4"); - expect(header!.textContent).toContain("openai/gpt-4o"); - }); - - it("renders header with 'Using default' for both models when both are null/undefined", () => { - const entries = [makeEntry()]; - const { container } = render(); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(header!.textContent).not.toContain("Using default"); - }); - - it("shows 'Using default' when executorModel has only provider but no modelId", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(header!.textContent).not.toContain("Using default"); - }); - - it("shows 'Using default' when executorModel has only modelId but no provider", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(header!.textContent).not.toContain("Using default"); - }); - - it("renders model info header with planning model when set", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy(); - expect(header!.textContent).not.toContain("Planning:"); - - fireEvent.click(screen.getByTestId("agent-log-model-expand")); - expect(header!.textContent).toContain("Planning:"); - expect(header!.textContent).toContain("anthropic/claude-opus-4"); - }); - - it("renders 'Using default' for planning when no planning model is set", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(header!.textContent).not.toContain("Using default"); - }); - - it("renders 'Using default' for planning when planningModel is undefined", () => { - const entries = [makeEntry()]; - const { container } = render(); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(header!.textContent).not.toContain("Using default"); - }); - - it("renders all three models when all are configured", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy(); - expect(container.querySelector('[data-provider="openai"]')).toBeTruthy(); - expect(container.querySelector('[data-provider="google"]')).toBeTruthy(); - expect(header!.textContent).not.toContain("anthropic/claude-opus-4"); - expect(header!.textContent).not.toContain("openai/gpt-4o"); - expect(header!.textContent).not.toContain("google/gemini-pro"); - - fireEvent.click(screen.getByTestId("agent-log-model-expand")); - expect(header!.textContent).toContain("anthropic/claude-opus-4"); - expect(header!.textContent).toContain("openai/gpt-4o"); - expect(header!.textContent).toContain("google/gemini-pro"); - }); - - it("shows 'Using default' for planning when planningModel has only provider but no modelId", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - const header = container.querySelector("[data-testid='agent-log-model-header']"); - expect(header).toBeTruthy(); - expect(header!.textContent).not.toContain("Using default"); - }); - }); - - describe("model header expand/collapse", () => { - it("shows only provider icons in collapsed state, hides model text", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - - expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy(); - expect(screen.getByTestId("agent-log-model-expand")).toBeTruthy(); - expect(container.textContent).not.toContain("Executor:"); - expect(container.textContent).not.toContain("claude-sonnet-4-5"); - }); - - it("shows model details when expand button is clicked", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - - fireEvent.click(screen.getByTestId("agent-log-model-expand")); - expect(container.textContent).toContain("Executor:"); - expect(container.textContent).toContain("anthropic/claude-sonnet-4-5"); - }); - - it("collapses model details when expand button is clicked again", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - - const button = screen.getByTestId("agent-log-model-expand"); - fireEvent.click(button); - expect(container.textContent).toContain("Executor:"); - fireEvent.click(button); - expect(container.textContent).not.toContain("Executor:"); - }); - - it("shows no provider icons when no model overrides are set", () => { - const entries = [makeEntry()]; - const { container } = render(); - - expect(container.querySelector("[data-provider]")).toBeNull(); - }); - - it("has aria-expanded=false when collapsed and aria-expanded=true when expanded", () => { - const entries = [makeEntry()]; - render( - , - ); - - const button = screen.getByTestId("agent-log-model-expand"); - expect(button.getAttribute("aria-expanded")).toBe("false"); - fireEvent.click(button); - expect(button.getAttribute("aria-expanded")).toBe("true"); - }); - - it("renders multiple provider icons for multiple overrides", () => { - const entries = [makeEntry()]; - const { container } = render( - , - ); - - expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy(); - expect(container.querySelector('[data-provider="openai"]')).toBeTruthy(); - expect(container.querySelector('[data-provider="google"]')).toBeTruthy(); - }); - }); - - describe("timestamp display", () => { - it("renders no timestamps for entries without agent field", () => { - const entries = [ - makeEntry({ text: "legacy 1", type: "text" }), - makeEntry({ text: "legacy 2", type: "text" }), - ]; - const { container } = render(); - const timestamps = container.querySelectorAll(".agent-log-timestamp"); - expect(timestamps).toHaveLength(0); - }); - - it("renders relative timestamps for recent entries next to the badge", () => { - const recentTimestamp = new Date(Date.now() - 5 * 60 * 1000).toISOString(); - const entries = [ - makeEntry({ text: "hello", type: "text", agent: "executor", timestamp: recentTimestamp }), - ]; - const { container } = render(); - const timestamp = container.querySelector(".agent-log-timestamp"); - expect(timestamp).toBeTruthy(); - expect(timestamp!.textContent).toBe("5m ago"); - - const badge = container.querySelector(".agent-log-agent-badge") as HTMLElement; - expect(badge).toBeTruthy(); - expect(badge.parentElement?.querySelector(".agent-log-timestamp")).toBeTruthy(); - }); - - it("renders 'just now' for entries less than a minute old", () => { - const recentTimestamp = new Date(Date.now() - 30 * 1000).toISOString(); - const entries = [ - makeEntry({ text: "hello", type: "text", agent: "executor", timestamp: recentTimestamp }), - ]; - const { container } = render(); - const timestamp = container.querySelector(".agent-log-timestamp"); - expect(timestamp!.textContent).toBe("just now"); - }); - - it("renders hours ago for older entries", () => { - const olderTimestamp = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(); - const entries = [ - makeEntry({ text: "hello", type: "text", agent: "executor", timestamp: olderTimestamp }), - ]; - const { container } = render(); - const timestamp = container.querySelector(".agent-log-timestamp"); - expect(timestamp!.textContent).toBe("3h ago"); - }); - - it("renders days ago for entries older than a day", () => { - const oldTimestamp = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(); - const entries = [ - makeEntry({ text: "hello", type: "text", agent: "executor", timestamp: oldTimestamp }), - ]; - const { container } = render(); - const timestamp = container.querySelector(".agent-log-timestamp"); - expect(timestamp!.textContent).toBe("2d ago"); - }); - - it("renders locale date for entries older than 7 days", () => { - const veryOldTimestamp = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); - const entries = [ - makeEntry({ text: "hello", type: "text", agent: "executor", timestamp: veryOldTimestamp }), - ]; - const { container } = render(); - const timestamp = container.querySelector(".agent-log-timestamp"); - // Should be a locale date string, not a relative time - expect(timestamp!.textContent).not.toContain("ago"); - expect(timestamp!.textContent).not.toBe("just now"); - }); - - it("uses the timestamp class inside the badge row", () => { - const entries = [makeEntry({ text: "hello", type: "text", agent: "executor" })]; - const { container } = render(); - const badge = container.querySelector(".agent-log-agent-badge") as HTMLElement; - expect(badge).toBeTruthy(); - const timestamp = badge.parentElement?.querySelector(".agent-log-timestamp") as HTMLElement; - expect(timestamp).toBeTruthy(); - expect(timestamp.classList.contains("agent-log-timestamp")).toBe(true); - // Theme styles are class-based now, not inline. - expect(timestamp.style.fontSize).toBe(""); - expect(timestamp.style.opacity).toBe(""); - }); - - it("renders the agent badge as a sticky overlay on a full-width text block", () => { - const entries = [makeEntry({ text: "long executor output", type: "text", agent: "executor" })]; - const { container } = render(); - const block = container.querySelector(".agent-log-text") as HTMLElement; - const badgeRow = container.querySelector(".agent-log-badge-row") as HTMLElement; - - expect(block).toBeTruthy(); - expect(badgeRow).toBeTruthy(); - expect(getComputedStyle(block).width).toBe("100%"); - expect(getComputedStyle(badgeRow).position).toBe("sticky"); - expect(getComputedStyle(badgeRow).left).not.toBe(""); - expect(getComputedStyle(badgeRow).pointerEvents).toBe("none"); - }); - - it("includes timestamp in the badge container for tool entries", () => { - const entries = [makeEntry({ text: "Bash", type: "tool", agent: "executor" })]; - const { container } = render(); - const toolDiv = container.querySelector(".agent-log-tool"); - expect(toolDiv).toBeTruthy(); - const badge = toolDiv!.querySelector(".agent-log-agent-badge") as HTMLElement; - expect(badge).toBeTruthy(); - expect(badge.parentElement?.querySelector(".agent-log-timestamp")).toBeTruthy(); - }); - - it("includes timestamp in the badge container for tool_result entries", () => { - const entries = [makeEntry({ text: "ok", type: "tool_result", agent: "executor" })]; - const { container } = render(); - const resultDiv = container.querySelector(".agent-log-tool-result"); - expect(resultDiv).toBeTruthy(); - const badge = resultDiv!.querySelector(".agent-log-agent-badge") as HTMLElement; - expect(badge).toBeTruthy(); - expect(badge.parentElement?.querySelector(".agent-log-timestamp")).toBeTruthy(); - }); - - it("includes timestamp in the badge container for tool_error entries", () => { - const entries = [makeEntry({ text: "fail", type: "tool_error", agent: "executor" })]; - const { container } = render(); - const errorDiv = container.querySelector(".agent-log-tool-error"); - expect(errorDiv).toBeTruthy(); - const badge = errorDiv!.querySelector(".agent-log-agent-badge") as HTMLElement; - expect(badge).toBeTruthy(); - expect(badge.parentElement?.querySelector(".agent-log-timestamp")).toBeTruthy(); - }); - - it("includes timestamp in the badge container for thinking entries", () => { - const entries = [makeEntry({ text: "hmm", type: "thinking", agent: "executor" })]; - const { container } = render(); - const thinkingSpan = container.querySelector(".agent-log-thinking"); - expect(thinkingSpan).toBeTruthy(); - const badge = thinkingSpan!.querySelector(".agent-log-agent-badge") as HTMLElement; - expect(badge).toBeTruthy(); - expect(badge.parentElement?.querySelector(".agent-log-timestamp")).toBeTruthy(); - }); - - it("shows exactly one timestamp for consecutive text entries from the same agent", () => { - const entries = [ - makeEntry({ text: "chunk 1", type: "text", agent: "executor" }), - makeEntry({ text: "chunk 2", type: "text", agent: "executor" }), - ]; - const { container } = render(); - const timestamps = container.querySelectorAll(".agent-log-timestamp"); - expect(timestamps).toHaveLength(1); - }); - - it("renders timestamps at each agent transition", () => { - const entries = [ - makeEntry({ text: "triage output", type: "text", agent: "triage" }), - makeEntry({ text: "executor output", type: "text", agent: "executor" }), - makeEntry({ text: "review notes", type: "text", agent: "reviewer" }), - ]; - const { container } = render(); - const badges = Array.from(container.querySelectorAll(".agent-log-agent-badge")); - const timestamps = container.querySelectorAll(".agent-log-timestamp"); - - expect(badges).toHaveLength(3); - expect(timestamps).toHaveLength(3); - expect(badges.map((badge) => badge.textContent)).toEqual(["[Plan]", "[executor]", "[reviewer]"]); - }); - - it("badge container includes both badge text and timestamp text", () => { - const recentTimestamp = new Date(Date.now() - 5 * 60 * 1000).toISOString(); - const entries = [ - makeEntry({ text: "hello", type: "text", agent: "executor", timestamp: recentTimestamp }), - ]; - const { container } = render(); - const badge = container.querySelector(".agent-log-agent-badge") as HTMLElement; - expect(badge).toBeTruthy(); - - const badgeContainer = badge.parentElement as HTMLElement; - expect(badgeContainer.textContent).toContain("[executor]"); - expect(badgeContainer.textContent).toContain("5m ago"); - }); - }); - - describe("horizontal overflow prevention", () => { - it("uses the scroll container class for overflow-x handling", () => { - const longString = "A".repeat(300); - const entries = [makeEntry({ text: longString })]; - const { container } = render(); - const scrollContainer = getScrollContainer(container); - expect(scrollContainer.classList.contains("agent-log-viewer-scroll")).toBe(true); - expect(scrollContainer.style.overflowX).toBe(""); - }); - - it("uses the scroll container class for overflow-wrap handling", () => { - const entries = [makeEntry({ text: "x".repeat(250) })]; - const { container } = render(); - const scrollContainer = getScrollContainer(container); - expect(scrollContainer.classList.contains("agent-log-viewer-scroll")).toBe(true); - expect(scrollContainer.style.overflowWrap).toBe(""); - }); - - it("renders pre elements with overflow-x auto for internal scrolling", () => { - const longLine = "const x = " + "'a'.repeat(500)"; - const entries = [makeEntry({ text: "```\n" + longLine + "\n```" })]; - const { container } = render(); - const pre = container.querySelector("pre") as HTMLElement; - expect(pre).toBeTruthy(); - expect(pre.style.overflowX).toBe("auto"); - expect(pre.style.maxWidth).toBe("100%"); - }); - - it("applies model-header wrapping via class", () => { - const entries = [makeEntry()]; - const { container } = render(); - const header = container.querySelector("[data-testid='agent-log-model-header']") as HTMLElement; - expect(header.classList.contains("agent-log-model-header")).toBe(true); - expect(header.style.flexWrap).toBe(""); - }); - }); - - describe("full-height layout", () => { - it("does not have a fixed maxHeight constraint", () => { - const entries = [makeEntry()]; - const { container } = render(); - const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; - // The viewer should NOT have a maxHeight of 500px (the old fixed constraint) - expect(viewer.style.maxHeight).not.toBe("500px"); - // maxHeight should be empty (unset) so the viewer can grow to fill available space - expect(viewer.style.maxHeight).toBe(""); - }); - - it("uses class-based overflow-y scrolling on the entries container", () => { - const entries = [makeEntry()]; - const { container } = render(); - const scrollContainer = getScrollContainer(container); - // Scrolling behavior is now defined in CSS. - expect(scrollContainer.classList.contains("agent-log-viewer-scroll")).toBe(true); - expect(scrollContainer.style.overflowY).toBe(""); - }); - - it("uses agent-log-viewer--streaming class when entries are present", () => { - const entries = [makeEntry()]; - const { container } = render(); - const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; - expect(viewer.classList.contains("agent-log-viewer")).toBe(true); - expect(viewer.classList.contains("agent-log-viewer--streaming")).toBe(true); - }); - - it("does not use streaming class on loading state", () => { - const { container } = render(); - const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; - expect(viewer.classList.contains("agent-log-viewer")).toBe(true); - expect(viewer.classList.contains("agent-log-viewer--streaming")).toBe(false); - }); - }); - - describe("sticky header layout", () => { - it("renders the model header as a sibling of the scroll container", () => { - const entries = [makeEntry()]; - const { container } = render(); - const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; - const header = screen.getByTestId("agent-log-model-header"); - const scrollContainer = getScrollContainer(container); - - expect(header.parentElement).toBe(viewer); - expect(scrollContainer.parentElement).toBe(viewer); - expect(scrollContainer.contains(header)).toBe(false); - }); - - it("renders log entry rows inside the scroll container", () => { - const entries = [ - makeEntry({ type: "text", text: "hello" }), - makeEntry({ type: "tool", text: "Bash" }), - ]; - const { container } = render(); - const scrollContainer = getScrollContainer(container); - - expect(scrollContainer.querySelector(".agent-log-text")).toBeTruthy(); - expect(scrollContainer.querySelector(".agent-log-tool")).toBeTruthy(); - }); - - it("renders pagination summary and load-more controls inside the scroll container", () => { - const entries = [makeEntry({ text: "hello" })]; - const { container } = render( - {}} - />, - ); - const scrollContainer = getScrollContainer(container); - - expect(scrollContainer.querySelector("[data-testid='agent-log-summary']")).toBeTruthy(); - expect(scrollContainer.querySelector("[data-testid='agent-log-load-more']")).toBeTruthy(); - }); - - it("renders the return-to-live button inside the scroll container", () => { - const entries = [ - makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), - makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), - ]; - const { container } = render(); - const scrollContainer = getScrollContainer(container); - - Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1000 }); - Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 200 }); - - scrollContainer.scrollTop = 300; - fireEvent.scroll(scrollContainer); - - const returnToLive = screen.getByTestId("agent-log-return-to-live"); - expect(returnToLive.parentElement).toBe(scrollContainer); - }); - }); - - describe("auto-scroll behavior", () => { - it("scrolls to bottom when streaming updates arrive and user is near the bottom", () => { - const initialEntries = [ - makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), - ]; - const streamedEntries = [ - ...initialEntries, - makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), - ]; - - const { rerender, container } = render(); - const viewer = getScrollContainer(container); - - let scrollHeight = 600; - Object.defineProperty(viewer, "scrollHeight", { - configurable: true, - get: () => scrollHeight, - }); - - viewer.scrollTop = 560; - rerender(); - - scrollHeight = 720; - rerender(); - - expect(viewer.scrollTop).toBe(720); - }); - - it("does not auto-scroll when streaming updates arrive and user is reading older output", () => { - const initialEntries = [ - makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), - ]; - const streamedEntries = [ - ...initialEntries, - makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), - ]; - - const { rerender, container } = render(); - const viewer = getScrollContainer(container); - - let scrollHeight = 1000; - Object.defineProperty(viewer, "scrollHeight", { - configurable: true, - get: () => scrollHeight, - }); - - viewer.scrollTop = 220; - rerender(); - - scrollHeight = 1120; - rerender(); - - expect(viewer.scrollTop).toBe(220); - }); - - it("keeps viewport anchored when older history is prepended", () => { - const initialEntries = [ - makeEntry({ text: "recent", timestamp: "2026-01-01T00:00:00Z" }), - ]; - const olderLoadedEntries = [ - makeEntry({ text: "older", timestamp: "2025-12-31T23:59:00Z" }), - ...initialEntries, - ]; - - const { rerender, container } = render(); - const viewer = getScrollContainer(container); - - let scrollHeight = 900; - Object.defineProperty(viewer, "scrollHeight", { - configurable: true, - get: () => scrollHeight, - }); - - viewer.scrollTop = 260; - rerender(); - - scrollHeight = 1030; - rerender(); - - // Anchored by delta (1030 - 900): 260 + 130 - expect(viewer.scrollTop).toBe(390); - }); - - it("shows return-to-live button when user scrolls away from bottom", () => { - const entries = [ - makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), - makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), - ]; - const { container } = render(); - const viewer = getScrollContainer(container); - - Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1000 }); - Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 }); - - viewer.scrollTop = 300; - fireEvent.scroll(viewer); - - expect(screen.getByTestId("agent-log-return-to-live")).toBeTruthy(); - }); - - it("hides return-to-live button when user is following live output", () => { - const entries = [ - makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), - makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), - ]; - const { container } = render(); - const viewer = getScrollContainer(container); - - Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1000 }); - Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 }); - - viewer.scrollTop = 760; - fireEvent.scroll(viewer); - - expect(screen.queryByTestId("agent-log-return-to-live")).toBeNull(); - }); - - it("returns to bottom and resumes following when return-to-live is clicked", () => { - const entries = [ - makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), - makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), - ]; - const { container } = render(); - const viewer = getScrollContainer(container); - - Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1000 }); - Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 }); - - viewer.scrollTop = 280; - fireEvent.scroll(viewer); - - const returnButton = screen.getByTestId("agent-log-return-to-live"); - fireEvent.click(returnButton); - - expect(viewer.scrollTop).toBe(1000); - expect(screen.queryByTestId("agent-log-return-to-live")).toBeNull(); - }); - - it("re-pins to bottom on resize while following", () => { - const resizeCallbacks: Array<() => void> = []; - const originalResizeObserver = globalThis.ResizeObserver; - - class ResizeObserverMock { - constructor(callback: ResizeObserverCallback) { - resizeCallbacks.push(() => callback([], this as unknown as ResizeObserver)); - } - - observe() {} - unobserve() {} - disconnect() {} - } - - Object.defineProperty(globalThis, "ResizeObserver", { - configurable: true, - value: ResizeObserverMock, - }); - - try { - const entries = [ - makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), - makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), - ]; - const { container } = render(); - const viewer = getScrollContainer(container); - - Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1200 }); - Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 }); - viewer.scrollTop = 980; - fireEvent.scroll(viewer); - - viewer.scrollTop = 640; - resizeCallbacks.forEach((callback) => callback()); - - expect(viewer.scrollTop).toBe(1200); - } finally { - if (originalResizeObserver) { - Object.defineProperty(globalThis, "ResizeObserver", { - configurable: true, - value: originalResizeObserver, - }); - } else { - delete (globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver; - } - } - }); - }); - - describe("pagination placement", () => { - it("renders the load-more control above the first log entry", () => { - const entries = [ - makeEntry({ text: "oldest", timestamp: "2026-01-01T00:00:00Z" }), - makeEntry({ text: "newest", timestamp: "2026-01-01T00:00:01Z" }), - ]; - - const { container } = render( - {}} - />, - ); - - const loadMore = screen.getByTestId("agent-log-load-more"); - const firstRow = container.querySelector(".agent-log-text") as HTMLElement; - expect(firstRow).toBeTruthy(); - - expect(loadMore.compareDocumentPosition(firstRow) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - }); - }); - - describe("long content preservation", () => { - it("renders very long text entries without truncation", () => { - const longText = "A".repeat(5000); - const entries = [makeEntry({ text: longText })]; - const { container } = render(); - const textSpans = container.querySelectorAll(".agent-log-text"); - expect(textSpans).toHaveLength(1); - expect(textSpans[0].textContent).toContain(longText); - }); - - it("renders very long detail text without truncation", () => { - const longDetail = "B".repeat(5000); - const entries = [makeEntry({ text: "Read", type: "tool", detail: longDetail })]; - const { container } = render(); - fireEvent.click(screen.getByTestId("tool-detail-toggle")); - const detail = container.querySelector(".agent-log-tool-detail"); - expect(detail).toBeTruthy(); - expect(detail!.textContent).toContain(longDetail); - expect(detail!.textContent!.length).toBe(5000); - }); - - it("renders multiline text content without truncation", () => { - const multilineText = [ - "## Analysis", - "", - "After reviewing the codebase:", - "", - "1. First issue found", - "2. Second issue found", - "", - "```typescript", - "const x = 1;", - "```", - "", - "Line " + "C".repeat(2000) + " end", - ].join("\n"); - const entries = [makeEntry({ text: multilineText })]; - const { container } = render(); - const textSpans = container.querySelectorAll(".agent-log-text"); - expect(textSpans).toHaveLength(1); - // The markdown-rendered content should still contain the essential parts - expect(textSpans[0].textContent).toContain("Analysis"); - expect(textSpans[0].textContent).toContain("First issue found"); - expect(textSpans[0].textContent).toContain("const x = 1"); - }); - - it("renders long tool_result detail without truncation", () => { - const longDetail = "D".repeat(5000); - const entries = [makeEntry({ text: "Bash", type: "tool_result", detail: longDetail })]; - const { container } = render(); - fireEvent.click(screen.getByTestId("tool-detail-toggle")); - const detail = container.querySelector(".agent-log-tool-detail"); - expect(detail).toBeTruthy(); - expect(detail!.textContent).toContain(longDetail); - }); - - it("renders long tool_error detail without truncation", () => { - const longDetail = "E".repeat(5000); - const entries = [makeEntry({ text: "Write", type: "tool_error", detail: longDetail })]; - const { container } = render(); - fireEvent.click(screen.getByTestId("tool-detail-toggle")); - const detail = container.querySelector(".agent-log-tool-detail"); - expect(detail).toBeTruthy(); - expect(detail!.textContent).toContain(longDetail); - }); - - it("preserves raw whitespace in tool detail blocks", () => { - const detailText = "stdout:\n line one\n indented line two\n"; - const entries = [makeEntry({ text: "Bash", type: "tool_result", detail: detailText })]; - const { container } = render(); - fireEvent.click(screen.getByTestId("tool-detail-toggle")); - const detail = container.querySelector(".agent-log-tool-detail") as HTMLElement; - expect(detail).toBeTruthy(); - expect(detail.tagName).toBe("PRE"); - expect(detail.textContent).toBe(detailText); - }); - }); - - describe("markdown rendering", () => { - it("renders plain text without markdown correctly", () => { - const entries = [ - makeEntry({ text: "Hello world, this is plain text." }), - ]; - const { container } = render(); - const textSpans = container.querySelectorAll(".agent-log-text"); - expect(textSpans).toHaveLength(1); - expect(textSpans[0].textContent).toContain("Hello world, this is plain text."); - }); - - it("renders text entries inside markdown-body in markdown mode", () => { - const entries = [ - makeEntry({ text: "Paragraph one\n\nParagraph two" }), - ]; - const { container } = render(); - const textRow = container.querySelector(".agent-log-text") as HTMLElement; - expect(textRow).toBeTruthy(); - - const proseContainer = textRow.querySelector(".markdown-body") as HTMLElement; - expect(proseContainer).toBeTruthy(); - expect(proseContainer.querySelectorAll("p")).toHaveLength(2); - }); - - it("renders thinking entries inside markdown-body in markdown mode", () => { - const entries = [ - makeEntry({ text: "Considering:\n\n- option A\n- option B", type: "thinking" }), - ]; - const { container } = render(); - const thinkingRow = container.querySelector(".agent-log-thinking") as HTMLElement; - expect(thinkingRow).toBeTruthy(); - - const proseContainer = thinkingRow.querySelector(".markdown-body") as HTMLElement; - expect(proseContainer).toBeTruthy(); - expect(proseContainer.querySelector("ul")).toBeTruthy(); - }); - - it("renders inline markdown elements (bold, italic, inline code)", () => { - const entries = [ - makeEntry({ text: "This is **bold** and *italic* with `inline code`." }), - ]; - const { container } = render(); - const textSpans = container.querySelectorAll(".agent-log-text"); - expect(textSpans).toHaveLength(1); - // Check that the markdown elements are rendered - const strong = textSpans[0].querySelector("strong"); - expect(strong).toBeTruthy(); - expect(strong!.textContent).toBe("bold"); - const em = textSpans[0].querySelector("em"); - expect(em).toBeTruthy(); - expect(em!.textContent).toBe("italic"); - const code = textSpans[0].querySelector("code"); - expect(code).toBeTruthy(); - expect(code!.textContent).toBe("inline code"); - }); - - it("renders file links inside inline code with the code wrapper preserved", () => { - const openFile = vi.fn(); - const entries = [ - makeEntry({ text: "Check `packages/engine/src/scheduler.ts:7` now." }), - ]; - const { container } = render( - - - , - ); - - const fileLink = screen.getByRole("button", { name: "packages/engine/src/scheduler.ts:7" }); - const code = fileLink.closest("code"); - expect(code).toBeTruthy(); - expect(code?.querySelector("button.file-path-link")).toBe(fileLink); - - fireEvent.click(fileLink); - expect(openFile).toHaveBeenCalledWith("packages/engine/src/scheduler.ts", { line: 7, col: undefined }); - expect(container.querySelectorAll("code button.file-path-link")).toHaveLength(1); - }); - - it("renders code blocks with GFM support", () => { - const entries = [ - makeEntry({ text: "```typescript\nconst x = 1;\n```" }), - ]; - const { container } = render(); - const textSpans = container.querySelectorAll(".agent-log-text"); - expect(textSpans).toHaveLength(1); - // Check that code block is rendered - const pre = textSpans[0].querySelector("pre"); - expect(pre).toBeTruthy(); - const code = pre!.querySelector("code"); - expect(code).toBeTruthy(); - expect(code!.textContent).toContain("const x = 1"); - }); - - it("renders GFM task lists", () => { - const entries = [ - makeEntry({ text: "- [x] Completed task\n- [ ] Pending task" }), - ]; - const { container } = render(); - const textSpans = container.querySelectorAll(".agent-log-text"); - expect(textSpans).toHaveLength(1); - // Check that task list is rendered - const ul = textSpans[0].querySelector("ul"); - expect(ul).toBeTruthy(); - const taskListItems = ul!.querySelectorAll("li"); - expect(taskListItems).toHaveLength(2); - // Check checkboxes - const checkboxes = ul!.querySelectorAll('input[type="checkbox"]'); - expect(checkboxes).toHaveLength(2); - expect((checkboxes[0] as HTMLInputElement).checked).toBe(true); - expect((checkboxes[1] as HTMLInputElement).checked).toBe(false); - }); - - it("renders blockquotes", () => { - const entries = [ - makeEntry({ text: "> This is a blockquote\n> with multiple lines" }), - ]; - const { container } = render(); - const textSpans = container.querySelectorAll(".agent-log-text"); - expect(textSpans).toHaveLength(1); - // Check that blockquote is rendered - const blockquote = textSpans[0].querySelector("blockquote"); - expect(blockquote).toBeTruthy(); - expect(blockquote!.textContent).toContain("This is a blockquote"); - }); - - it("renders markdown in thinking entries", () => { - const entries = [ - makeEntry({ text: "Let me think about **this problem**...", type: "thinking" }), - ]; - const { container } = render(); - const thinkingSpans = container.querySelectorAll(".agent-log-thinking"); - expect(thinkingSpans).toHaveLength(1); - const strong = thinkingSpans[0].querySelector("strong"); - expect(strong).toBeTruthy(); - expect(strong!.textContent).toBe("this problem"); - }); - - it("renders mixed content with markdown and plain text", () => { - const entries = [ - makeEntry({ text: "The code:\n\n```js\nconsole.log('hello');\n```\n\nworks!" }), - ]; - const { container } = render(); - const textSpans = container.querySelectorAll(".agent-log-text"); - expect(textSpans).toHaveLength(1); - const pre = textSpans[0].querySelector("pre"); - expect(pre).toBeTruthy(); - // Plain text before and after should be preserved - expect(textSpans[0].textContent).toContain("The code:"); - 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(); - 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(); - // In markdown mode, bold should be rendered as - 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(); - 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("FN-3847: uses accent text color for pressed markdown/tools toggles", () => { - window.localStorage.setItem("fn-agent-log-markdown", "true"); - window.localStorage.setItem("fn-agent-log-tool-output", "true"); - const entries = [makeEntry({ text: "hello" })]; - const { container } = render(); - - const markdownToggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; - const toolsToggle = container.querySelector("[data-testid='agent-log-tool-output-toggle']") as HTMLButtonElement; - const fullscreenToggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; - - expect(markdownToggle.getAttribute("aria-pressed")).toBe("true"); - expect(toolsToggle.getAttribute("aria-pressed")).toBe("true"); - - const markdownColor = getComputedStyle(markdownToggle).color; - const toolsColor = getComputedStyle(toolsToggle).color; - const unpressedColor = getComputedStyle(fullscreenToggle).color; - - // Contract: unpressed toggles keep the shared muted button color, pressed toggles switch to accent foreground. - expect(markdownColor.length).toBeGreaterThan(0); - expect(toolsColor.length).toBeGreaterThan(0); - expect(markdownColor).toBe(toolsColor); - expect(markdownColor).not.toBe(unpressedColor); - }); - - it("switches to plain text mode when clicked", () => { - const entries = [makeEntry({ text: "**bold** and *italic*" })]; - const { container } = render(); - const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; - - // Markdown mode starts with prose container + rendered markdown - const markdownModeTextRow = container.querySelector(".agent-log-text") as HTMLElement; - expect(markdownModeTextRow.querySelector(".markdown-body")).toBeTruthy(); - expect(markdownModeTextRow.querySelector("strong")?.textContent).toBe("bold"); - - // 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).toContain("**bold** and *italic*"); - const plainBlock = textSpans[0].querySelector(".agent-log-plain-block") as HTMLElement; - expect(plainBlock).toBeTruthy(); - // Plain mode should remove markdown rendering/prose container - expect(textSpans[0].querySelector(".markdown-body")).toBeNull(); - expect(textSpans[0].querySelector("strong")).toBeNull(); - expect(textSpans[0].querySelector("em")).toBeNull(); - }); - - it("concatenates grouped text into a single markdown render", () => { - const entries = [ - makeEntry({ text: "**bold", type: "text", agent: "executor" }), - makeEntry({ text: "** text", type: "text", agent: "executor" }), - ]; - const { container } = render(); - - const textRows = container.querySelectorAll(".agent-log-text"); - expect(textRows).toHaveLength(1); - expect(textRows[0].querySelectorAll(".markdown-body")).toHaveLength(1); - const strong = textRows[0].querySelector("strong"); - expect(strong).toBeTruthy(); - expect(strong!.textContent).toBe("bold"); - }); - - it("joins grouped chunks inline in plain text mode", () => { - const entries = [ - makeEntry({ text: "hello", type: "text", agent: "executor" }), - makeEntry({ text: " world", type: "text", agent: "executor" }), - ]; - const { container } = render(); - const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; - - fireEvent.click(toggle); - - const textRows = container.querySelectorAll(".agent-log-text"); - expect(textRows).toHaveLength(1); - expect(textRows[0].querySelector(".markdown-body")).toBeNull(); - expect(textRows[0].textContent).toContain("hello world"); - }); - - it("toggles back to markdown mode from plain text", () => { - const entries = [makeEntry({ text: "**bold** text" })]; - const { container } = render(); - 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("preserves line breaks in plain text mode for thinking entries", () => { - const entries = [makeEntry({ text: "line1\nline2\nline3", type: "thinking", agent: "executor" })]; - const { container } = render(); - const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement; - - fireEvent.click(toggle); - - const plainThinking = container.querySelector(".agent-log-thinking .agent-log-plain-block") as HTMLElement; - expect(plainThinking).toBeTruthy(); - expect(plainThinking.textContent).toContain("line1\nline2\nline3"); - }); - - 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(); - 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(); - 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 } = render(); - 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: ' and bold' }), - ]; - const { container } = render(); - 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(''); - expect(textSpans[0].textContent).toContain("bold"); - // 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(); - // 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(); - }); - }); - - describe("tool output toggle", () => { - it("renders the tool output toggle defaulting to On", () => { - const entries = [makeEntry()]; - const { container } = render(); - 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(); - 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("keeps the latest non-tool message visible as its own row when tools are hidden", () => { - const entries = [ - makeEntry({ text: "Starting plan", type: "text", agent: "executor", timestamp: "2026-01-01T00:00:00Z" }), - makeEntry({ text: "read file", type: "tool", agent: "executor", timestamp: "2026-01-01T00:00:01Z" }), - makeEntry({ text: "Final answer", type: "text", agent: "executor", timestamp: "2026-01-01T00:00:02Z" }), - ]; - const { container } = render(); - const toggle = container.querySelector("[data-testid='agent-log-tool-output-toggle']") as HTMLButtonElement; - - fireEvent.click(toggle); - - const textRows = container.querySelectorAll(".agent-log-text"); - expect(textRows).toHaveLength(2); - expect(textRows[0].textContent).toContain("Starting plan"); - expect(textRows[1].textContent).toContain("Final answer"); - expect(container.querySelectorAll(".agent-log-agent-badge")).toHaveLength(2); - expect(container.querySelectorAll(".agent-log-timestamp")).toHaveLength(2); - }); - - 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(); - 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( - , - ); - 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(); - 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(); - 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(); - 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(); - 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(); - 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()]; - const { container } = render(); - const markdownToggle = container.querySelector( - "[data-testid='agent-log-mode-toggle']", - ) as HTMLButtonElement; - const fullscreenToggle = container.querySelector( - "[data-testid='agent-log-fullscreen-toggle']", - ) as HTMLButtonElement; - - const markdownStyle = getComputedStyle(markdownToggle); - const fullscreenStyle = getComputedStyle(fullscreenToggle); - - expect(markdownStyle.minWidth).toBe(fullscreenStyle.minWidth); - expect(markdownStyle.minHeight).toBe(fullscreenStyle.minHeight); - expect(markdownStyle.minWidth).not.toBe("0px"); - expect(markdownStyle.minHeight).not.toBe("0px"); - }); - - it("adds visible gap spacing between header toggle buttons", () => { - const entries = [makeEntry()]; - const { container } = render(); - const toggleGroup = container.querySelector(".agent-log-model-header-toggle") as HTMLElement; - const toggleGroupStyle = getComputedStyle(toggleGroup); - - expect(toggleGroupStyle.gap).not.toBe(""); - expect(toggleGroupStyle.gap).not.toBe("normal"); - }); - - it("renders the fullscreen toggle button in the model info header", () => { - const entries = [makeEntry()]; - const { container } = render(); - const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']"); - expect(toggle).toBeTruthy(); - }); - - it("has correct aria attributes on the fullscreen toggle", () => { - const entries = [makeEntry()]; - const { container } = render(); - const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; - expect(toggle).toBeTruthy(); - expect(toggle.getAttribute("aria-label")).toBe("Expand agent log to full screen"); - expect(toggle.getAttribute("title")).toBe("Expand agent log to full screen"); - }); - - it("adds fullscreen class when toggle is clicked", () => { - const entries = [makeEntry()]; - const { container } = render(); - const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; - const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; - - // Initially not fullscreen - expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(false); - - // Click to enter fullscreen - fireEvent.click(toggle); - - // Should have fullscreen class - expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(true); - }); - - it("removes fullscreen class when toggle is clicked while in fullscreen", () => { - const entries = [makeEntry()]; - const { container } = render(); - const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; - const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; - - // Enter fullscreen - fireEvent.click(toggle); - expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(true); - - // Exit fullscreen - fireEvent.click(toggle); - expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(false); - }); - - it("updates aria label when toggling fullscreen", () => { - const entries = [makeEntry()]; - const { container } = render(); - const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; - - // Initially shows expand label - expect(toggle.getAttribute("aria-label")).toBe("Expand agent log to full screen"); - - // Enter fullscreen - fireEvent.click(toggle); - expect(toggle.getAttribute("aria-label")).toBe("Exit full screen"); - - // Exit fullscreen - fireEvent.click(toggle); - expect(toggle.getAttribute("aria-label")).toBe("Expand agent log to full screen"); - }); - - it("exits fullscreen when Escape key is pressed", () => { - const entries = [makeEntry()]; - const { container } = render(); - const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; - const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; - - // Enter fullscreen - fireEvent.click(toggle); - expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(true); - - // Press Escape to exit - fireEvent.keyDown(document, { key: "Escape" }); - - expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(false); - }); - - it("does nothing when Escape key is pressed while not in fullscreen", () => { - const entries = [makeEntry()]; - const { container } = render(); - const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; - const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; - - // Initially not fullscreen - expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(false); - - // Press Escape - should do nothing - fireEvent.keyDown(document, { key: "Escape" }); - - expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(false); - - // Toggle should still work normally - fireEvent.click(toggle); - expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(true); - }); - - it("only responds to Escape key when in fullscreen mode", () => { - const entries = [makeEntry()]; - const { container, unmount } = render(); - const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; - const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']") as HTMLButtonElement; - - // Press Escape when not fullscreen - no effect - fireEvent.keyDown(document, { key: "Escape" }); - expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(false); - - // Enter fullscreen - fireEvent.click(toggle); - expect(viewer.classList.contains("agent-log-viewer--fullscreen")).toBe(true); - - // Clean up to remove the keydown listener - unmount(); - - // Verify the listener was removed (no errors should occur when Escape is pressed after unmount) - }); - }); -}); diff --git a/scripts/line-count-baseline.json b/scripts/line-count-baseline.json index cf2f03d1f6..757d155af6 100644 --- a/scripts/line-count-baseline.json +++ b/scripts/line-count-baseline.json @@ -34,7 +34,6 @@ "packages/dashboard/app/components/TaskDetailModal.tsx": 4636, "packages/dashboard/app/components/TerminalModal.tsx": 2313, "packages/dashboard/app/components/WorkflowNodeEditor.tsx": 4868, - "packages/dashboard/app/components/__tests__/AgentLogViewer.test.tsx": 2010, "packages/dashboard/app/components/__tests__/AgentsView.test.tsx": 2817, "packages/dashboard/app/components/__tests__/App.test.tsx": 4437, "packages/dashboard/app/components/__tests__/ChatView.test.tsx": 5822,