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.
|
// 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 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 {
|
function stripAnsi(value: string): string {
|
||||||
return value.replace(ANSI_ESCAPE_PATTERN, "");
|
return value.replace(ANSI_ESCAPE_PATTERN, "");
|
||||||
@@ -100,6 +105,7 @@ export function DevServerLogViewer({
|
|||||||
const prevRunningRef = useRef(isRunning);
|
const prevRunningRef = useRef(isRunning);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [isUserScrolling, setIsUserScrolling] = useState(false);
|
const [isUserScrolling, setIsUserScrolling] = useState(false);
|
||||||
|
const isUserScrollingRef = useRef(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [severityFilter, setSeverityFilter] = useState<LogSeverityFilter>("all");
|
const [severityFilter, setSeverityFilter] = useState<LogSeverityFilter>("all");
|
||||||
|
|
||||||
@@ -122,6 +128,11 @@ export function DevServerLogViewer({
|
|||||||
|
|
||||||
const matchCount = filteredEntries.length;
|
const matchCount = filteredEntries.length;
|
||||||
|
|
||||||
|
const setManualScrollState = useCallback((isManualScrolling: boolean) => {
|
||||||
|
isUserScrollingRef.current = isManualScrolling;
|
||||||
|
setIsUserScrolling(isManualScrolling);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
const scrollToBottom = useCallback(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) {
|
if (!container) {
|
||||||
@@ -129,8 +140,8 @@ export function DevServerLogViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
container.scrollTop = container.scrollHeight;
|
container.scrollTop = container.scrollHeight;
|
||||||
setIsUserScrolling(false);
|
setManualScrollState(false);
|
||||||
}, []);
|
}, [setManualScrollState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const previousRunning = prevRunningRef.current;
|
const previousRunning = prevRunningRef.current;
|
||||||
@@ -151,10 +162,8 @@ export function DevServerLogViewer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const threshold = 50;
|
setManualScrollState(!isNearBottom(container));
|
||||||
const atBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - threshold;
|
}, [setManualScrollState]);
|
||||||
setIsUserScrolling(!atBottom);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading || entries.length === 0) {
|
if (loading || entries.length === 0) {
|
||||||
@@ -167,6 +176,28 @@ export function DevServerLogViewer({
|
|||||||
}
|
}
|
||||||
}, [entries, isRunning, isUserScrolling, loading, scrollToBottom]);
|
}, [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) {
|
if (loading && entries.length === 0) {
|
||||||
return (
|
return (
|
||||||
<section className="devserver-log-viewer" data-testid="devserver-log-viewer">
|
<section className="devserver-log-viewer" data-testid="devserver-log-viewer">
|
||||||
|
|||||||
@@ -183,6 +183,26 @@ describe("DevServerLogViewer", () => {
|
|||||||
expect(container).not.toHaveClass("devserver-log-viewer--fullscreen");
|
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 () => {
|
it("auto-scrolls when new entries arrive while running", async () => {
|
||||||
const { rerender } = renderViewer({
|
const { rerender } = renderViewer({
|
||||||
entries: [createEntry({ id: 1, text: "line 1" })],
|
entries: [createEntry({ id: 1, text: "line 1" })],
|
||||||
@@ -230,4 +250,31 @@ describe("DevServerLogViewer", () => {
|
|||||||
expect(scrollTopSetter).toHaveBeenCalled();
|
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 { readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
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 "@testing-library/jest-dom";
|
||||||
import { CommandCenter } from "../CommandCenter";
|
import { CommandCenter } from "../CommandCenter";
|
||||||
import { BUG_URL_MAX_ENCODED } from "../areas/SystemControlsArea";
|
import { BUG_URL_MAX_ENCODED } from "../areas/SystemControlsArea";
|
||||||
@@ -14,6 +14,7 @@ const mockFetchSystemLogs = vi.fn();
|
|||||||
const mockFetchNodeSystemStats = vi.fn();
|
const mockFetchNodeSystemStats = vi.fn();
|
||||||
const mockFetchGlobalSettings = vi.fn();
|
const mockFetchGlobalSettings = vi.fn();
|
||||||
const mockFetchNodes = vi.fn();
|
const mockFetchNodes = vi.fn();
|
||||||
|
const subscribeSseMock = vi.fn(() => () => undefined);
|
||||||
|
|
||||||
vi.mock("../../../api/legacy", () => ({
|
vi.mock("../../../api/legacy", () => ({
|
||||||
fetchCodebaseMetrics: vi.fn().mockResolvedValue({ tokenEstimate: 0, sourceFileCount: 0, sourceByteCount: 0, diskBytes: 0, diskFileCount: 0, method: "local", truncated: false }),
|
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", () => ({
|
vi.mock("../../../sse-bus", () => ({
|
||||||
subscribeSse: vi.fn(() => () => undefined),
|
subscribeSse: (...args: unknown[]) => subscribeSseMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../../hooks/useAppSettings", () => ({
|
vi.mock("../../../hooks/useAppSettings", () => ({
|
||||||
@@ -170,6 +171,7 @@ describe("SystemControlsArea layout integration", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
subscribeSseMock.mockImplementation(() => () => undefined);
|
||||||
apiMock.mockImplementation((path: string) => Promise.resolve(emptyOverviewResponse(path)));
|
apiMock.mockImplementation((path: string) => Promise.resolve(emptyOverviewResponse(path)));
|
||||||
mockFetchSystemInfo.mockResolvedValue(systemInfoFixture());
|
mockFetchSystemInfo.mockResolvedValue(systemInfoFixture());
|
||||||
mockFetchSystemLogs.mockResolvedValue({
|
mockFetchSystemLogs.mockResolvedValue({
|
||||||
@@ -417,6 +419,81 @@ describe("SystemControlsArea layout integration", () => {
|
|||||||
Element.prototype.scrollIntoView = original;
|
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 () => {
|
it("hides build-and-link-local when the host is not a source checkout", async () => {
|
||||||
mockFetchSystemInfo.mockResolvedValue(
|
mockFetchSystemInfo.mockResolvedValue(
|
||||||
systemInfoFixture({
|
systemInfoFixture({
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ export const BUG_URL_MAX_ENCODED = 8000;
|
|||||||
const BUG_URL_TRUNCATION_MARKER = "\n…(truncated)";
|
const BUG_URL_TRUNCATION_MARKER = "\n…(truncated)";
|
||||||
const BUG_URL_BODY_QUERY_PREFIX = "?body=";
|
const BUG_URL_BODY_QUERY_PREFIX = "?body=";
|
||||||
const GITHUB_NEW_ISSUE_URL = "https://github.com/Runfusion/Fusion/issues/new";
|
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 {
|
function buildBugReportIssueUrl(body: string): string {
|
||||||
const prefix = `${GITHUB_NEW_ISSUE_URL}${BUG_URL_BODY_QUERY_PREFIX}`;
|
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 [job, setJob] = useState<SystemRebuildJobSnapshot | null>(null);
|
||||||
const [jobLines, setJobLines] = useState<SystemRebuildJobLine[]>([]);
|
const [jobLines, setJobLines] = useState<SystemRebuildJobLine[]>([]);
|
||||||
const jobOutputRef = useRef<HTMLPreElement | null>(null);
|
const jobOutputRef = useRef<HTMLPreElement | null>(null);
|
||||||
|
const jobFollowingRef = useRef(true);
|
||||||
const jobSectionRef = useRef<HTMLDivElement | null>(null);
|
const jobSectionRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const [restartPhase, setRestartPhase] = useState<RestartPhase>(null);
|
const [restartPhase, setRestartPhase] = useState<RestartPhase>(null);
|
||||||
@@ -135,9 +141,28 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
|
|||||||
const [logsOpen, setLogsOpen] = useState(false);
|
const [logsOpen, setLogsOpen] = useState(false);
|
||||||
const [logEntries, setLogEntries] = useState<SystemLogEntryDto[]>([]);
|
const [logEntries, setLogEntries] = useState<SystemLogEntryDto[]>([]);
|
||||||
const logOutputRef = useRef<HTMLDivElement | null>(null);
|
const logOutputRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const logFollowingRef = useRef(true);
|
||||||
|
|
||||||
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResponse | null>(null);
|
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 () => {
|
const loadInfo = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const next = await fetchSystemInfo();
|
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
|
// Adopting a different (resumed) job — clear stale lines so the new
|
||||||
// job's stream doesn't render mixed with the previous job's output.
|
// job's stream doesn't render mixed with the previous job's output.
|
||||||
setJobLines([]);
|
setJobLines([]);
|
||||||
|
jobFollowingRef.current = true;
|
||||||
return next.activeRebuild;
|
return next.activeRebuild;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -227,8 +253,10 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
|
|||||||
}, [job?.id, job?.status, info?.pid, t, toast]);
|
}, [job?.id, job?.status, info?.pid, t, toast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = jobOutputRef.current;
|
const output = jobOutputRef.current;
|
||||||
if (el) el.scrollTop = el.scrollHeight;
|
if (output && jobFollowingRef.current) {
|
||||||
|
output.scrollTop = output.scrollHeight;
|
||||||
|
}
|
||||||
}, [jobLines]);
|
}, [jobLines]);
|
||||||
|
|
||||||
// ── Restart wait loop: server is back when /system/info answers with a new PID ──
|
// ── 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.
|
// a reconnect's replay overwrites rather than appends past the cap boundary.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!logsOpen || !info?.logsSupported) return;
|
if (!logsOpen || !info?.logsSupported) return;
|
||||||
|
logFollowingRef.current = true;
|
||||||
setLogEntries([]);
|
setLogEntries([]);
|
||||||
const unsubscribe = subscribeSse("/api/system/logs/stream", {
|
const unsubscribe = subscribeSse("/api/system/logs/stream", {
|
||||||
events: {
|
events: {
|
||||||
@@ -294,8 +323,10 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
|
|||||||
}, [logsOpen, info?.logsSupported]);
|
}, [logsOpen, info?.logsSupported]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = logOutputRef.current;
|
const output = logOutputRef.current;
|
||||||
if (el) el.scrollTop = el.scrollHeight;
|
if (output && logFollowingRef.current) {
|
||||||
|
output.scrollTop = output.scrollHeight;
|
||||||
|
}
|
||||||
}, [logEntries]);
|
}, [logEntries]);
|
||||||
|
|
||||||
// ── Actions ───────────────────────────────────────────────────────────────
|
// ── Actions ───────────────────────────────────────────────────────────────
|
||||||
@@ -331,6 +362,7 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
|
|||||||
}, [job?.id, job?.status]);
|
}, [job?.id, job?.status]);
|
||||||
|
|
||||||
const adoptJob = useCallback((snapshot: SystemRebuildJobSnapshot) => {
|
const adoptJob = useCallback((snapshot: SystemRebuildJobSnapshot) => {
|
||||||
|
jobFollowingRef.current = true;
|
||||||
setJobLines([]);
|
setJobLines([]);
|
||||||
setJob(snapshot);
|
setJob(snapshot);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -877,7 +909,7 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
|
|||||||
{jobStatusLabel}
|
{jobStatusLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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")}
|
{jobLines.map((line) => `${line.stream === "stderr" ? "! " : ""}${line.text}`).join("\n")}
|
||||||
</pre>
|
</pre>
|
||||||
{job.status === "failed" && job.error ? (
|
{job.status === "failed" && job.error ? (
|
||||||
@@ -910,7 +942,7 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{logsOpen && info?.logsSupported ? (
|
{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) => (
|
{logEntries.map((entry, index) => (
|
||||||
<div key={`${entry.timestamp}-${index}`} className={`cc-syscontrols-log-line cc-syscontrols-log-line--${entry.level}`}>
|
<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>
|
<span className="cc-syscontrols-log-time">{formatLogTimestamp(entry.timestamp)}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user