From ed2ffc48857093c3c29388516e42e9e425f35c1c Mon Sep 17 00:00:00 2001
From: gsxdsm
Date: Sat, 18 Jul 2026 18:04:26 -0700
Subject: [PATCH] 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)
---
.changeset/fn-8346-live-tail-scroll-follow.md | 7 ++
.../app/components/DevServerLogViewer.tsx | 43 ++++++++--
.../__tests__/DevServerLogViewer.test.tsx | 47 +++++++++++
.../__tests__/SystemControlsArea.test.tsx | 81 ++++++++++++++++++-
.../areas/SystemControlsArea.tsx | 44 ++++++++--
5 files changed, 208 insertions(+), 14 deletions(-)
create mode 100644 .changeset/fn-8346-live-tail-scroll-follow.md
diff --git a/.changeset/fn-8346-live-tail-scroll-follow.md b/.changeset/fn-8346-live-tail-scroll-follow.md
new file mode 100644
index 0000000000..25be75cefd
--- /dev/null
+++ b/.changeset/fn-8346-live-tail-scroll-follow.md
@@ -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.
diff --git a/packages/dashboard/app/components/DevServerLogViewer.tsx b/packages/dashboard/app/components/DevServerLogViewer.tsx
index 3e4e01e741..ac19c472cd 100644
--- a/packages/dashboard/app/components/DevServerLogViewer.tsx
+++ b/packages/dashboard/app/components/DevServerLogViewer.tsx
@@ -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("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 (
diff --git a/packages/dashboard/app/components/__tests__/DevServerLogViewer.test.tsx b/packages/dashboard/app/components/__tests__/DevServerLogViewer.test.tsx
index 00de1204ba..016fd86543 100644
--- a/packages/dashboard/app/components/__tests__/DevServerLogViewer.test.tsx
+++ b/packages/dashboard/app/components/__tests__/DevServerLogViewer.test.tsx
@@ -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(
+ ,
+ );
+ 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(
+ ,
+ );
+ await waitFor(() => expect(geometry.scrollTop).toBe(700));
+ });
});
diff --git a/packages/dashboard/app/components/command-center/__tests__/SystemControlsArea.test.tsx b/packages/dashboard/app/components/command-center/__tests__/SystemControlsArea.test.tsx
index ec9d3199e7..853fab44eb 100644
--- a/packages/dashboard/app/components/command-center/__tests__/SystemControlsArea.test.tsx
+++ b/packages/dashboard/app/components/command-center/__tests__/SystemControlsArea.test.tsx
@@ -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 void> }).events;
+ }
+
+ it("keeps manually scrolled rebuild output in place while SSE lines grow", async () => {
+ render();
+ 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();
+ 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({
diff --git a/packages/dashboard/app/components/command-center/areas/SystemControlsArea.tsx b/packages/dashboard/app/components/command-center/areas/SystemControlsArea.tsx
index 4940f26c54..aa983012c5 100644
--- a/packages/dashboard/app/components/command-center/areas/SystemControlsArea.tsx
+++ b/packages/dashboard/app/components/command-center/areas/SystemControlsArea.tsx
@@ -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(null);
const [jobLines, setJobLines] = useState([]);
const jobOutputRef = useRef(null);
+ const jobFollowingRef = useRef(true);
const jobSectionRef = useRef(null);
const [restartPhase, setRestartPhase] = useState(null);
@@ -135,9 +141,28 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
const [logsOpen, setLogsOpen] = useState(false);
const [logEntries, setLogEntries] = useState([]);
const logOutputRef = useRef(null);
+ const logFollowingRef = useRef(true);
const [updateCheckResult, setUpdateCheckResult] = useState(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}
-
+
{jobLines.map((line) => `${line.stream === "stderr" ? "! " : ""}${line.text}`).join("\n")}
{job.status === "failed" && job.error ? (
@@ -910,7 +942,7 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
) : null}
{logsOpen && info?.logsSupported ? (
-
+
{logEntries.map((entry, index) => (
{formatLogTimestamp(entry.timestamp)}