feat(FN-2600): merge fusion/fn-2600 (auto-resolved)

- feat(FN-2600): finalize return-to-live button styling
- test(FN-2600): cover return-to-live and top load-more placement
- feat(FN-2600): add live-follow state and return-to-live control
- fix(FN-2600): clean chronological log documentation and test wording
- test(FN-2600): align log viewer assertions to chronological rendering
- fix(FN-2600): correct chronological badge transition detection
- feat(FN-2600): complete Step 1 — reverse log rendering chronology
This commit is contained in:
Fusion
2026-04-26 13:49:26 -07:00
committed by gsxdsm
parent 79ce48c51e
commit 2f5d84c1ba
4 changed files with 222 additions and 79 deletions

View File

@@ -49,7 +49,11 @@ const markdownComponents: Components = {
), ),
}; };
const TOP_FOLLOW_THRESHOLD_PX = 50; const BOTTOM_FOLLOW_THRESHOLD_PX = 50;
function isNearBottom(container: HTMLDivElement): boolean {
return container.scrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD_PX;
}
function getEntrySignature(entry: AgentLogEntry): string { function getEntrySignature(entry: AgentLogEntry): string {
return [ return [
@@ -97,7 +101,7 @@ interface AgentLogViewerProps {
* Renders agent log entries in a scrollable, monospace container. * Renders agent log entries in a scrollable, monospace container.
* *
* Features: * Features:
* - Displays entries in reverse chronological order (newest first) * - Displays entries in chronological order (oldest first, newest last)
* - Auto-scrolls to keep latest entries visible when streaming * - Auto-scrolls to keep latest entries visible when streaming
* - Supports toggling between markdown-formatted and plain-text rendering * - Supports toggling between markdown-formatted and plain-text rendering
* - "Load More" button to fetch older entries when pagination is enabled * - "Load More" button to fetch older entries when pagination is enabled
@@ -124,38 +128,47 @@ export function AgentLogViewer({
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const previousEntryCountRef = useRef<number>(0); const previousEntryCountRef = useRef<number>(0);
const previousScrollHeightRef = useRef<number>(0); const previousScrollHeightRef = useRef<number>(0);
const previousOldestEntryKeyRef = useRef<string | null>(null);
const previousNewestEntryKeyRef = useRef<string | null>(null); const previousNewestEntryKeyRef = useRef<string | null>(null);
const [renderMarkdown, setRenderMarkdown] = useState(true); const [renderMarkdown, setRenderMarkdown] = useState(true);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [modelHeaderExpanded, setModelHeaderExpanded] = useState(false); const [modelHeaderExpanded, setModelHeaderExpanded] = useState(false);
const [isFollowing, setIsFollowing] = useState(true);
const chronologicalEntryKeys = useMemo( const chronologicalEntryKeys = useMemo(
() => buildEntryRenderKeys(entries), () => buildEntryRenderKeys(entries),
[entries], [entries],
); );
// Newest entries render first. When streaming prepends content while the reader is away // Keep live-follow pinned to the bottom when new streamed entries append.
// from the top, keep the viewport anchored by offsetting scrollTop with the added height. // When older history is prepended (load more), preserve viewport position.
// Near the top, preserve live-follow behavior by snapping back to the latest output.
useLayoutEffect(() => { useLayoutEffect(() => {
const container = containerRef.current; const container = containerRef.current;
if (!container) return; if (!container) return;
const newEntryCount = entries.length; const newEntryCount = entries.length;
const previousCount = previousEntryCountRef.current; const previousCount = previousEntryCountRef.current;
const previousScrollHeight = previousScrollHeightRef.current; const previousScrollHeight = previousScrollHeightRef.current || container.scrollHeight;
const oldestEntryKey = chronologicalEntryKeys[0] ?? null;
const newestEntryKey = chronologicalEntryKeys[chronologicalEntryKeys.length - 1] ?? null; const newestEntryKey = chronologicalEntryKeys[chronologicalEntryKeys.length - 1] ?? null;
const oldestEntryChanged = previousOldestEntryKeyRef.current !== oldestEntryKey;
const newestEntryChanged = previousNewestEntryKeyRef.current !== newestEntryKey; const newestEntryChanged = previousNewestEntryKeyRef.current !== newestEntryKey;
// Only adjust scroll for streaming updates (which append to chronological data
// and therefore prepend in this reversed viewer).
if (newEntryCount > previousCount) { if (newEntryCount > previousCount) {
const isNearTop = container.scrollTop <= TOP_FOLLOW_THRESHOLD_PX; if (previousCount === 0) {
container.scrollTop = container.scrollHeight;
} else {
const wasNearBottom =
previousScrollHeight - (container.scrollTop + container.clientHeight) <=
BOTTOM_FOLLOW_THRESHOLD_PX;
const appendedLiveEntry = newestEntryChanged && !oldestEntryChanged;
const prependedOlderEntries = oldestEntryChanged && !newestEntryChanged;
if (newestEntryChanged) { if (appendedLiveEntry && wasNearBottom) {
if (isNearTop) { container.scrollTop = container.scrollHeight;
container.scrollTop = 0; }
} else {
if (prependedOlderEntries) {
const heightDelta = container.scrollHeight - previousScrollHeight; const heightDelta = container.scrollHeight - previousScrollHeight;
if (heightDelta > 0) { if (heightDelta > 0) {
container.scrollTop += heightDelta; container.scrollTop += heightDelta;
@@ -166,9 +179,24 @@ export function AgentLogViewer({
previousEntryCountRef.current = newEntryCount; previousEntryCountRef.current = newEntryCount;
previousScrollHeightRef.current = container.scrollHeight; previousScrollHeightRef.current = container.scrollHeight;
previousOldestEntryKeyRef.current = oldestEntryKey;
previousNewestEntryKeyRef.current = newestEntryKey; previousNewestEntryKeyRef.current = newestEntryKey;
setIsFollowing(isNearBottom(container));
}, [entries, chronologicalEntryKeys]); }, [entries, chronologicalEntryKeys]);
const handleScroll = useCallback(() => {
const container = containerRef.current;
if (!container) return;
setIsFollowing(isNearBottom(container));
}, []);
const scrollToLive = useCallback(() => {
const container = containerRef.current;
if (!container) return;
container.scrollTop = container.scrollHeight;
setIsFollowing(true);
}, []);
// Escape key handler to exit fullscreen mode // Escape key handler to exit fullscreen mode
const handleKeyDown = useCallback((e: KeyboardEvent) => { const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "Escape" && isFullscreen) { if (e.key === "Escape" && isFullscreen) {
@@ -238,15 +266,12 @@ export function AgentLogViewer({
); );
} }
// Reverse entries so newest appear first
const reversedEntries = [...entries].reverse();
const reversedEntryKeys = [...chronologicalEntryKeys].reverse();
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className={`agent-log-viewer agent-log-viewer--streaming${isFullscreen ? " agent-log-viewer--fullscreen" : ""}`} className={`agent-log-viewer agent-log-viewer--streaming${isFullscreen ? " agent-log-viewer--fullscreen" : ""}`}
data-testid="agent-log-viewer" data-testid="agent-log-viewer"
onScroll={handleScroll}
> >
{/* Model info header */} {/* Model info header */}
<div className="agent-log-model-header" data-testid="agent-log-model-header"> <div className="agent-log-model-header" data-testid="agent-log-model-header">
@@ -339,13 +364,32 @@ export function AgentLogViewer({
</div> </div>
)} )}
{reversedEntries.map((entry, i) => { {hasMore && onLoadMore && (
const rowKey = reversedEntryKeys[i] ?? `${getEntrySignature(entry)}|fallback`; <div className="agent-log-load-more" data-testid="agent-log-load-more">
// Look at previous entry in reversed array (= next chronologically) for deduplication <button
const prev = reversedEntries[i - 1]; className="agent-log-mode-toggle"
onClick={onLoadMore}
disabled={loadingMore}
data-testid="agent-log-load-more-button"
>
{loadingMore ? (
<>
<Loader2 size={14} className="animate-spin" />
Loading
</>
) : (
"Load More"
)}
</button>
</div>
)}
{entries.map((entry, i) => {
const rowKey = chronologicalEntryKeys[i] ?? `${getEntrySignature(entry)}|fallback`;
const prev = entries[i - 1];
const isBlockLevel = entry.type === "tool" || entry.type === "tool_result" || entry.type === "tool_error"; const isBlockLevel = entry.type === "tool" || entry.type === "tool_result" || entry.type === "tool_error";
const showBadge = entry.agent const showBadge = entry.agent
? isBlockLevel || i === 0 || (prev && (prev.agent !== entry.agent || prev.type !== entry.type)) ? isBlockLevel || !prev || prev.agent !== entry.agent || prev.type !== entry.type
: false; : false;
const timestampSpan = showBadge ? ( const timestampSpan = showBadge ? (
@@ -422,25 +466,16 @@ export function AgentLogViewer({
); );
})} })}
{/* Load More button */} {!isFollowing && (
{hasMore && onLoadMore && ( <button
<div className="agent-log-load-more" data-testid="agent-log-load-more"> type="button"
<button className="agent-log-return-to-live"
className="agent-log-mode-toggle" onClick={scrollToLive}
onClick={onLoadMore} data-testid="agent-log-return-to-live"
disabled={loadingMore} >
data-testid="agent-log-load-more-button" <ChevronDown size={12} />
> <span>Live</span>
{loadingMore ? ( </button>
<>
<Loader2 size={14} className="animate-spin" />
Loading
</>
) : (
"Load More"
)}
</button>
</div>
)} )}
</div> </div>
); );

View File

@@ -433,13 +433,42 @@
.agent-log-load-more { .agent-log-load-more {
padding: var(--space-md); padding: var(--space-md);
text-align: center; text-align: center;
border-top: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.agent-log-load-more .agent-log-mode-toggle { .agent-log-load-more .agent-log-mode-toggle {
min-width: 120px; min-width: 120px;
} }
.agent-log-return-to-live {
position: sticky;
bottom: var(--space-md);
margin-left: auto;
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
color: var(--text-muted);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
font-size: 11px;
cursor: pointer;
transition: all var(--transition-fast);
z-index: 2;
}
.agent-log-return-to-live:hover {
background: var(--card-hover);
color: var(--text);
}
.agent-log-return-to-live:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
/* Fullscreen mode for agent log viewer */ /* Fullscreen mode for agent log viewer */
.agent-log-viewer--fullscreen { .agent-log-viewer--fullscreen {
position: fixed; position: fixed;
@@ -1480,4 +1509,8 @@
min-width: 36px; min-width: 36px;
min-height: 36px; min-height: 36px;
} }
.agent-log-return-to-live {
bottom: var(--space-sm);
}
} }

View File

@@ -49,7 +49,7 @@ describe("AgentLogViewer", () => {
consoleErrorSpy.mockRestore(); consoleErrorSpy.mockRestore();
}); });
it("renders text entries as spans in reverse order (newest first)", () => { it("renders text entries in chronological order (oldest first)", () => {
const entries = [ const entries = [
makeEntry({ text: "first chunk" }), makeEntry({ text: "first chunk" }),
makeEntry({ text: "second chunk" }), makeEntry({ text: "second chunk" }),
@@ -57,12 +57,11 @@ describe("AgentLogViewer", () => {
const { container } = render(<AgentLogViewer entries={entries} loading={false} />); const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const textSpans = container.querySelectorAll(".agent-log-text"); const textSpans = container.querySelectorAll(".agent-log-text");
expect(textSpans).toHaveLength(2); expect(textSpans).toHaveLength(2);
// Reversed order: second chunk first, then first chunk expect(textSpans[0].textContent).toContain("first chunk");
expect(textSpans[0].textContent).toContain("second chunk"); expect(textSpans[1].textContent).toContain("second chunk");
expect(textSpans[1].textContent).toContain("first chunk");
}); });
it("keeps existing DOM rows stable when a new live entry appears at the top", () => { it("keeps existing DOM rows stable when a new live entry appears at the bottom", () => {
const initialEntries = [ const initialEntries = [
makeEntry({ text: "first chunk", timestamp: "2026-01-01T00:00:00Z" }), makeEntry({ text: "first chunk", timestamp: "2026-01-01T00:00:00Z" }),
makeEntry({ text: "second chunk", timestamp: "2026-01-01T00:00:01Z" }), makeEntry({ text: "second chunk", timestamp: "2026-01-01T00:00:01Z" }),
@@ -73,10 +72,10 @@ describe("AgentLogViewer", () => {
); );
const initialTextRows = container.querySelectorAll(".agent-log-text"); const initialTextRows = container.querySelectorAll(".agent-log-text");
const secondChunkNode = initialTextRows[0] as HTMLElement; const firstChunkNode = initialTextRows[0] as HTMLElement;
const firstChunkNode = initialTextRows[1] as HTMLElement; const secondChunkNode = initialTextRows[1] as HTMLElement;
expect(secondChunkNode.textContent).toContain("second chunk");
expect(firstChunkNode.textContent).toContain("first chunk"); expect(firstChunkNode.textContent).toContain("first chunk");
expect(secondChunkNode.textContent).toContain("second chunk");
const withLiveUpdate = [ const withLiveUpdate = [
...initialEntries, ...initialEntries,
@@ -87,11 +86,11 @@ describe("AgentLogViewer", () => {
const updatedTextRows = container.querySelectorAll(".agent-log-text"); const updatedTextRows = container.querySelectorAll(".agent-log-text");
expect(updatedTextRows).toHaveLength(3); expect(updatedTextRows).toHaveLength(3);
expect(updatedTextRows[0].textContent).toContain("third chunk"); expect(updatedTextRows[0].textContent).toContain("first chunk");
expect(updatedTextRows[1].textContent).toContain("second chunk"); expect(updatedTextRows[1].textContent).toContain("second chunk");
expect(updatedTextRows[2].textContent).toContain("first chunk"); expect(updatedTextRows[2].textContent).toContain("third chunk");
expect(updatedTextRows[0]).toBe(firstChunkNode);
expect(updatedTextRows[1]).toBe(secondChunkNode); expect(updatedTextRows[1]).toBe(secondChunkNode);
expect(updatedTextRows[2]).toBe(firstChunkNode);
}); });
it("avoids duplicate-key collisions when entries are exact duplicates", () => { it("avoids duplicate-key collisions when entries are exact duplicates", () => {
@@ -136,18 +135,17 @@ describe("AgentLogViewer", () => {
expect(toolDiv!.textContent).toContain("Read"); expect(toolDiv!.textContent).toContain("Read");
}); });
it("renders a mix of text and tool entries in reverse order", () => { it("renders a mix of text and tool entries in chronological order", () => {
const entries = [ const entries = [
makeEntry({ text: "Starting...", type: "text" }), makeEntry({ text: "Starting...", type: "text" }),
makeEntry({ text: "Bash", type: "tool" }), makeEntry({ text: "Bash", type: "tool" }),
makeEntry({ text: "Done!", type: "text" }), makeEntry({ text: "Done!", type: "text" }),
]; ];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />); const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
// Reversed order: Done! (text), Bash (tool), Starting... (text)
const textSpans = container.querySelectorAll(".agent-log-text"); const textSpans = container.querySelectorAll(".agent-log-text");
expect(textSpans).toHaveLength(2); expect(textSpans).toHaveLength(2);
expect(textSpans[0].textContent).toContain("Done!"); expect(textSpans[0].textContent).toContain("Starting...");
expect(textSpans[1].textContent).toContain("Starting..."); expect(textSpans[1].textContent).toContain("Done!");
const toolDivs = container.querySelectorAll(".agent-log-tool"); const toolDivs = container.querySelectorAll(".agent-log-tool");
expect(toolDivs).toHaveLength(1); expect(toolDivs).toHaveLength(1);
@@ -196,7 +194,7 @@ describe("AgentLogViewer", () => {
}); });
describe("agent badge deduplication", () => { describe("agent badge deduplication", () => {
it("shows badge only on the first (newest) of consecutive text entries from the same agent", () => { it("shows badge only on the first (oldest) of consecutive text entries from the same agent", () => {
const entries = [ const entries = [
makeEntry({ text: "chunk 1", type: "text", agent: "executor" }), makeEntry({ text: "chunk 1", type: "text", agent: "executor" }),
makeEntry({ text: "chunk 2", type: "text", agent: "executor" }), makeEntry({ text: "chunk 2", type: "text", agent: "executor" }),
@@ -205,11 +203,11 @@ describe("AgentLogViewer", () => {
const { container } = render(<AgentLogViewer entries={entries} loading={false} />); const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const badges = container.querySelectorAll(".agent-log-agent-badge"); const badges = container.querySelectorAll(".agent-log-agent-badge");
expect(badges).toHaveLength(1); expect(badges).toHaveLength(1);
// In reversed order, the newest (chunk 3) gets the badge // In chronological order, the oldest (chunk 1) gets the badge
expect(badges[0].textContent).toBe("[executor]"); expect(badges[0].textContent).toBe("[executor]");
}); });
it("shows badge on each agent transition in reversed order", () => { it("shows badge on each agent transition in chronological order", () => {
const entries = [ const entries = [
makeEntry({ text: "hello", type: "text", agent: "triage" }), makeEntry({ text: "hello", type: "text", agent: "triage" }),
makeEntry({ text: "world", type: "text", agent: "triage" }), makeEntry({ text: "world", type: "text", agent: "triage" }),
@@ -219,13 +217,11 @@ describe("AgentLogViewer", () => {
const { container } = render(<AgentLogViewer entries={entries} loading={false} />); const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const badges = container.querySelectorAll(".agent-log-agent-badge"); const badges = container.querySelectorAll(".agent-log-agent-badge");
expect(badges).toHaveLength(2); expect(badges).toHaveLength(2);
// Reversed order: done (executor), starting (executor), world (triage), hello (triage) expect(badges[0].textContent).toBe("[triage]");
// Badge on done (i=0) and world (transition from executor to triage) expect(badges[1].textContent).toBe("[executor]");
expect(badges[0].textContent).toBe("[executor]");
expect(badges[1].textContent).toBe("[triage]");
}); });
it("shows badge on text, tool, and text-after-tool (same agent, type change) in reversed order", () => { it("shows badge on text, tool, and text-after-tool (same agent, type change) in chronological order", () => {
const entries = [ const entries = [
makeEntry({ text: "reading...", type: "text", agent: "executor" }), makeEntry({ text: "reading...", type: "text", agent: "executor" }),
makeEntry({ text: "Read", type: "tool", agent: "executor" }), makeEntry({ text: "Read", type: "tool", agent: "executor" }),
@@ -233,12 +229,12 @@ describe("AgentLogViewer", () => {
]; ];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />); const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const badges = container.querySelectorAll(".agent-log-agent-badge"); const badges = container.querySelectorAll(".agent-log-agent-badge");
// Reversed: got it (text), Read (tool), reading... (text) // Chronological: reading... (text), Read (tool), got it (text)
// Badge on got it (i=0), Read (always block-level), reading... (type changed from tool) // Badge on reading... (i=0), Read (always block-level), got it (type changed from tool)
expect(badges).toHaveLength(3); expect(badges).toHaveLength(3);
}); });
it("shows badge only on the first (newest) of consecutive thinking entries from the same agent", () => { it("shows badge only on the first (oldest) of consecutive thinking entries from the same agent", () => {
const entries = [ const entries = [
makeEntry({ text: "hmm", type: "thinking", agent: "triage" }), makeEntry({ text: "hmm", type: "thinking", agent: "triage" }),
makeEntry({ text: "let me think", type: "thinking", agent: "triage" }), makeEntry({ text: "let me think", type: "thinking", agent: "triage" }),
@@ -247,7 +243,7 @@ describe("AgentLogViewer", () => {
const { container } = render(<AgentLogViewer entries={entries} loading={false} />); const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const badges = container.querySelectorAll(".agent-log-agent-badge"); const badges = container.querySelectorAll(".agent-log-agent-badge");
expect(badges).toHaveLength(1); expect(badges).toHaveLength(1);
// In reversed order, the newest (ok) gets the badge // In chronological order, the oldest (hmm) gets the badge
expect(badges[0].textContent).toBe("[triage]"); expect(badges[0].textContent).toBe("[triage]");
}); });
@@ -721,7 +717,7 @@ describe("AgentLogViewer", () => {
expect(badges).toHaveLength(3); expect(badges).toHaveLength(3);
expect(timestamps).toHaveLength(3); expect(timestamps).toHaveLength(3);
expect(badges.map((badge) => badge.textContent)).toEqual(["[reviewer]", "[executor]", "[triage]"]); expect(badges.map((badge) => badge.textContent)).toEqual(["[triage]", "[executor]", "[reviewer]"]);
}); });
it("badge container includes both badge text and timestamp text", () => { it("badge container includes both badge text and timestamp text", () => {
@@ -813,7 +809,7 @@ describe("AgentLogViewer", () => {
}); });
describe("auto-scroll behavior", () => { describe("auto-scroll behavior", () => {
it("scrolls to top when streaming updates arrive and user is near the top", () => { it("scrolls to bottom when streaming updates arrive and user is near the bottom", () => {
const initialEntries = [ const initialEntries = [
makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }),
]; ];
@@ -831,16 +827,16 @@ describe("AgentLogViewer", () => {
get: () => scrollHeight, get: () => scrollHeight,
}); });
viewer.scrollTop = 20; viewer.scrollTop = 560;
rerender(<AgentLogViewer entries={[...initialEntries]} loading={false} />); rerender(<AgentLogViewer entries={[...initialEntries]} loading={false} />);
scrollHeight = 720; scrollHeight = 720;
rerender(<AgentLogViewer entries={streamedEntries} loading={false} />); rerender(<AgentLogViewer entries={streamedEntries} loading={false} />);
expect(viewer.scrollTop).toBe(0); expect(viewer.scrollTop).toBe(720);
}); });
it("keeps the viewport anchored when streaming updates arrive and user is reading older output", () => { it("does not auto-scroll when streaming updates arrive and user is reading older output", () => {
const initialEntries = [ const initialEntries = [
makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }), makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }),
]; ];
@@ -864,11 +860,10 @@ describe("AgentLogViewer", () => {
scrollHeight = 1120; scrollHeight = 1120;
rerender(<AgentLogViewer entries={streamedEntries} loading={false} />); rerender(<AgentLogViewer entries={streamedEntries} loading={false} />);
// Anchored by delta (1120 - 1000): 220 + 120 expect(viewer.scrollTop).toBe(220);
expect(viewer.scrollTop).toBe(340);
}); });
it("does not offset scroll when loading older history at the bottom", () => { it("keeps viewport anchored when older history is prepended", () => {
const initialEntries = [ const initialEntries = [
makeEntry({ text: "recent", timestamp: "2026-01-01T00:00:00Z" }), makeEntry({ text: "recent", timestamp: "2026-01-01T00:00:00Z" }),
]; ];
@@ -892,7 +887,87 @@ describe("AgentLogViewer", () => {
scrollHeight = 1030; scrollHeight = 1030;
rerender(<AgentLogViewer entries={olderLoadedEntries} loading={false} />); rerender(<AgentLogViewer entries={olderLoadedEntries} loading={false} />);
expect(viewer.scrollTop).toBe(260); // 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(<AgentLogViewer entries={entries} loading={false} />);
const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLDivElement;
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(<AgentLogViewer entries={entries} loading={false} />);
const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLDivElement;
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(<AgentLogViewer entries={entries} loading={false} />);
const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLDivElement;
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();
});
});
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(
<AgentLogViewer
entries={entries}
loading={false}
hasMore={true}
onLoadMore={() => {}}
/>,
);
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();
}); });
}); });

View File

@@ -31,7 +31,7 @@ function capLogEntries(entries: AgentLogEntry[]): AgentLogEntry[] {
* **Pagination semantics**: * **Pagination semantics**:
* - Entries are returned in chronological order (oldest first) from the API * - Entries are returned in chronological order (oldest first) from the API
* - Entries are stored in chronological order * - Entries are stored in chronological order
* - The UI displays newest first by reversing the array * - The UI displays entries in chronological order (oldest first)
* - `loadMore()` fetches the next 100 older entries and prepends them * - `loadMore()` fetches the next 100 older entries and prepends them
* *
* When `enabled` is true: * When `enabled` is true: