feat(FN-1387): add fullscreen expand toggle to agent log viewer

- Add expand/collapse fullscreen button to AgentLogViewer header
- Implement fullscreen mode with fixed overlay positioning
- Add CSS styles for fullscreen container and header visibility
- Add comprehensive tests for fullscreen toggle functionality
This commit is contained in:
gsxdsm
2026-04-09 23:22:28 -07:00
parent bba1e45de7
commit b1fc0369e6
3 changed files with 178 additions and 2 deletions

View File

@@ -1,9 +1,10 @@
import type { AgentLogEntry } from "@fusion/core";
import { ProviderIcon } from "./ProviderIcon";
import { useRef, useEffect, useState } from "react";
import { useRef, useEffect, useState, useCallback } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Components } from "react-markdown";
import { Maximize2, Minimize2 } from "lucide-react";
function formatTimestamp(iso: string): string {
const date = new Date(iso);
@@ -71,6 +72,7 @@ export function AgentLogViewer({ entries, loading, executorModel, validatorModel
const containerRef = useRef<HTMLDivElement>(null);
const previousEntryCountRef = useRef<number>(0);
const [renderMarkdown, setRenderMarkdown] = useState(true);
const [isFullscreen, setIsFullscreen] = useState(false);
// Auto-scroll to top when new entries arrive (since newest are first)
useEffect(() => {
@@ -93,6 +95,22 @@ export function AgentLogViewer({ entries, loading, executorModel, validatorModel
previousEntryCountRef.current = newEntryCount;
}, [entries]);
// Escape key handler to exit fullscreen mode
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "Escape" && isFullscreen) {
setIsFullscreen(false);
}
}, [isFullscreen]);
useEffect(() => {
if (isFullscreen) {
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}
}, [isFullscreen, handleKeyDown]);
if (loading && entries.length === 0) {
return (
<div className="agent-log-viewer" data-testid="agent-log-viewer">
@@ -119,7 +137,7 @@ export function AgentLogViewer({ entries, loading, executorModel, validatorModel
return (
<div
ref={containerRef}
className="agent-log-viewer agent-log-viewer--streaming"
className={`agent-log-viewer agent-log-viewer--streaming${isFullscreen ? " agent-log-viewer--fullscreen" : ""}`}
data-testid="agent-log-viewer"
>
{/* Model info header */}
@@ -169,6 +187,15 @@ export function AgentLogViewer({ entries, loading, executorModel, validatorModel
>
{renderMarkdown ? "Markdown" : "Plain"}
</button>
<button
className="agent-log-mode-toggle"
onClick={() => setIsFullscreen((prev) => !prev)}
aria-label={isFullscreen ? "Exit full screen" : "Expand agent log to full screen"}
data-testid="agent-log-fullscreen-toggle"
title={isFullscreen ? "Exit full screen" : "Expand agent log to full screen"}
>
{isFullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</button>
</div>
</div>
{reversedEntries.map((entry, i) => {

View File

@@ -3,6 +3,13 @@ import { render, screen, fireEvent } from "@testing-library/react";
import { AgentLogViewer } from "../AgentLogViewer";
import type { AgentLogEntry } from "@fusion/core";
// Mock lucide-react icons used by AgentLogViewer and ProviderIcon
vi.mock("lucide-react", () => ({
Maximize2: () => null,
Minimize2: () => null,
Cpu: () => null,
}));
function makeEntry(overrides: Partial<AgentLogEntry> = {}): AgentLogEntry {
return {
timestamp: "2026-01-01T00:00:00Z",
@@ -1013,4 +1020,125 @@ describe("AgentLogViewer", () => {
expect(textSpans[0].querySelector("script")).toBeNull();
});
});
describe("fullscreen toggle", () => {
it("renders the fullscreen toggle button in the model info header", () => {
const entries = [makeEntry()];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const toggle = container.querySelector("[data-testid='agent-log-fullscreen-toggle']");
expect(toggle).toBeTruthy();
});
it("has correct aria attributes on the fullscreen toggle", () => {
const entries = [makeEntry()];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
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(<AgentLogViewer entries={entries} loading={false} />);
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(<AgentLogViewer entries={entries} loading={false} />);
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(<AgentLogViewer entries={entries} loading={false} />);
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(<AgentLogViewer entries={entries} loading={false} />);
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(<AgentLogViewer entries={entries} loading={false} />);
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(<AgentLogViewer entries={entries} loading={false} />);
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)
});
});
});

View File

@@ -3717,6 +3717,27 @@ body {
border-color: var(--accent);
}
/* Fullscreen mode for agent log viewer */
.agent-log-viewer--fullscreen {
position: fixed;
inset: 0;
z-index: 10000;
background: var(--surface);
overflow-y: auto;
overflow-x: hidden;
padding: var(--space-lg);
}
.agent-log-viewer--fullscreen .agent-log-model-header {
position: sticky;
top: 0;
background: var(--surface);
z-index: 1;
padding-bottom: var(--space-md);
margin-bottom: var(--space-md);
border-bottom: 1px solid var(--border);
}
.detail-spec-edit-trigger {
margin-bottom: var(--space-md);
}