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:
7
.changeset/fn-8346-live-tail-scroll-follow.md
Normal file
7
.changeset/fn-8346-live-tail-scroll-follow.md
Normal 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.
|
||||
@@ -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">
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user