diff --git a/.changeset/fn-6337-chat-scroll-settle.md b/.changeset/fn-6337-chat-scroll-settle.md new file mode 100644 index 0000000000..9ed328d38d --- /dev/null +++ b/.changeset/fn-6337-chat-scroll-settle.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Reliably settle the task detail Chat transcript to the latest output on load and tab reactivation, including after collapsible thinking/tool groups reflow. diff --git a/packages/dashboard/app/components/TaskChatTab.tsx b/packages/dashboard/app/components/TaskChatTab.tsx index 7551662a2a..c430611082 100644 --- a/packages/dashboard/app/components/TaskChatTab.tsx +++ b/packages/dashboard/app/components/TaskChatTab.tsx @@ -339,6 +339,7 @@ export function TaskChatTab({ task, projectId, active, addToast }: TaskChatTabPr const previousEntryCountRef = useRef(0); const previousScrollHeightRef = useRef(0); const previousActiveRef = useRef(false); + const anchorFrameRef = useRef(null); const textareaRef = useRef(null); const groups = useMemo(() => groupEntriesByAgent(entries), [entries]); @@ -359,6 +360,49 @@ export function TaskChatTab({ task, projectId, active, addToast }: TaskChatTabPr resizeComposer(); }, [draft, resizeComposer]); + const cancelAnchorTranscriptFrame = useCallback(() => { + if (anchorFrameRef.current === null) return; + window.cancelAnimationFrame(anchorFrameRef.current); + anchorFrameRef.current = null; + }, []); + + const anchorTranscriptToBottom = useCallback((container: HTMLElement) => { + cancelAnchorTranscriptFrame(); + if (!container.isConnected) return; + + let frame = 0; + let stableFrames = 0; + let lastScrollHeight = -1; + const maxFrames = 6; + + const writeBottom = () => { + anchorFrameRef.current = null; + if (!container.isConnected) return; + + container.scrollTop = container.scrollHeight; + previousScrollHeightRef.current = container.scrollHeight; + if (container.scrollHeight === lastScrollHeight) { + stableFrames += 1; + } else { + stableFrames = 0; + lastScrollHeight = container.scrollHeight; + } + + frame += 1; + if (frame >= maxFrames || stableFrames >= 2) { + return; + } + + anchorFrameRef.current = window.requestAnimationFrame(writeBottom); + }; + + writeBottom(); + }, [cancelAnchorTranscriptFrame]); + + useLayoutEffect(() => () => { + cancelAnchorTranscriptFrame(); + }, [cancelAnchorTranscriptFrame]); + useLayoutEffect(() => { const container = transcriptRef.current; const wasActive = previousActiveRef.current; @@ -369,10 +413,14 @@ export function TaskChatTab({ task, projectId, active, addToast }: TaskChatTabPr const receivedInitialEntries = previousEntryCountRef.current === 0; if (!becameActive && !receivedInitialEntries) return; - container.scrollTop = container.scrollHeight; + anchorTranscriptToBottom(container); previousEntryCountRef.current = entries.length; previousScrollHeightRef.current = container.scrollHeight; - }, [active, entries.length]); + + return () => { + cancelAnchorTranscriptFrame(); + }; + }, [active, anchorTranscriptToBottom, cancelAnchorTranscriptFrame, entries.length]); useLayoutEffect(() => { const container = transcriptRef.current; diff --git a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx index 0d07865e9e..6c7fa69019 100644 --- a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx @@ -21,6 +21,8 @@ const mockedAddSteeringComment = vi.mocked(addSteeringComment); const originalScrollTopDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollTop"); const originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight"); const originalClientHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight"); +const originalRequestAnimationFrame = window.requestAnimationFrame; +const originalCancelAnimationFrame = window.cancelAnimationFrame; function makeTask(overrides: Partial = {}): Task { return { @@ -134,6 +136,47 @@ function mockMatchMedia(matches: boolean) { }); } +function mockRequestAnimationFrame() { + let nextId = 1; + const callbacks = new Map(); + const requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + const id = nextId; + nextId += 1; + callbacks.set(id, callback); + return id; + }); + const cancelAnimationFrame = vi.fn((id: number) => { + callbacks.delete(id); + }); + + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + writable: true, + value: requestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + writable: true, + value: cancelAnimationFrame, + }); + + return { + requestAnimationFrame, + cancelAnimationFrame, + flushNext() { + const next = callbacks.entries().next(); + if (next.done) return false; + const [id, callback] = next.value; + callbacks.delete(id); + callback(performance.now()); + return true; + }, + get pendingCount() { + return callbacks.size; + }, + }; +} + describe("TaskChatTab", () => { beforeEach(() => { vi.clearAllMocks(); @@ -144,6 +187,16 @@ describe("TaskChatTab", () => { restoreMetricDescriptor("scrollTop", originalScrollTopDescriptor); restoreMetricDescriptor("scrollHeight", originalScrollHeightDescriptor); restoreMetricDescriptor("clientHeight", originalClientHeightDescriptor); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + writable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + writable: true, + value: originalCancelAnimationFrame, + }); }); it("subscribes to live agent logs only when active", () => { @@ -412,6 +465,61 @@ describe("TaskChatTab", () => { expect(metrics.scrollTop).toBe(metrics.scrollHeight); }); + it("FN-6337: re-pins populated transcripts to the bottom after async height growth", () => { + const raf = mockRequestAnimationFrame(); + const metrics = mockTranscriptMetrics({ scrollHeight: 600, clientHeight: 240, initialScrollTop: 0 }); + mockLogs([ + makeEntry({ agent: "executor", text: "older output" }), + makeEntry({ agent: "executor", type: "thinking", text: "expanded thinking", timestamp: "2026-06-12T00:00:01.000Z" }), + makeEntry({ agent: "executor", type: "tool", text: "bash", detail: "pnpm test", timestamp: "2026-06-12T00:00:02.000Z" }), + ]); + + render(); + + expect(metrics.scrollTop).toBe(600); + metrics.scrollHeight = 900; + expect(raf.flushNext()).toBe(true); + expect(metrics.scrollTop).toBe(900); + + metrics.scrollHeight = 1200; + expect(raf.flushNext()).toBe(true); + expect(metrics.scrollTop).toBe(1200); + + expect(raf.flushNext()).toBe(true); + expect(metrics.scrollTop).toBe(1200); + expect(raf.flushNext()).toBe(true); + expect(metrics.scrollTop).toBe(metrics.scrollHeight); + expect(raf.pendingCount).toBe(0); + }); + + it("FN-6337: bounds and cleans up the settle loop", () => { + const raf = mockRequestAnimationFrame(); + const metrics = mockTranscriptMetrics({ scrollHeight: 500, clientHeight: 240, initialScrollTop: 0 }); + mockLogs([makeEntry({ agent: "executor", text: "output" })]); + + const { unmount } = render(); + + for (let frame = 0; frame < 5; frame += 1) { + metrics.scrollHeight += 100; + expect(raf.flushNext()).toBe(true); + } + expect(metrics.scrollTop).toBe(1000); + expect(raf.pendingCount).toBe(0); + + metrics.scrollHeight = 1300; + mockLogs([makeEntry({ agent: "executor", text: "output after remount" })]); + const mountedAgain = render(); + expect(raf.pendingCount).toBe(1); + mountedAgain.unmount(); + expect(raf.cancelAnimationFrame).toHaveBeenCalled(); + expect(raf.pendingCount).toBe(0); + + metrics.scrollHeight = 1600; + expect(raf.flushNext()).toBe(false); + expect(metrics.scrollTop).toBe(1300); + unmount(); + }); + it("does not mutate scroll position for an empty transcript", () => { const metrics = mockTranscriptMetrics({ scrollHeight: 900, clientHeight: 240, initialScrollTop: 25 }); mockLogs([]);