FN-8339: preserve manual scroll position during streaming
Keep live chat and log viewers from snapping readers back to the bottom while streamed content grows. - Track pinned-bottom state synchronously across task chat, general chat, and agent logs. - Follow streamed DOM and layout growth only when the reader remains at the tail. - Add regression coverage and a patch changeset. Files changed: .changeset/fn-8339-chat-scroll-follow.md | 7 +++ .../dashboard/app/components/AgentLogViewer.tsx | 52 +++++++++++-------- packages/dashboard/app/components/ChatView.tsx | 4 ++ packages/dashboard/app/components/TaskChatTab.tsx | 59 ++++++++++++++++------ .../__tests__/AgentLogViewer.layout.test.tsx | 58 ++++++++++++++++++++- .../__tests__/ChatView.streaming-thread.test.tsx | 18 ++++++- .../app/components/__tests__/TaskChatTab.test.tsx | 34 +++++++++++++ 7 files changed, 195 insertions(+), 37 deletions(-) Fusion-Task-Id: FN-8339 Fusion-Task-Lineage: 8f1290ea-de87-4edc-b343-f50c0ba55bad Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8339-chat-scroll-follow.md
Normal file
7
.changeset/fn-8339-chat-scroll-follow.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Allow manual scrolling during generation in task chat, agent logs, and chat.
|
||||
category: fix
|
||||
dev: Preserve pinned-bottom follow state across streamed DOM growth and observer callbacks.
|
||||
@@ -343,6 +343,12 @@ export function AgentLogViewer({
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [modelHeaderExpanded, setModelHeaderExpanded] = useState(false);
|
||||
const [isFollowing, setIsFollowing] = useState(true);
|
||||
const isFollowingRef = useRef(true);
|
||||
|
||||
const setFollowing = useCallback((following: boolean) => {
|
||||
isFollowingRef.current = following;
|
||||
setIsFollowing(following);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
writeBooleanPref(MARKDOWN_TOGGLE_STORAGE_KEY, renderMarkdown);
|
||||
@@ -384,6 +390,10 @@ export function AgentLogViewer({
|
||||
[renderEntries, chronologicalEntryKeys],
|
||||
);
|
||||
|
||||
/*
|
||||
FNXC:AgentLog 2026-07-18-14:09:
|
||||
FN-8339 makes log following an explicit pinned-bottom contract: append and in-place stream growth may move the viewport only while it is pinned. Observe both viewport layout and DOM growth, and keep the current value in a ref because observer callbacks can run before React commits the scroll-state render; a real scroll-away must synchronously unsnap those callbacks.
|
||||
*/
|
||||
// Keep live-follow pinned to the bottom when new streamed entries append.
|
||||
// When older history is prepended (load more), preserve viewport position.
|
||||
useLayoutEffect(() => {
|
||||
@@ -402,9 +412,7 @@ export function AgentLogViewer({
|
||||
if (previousCount === 0) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
} else {
|
||||
const wasNearBottom =
|
||||
previousScrollHeight - (container.scrollTop + container.clientHeight) <=
|
||||
BOTTOM_FOLLOW_THRESHOLD_PX;
|
||||
const wasNearBottom = isFollowingRef.current;
|
||||
const appendedLiveEntry = newestEntryChanged && !oldestEntryChanged;
|
||||
const prependedOlderEntries = oldestEntryChanged && !newestEntryChanged;
|
||||
|
||||
@@ -421,46 +429,50 @@ export function AgentLogViewer({
|
||||
}
|
||||
}
|
||||
|
||||
if (newEntryCount !== previousCount) {
|
||||
setFollowing(isNearBottom(container));
|
||||
}
|
||||
previousEntryCountRef.current = newEntryCount;
|
||||
previousScrollHeightRef.current = container.scrollHeight;
|
||||
previousOldestEntryKeyRef.current = oldestEntryKey;
|
||||
previousNewestEntryKeyRef.current = newestEntryKey;
|
||||
setIsFollowing(isNearBottom(container));
|
||||
}, [entries, chronologicalEntryKeys]);
|
||||
}, [entries, chronologicalEntryKeys, setFollowing]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
setIsFollowing(isNearBottom(container));
|
||||
}, []);
|
||||
setFollowing(isNearBottom(container));
|
||||
}, [setFollowing]);
|
||||
|
||||
const scrollToLive = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
setIsFollowing(true);
|
||||
}, []);
|
||||
setFollowing(true);
|
||||
}, [setFollowing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (!isFollowing) {
|
||||
const followTail = () => {
|
||||
if (!isFollowingRef.current) {
|
||||
return;
|
||||
}
|
||||
container.scrollTop = container.scrollHeight;
|
||||
});
|
||||
};
|
||||
const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(followTail);
|
||||
resizeObserver?.observe(container);
|
||||
const mutationObserver = typeof MutationObserver === "undefined" ? null : new MutationObserver(followTail);
|
||||
mutationObserver?.observe(container, { childList: true, characterData: true, subtree: true });
|
||||
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [isFollowing]);
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
mutationObserver?.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Escape key handler to exit fullscreen mode
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
|
||||
@@ -1320,6 +1320,10 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
anchorToBottom,
|
||||
]);
|
||||
|
||||
/*
|
||||
FNXC:Chat 2026-07-18-14:09:
|
||||
FN-8339 confirms regular Chat shares the pinned-bottom invariant with task chat and agent logs. `isUserScrollingRef` changes synchronously on a genuine scroll event, so streamed deltas and their settle frames must return without writing while the reader is above the bottom threshold; explicit jump-to-latest resets that ref before anchoring.
|
||||
*/
|
||||
// Scroll thread container to bottom during streaming only when already pinned.
|
||||
useEffect(() => {
|
||||
if (!isStreaming || isUserScrollingRef.current) {
|
||||
|
||||
@@ -627,6 +627,7 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
|
||||
const sendingRef = useRef(false);
|
||||
const [optimisticMessages, setOptimisticMessages] = useState<UserChatMessage[]>([]);
|
||||
const [isTranscriptAtBottom, setIsTranscriptAtBottom] = useState(true);
|
||||
const isTranscriptAtBottomRef = useRef(true);
|
||||
const thinkingDefaultOpen = task.column === "in-progress" || task.column === "in-review";
|
||||
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||
const previousEntryCountRef = useRef(0);
|
||||
@@ -704,6 +705,11 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
|
||||
anchorFrameRef.current = null;
|
||||
}, []);
|
||||
|
||||
const setTranscriptFollowing = useCallback((following: boolean) => {
|
||||
isTranscriptAtBottomRef.current = following;
|
||||
setIsTranscriptAtBottom(following);
|
||||
}, []);
|
||||
|
||||
const anchorTranscriptToBottom = useCallback((container: HTMLElement) => {
|
||||
cancelAnchorTranscriptFrame();
|
||||
if (!container.isConnected) return;
|
||||
@@ -715,11 +721,11 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
|
||||
|
||||
const writeBottom = () => {
|
||||
anchorFrameRef.current = null;
|
||||
if (!container.isConnected) return;
|
||||
if (!container.isConnected || !isTranscriptAtBottomRef.current) return;
|
||||
|
||||
container.scrollTop = container.scrollHeight;
|
||||
previousScrollHeightRef.current = container.scrollHeight;
|
||||
setIsTranscriptAtBottom(true);
|
||||
setTranscriptFollowing(true);
|
||||
if (container.scrollHeight === lastScrollHeight) {
|
||||
stableFrames += 1;
|
||||
} else {
|
||||
@@ -736,7 +742,31 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
|
||||
};
|
||||
|
||||
writeBottom();
|
||||
}, [cancelAnchorTranscriptFrame]);
|
||||
}, [cancelAnchorTranscriptFrame, setTranscriptFollowing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const container = transcriptRef.current;
|
||||
if (!container) return;
|
||||
|
||||
/*
|
||||
FNXC:TaskDetailChat 2026-07-18-14:09:
|
||||
FN-8339 requires live task output to follow its tail only while the reader remains pinned. Streamed text can grow an existing DOM block without changing the entry count, so observe both layout and DOM growth; the ref is updated synchronously by real scroll events and prevents an in-flight observer or settle frame from yanking a reader back down. TaskPlannerChatTab, WorkflowResultsTab, DevServerLogViewer, and SystemControlsArea have separate transcript ownership and their matching force-follow behavior is deferred to FN-8346 rather than silently changing those surfaces here.
|
||||
*/
|
||||
const followTail = () => {
|
||||
if (!isTranscriptAtBottomRef.current) return;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
previousScrollHeightRef.current = container.scrollHeight;
|
||||
};
|
||||
const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(followTail);
|
||||
resizeObserver?.observe(container);
|
||||
const mutationObserver = typeof MutationObserver === "undefined" ? null : new MutationObserver(followTail);
|
||||
mutationObserver?.observe(container, { childList: true, characterData: true, subtree: true });
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
mutationObserver?.disconnect();
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
useLayoutEffect(() => () => {
|
||||
cancelAnchorTranscriptFrame();
|
||||
@@ -752,6 +782,7 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
|
||||
const receivedInitialItems = previousEntryCountRef.current === 0;
|
||||
if (!becameActive && !receivedInitialItems) return;
|
||||
|
||||
setTranscriptFollowing(true);
|
||||
anchorTranscriptToBottom(container);
|
||||
previousEntryCountRef.current = transcriptItemCount;
|
||||
previousScrollHeightRef.current = container.scrollHeight;
|
||||
@@ -759,7 +790,7 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
|
||||
return () => {
|
||||
cancelAnchorTranscriptFrame();
|
||||
};
|
||||
}, [active, anchorTranscriptToBottom, cancelAnchorTranscriptFrame, transcriptItemCount]);
|
||||
}, [active, anchorTranscriptToBottom, cancelAnchorTranscriptFrame, setTranscriptFollowing, transcriptItemCount]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = transcriptRef.current;
|
||||
@@ -803,28 +834,26 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
|
||||
const heightDelta = container.scrollHeight - previousHeight;
|
||||
container.scrollTop = previousTop + Math.max(0, heightDelta);
|
||||
pendingPrependScrollHeightRef.current = null;
|
||||
setIsTranscriptAtBottom(isTranscriptNearBottom(container));
|
||||
setTranscriptFollowing(isTranscriptNearBottom(container));
|
||||
} else if (transcriptItemCount > previousCount) {
|
||||
const shouldFollow = previousCount === 0 || previousScrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD;
|
||||
const shouldFollow = previousCount === 0 || isTranscriptAtBottomRef.current;
|
||||
if (shouldFollow) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
setIsTranscriptAtBottom(true);
|
||||
setTranscriptFollowing(true);
|
||||
} else {
|
||||
setIsTranscriptAtBottom(isTranscriptNearBottom(container));
|
||||
setTranscriptFollowing(isTranscriptNearBottom(container));
|
||||
}
|
||||
if (pendingPrependScrollHeightRef.current !== null) {
|
||||
pendingPrependScrollHeightRef.current = container.scrollHeight;
|
||||
pendingPrependScrollTopRef.current = container.scrollTop;
|
||||
}
|
||||
} else {
|
||||
setIsTranscriptAtBottom(isTranscriptNearBottom(container));
|
||||
}
|
||||
|
||||
previousEntryCountRef.current = transcriptItemCount;
|
||||
previousScrollHeightRef.current = container.scrollHeight;
|
||||
previousFirstEntryKeyRef.current = firstEntryKey;
|
||||
previousAgentEntryCountRef.current = entries.length;
|
||||
}, [active, entries.length, firstEntryKey, transcriptItemCount]);
|
||||
}, [active, entries.length, firstEntryKey, setTranscriptFollowing, transcriptItemCount]);
|
||||
|
||||
const loadPreviousMessages = useCallback(async () => {
|
||||
const container = transcriptRef.current;
|
||||
@@ -843,19 +872,19 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
|
||||
const container = transcriptRef.current;
|
||||
if (!container) return;
|
||||
previousScrollHeightRef.current = container.scrollHeight;
|
||||
setIsTranscriptAtBottom(isTranscriptNearBottom(container));
|
||||
setTranscriptFollowing(isTranscriptNearBottom(container));
|
||||
if (container.scrollTop <= TOP_LOAD_THRESHOLD) {
|
||||
void loadPreviousMessages();
|
||||
}
|
||||
}, [loadPreviousMessages]);
|
||||
}, [loadPreviousMessages, setTranscriptFollowing]);
|
||||
|
||||
const scrollTranscriptToBottom = useCallback(() => {
|
||||
const container = transcriptRef.current;
|
||||
if (!container) return;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
previousScrollHeightRef.current = container.scrollHeight;
|
||||
setIsTranscriptAtBottom(true);
|
||||
}, []);
|
||||
setTranscriptFollowing(true);
|
||||
}, [setTranscriptFollowing]);
|
||||
|
||||
const handleSubmit = useCallback(async (event?: React.FormEvent) => {
|
||||
event?.preventDefault();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { AgentLogViewer } from "../AgentLogViewer";
|
||||
import { makeEntry, getScrollContainer } from "./AgentLogViewer.test-helpers";
|
||||
import "../../styles.css";
|
||||
@@ -201,6 +201,7 @@ describe("AgentLogViewer", () => {
|
||||
});
|
||||
|
||||
viewer.scrollTop = 220;
|
||||
fireEvent.scroll(viewer);
|
||||
rerender(<AgentLogViewer entries={[...initialEntries]} loading={false} />);
|
||||
|
||||
scrollHeight = 1120;
|
||||
@@ -292,6 +293,61 @@ describe("AgentLogViewer", () => {
|
||||
expect(screen.queryByTestId("agent-log-return-to-live")).toBeNull();
|
||||
});
|
||||
|
||||
it("FN-8339: does not let observers override a user who scrolls up during streamed growth", async () => {
|
||||
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 streamingEntry = makeEntry({ text: "streaming output" });
|
||||
const { container, rerender } = render(<AgentLogViewer entries={[streamingEntry]} loading={false} />);
|
||||
const viewer = getScrollContainer(container);
|
||||
let scrollHeight = 1000;
|
||||
Object.defineProperty(viewer, "scrollHeight", { configurable: true, get: () => scrollHeight });
|
||||
Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 });
|
||||
|
||||
viewer.scrollTop = 300;
|
||||
fireEvent.scroll(viewer);
|
||||
scrollHeight = 1400;
|
||||
resizeCallbacks.forEach((callback) => callback());
|
||||
rerender(<AgentLogViewer entries={[{ ...streamingEntry, text: "streaming output grows in place" }]} loading={false} />);
|
||||
|
||||
await waitFor(() => expect(viewer.scrollTop).toBe(300));
|
||||
} finally {
|
||||
if (originalResizeObserver) {
|
||||
Object.defineProperty(globalThis, "ResizeObserver", { configurable: true, value: originalResizeObserver });
|
||||
} else {
|
||||
delete (globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("FN-8339: follows in-place streamed growth while pinned", async () => {
|
||||
const streamingEntry = makeEntry({ text: "streaming output" });
|
||||
const { container, rerender } = render(<AgentLogViewer entries={[streamingEntry]} loading={false} />);
|
||||
const viewer = getScrollContainer(container);
|
||||
let scrollHeight = 1000;
|
||||
Object.defineProperty(viewer, "scrollHeight", { configurable: true, get: () => scrollHeight });
|
||||
Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 });
|
||||
|
||||
viewer.scrollTop = 800;
|
||||
fireEvent.scroll(viewer);
|
||||
scrollHeight = 1400;
|
||||
rerender(<AgentLogViewer entries={[{ ...streamingEntry, text: "streaming output grows in place" }]} loading={false} />);
|
||||
|
||||
await waitFor(() => expect(viewer.scrollTop).toBe(1400));
|
||||
});
|
||||
|
||||
it("re-pins to bottom on resize while following", () => {
|
||||
const resizeCallbacks: Array<() => void> = [];
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ChatView } from "../ChatView";
|
||||
import type { ChatMessage, ChatSession } from "@fusion/core";
|
||||
@@ -256,6 +256,22 @@ describe("FN-6599 ChatView streaming prior thread", () => {
|
||||
expect(screen.getByText("Second answer")).toBeInTheDocument();
|
||||
};
|
||||
|
||||
/* FN-8339: streamed in-place growth must not override manual scroll-away on either breakpoint. */
|
||||
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
|
||||
let scrollTop = 180;
|
||||
let scrollHeight = 1200;
|
||||
Object.defineProperty(messagesContainer, "scrollTop", {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value: number) => { scrollTop = value; },
|
||||
});
|
||||
Object.defineProperty(messagesContainer, "scrollHeight", { configurable: true, get: () => scrollHeight });
|
||||
Object.defineProperty(messagesContainer, "clientHeight", { configurable: true, value: 240 });
|
||||
fireEvent.scroll(messagesContainer);
|
||||
scrollHeight = 1500;
|
||||
act(() => attachedHandlers?.onText(" while reading earlier output"));
|
||||
expect(scrollTop).toBe(180);
|
||||
|
||||
act(() => {
|
||||
subscribeHandler["chat:session:updated"]?.({
|
||||
data: JSON.stringify({
|
||||
|
||||
@@ -1334,6 +1334,40 @@ describe("TaskChatTab", () => {
|
||||
expect(raf.pendingCount).toBe(0);
|
||||
});
|
||||
|
||||
it("FN-8339: does not let tail growth override manual scrolling during streamed output", async () => {
|
||||
const raf = mockRequestAnimationFrame();
|
||||
const metrics = mockTranscriptMetrics({ scrollHeight: 800, clientHeight: 240, initialScrollTop: 0 });
|
||||
const streamingEntry = makeEntry({ agent: "executor", text: "streaming output" });
|
||||
mockLogs([streamingEntry]);
|
||||
|
||||
const { rerender } = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||
expect(metrics.scrollTop).toBe(800);
|
||||
|
||||
metrics.scrollTop = 180;
|
||||
fireEvent.scroll(screen.getByTestId("task-chat-transcript"));
|
||||
metrics.scrollHeight = 1200;
|
||||
expect(raf.flushNext()).toBe(true);
|
||||
mockLogs([{ ...streamingEntry, text: "streaming output grows in place" }]);
|
||||
rerender(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(metrics.scrollTop).toBe(180));
|
||||
});
|
||||
|
||||
it("FN-8339: follows in-place streamed growth while the task transcript is pinned", async () => {
|
||||
const metrics = mockTranscriptMetrics({ scrollHeight: 800, clientHeight: 240, initialScrollTop: 0 });
|
||||
const streamingEntry = makeEntry({ agent: "executor", text: "streaming output" });
|
||||
mockLogs([streamingEntry]);
|
||||
|
||||
const { rerender } = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||
expect(metrics.scrollTop).toBe(800);
|
||||
|
||||
metrics.scrollHeight = 1200;
|
||||
mockLogs([{ ...streamingEntry, text: "streaming output grows in place" }]);
|
||||
rerender(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(metrics.scrollTop).toBe(1200));
|
||||
});
|
||||
|
||||
it("FN-6337: bounds and cleans up the settle loop", () => {
|
||||
const raf = mockRequestAnimationFrame();
|
||||
const metrics = mockTranscriptMetrics({ scrollHeight: 500, clientHeight: 240, initialScrollTop: 0 });
|
||||
|
||||
Reference in New Issue
Block a user