From 749528c114aa5bef3b8b16f33792932230cc13e5 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 4 May 2026 14:40:31 -0400 Subject: [PATCH] Hold live tape updates while scrolled away - pause incoming rows when a tape is not at top - show missed item count only while new data is hidden - wire list refs so scroll state can resume cleanly --- apps/web/app/globals.css | 21 +++++++++++-- apps/web/app/terminal.tsx | 64 +++++++++++++++++++++++++++++---------- 2 files changed, 66 insertions(+), 19 deletions(-) diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 654e8c9..5af91c1 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -835,11 +835,26 @@ h3 { } .missed-count { - width: 86px; + display: inline-flex; + align-items: center; + justify-content: flex-start; + max-width: 0; + overflow: hidden; + white-space: nowrap; font-size: 0.72rem; color: var(--accent); - text-align: right; - white-space: nowrap; + opacity: 0; + transform: translateX(6px); + transition: + max-width 180ms ease, + opacity 140ms ease, + transform 180ms ease; +} + +.missed-count-visible { + max-width: 92px; + opacity: 1; + transform: translateX(0); } .list { diff --git a/apps/web/app/terminal.tsx b/apps/web/app/terminal.tsx index cf5d79b..24a0e5d 100644 --- a/apps/web/app/terminal.tsx +++ b/apps/web/app/terminal.tsx @@ -1127,6 +1127,7 @@ export const getOptionTableSnapshot = ( type ListScrollState = { listRef: React.RefObject; + setListRef: (node: HTMLDivElement | null) => void; isAtTop: boolean; isAtTopRef: React.MutableRefObject; missed: number; @@ -1137,12 +1138,18 @@ type ListScrollState = { const useListScroll = (): ListScrollState => { const listRef = useRef(null); + const [listNode, setListNode] = useState(null); const [isAtTop, setIsAtTop] = useState(true); const [missed, setMissed] = useState(0); const [resumeTick, setResumeTick] = useState(0); const isAtTopRef = useRef(true); const prevAtTopRef = useRef(true); + const setListRef = useCallback((node: HTMLDivElement | null) => { + listRef.current = node; + setListNode(node); + }, []); + useEffect(() => { isAtTopRef.current = isAtTop; }, [isAtTop]); @@ -1169,8 +1176,7 @@ const useListScroll = (): ListScrollState => { }, [isAtTopRef]); useEffect(() => { - const el = listRef.current; - if (!el) { + if (!listNode) { return; } @@ -1179,12 +1185,12 @@ const useListScroll = (): ListScrollState => { }; updateScrollState(); - el.addEventListener("scroll", onScroll); + listNode.addEventListener("scroll", onScroll); return () => { - el.removeEventListener("scroll", onScroll); + listNode.removeEventListener("scroll", onScroll); }; - }, [updateScrollState]); + }, [listNode, updateScrollState]); const onNewItems = useCallback((count: number) => { if (count <= 0) { @@ -1212,6 +1218,7 @@ const useListScroll = (): ListScrollState => { return { listRef, + setListRef, isAtTop, isAtTopRef, missed, @@ -1846,6 +1853,8 @@ type PausableTapeViewConfig = { captureScroll?: () => void; getItemTs?: (item: T) => number; retentionLimit?: number; + shouldHold?: () => boolean; + resumeSignal?: number; }; const usePausableTapeView = ( @@ -1872,11 +1881,12 @@ const usePausableTapeView = ( return; } + const holdForScroll = config.shouldHold ? config.shouldHold() : false; setData((current) => { const next = reducePausableTapeData( current, config.sourceItems, - paused, + paused || holdForScroll, config.retentionLimit ?? LIVE_HOT_WINDOW ); if (next === current) { @@ -1897,6 +1907,7 @@ const usePausableTapeView = ( config.onNewItems, config.captureScroll, config.retentionLimit, + config.shouldHold, paused ]); @@ -1905,6 +1916,11 @@ const usePausableTapeView = ( return; } + const holdForScroll = config.shouldHold ? config.shouldHold() : false; + if (holdForScroll) { + return; + } + setData((current) => { const next = flushPausableTapeData(current, config.retentionLimit ?? LIVE_HOT_WINDOW); if (next === current) { @@ -1918,7 +1934,15 @@ const usePausableTapeView = ( return next; }); - }, [config.captureScroll, config.enabled, config.onNewItems, config.retentionLimit, paused]); + }, [ + config.captureScroll, + config.enabled, + config.onNewItems, + config.retentionLimit, + config.resumeSignal, + config.shouldHold, + paused + ]); const togglePause = useCallback(() => { setPaused((current) => !current); @@ -2841,7 +2865,9 @@ const TapeControls = ({ paused, onTogglePause, isAtTop, missed, onJump }: TapeCo - {active ? `+${missed} new` : ""} + + +{missed} new + ); }; @@ -4233,7 +4259,9 @@ const useTerminalState = () => { freshnessMs: LIVE_OPTIONS_STALE_MS, retentionLimit: LIVE_HOT_WINDOW_OPTIONS, captureScroll: optionsAnchor.capture, - onNewItems: optionsScroll.onNewItems + onNewItems: optionsScroll.onNewItems, + shouldHold: () => !optionsScroll.isAtTopRef.current, + resumeSignal: optionsScroll.resumeTick }); const liveEquities = usePausableTapeView({ enabled: mode === "live", @@ -4242,7 +4270,9 @@ const useTerminalState = () => { lastUpdate: liveSession.lastUpdate, freshnessMs: LIVE_EQUITIES_STALE_MS, captureScroll: equitiesAnchor.capture, - onNewItems: equitiesScroll.onNewItems + onNewItems: equitiesScroll.onNewItems, + shouldHold: () => !equitiesScroll.isAtTopRef.current, + resumeSignal: equitiesScroll.resumeTick }); const liveFlow = usePausableTapeView({ enabled: mode === "live", @@ -4252,6 +4282,8 @@ const useTerminalState = () => { freshnessMs: LIVE_FLOW_STALE_MS, captureScroll: flowAnchor.capture, onNewItems: flowScroll.onNewItems, + shouldHold: () => !flowScroll.isAtTopRef.current, + resumeSignal: flowScroll.resumeTick, getItemTs: (item) => item.source_ts }); @@ -5494,7 +5526,7 @@ const OptionsPane = ({ limit }: OptionsPaneProps) => { : "Replay queue empty. Ensure ClickHouse has data."} ) : ( -
+
TIME @@ -5660,7 +5692,7 @@ const EquitiesPane = ({ limit }: EquitiesPaneProps) => { : "Replay queue empty. Ensure ClickHouse has data."}
) : ( -
+
TIME @@ -5753,7 +5785,7 @@ const FlowPane = ({ limit, title = "Flow" }: FlowPaneProps) => { : "Replay queue empty. Ensure ClickHouse has data."}
) : ( -
+
TIME @@ -5892,7 +5924,7 @@ const AlertsPane = ({ limit, withStrip = false, className }: AlertsPaneProps) => : "Replay queue empty. Ensure ClickHouse has data."}
) : ( -
+
TIME @@ -5988,7 +6020,7 @@ const ClassifierPane = ({ limit, className }: ClassifierPaneProps) => { : "Replay queue empty. Ensure ClickHouse has data."}
) : ( -
+
TIME @@ -6073,7 +6105,7 @@ const DarkPane = ({ limit, className }: DarkPaneProps) => { : "Replay queue empty. Ensure ClickHouse has data."}
) : ( -
+
TIME