From ae4e550c1fdc97ee02025629fc3d17a8ec8cc334 Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Sat, 5 Sep 2026 18:08:23 +0100 Subject: [PATCH] Anchor log viewer scroll to visible content and add 1m/2m timeline presets The log table no longer jumps when a sliding window prunes rows from the front: scroll position now tracks the same visible row instead of holding scrollTop constant in pixels. Also adds 1m/2m to the timeline quick-range presets. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o --- webapp/src/components/LogPreview.tsx | 46 +++++++++++++++++++++++++-- webapp/src/components/LogTimeline.tsx | 4 ++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/webapp/src/components/LogPreview.tsx b/webapp/src/components/LogPreview.tsx index d7a46dd..61506b8 100644 --- a/webapp/src/components/LogPreview.tsx +++ b/webapp/src/components/LogPreview.tsx @@ -82,9 +82,51 @@ function ColumnPicker({ ) } +/// Keeps the scroll position anchored to the same visible row across a `logs` update (e.g. a sliding time window +/// pruning old rows from the front), instead of the browser's default of holding `scrollTop` constant in pixels — +/// which would otherwise scroll the view to different content whenever rows are removed above it. +function useScrollAnchor(containerRef: React.RefObject, deps: unknown[]) { + const pendingAnchor = useRef<{ key: string; offsetTop: number } | null>(null) + + useEffect(() => { + const container = containerRef.current + if (!container) return + + if (pendingAnchor.current) { + const { key, offsetTop } = pendingAnchor.current + const row = container.querySelector(`[data-row-key="${CSS.escape(key)}"]`) + if (row) { + const rowTop = row.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop += rowTop - offsetTop + } + pendingAnchor.current = null + } + + // Runs right before the DOM mutates for the next update (React calls a layout effect's cleanup + // synchronously before re-running it), so this still sees the pre-update DOM — the same idea as + // `getSnapshotBeforeUpdate`, expressed with a plain effect. + return () => { + const containerRect = container.getBoundingClientRect() + for (const row of container.querySelectorAll('[data-row-key]')) { + const rect = row.getBoundingClientRect() + if (rect.bottom > containerRect.top) { + pendingAnchor.current = { + key: row.getAttribute('data-row-key')!, + offsetTop: rect.top - containerRect.top, + } + break + } + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps) +} + export function LogPreview({ logs, params }: { logs: LogEntry[]; params: ParamDeclaration[] }) { const paramNames = params.map((d) => d.name) const [visibleParamColumns, setVisibleParamColumns] = useState(loadVisibleParamColumns) + const scrollContainerRef = useRef(null) + useScrollAnchor(scrollContainerRef, [logs]) const visible = new Set(visibleParamColumns) @@ -110,7 +152,7 @@ export function LogPreview({ logs, params }: { logs: LogEntry[]; params: ParamDe -
+
@@ -126,7 +168,7 @@ export function LogPreview({ logs, params }: { logs: LogEntry[]; params: ParamDe {logs.map((log) => ( - +
{log.sequence} {new Date(log.timestamp_ms).toLocaleTimeString()} diff --git a/webapp/src/components/LogTimeline.tsx b/webapp/src/components/LogTimeline.tsx index e1e43d4..9bdc13b 100644 --- a/webapp/src/components/LogTimeline.tsx +++ b/webapp/src/components/LogTimeline.tsx @@ -9,10 +9,12 @@ export interface TimeRange { } // `'all'` clears the range (the implicit default); the rest resolve to a window ending "now" at the moment they're (re-)computed — see `computePresetRange`. -export type TimeRangePresetId = 'all' | '5m' | '15m' | '1h' | '24h' +export type TimeRangePresetId = 'all' | '1m' | '2m' | '5m' | '15m' | '1h' | '24h' export const TIME_RANGE_PRESETS: { id: TimeRangePresetId; label: string; ms: number | null }[] = [ { id: 'all', label: 'All', ms: null }, + { id: '1m', label: '1m', ms: 60 * 1000 }, + { id: '2m', label: '2m', ms: 2 * 60 * 1000 }, { id: '5m', label: '5m', ms: 5 * 60 * 1000 }, { id: '15m', label: '15m', ms: 15 * 60 * 1000 }, { id: '1h', label: '1h', ms: 60 * 60 * 1000 },