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:
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user