From d31a43d5863f6f4b2b7e85368376eb43fd22e892 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 18 Jul 2026 20:02:34 -0700 Subject: [PATCH] FN-8345: preserve manual live-log scrolling Keep live workflow output at the tail only while the reader remains pinned. - Track whether readers remain near the live log tail before following output. - Observe in-place streamed content growth and resume following after re-pinning. - Cover desktop and mobile scrolling behavior with workflow log regression tests. Files changed: .changeset/fn-8345-workflow-live-log-scroll.md | 7 + .../app/components/WorkflowResultsTab.tsx | 113 ++++++++++------ .../__tests__/WorkflowResultsTab.test.tsx | 146 ++++++++++++++++++++- 3 files changed, 225 insertions(+), 41 deletions(-) Fusion-Task-Id: FN-8345 Fusion-Task-Lineage: 44075099-738d-4c61-b482-dd7c500539d3 Co-authored-by: Fusion (runfusion.ai) --- .../fn-8345-workflow-live-log-scroll.md | 7 + .../app/components/WorkflowResultsTab.tsx | 113 +++++++++----- .../__tests__/WorkflowResultsTab.test.tsx | 146 +++++++++++++++++- 3 files changed, 225 insertions(+), 41 deletions(-) create mode 100644 .changeset/fn-8345-workflow-live-log-scroll.md diff --git a/.changeset/fn-8345-workflow-live-log-scroll.md b/.changeset/fn-8345-workflow-live-log-scroll.md new file mode 100644 index 0000000000..580e9df5eb --- /dev/null +++ b/.changeset/fn-8345-workflow-live-log-scroll.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Allow manual scrolling during generation in the task Workflow tab live log. +category: fix +dev: Live workflow output now follows only while pinned near the latest entry. diff --git a/packages/dashboard/app/components/WorkflowResultsTab.tsx b/packages/dashboard/app/components/WorkflowResultsTab.tsx index dbb80eeec2..7be22603d1 100644 --- a/packages/dashboard/app/components/WorkflowResultsTab.tsx +++ b/packages/dashboard/app/components/WorkflowResultsTab.tsx @@ -216,6 +216,8 @@ function formatModelValue(selection: { provider?: string; modelId?: string } | u * Renders live agent log output for a running (pending) workflow step. * Filters entries to show only those timestamped on or after the step's startedAt. */ +const BOTTOM_FOLLOW_THRESHOLD_PX = 50; + function LiveAgentLogOutput({ entries, startedAt, @@ -228,6 +230,8 @@ function LiveAgentLogOutput({ t: ReturnType["t"]; }) { const containerRef = useRef(null); + const contentRef = useRef(null); + const isFollowingRef = useRef(true); const startedAtMs = new Date(startedAt).getTime(); // Filter entries to only show those from this step's time window @@ -236,12 +240,38 @@ function LiveAgentLogOutput({ return entryMs >= startedAtMs; }); - // Auto-scroll to bottom as new entries arrive - useEffect(() => { + const isNearBottom = useCallback((container: HTMLDivElement) => ( + container.scrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD_PX + ), []); + + const followTail = useCallback(() => { + const container = containerRef.current; + if (!container || !isFollowingRef.current) return; + container.scrollTop = container.scrollHeight; + }, []); + + const handleScroll = useCallback(() => { const container = containerRef.current; if (!container) return; - container.scrollTop = container.scrollHeight; - }, [stepEntries.length]); + isFollowingRef.current = isNearBottom(container); + }, [isNearBottom]); + + /* + FNXC:WorkflowLiveLog 2026-07-18-16:10: + FN-8345 requires live workflow output to follow streamed growth only while the reader remains pinned near the tail. A manual scroll-up disables follow until the reader returns to the bottom; observing the content wrapper also covers streamed text that grows without adding an entry. + */ + useEffect(() => { + followTail(); + }, [followTail, stepEntries]); + + useEffect(() => { + const content = contentRef.current; + if (!content || typeof ResizeObserver === "undefined") return; + + const observer = new ResizeObserver(followTail); + observer.observe(content); + return () => observer.disconnect(); + }, [followTail, stepEntries.length]); if (stepEntries.length === 0) { return ( @@ -256,46 +286,49 @@ function LiveAgentLogOutput({ ref={containerRef} className="workflow-live-log" data-testid={`workflow-live-log-${stepId}`} + onScroll={handleScroll} > - {stepEntries.map((entry, i) => { - if (entry.type === "tool") { +
+ {stepEntries.map((entry, i) => { + if (entry.type === "tool") { + return ( +
+ ⚡ {linkifyFilePaths(entry.text)} + {entry.detail && — {linkifyFilePaths(entry.detail)}} +
+ ); + } + if (entry.type === "tool_result") { + return ( +
+ ✓ {linkifyFilePaths(entry.text)} + {entry.detail && — {linkifyFilePaths(entry.detail)}} +
+ ); + } + if (entry.type === "tool_error") { + return ( +
+ ✗ {linkifyFilePaths(entry.text)} + {entry.detail && — {linkifyFilePaths(entry.detail)}} +
+ ); + } + if (entry.type === "thinking") { + return ( +
+ {linkifyFilePaths(entry.text)} +
+ ); + } + // Default: text entries return ( -
- ⚡ {linkifyFilePaths(entry.text)} - {entry.detail && — {linkifyFilePaths(entry.detail)}} -
- ); - } - if (entry.type === "tool_result") { - return ( -
- ✓ {linkifyFilePaths(entry.text)} - {entry.detail && — {linkifyFilePaths(entry.detail)}} -
- ); - } - if (entry.type === "tool_error") { - return ( -
- ✗ {linkifyFilePaths(entry.text)} - {entry.detail && — {linkifyFilePaths(entry.detail)}} -
- ); - } - if (entry.type === "thinking") { - return ( -
+ {linkifyFilePaths(entry.text)} -
+ ); - } - // Default: text entries - return ( - - {linkifyFilePaths(entry.text)} - - ); - })} + })} +
); } diff --git a/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx index 8319a4769d..a2a8f5fe29 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterAll, vi } from "vitest"; -import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; +import { act, render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import { WorkflowResultsTab } from "../WorkflowResultsTab"; import * as api from "../../api"; import { useAgentLogs } from "../../hooks/useAgentLogs"; @@ -31,6 +31,40 @@ const mockedSubmitTaskWorkflowInput = vi.spyOn(api, "submitTaskWorkflowInput"); const mockedApproveTaskWorkflowCli = vi.spyOn(api, "approveTaskWorkflowCli"); const mockedUseAgentLogs = vi.mocked(useAgentLogs); +function mockWorkflowLiveLogGeometry(container: HTMLDivElement, initialScrollTop = 0) { + let scrollTop = initialScrollTop; + let scrollHeight = 1000; + Object.defineProperties(container, { + scrollHeight: { configurable: true, get: () => scrollHeight }, + clientHeight: { configurable: true, get: () => 200 }, + scrollTop: { + configurable: true, + get: () => scrollTop, + set: (value: number) => { scrollTop = Number(value); }, + }, + }); + return { + get scrollTop() { return scrollTop; }, + set scrollTop(value: number) { scrollTop = value; }, + get scrollHeight() { return scrollHeight; }, + set scrollHeight(value: number) { scrollHeight = value; }, + }; +} + +function mockWorkflowViewport(isMobile: boolean) { + Object.defineProperty(window, "innerWidth", { configurable: true, value: isMobile ? 375 : 1280 }); + vi.stubGlobal("matchMedia", vi.fn().mockImplementation((query: string) => ({ + matches: isMobile && query.includes("max-width: 768px"), + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }))); +} + describe("WorkflowResultsTab", () => { const mockWorkflowSteps: WorkflowStep[] = [ { @@ -734,6 +768,116 @@ describe("WorkflowResultsTab", () => { expect(within(liveLogPanel).getByText("Current workflow output")).toBeInTheDocument(); }); + describe("FN-8345: live workflow log scroll following", () => { + const initialEntries: AgentLogEntry[] = [{ + timestamp: "2026-03-31T10:03:25Z", taskId: "FN-001", text: "Streaming output", type: "text", + }]; + const appendedEntries: AgentLogEntry[] = [...initialEntries, { + timestamp: "2026-03-31T10:03:26Z", taskId: "FN-001", text: "More streaming output", type: "text", + }]; + + function renderLiveLog(entries: AgentLogEntry[]) { + mockedUseAgentLogs.mockReturnValue({ entries, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: entries.length, loadingMore: false }); + return render(); + } + + it.each([{ name: "desktop", isMobile: false }, { name: "mobile", isMobile: true }])("does not override an unsnapped $name reader during appended streaming growth", ({ isMobile }) => { + mockWorkflowViewport(isMobile); + const view = renderLiveLog(initialEntries); + const container = screen.getByTestId("workflow-live-log-WS-004") as HTMLDivElement; + const geometry = mockWorkflowLiveLogGeometry(container, 800); + + geometry.scrollTop = 200; + fireEvent.scroll(container); + geometry.scrollHeight = 1200; + mockedUseAgentLogs.mockReturnValue({ entries: appendedEntries, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: appendedEntries.length, loadingMore: false }); + view.rerender(); + + expect(geometry.scrollTop).toBe(200); + }); + + it("follows appended streaming growth while pinned at the bottom", () => { + const view = renderLiveLog(initialEntries); + const container = screen.getByTestId("workflow-live-log-WS-004") as HTMLDivElement; + const geometry = mockWorkflowLiveLogGeometry(container, 800); + + fireEvent.scroll(container); + geometry.scrollHeight = 1200; + mockedUseAgentLogs.mockReturnValue({ entries: appendedEntries, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: appendedEntries.length, loadingMore: false }); + view.rerender(); + + expect(geometry.scrollTop).toBe(1200); + }); + + it.each([{ name: "desktop", isMobile: false }, { name: "mobile", isMobile: true }])("re-pins and follows later streaming growth on $name", ({ isMobile }) => { + mockWorkflowViewport(isMobile); + const view = renderLiveLog(initialEntries); + const container = screen.getByTestId("workflow-live-log-WS-004") as HTMLDivElement; + const geometry = mockWorkflowLiveLogGeometry(container, 800); + + geometry.scrollTop = 200; + fireEvent.scroll(container); + geometry.scrollTop = 960; + fireEvent.scroll(container); + geometry.scrollHeight = 1200; + mockedUseAgentLogs.mockReturnValue({ entries: appendedEntries, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: appendedEntries.length, loadingMore: false }); + view.rerender(); + + expect(geometry.scrollTop).toBe(1200); + }); + + it("anchors to the bottom when the live log first becomes scrollable", () => { + const proto = HTMLElement.prototype; + const originalScrollHeight = Object.getOwnPropertyDescriptor(proto, "scrollHeight"); + const originalClientHeight = Object.getOwnPropertyDescriptor(proto, "clientHeight"); + const originalScrollTop = Object.getOwnPropertyDescriptor(proto, "scrollTop"); + let scrollTop = 0; + try { + Object.defineProperties(proto, { + scrollHeight: { configurable: true, get(this: HTMLElement) { return this.classList.contains("workflow-live-log") ? 1000 : originalScrollHeight?.get?.call(this) ?? 0; } }, + clientHeight: { configurable: true, get(this: HTMLElement) { return this.classList.contains("workflow-live-log") ? 200 : originalClientHeight?.get?.call(this) ?? 0; } }, + scrollTop: { + configurable: true, + get(this: HTMLElement) { return this.classList.contains("workflow-live-log") ? scrollTop : originalScrollTop?.get?.call(this) ?? 0; }, + set(this: HTMLElement, value: number) { + if (this.classList.contains("workflow-live-log")) scrollTop = Number(value); + else originalScrollTop?.set?.call(this, value); + }, + }, + }); + renderLiveLog(initialEntries); + expect(scrollTop).toBe(1000); + } finally { + if (originalScrollHeight) Object.defineProperty(proto, "scrollHeight", originalScrollHeight); + else Reflect.deleteProperty(proto, "scrollHeight"); + if (originalClientHeight) Object.defineProperty(proto, "clientHeight", originalClientHeight); + else Reflect.deleteProperty(proto, "clientHeight"); + if (originalScrollTop) Object.defineProperty(proto, "scrollTop", originalScrollTop); + else Reflect.deleteProperty(proto, "scrollTop"); + } + }); + + it("follows in-place streamed text growth through the content ResizeObserver", () => { + let resizeCallback: ResizeObserverCallback | undefined; + vi.stubGlobal("ResizeObserver", class { + observe() {} + disconnect() {} + constructor(callback: ResizeObserverCallback) { resizeCallback = callback; } + }); + const view = renderLiveLog(initialEntries); + const container = screen.getByTestId("workflow-live-log-WS-004") as HTMLDivElement; + const geometry = mockWorkflowLiveLogGeometry(container, 800); + fireEvent.scroll(container); + geometry.scrollHeight = 1200; + const expandedEntry = [{ ...initialEntries[0], text: "Streaming output that grew in place" }]; + mockedUseAgentLogs.mockReturnValue({ entries: expandedEntry, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: expandedEntry.length, loadingMore: false }); + view.rerender(); + act(() => resizeCallback?.([] as ResizeObserverEntry[], {} as ResizeObserver)); + + expect(geometry.scrollTop).toBe(1200); + }); + }); + it("renders advisory findings under Polish notes and keeps failure counts non-blocking", () => { const advisoryResults: WorkflowStepResult[] = [ {