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:
gsxdsm
2026-07-18 16:23:02 -07:00
parent c95e08ea21
commit 0a581f50db
7 changed files with 194 additions and 36 deletions

View 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.

View File

@@ -343,6 +343,12 @@ export function AgentLogViewer({
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 [isFollowing, setIsFollowing] = useState(true);
const isFollowingRef = useRef(true);
const setFollowing = useCallback((following: boolean) => {
isFollowingRef.current = following;
setIsFollowing(following);
}, []);
useEffect(() => { useEffect(() => {
writeBooleanPref(MARKDOWN_TOGGLE_STORAGE_KEY, renderMarkdown); writeBooleanPref(MARKDOWN_TOGGLE_STORAGE_KEY, renderMarkdown);
@@ -384,6 +390,10 @@ export function AgentLogViewer({
[renderEntries, chronologicalEntryKeys], [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. // Keep live-follow pinned to the bottom when new streamed entries append.
// When older history is prepended (load more), preserve viewport position. // When older history is prepended (load more), preserve viewport position.
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -402,9 +412,7 @@ export function AgentLogViewer({
if (previousCount === 0) { if (previousCount === 0) {
container.scrollTop = container.scrollHeight; container.scrollTop = container.scrollHeight;
} else { } else {
const wasNearBottom = const wasNearBottom = isFollowingRef.current;
previousScrollHeight - (container.scrollTop + container.clientHeight) <=
BOTTOM_FOLLOW_THRESHOLD_PX;
const appendedLiveEntry = newestEntryChanged && !oldestEntryChanged; const appendedLiveEntry = newestEntryChanged && !oldestEntryChanged;
const prependedOlderEntries = oldestEntryChanged && !newestEntryChanged; const prependedOlderEntries = oldestEntryChanged && !newestEntryChanged;
@@ -421,46 +429,50 @@ export function AgentLogViewer({
} }
} }
if (newEntryCount !== previousCount) {
setFollowing(isNearBottom(container));
}
previousEntryCountRef.current = newEntryCount; previousEntryCountRef.current = newEntryCount;
previousScrollHeightRef.current = container.scrollHeight; previousScrollHeightRef.current = container.scrollHeight;
previousOldestEntryKeyRef.current = oldestEntryKey; previousOldestEntryKeyRef.current = oldestEntryKey;
previousNewestEntryKeyRef.current = newestEntryKey; previousNewestEntryKeyRef.current = newestEntryKey;
setIsFollowing(isNearBottom(container)); }, [entries, chronologicalEntryKeys, setFollowing]);
}, [entries, chronologicalEntryKeys]);
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
const container = containerRef.current; const container = containerRef.current;
if (!container) return; if (!container) return;
setIsFollowing(isNearBottom(container)); setFollowing(isNearBottom(container));
}, []); }, [setFollowing]);
const scrollToLive = useCallback(() => { const scrollToLive = useCallback(() => {
const container = containerRef.current; const container = containerRef.current;
if (!container) return; if (!container) return;
container.scrollTop = container.scrollHeight; container.scrollTop = container.scrollHeight;
setIsFollowing(true); setFollowing(true);
}, []); }, [setFollowing]);
useEffect(() => { useEffect(() => {
if (typeof ResizeObserver === "undefined") {
return;
}
const container = containerRef.current; const container = containerRef.current;
if (!container) { if (!container) {
return; return;
} }
const observer = new ResizeObserver(() => { const followTail = () => {
if (!isFollowing) { if (!isFollowingRef.current) {
return; return;
} }
container.scrollTop = container.scrollHeight; 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 () => {
return () => observer.disconnect(); resizeObserver?.disconnect();
}, [isFollowing]); mutationObserver?.disconnect();
};
}, []);
// Escape key handler to exit fullscreen mode // Escape key handler to exit fullscreen mode
const handleKeyDown = useCallback((e: KeyboardEvent) => { const handleKeyDown = useCallback((e: KeyboardEvent) => {

View File

@@ -1320,6 +1320,10 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
anchorToBottom, 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. // Scroll thread container to bottom during streaming only when already pinned.
useEffect(() => { useEffect(() => {
if (!isStreaming || isUserScrollingRef.current) { if (!isStreaming || isUserScrollingRef.current) {

View File

@@ -627,6 +627,7 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
const sendingRef = useRef(false); const sendingRef = useRef(false);
const [optimisticMessages, setOptimisticMessages] = useState<UserChatMessage[]>([]); const [optimisticMessages, setOptimisticMessages] = useState<UserChatMessage[]>([]);
const [isTranscriptAtBottom, setIsTranscriptAtBottom] = useState(true); const [isTranscriptAtBottom, setIsTranscriptAtBottom] = useState(true);
const isTranscriptAtBottomRef = useRef(true);
const thinkingDefaultOpen = task.column === "in-progress" || task.column === "in-review"; const thinkingDefaultOpen = task.column === "in-progress" || task.column === "in-review";
const transcriptRef = useRef<HTMLDivElement>(null); const transcriptRef = useRef<HTMLDivElement>(null);
const previousEntryCountRef = useRef(0); const previousEntryCountRef = useRef(0);
@@ -704,6 +705,11 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
anchorFrameRef.current = null; anchorFrameRef.current = null;
}, []); }, []);
const setTranscriptFollowing = useCallback((following: boolean) => {
isTranscriptAtBottomRef.current = following;
setIsTranscriptAtBottom(following);
}, []);
const anchorTranscriptToBottom = useCallback((container: HTMLElement) => { const anchorTranscriptToBottom = useCallback((container: HTMLElement) => {
cancelAnchorTranscriptFrame(); cancelAnchorTranscriptFrame();
if (!container.isConnected) return; if (!container.isConnected) return;
@@ -715,11 +721,11 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
const writeBottom = () => { const writeBottom = () => {
anchorFrameRef.current = null; anchorFrameRef.current = null;
if (!container.isConnected) return; if (!container.isConnected || !isTranscriptAtBottomRef.current) return;
container.scrollTop = container.scrollHeight; container.scrollTop = container.scrollHeight;
previousScrollHeightRef.current = container.scrollHeight; previousScrollHeightRef.current = container.scrollHeight;
setIsTranscriptAtBottom(true); setTranscriptFollowing(true);
if (container.scrollHeight === lastScrollHeight) { if (container.scrollHeight === lastScrollHeight) {
stableFrames += 1; stableFrames += 1;
} else { } else {
@@ -736,7 +742,31 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
}; };
writeBottom(); 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(() => () => { useLayoutEffect(() => () => {
cancelAnchorTranscriptFrame(); cancelAnchorTranscriptFrame();
@@ -752,6 +782,7 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
const receivedInitialItems = previousEntryCountRef.current === 0; const receivedInitialItems = previousEntryCountRef.current === 0;
if (!becameActive && !receivedInitialItems) return; if (!becameActive && !receivedInitialItems) return;
setTranscriptFollowing(true);
anchorTranscriptToBottom(container); anchorTranscriptToBottom(container);
previousEntryCountRef.current = transcriptItemCount; previousEntryCountRef.current = transcriptItemCount;
previousScrollHeightRef.current = container.scrollHeight; previousScrollHeightRef.current = container.scrollHeight;
@@ -759,7 +790,7 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
return () => { return () => {
cancelAnchorTranscriptFrame(); cancelAnchorTranscriptFrame();
}; };
}, [active, anchorTranscriptToBottom, cancelAnchorTranscriptFrame, transcriptItemCount]); }, [active, anchorTranscriptToBottom, cancelAnchorTranscriptFrame, setTranscriptFollowing, transcriptItemCount]);
useLayoutEffect(() => { useLayoutEffect(() => {
const container = transcriptRef.current; const container = transcriptRef.current;
@@ -803,28 +834,26 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
const heightDelta = container.scrollHeight - previousHeight; const heightDelta = container.scrollHeight - previousHeight;
container.scrollTop = previousTop + Math.max(0, heightDelta); container.scrollTop = previousTop + Math.max(0, heightDelta);
pendingPrependScrollHeightRef.current = null; pendingPrependScrollHeightRef.current = null;
setIsTranscriptAtBottom(isTranscriptNearBottom(container)); setTranscriptFollowing(isTranscriptNearBottom(container));
} else if (transcriptItemCount > previousCount) { } else if (transcriptItemCount > previousCount) {
const shouldFollow = previousCount === 0 || previousScrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD; const shouldFollow = previousCount === 0 || isTranscriptAtBottomRef.current;
if (shouldFollow) { if (shouldFollow) {
container.scrollTop = container.scrollHeight; container.scrollTop = container.scrollHeight;
setIsTranscriptAtBottom(true); setTranscriptFollowing(true);
} else { } else {
setIsTranscriptAtBottom(isTranscriptNearBottom(container)); setTranscriptFollowing(isTranscriptNearBottom(container));
} }
if (pendingPrependScrollHeightRef.current !== null) { if (pendingPrependScrollHeightRef.current !== null) {
pendingPrependScrollHeightRef.current = container.scrollHeight; pendingPrependScrollHeightRef.current = container.scrollHeight;
pendingPrependScrollTopRef.current = container.scrollTop; pendingPrependScrollTopRef.current = container.scrollTop;
} }
} else {
setIsTranscriptAtBottom(isTranscriptNearBottom(container));
} }
previousEntryCountRef.current = transcriptItemCount; previousEntryCountRef.current = transcriptItemCount;
previousScrollHeightRef.current = container.scrollHeight; previousScrollHeightRef.current = container.scrollHeight;
previousFirstEntryKeyRef.current = firstEntryKey; previousFirstEntryKeyRef.current = firstEntryKey;
previousAgentEntryCountRef.current = entries.length; previousAgentEntryCountRef.current = entries.length;
}, [active, entries.length, firstEntryKey, transcriptItemCount]); }, [active, entries.length, firstEntryKey, setTranscriptFollowing, transcriptItemCount]);
const loadPreviousMessages = useCallback(async () => { const loadPreviousMessages = useCallback(async () => {
const container = transcriptRef.current; const container = transcriptRef.current;
@@ -843,19 +872,19 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
const container = transcriptRef.current; const container = transcriptRef.current;
if (!container) return; if (!container) return;
previousScrollHeightRef.current = container.scrollHeight; previousScrollHeightRef.current = container.scrollHeight;
setIsTranscriptAtBottom(isTranscriptNearBottom(container)); setTranscriptFollowing(isTranscriptNearBottom(container));
if (container.scrollTop <= TOP_LOAD_THRESHOLD) { if (container.scrollTop <= TOP_LOAD_THRESHOLD) {
void loadPreviousMessages(); void loadPreviousMessages();
} }
}, [loadPreviousMessages]); }, [loadPreviousMessages, setTranscriptFollowing]);
const scrollTranscriptToBottom = useCallback(() => { const scrollTranscriptToBottom = useCallback(() => {
const container = transcriptRef.current; const container = transcriptRef.current;
if (!container) return; if (!container) return;
container.scrollTop = container.scrollHeight; container.scrollTop = container.scrollHeight;
previousScrollHeightRef.current = container.scrollHeight; previousScrollHeightRef.current = container.scrollHeight;
setIsTranscriptAtBottom(true); setTranscriptFollowing(true);
}, []); }, [setTranscriptFollowing]);
const handleSubmit = useCallback(async (event?: React.FormEvent) => { const handleSubmit = useCallback(async (event?: React.FormEvent) => {
event?.preventDefault(); event?.preventDefault();

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; 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 { AgentLogViewer } from "../AgentLogViewer";
import { makeEntry, getScrollContainer } from "./AgentLogViewer.test-helpers"; import { makeEntry, getScrollContainer } from "./AgentLogViewer.test-helpers";
import "../../styles.css"; import "../../styles.css";
@@ -201,6 +201,7 @@ describe("AgentLogViewer", () => {
}); });
viewer.scrollTop = 220; viewer.scrollTop = 220;
fireEvent.scroll(viewer);
rerender(<AgentLogViewer entries={[...initialEntries]} loading={false} />); rerender(<AgentLogViewer entries={[...initialEntries]} loading={false} />);
scrollHeight = 1120; scrollHeight = 1120;
@@ -292,6 +293,61 @@ describe("AgentLogViewer", () => {
expect(screen.queryByTestId("agent-log-return-to-live")).toBeNull(); 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", () => { it("re-pins to bottom on resize while following", () => {
const resizeCallbacks: Array<() => void> = []; const resizeCallbacks: Array<() => void> = [];
const originalResizeObserver = globalThis.ResizeObserver; const originalResizeObserver = globalThis.ResizeObserver;

View File

@@ -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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ChatView } from "../ChatView"; import { ChatView } from "../ChatView";
import type { ChatMessage, ChatSession } from "@fusion/core"; import type { ChatMessage, ChatSession } from "@fusion/core";
@@ -256,6 +256,22 @@ describe("FN-6599 ChatView streaming prior thread", () => {
expect(screen.getByText("Second answer")).toBeInTheDocument(); 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(() => { act(() => {
subscribeHandler["chat:session:updated"]?.({ subscribeHandler["chat:session:updated"]?.({
data: JSON.stringify({ data: JSON.stringify({

View File

@@ -1334,6 +1334,40 @@ describe("TaskChatTab", () => {
expect(raf.pendingCount).toBe(0); 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", () => { it("FN-6337: bounds and cleans up the settle loop", () => {
const raf = mockRequestAnimationFrame(); const raf = mockRequestAnimationFrame();
const metrics = mockTranscriptMetrics({ scrollHeight: 500, clientHeight: 240, initialScrollTop: 0 }); const metrics = mockTranscriptMetrics({ scrollHeight: 500, clientHeight: 240, initialScrollTop: 0 });