FN-8346: preserve manual scroll position in live logs

Preserve the sticky-bottom follow invariant across streamed Dev Server and System Controls output.

- Track pinned-bottom state synchronously for rebuild and system-log streams.
- Follow in-place Dev Server transcript mutations only while the reader is pinned.
- Cover manual unsnapping, stream growth, and re-pinning behavior.
- Add a patch changeset for live-log scrolling.

Files changed:
 .changeset/fn-8346-live-tail-scroll-follow.md      |  7 ++
 .../app/components/DevServerLogViewer.tsx          | 43 ++++++++++--
 .../__tests__/DevServerLogViewer.test.tsx          | 47 +++++++++++++
 .../__tests__/SystemControlsArea.test.tsx          | 81 +++++++++++++++++++++-
 .../command-center/areas/SystemControlsArea.tsx    | 44 ++++++++++--
 5 files changed, 208 insertions(+), 14 deletions(-)

Fusion-Task-Id: FN-8346

Fusion-Task-Lineage: c1eeb84d-c0af-473b-99f1-0d8564ac8a24

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-18 18:04:26 -07:00
parent 059a71f5e9
commit ed2ffc4885
5 changed files with 208 additions and 14 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Preserve manual scrolling in System Controls and Dev Server live logs.
category: fix
dev: Applies the shared pinned-bottom follow invariant to streamed output tails.

View File

@@ -21,6 +21,11 @@ type LogSeverityFilter = "all" | LogSeverity;
// eslint-disable-next-line no-control-regex -- ANSI escape stripping is required for readable terminal logs.
const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g;
const BOTTOM_FOLLOW_THRESHOLD_PX = 50;
function isNearBottom(container: HTMLElement): boolean {
return container.scrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD_PX;
}
function stripAnsi(value: string): string {
return value.replace(ANSI_ESCAPE_PATTERN, "");
@@ -100,6 +105,7 @@ export function DevServerLogViewer({
const prevRunningRef = useRef(isRunning);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isUserScrolling, setIsUserScrolling] = useState(false);
const isUserScrollingRef = useRef(false);
const [searchQuery, setSearchQuery] = useState("");
const [severityFilter, setSeverityFilter] = useState<LogSeverityFilter>("all");
@@ -122,6 +128,11 @@ export function DevServerLogViewer({
const matchCount = filteredEntries.length;
const setManualScrollState = useCallback((isManualScrolling: boolean) => {
isUserScrollingRef.current = isManualScrolling;
setIsUserScrolling(isManualScrolling);
}, []);
const scrollToBottom = useCallback(() => {
const container = containerRef.current;
if (!container) {
@@ -129,8 +140,8 @@ export function DevServerLogViewer({
}
container.scrollTop = container.scrollHeight;
setIsUserScrolling(false);
}, []);
setManualScrollState(false);
}, [setManualScrollState]);
useEffect(() => {
const previousRunning = prevRunningRef.current;
@@ -151,10 +162,8 @@ export function DevServerLogViewer({
return;
}
const threshold = 50;
const atBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - threshold;
setIsUserScrolling(!atBottom);
}, []);
setManualScrollState(!isNearBottom(container));
}, [setManualScrollState]);
useEffect(() => {
if (loading || entries.length === 0) {
@@ -167,6 +176,28 @@ export function DevServerLogViewer({
}
}, [entries, isRunning, isUserScrolling, loading, scrollToBottom]);
/*
FNXC:DevServer 2026-07-18-16:16:
FN-8346 extends FN-8339's pinned-bottom invariant to in-place dev-server
transcript growth. MutationObserver watches the growing log content rather
than the fixed viewport; its synchronous ref guard preserves a manual
scroll-up even when streamed text changes without increasing entry count.
*/
useEffect(() => {
const container = containerRef.current;
if (!container || !isRunning || typeof MutationObserver === "undefined") {
return;
}
const observer = new MutationObserver(() => {
if (!isUserScrollingRef.current) {
scrollToBottom();
}
});
observer.observe(container, { childList: true, characterData: true, subtree: true });
return () => observer.disconnect();
}, [isRunning, scrollToBottom]);
if (loading && entries.length === 0) {
return (
<section className="devserver-log-viewer" data-testid="devserver-log-viewer">

View File

@@ -183,6 +183,26 @@ describe("DevServerLogViewer", () => {
expect(container).not.toHaveClass("devserver-log-viewer--fullscreen");
});
function installScrollGeometry(element: HTMLElement, scrollHeight = 500, clientHeight = 100) {
let scrollTop = 0;
Object.defineProperties(element, {
scrollHeight: { configurable: true, get: () => scrollHeight },
clientHeight: { configurable: true, get: () => clientHeight },
scrollTop: {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = value;
},
},
});
return {
get scrollTop() { return scrollTop; },
set scrollTop(value: number) { scrollTop = value; },
setScrollHeight(value: number) { scrollHeight = value; },
};
}
it("auto-scrolls when new entries arrive while running", async () => {
const { rerender } = renderViewer({
entries: [createEntry({ id: 1, text: "line 1" })],
@@ -230,4 +250,31 @@ describe("DevServerLogViewer", () => {
expect(scrollTopSetter).toHaveBeenCalled();
});
});
it("follows in-place stream growth only while pinned, and jump-to-latest re-pins", async () => {
const entries = [createEntry({ id: 1, text: "streaming" })];
const { rerender } = renderViewer({ entries, isRunning: true, total: 1 });
const content = screen.getByTestId("devserver-log-content");
const geometry = installScrollGeometry(content);
geometry.scrollTop = 100;
fireEvent.scroll(content);
geometry.setScrollHeight(600);
entries[0].text = "streaming more";
rerender(
<DevServerLogViewer entries={entries} loading={false} loadingMore={false} hasMore={false} total={1} onLoadMore={vi.fn()} isRunning />,
);
await waitFor(() => expect(geometry.scrollTop).toBe(100));
expect(screen.getByTestId("devserver-log-jump-button")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("devserver-log-jump-button"));
expect(geometry.scrollTop).toBe(600);
geometry.setScrollHeight(700);
entries[0].text = "streaming even more";
rerender(
<DevServerLogViewer entries={entries} loading={false} loadingMore={false} hasMore={false} total={1} onLoadMore={vi.fn()} isRunning />,
);
await waitFor(() => expect(geometry.scrollTop).toBe(700));
});
});

View File

@@ -1,7 +1,7 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import "@testing-library/jest-dom";
import { CommandCenter } from "../CommandCenter";
import { BUG_URL_MAX_ENCODED } from "../areas/SystemControlsArea";
@@ -14,6 +14,7 @@ const mockFetchSystemLogs = vi.fn();
const mockFetchNodeSystemStats = vi.fn();
const mockFetchGlobalSettings = vi.fn();
const mockFetchNodes = vi.fn();
const subscribeSseMock = vi.fn(() => () => undefined);
vi.mock("../../../api/legacy", () => ({
fetchCodebaseMetrics: vi.fn().mockResolvedValue({ tokenEstimate: 0, sourceFileCount: 0, sourceByteCount: 0, diskBytes: 0, diskFileCount: 0, method: "local", truncated: false }),
@@ -66,7 +67,7 @@ vi.mock("../../../api", () => ({
}));
vi.mock("../../../sse-bus", () => ({
subscribeSse: vi.fn(() => () => undefined),
subscribeSse: (...args: unknown[]) => subscribeSseMock(...args),
}));
vi.mock("../../../hooks/useAppSettings", () => ({
@@ -170,6 +171,7 @@ describe("SystemControlsArea layout integration", () => {
beforeEach(() => {
vi.clearAllMocks();
subscribeSseMock.mockImplementation(() => () => undefined);
apiMock.mockImplementation((path: string) => Promise.resolve(emptyOverviewResponse(path)));
mockFetchSystemInfo.mockResolvedValue(systemInfoFixture());
mockFetchSystemLogs.mockResolvedValue({
@@ -417,6 +419,81 @@ describe("SystemControlsArea layout integration", () => {
Element.prototype.scrollIntoView = original;
});
function installScrollGeometry(element: HTMLElement, scrollHeight = 500, clientHeight = 100) {
let scrollTop = 0;
Object.defineProperties(element, {
scrollHeight: { configurable: true, get: () => scrollHeight },
clientHeight: { configurable: true, get: () => clientHeight },
scrollTop: {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = value;
},
},
});
return {
get scrollTop() { return scrollTop; },
set scrollTop(value: number) { scrollTop = value; },
setScrollHeight(value: number) { scrollHeight = value; },
};
}
function getStreamEvents(path: string) {
const call = [...subscribeSseMock.mock.calls].reverse().find(([url]) => url === path);
expect(call).toBeDefined();
return (call?.[1] as { events: Record<string, (event: MessageEvent) => void> }).events;
}
it("keeps manually scrolled rebuild output in place while SSE lines grow", async () => {
render(<CommandCenter projectId="proj-1" />);
fireEvent.click(screen.getByTestId("command-center-tab-system"));
const rebuild = await screen.findByTestId("cc-syscontrol-rebuild-app");
fireEvent.click(within(rebuild).getByRole("button", { name: "Rebuild" }));
const output = (await screen.findByTestId("cc-system-rebuild-output")).querySelector("pre")!;
const geometry = installScrollGeometry(output);
const events = getStreamEvents("/api/system/jobs/job-1/stream");
await act(async () => events.line(new MessageEvent("message", { data: JSON.stringify({ i: 1, stream: "stdout", text: "first" }) })));
geometry.scrollTop = 100;
fireEvent.scroll(output);
geometry.setScrollHeight(600);
await act(async () => events.line(new MessageEvent("message", { data: JSON.stringify({ i: 2, stream: "stdout", text: "second" }) })));
expect(geometry.scrollTop).toBe(100);
geometry.scrollTop = 500;
fireEvent.scroll(output);
geometry.setScrollHeight(700);
await act(async () => events.line(new MessageEvent("message", { data: JSON.stringify({ i: 3, stream: "stdout", text: "third" }) })));
expect(geometry.scrollTop).toBe(700);
});
it("keeps manually scrolled live server logs in place while SSE lines grow", async () => {
render(<CommandCenter projectId="proj-1" />);
fireEvent.click(screen.getByTestId("command-center-tab-system"));
await screen.findByTestId("cc-system-controls");
fireEvent.click(screen.getByTestId("cc-system-logs-toggle"));
const output = await screen.findByText("No log entries yet.");
const container = output.parentElement!;
const geometry = installScrollGeometry(container);
const events = getStreamEvents("/api/system/logs/stream");
await act(async () => events.log(new MessageEvent("message", { data: JSON.stringify({ timestamp: "2026-07-18T00:00:00.000Z", level: "info", message: "first" }) })));
geometry.scrollTop = 100;
fireEvent.scroll(container);
geometry.setScrollHeight(600);
await act(async () => events.log(new MessageEvent("message", { data: JSON.stringify({ timestamp: "2026-07-18T00:00:01.000Z", level: "info", message: "second" }) })));
expect(geometry.scrollTop).toBe(100);
geometry.scrollTop = 500;
fireEvent.scroll(container);
geometry.setScrollHeight(700);
await act(async () => events.log(new MessageEvent("message", { data: JSON.stringify({ timestamp: "2026-07-18T00:00:02.000Z", level: "info", message: "third" }) })));
expect(geometry.scrollTop).toBe(700);
});
it("hides build-and-link-local when the host is not a source checkout", async () => {
mockFetchSystemInfo.mockResolvedValue(
systemInfoFixture({

View File

@@ -77,6 +77,11 @@ export const BUG_URL_MAX_ENCODED = 8000;
const BUG_URL_TRUNCATION_MARKER = "\n…(truncated)";
const BUG_URL_BODY_QUERY_PREFIX = "?body=";
const GITHUB_NEW_ISSUE_URL = "https://github.com/Runfusion/Fusion/issues/new";
const BOTTOM_FOLLOW_THRESHOLD_PX = 50;
function isNearBottom(container: HTMLElement): boolean {
return container.scrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD_PX;
}
function buildBugReportIssueUrl(body: string): string {
const prefix = `${GITHUB_NEW_ISSUE_URL}${BUG_URL_BODY_QUERY_PREFIX}`;
@@ -127,6 +132,7 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
const [job, setJob] = useState<SystemRebuildJobSnapshot | null>(null);
const [jobLines, setJobLines] = useState<SystemRebuildJobLine[]>([]);
const jobOutputRef = useRef<HTMLPreElement | null>(null);
const jobFollowingRef = useRef(true);
const jobSectionRef = useRef<HTMLDivElement | null>(null);
const [restartPhase, setRestartPhase] = useState<RestartPhase>(null);
@@ -135,9 +141,28 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
const [logsOpen, setLogsOpen] = useState(false);
const [logEntries, setLogEntries] = useState<SystemLogEntryDto[]>([]);
const logOutputRef = useRef<HTMLDivElement | null>(null);
const logFollowingRef = useRef(true);
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResponse | null>(null);
/*
FNXC:SystemPanel 2026-07-18-16:12:
FN-8346 mirrors FN-8339's pinned-bottom contract for independently streamed
System Controls tails. A tail may follow growth only while its reader remains
within the bottom threshold; an onScroll geometry check synchronously unsnaps
it so SSE updates never override a manual scroll-up. Starting a new stream
resets its pin, preserving the initial/latest-output anchor.
*/
const updateJobFollowState = useCallback(() => {
const output = jobOutputRef.current;
if (output) jobFollowingRef.current = isNearBottom(output);
}, []);
const updateLogFollowState = useCallback(() => {
const output = logOutputRef.current;
if (output) logFollowingRef.current = isNearBottom(output);
}, []);
const loadInfo = useCallback(async () => {
try {
const next = await fetchSystemInfo();
@@ -149,6 +174,7 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
// Adopting a different (resumed) job — clear stale lines so the new
// job's stream doesn't render mixed with the previous job's output.
setJobLines([]);
jobFollowingRef.current = true;
return next.activeRebuild;
});
}
@@ -227,8 +253,10 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
}, [job?.id, job?.status, info?.pid, t, toast]);
useEffect(() => {
const el = jobOutputRef.current;
if (el) el.scrollTop = el.scrollHeight;
const output = jobOutputRef.current;
if (output && jobFollowingRef.current) {
output.scrollTop = output.scrollHeight;
}
}, [jobLines]);
// ── Restart wait loop: server is back when /system/info answers with a new PID ──
@@ -273,6 +301,7 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
// a reconnect's replay overwrites rather than appends past the cap boundary.
useEffect(() => {
if (!logsOpen || !info?.logsSupported) return;
logFollowingRef.current = true;
setLogEntries([]);
const unsubscribe = subscribeSse("/api/system/logs/stream", {
events: {
@@ -294,8 +323,10 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
}, [logsOpen, info?.logsSupported]);
useEffect(() => {
const el = logOutputRef.current;
if (el) el.scrollTop = el.scrollHeight;
const output = logOutputRef.current;
if (output && logFollowingRef.current) {
output.scrollTop = output.scrollHeight;
}
}, [logEntries]);
// ── Actions ───────────────────────────────────────────────────────────────
@@ -331,6 +362,7 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
}, [job?.id, job?.status]);
const adoptJob = useCallback((snapshot: SystemRebuildJobSnapshot) => {
jobFollowingRef.current = true;
setJobLines([]);
setJob(snapshot);
}, []);
@@ -877,7 +909,7 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
{jobStatusLabel}
</span>
</div>
<pre ref={jobOutputRef} className="cc-syscontrols-output" aria-live="polite">
<pre ref={jobOutputRef} className="cc-syscontrols-output" aria-live="polite" onScroll={updateJobFollowState}>
{jobLines.map((line) => `${line.stream === "stderr" ? "! " : ""}${line.text}`).join("\n")}
</pre>
{job.status === "failed" && job.error ? (
@@ -910,7 +942,7 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
</p>
) : null}
{logsOpen && info?.logsSupported ? (
<div ref={logOutputRef} className="cc-syscontrols-output cc-syscontrols-logs" aria-live="polite">
<div ref={logOutputRef} className="cc-syscontrols-output cc-syscontrols-logs" aria-live="polite" onScroll={updateLogFollowState}>
{logEntries.map((entry, index) => (
<div key={`${entry.timestamp}-${index}`} className={`cc-syscontrols-log-line cc-syscontrols-log-line--${entry.level}`}>
<span className="cc-syscontrols-log-time">{formatLogTimestamp(entry.timestamp)}</span>