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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-05 18:08:23 +01:00
co-authored by Claude Sonnet 5
parent 070e835fa4
commit ae4e550c1f
2 changed files with 47 additions and 3 deletions
+44 -2
View File
@@ -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<HTMLDivElement | null>, 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[] }) { export function LogPreview({ logs, params }: { logs: LogEntry[]; params: ParamDeclaration[] }) {
const paramNames = params.map((d) => d.name) const paramNames = params.map((d) => d.name)
const [visibleParamColumns, setVisibleParamColumns] = useState<string[]>(loadVisibleParamColumns) const [visibleParamColumns, setVisibleParamColumns] = useState<string[]>(loadVisibleParamColumns)
const scrollContainerRef = useRef<HTMLDivElement>(null)
useScrollAnchor(scrollContainerRef, [logs])
const visible = new Set(visibleParamColumns) const visible = new Set(visibleParamColumns)
@@ -110,7 +152,7 @@ export function LogPreview({ logs, params }: { logs: LogEntry[]; params: ParamDe
</div> </div>
<ColumnPicker paramNames={paramNames} visible={visible} onToggle={toggleColumn} /> <ColumnPicker paramNames={paramNames} visible={visible} onToggle={toggleColumn} />
</div> </div>
<div className="flex-1 overflow-auto"> <div ref={scrollContainerRef} className="flex-1 overflow-auto">
<table className="w-full border-collapse text-left text-xs"> <table className="w-full border-collapse text-left text-xs">
<thead className="sticky top-0 bg-surface-0 text-text-subtle"> <thead className="sticky top-0 bg-surface-0 text-text-subtle">
<tr> <tr>
@@ -126,7 +168,7 @@ export function LogPreview({ logs, params }: { logs: LogEntry[]; params: ParamDe
</thead> </thead>
<tbody> <tbody>
{logs.map((log) => ( {logs.map((log) => (
<tr key={log.sequence} className="border-t border-border-faint hover:bg-surface-1/60"> <tr key={log.sequence} data-row-key={log.sequence} className="border-t border-border-faint hover:bg-surface-1/60">
<td className="whitespace-nowrap px-3 py-1 text-text-subtle">{log.sequence}</td> <td className="whitespace-nowrap px-3 py-1 text-text-subtle">{log.sequence}</td>
<td className="whitespace-nowrap px-3 py-1 text-text-subtle"> <td className="whitespace-nowrap px-3 py-1 text-text-subtle">
{new Date(log.timestamp_ms).toLocaleTimeString()} {new Date(log.timestamp_ms).toLocaleTimeString()}
+3 -1
View File
@@ -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`. // `'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 }[] = [ export const TIME_RANGE_PRESETS: { id: TimeRangePresetId; label: string; ms: number | null }[] = [
{ id: 'all', label: 'All', ms: 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: '5m', label: '5m', ms: 5 * 60 * 1000 },
{ id: '15m', label: '15m', ms: 15 * 60 * 1000 }, { id: '15m', label: '15m', ms: 15 * 60 * 1000 },
{ id: '1h', label: '1h', ms: 60 * 60 * 1000 }, { id: '1h', label: '1h', ms: 60 * 60 * 1000 },