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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-18 20:02:34 -07:00
parent 996155734a
commit d31a43d586
3 changed files with 225 additions and 41 deletions

View File

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

View File

@@ -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<typeof useTranslation>["t"];
}) {
const containerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(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") {
<div ref={contentRef}>
{stepEntries.map((entry, i) => {
if (entry.type === "tool") {
return (
<div key={i} className="workflow-live-log-tool">
⚡ {linkifyFilePaths(entry.text)}
{entry.detail && <span className="workflow-live-log-detail"> — {linkifyFilePaths(entry.detail)}</span>}
</div>
);
}
if (entry.type === "tool_result") {
return (
<div key={i} className="workflow-live-log-tool-result">
✓ {linkifyFilePaths(entry.text)}
{entry.detail && <span className="workflow-live-log-detail"> — {linkifyFilePaths(entry.detail)}</span>}
</div>
);
}
if (entry.type === "tool_error") {
return (
<div key={i} className="workflow-live-log-tool-error">
✗ {linkifyFilePaths(entry.text)}
{entry.detail && <span className="workflow-live-log-detail"> — {linkifyFilePaths(entry.detail)}</span>}
</div>
);
}
if (entry.type === "thinking") {
return (
<div key={i} className="workflow-live-log-thinking">
{linkifyFilePaths(entry.text)}
</div>
);
}
// Default: text entries
return (
<div key={i} className="workflow-live-log-tool">
⚡ {linkifyFilePaths(entry.text)}
{entry.detail && <span className="workflow-live-log-detail"> — {linkifyFilePaths(entry.detail)}</span>}
</div>
);
}
if (entry.type === "tool_result") {
return (
<div key={i} className="workflow-live-log-tool-result">
✓ {linkifyFilePaths(entry.text)}
{entry.detail && <span className="workflow-live-log-detail"> — {linkifyFilePaths(entry.detail)}</span>}
</div>
);
}
if (entry.type === "tool_error") {
return (
<div key={i} className="workflow-live-log-tool-error">
✗ {linkifyFilePaths(entry.text)}
{entry.detail && <span className="workflow-live-log-detail"> — {linkifyFilePaths(entry.detail)}</span>}
</div>
);
}
if (entry.type === "thinking") {
return (
<div key={i} className="workflow-live-log-thinking">
<span key={i} className="workflow-live-log-text">
{linkifyFilePaths(entry.text)}
</div>
</span>
);
}
// Default: text entries
return (
<span key={i} className="workflow-live-log-text">
{linkifyFilePaths(entry.text)}
</span>
);
})}
})}
</div>
</div>
);
}

View File

@@ -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(<WorkflowResultsTab taskId="FN-001" results={mockResults} isTaskInProgress />);
}
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(<WorkflowResultsTab taskId="FN-001" results={mockResults} isTaskInProgress />);
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(<WorkflowResultsTab taskId="FN-001" results={mockResults} isTaskInProgress />);
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(<WorkflowResultsTab taskId="FN-001" results={mockResults} isTaskInProgress />);
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(<WorkflowResultsTab taskId="FN-001" results={mockResults} isTaskInProgress />);
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[] = [
{