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:
7
.changeset/fn-8345-workflow-live-log-scroll.md
Normal file
7
.changeset/fn-8345-workflow-live-log-scroll.md
Normal 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.
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user