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)}