fix(FN-2300): preserve log viewport behavior during streaming

- Update AgentLogViewer to anchor scroll position when new streamed entries prepend while the user is reading older logs
- Keep live-follow behavior near the top and avoid false scroll adjustments when loading older history
- Add focused auto-scroll regression tests covering near-top follow, anchored reading, and history pagination cases
- Improve log readability styling by using theme tokens and consistent text color for normal and thinking log lines
- Resolve release changelog conflicts and retain both existing 0.1.1 notes and FN-2300 changelog entry
This commit is contained in:
Fusion
2026-04-23 13:05:01 -07:00
committed by gsxdsm
parent ac30b26d57
commit 3b902f435b
5 changed files with 122 additions and 34 deletions

View File

@@ -1,6 +1,6 @@
import type { AgentLogEntry } from "@fusion/core";
import { ProviderIcon } from "./ProviderIcon";
import { useRef, useEffect, useState, useCallback } from "react";
import { useRef, useEffect, useState, useCallback, useLayoutEffect } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Components } from "react-markdown";
@@ -49,6 +49,16 @@ const markdownComponents: Components = {
),
};
const TOP_FOLLOW_THRESHOLD_PX = 50;
function getEntryKey(entry: AgentLogEntry | undefined): string | null {
if (!entry) {
return null;
}
return [entry.timestamp, entry.agent, entry.type, entry.text, entry.detail].join("|");
}
interface ModelInfo {
provider?: string;
modelId?: string;
@@ -100,28 +110,44 @@ export function AgentLogViewer({
}: AgentLogViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const previousEntryCountRef = useRef<number>(0);
const previousScrollHeightRef = useRef<number>(0);
const previousNewestEntryKeyRef = useRef<string | null>(null);
const [renderMarkdown, setRenderMarkdown] = useState(true);
const [isFullscreen, setIsFullscreen] = useState(false);
// Auto-scroll to top when new entries arrive (since newest are first)
useEffect(() => {
// Newest entries render first. When streaming prepends content while the reader is away
// from the top, keep the viewport anchored by offsetting scrollTop with the added height.
// Near the top, preserve live-follow behavior by snapping back to the latest output.
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const newEntryCount = entries.length;
const previousCount = previousEntryCountRef.current;
const previousScrollHeight = previousScrollHeightRef.current;
const newestEntryKey = getEntryKey(entries[entries.length - 1]);
const newestEntryChanged = previousNewestEntryKeyRef.current !== newestEntryKey;
// Only scroll if new entries were added and user is near the top
// Only adjust scroll for streaming updates (which append to chronological data
// and therefore prepend in this reversed viewer).
if (newEntryCount > previousCount) {
// Check if user is already near the top (within 50px)
const isNearTop = container.scrollTop <= 50;
const isNearTop = container.scrollTop <= TOP_FOLLOW_THRESHOLD_PX;
if (isNearTop) {
container.scrollTop = 0;
if (newestEntryChanged) {
if (isNearTop) {
container.scrollTop = 0;
} else {
const heightDelta = container.scrollHeight - previousScrollHeight;
if (heightDelta > 0) {
container.scrollTop += heightDelta;
}
}
}
}
previousEntryCountRef.current = newEntryCount;
previousScrollHeightRef.current = container.scrollHeight;
previousNewestEntryKeyRef.current = newestEntryKey;
}, [entries]);
// Escape key handler to exit fullscreen mode

View File

@@ -654,32 +654,86 @@ describe("AgentLogViewer", () => {
});
describe("auto-scroll behavior", () => {
it("scrolls to top when new entries arrive and user is near the top", () => {
const { rerender, container } = render(<AgentLogViewer entries={[makeEntry({ text: "first" })]} loading={false} />);
it("scrolls to top when streaming updates arrive and user is near the top", () => {
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(<AgentLogViewer entries={initialEntries} loading={false} />);
const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLDivElement;
// Simulate user being at the top
viewer.scrollTop = 0;
// Add a new entry
rerender(<AgentLogViewer entries={[makeEntry({ text: "second" }), makeEntry({ text: "first" })]} loading={false} />);
// Should have scrolled to top (newest first)
let scrollHeight = 600;
Object.defineProperty(viewer, "scrollHeight", {
configurable: true,
get: () => scrollHeight,
});
viewer.scrollTop = 20;
rerender(<AgentLogViewer entries={[...initialEntries]} loading={false} />);
scrollHeight = 720;
rerender(<AgentLogViewer entries={streamedEntries} loading={false} />);
expect(viewer.scrollTop).toBe(0);
});
it("does not auto-scroll when user has scrolled down", () => {
const { rerender, container } = render(<AgentLogViewer entries={[makeEntry({ text: "first" })]} loading={false} />);
it("keeps the viewport anchored 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(<AgentLogViewer entries={initialEntries} loading={false} />);
const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLDivElement;
// Simulate user scrolling down past the threshold
Object.defineProperty(viewer, 'scrollTop', { value: 100, writable: true });
// Add a new entry
rerender(<AgentLogViewer entries={[makeEntry({ text: "second" }), makeEntry({ text: "first" })]} loading={false} />);
// Should not have scrolled (scrollTop should remain 100)
expect(viewer.scrollTop).toBe(100);
let scrollHeight = 1000;
Object.defineProperty(viewer, "scrollHeight", {
configurable: true,
get: () => scrollHeight,
});
viewer.scrollTop = 220;
rerender(<AgentLogViewer entries={[...initialEntries]} loading={false} />);
scrollHeight = 1120;
rerender(<AgentLogViewer entries={streamedEntries} loading={false} />);
// Anchored by delta (1120 - 1000): 220 + 120
expect(viewer.scrollTop).toBe(340);
});
it("does not offset scroll when loading older history at the bottom", () => {
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(<AgentLogViewer entries={initialEntries} loading={false} />);
const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLDivElement;
let scrollHeight = 900;
Object.defineProperty(viewer, "scrollHeight", {
configurable: true,
get: () => scrollHeight,
});
viewer.scrollTop = 260;
rerender(<AgentLogViewer entries={[...initialEntries]} loading={false} />);
scrollHeight = 1030;
rerender(<AgentLogViewer entries={olderLoadedEntries} loading={false} />);
expect(viewer.scrollTop).toBe(260);
});
});