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
This commit is contained in:
dirtydishes 2026-05-04 14:40:31 -04:00
parent 623f7df113
commit 749528c114
2 changed files with 66 additions and 19 deletions

View file

@ -835,11 +835,26 @@ h3 {
} }
.missed-count { .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; font-size: 0.72rem;
color: var(--accent); color: var(--accent);
text-align: right; opacity: 0;
white-space: nowrap; 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 { .list {

View file

@ -1127,6 +1127,7 @@ export const getOptionTableSnapshot = (
type ListScrollState = { type ListScrollState = {
listRef: React.RefObject<HTMLDivElement>; listRef: React.RefObject<HTMLDivElement>;
setListRef: (node: HTMLDivElement | null) => void;
isAtTop: boolean; isAtTop: boolean;
isAtTopRef: React.MutableRefObject<boolean>; isAtTopRef: React.MutableRefObject<boolean>;
missed: number; missed: number;
@ -1137,12 +1138,18 @@ type ListScrollState = {
const useListScroll = (): ListScrollState => { const useListScroll = (): ListScrollState => {
const listRef = useRef<HTMLDivElement | null>(null); const listRef = useRef<HTMLDivElement | null>(null);
const [listNode, setListNode] = useState<HTMLDivElement | null>(null);
const [isAtTop, setIsAtTop] = useState(true); const [isAtTop, setIsAtTop] = useState(true);
const [missed, setMissed] = useState(0); const [missed, setMissed] = useState(0);
const [resumeTick, setResumeTick] = useState(0); const [resumeTick, setResumeTick] = useState(0);
const isAtTopRef = useRef(true); const isAtTopRef = useRef(true);
const prevAtTopRef = useRef(true); const prevAtTopRef = useRef(true);
const setListRef = useCallback((node: HTMLDivElement | null) => {
listRef.current = node;
setListNode(node);
}, []);
useEffect(() => { useEffect(() => {
isAtTopRef.current = isAtTop; isAtTopRef.current = isAtTop;
}, [isAtTop]); }, [isAtTop]);
@ -1169,8 +1176,7 @@ const useListScroll = (): ListScrollState => {
}, [isAtTopRef]); }, [isAtTopRef]);
useEffect(() => { useEffect(() => {
const el = listRef.current; if (!listNode) {
if (!el) {
return; return;
} }
@ -1179,12 +1185,12 @@ const useListScroll = (): ListScrollState => {
}; };
updateScrollState(); updateScrollState();
el.addEventListener("scroll", onScroll); listNode.addEventListener("scroll", onScroll);
return () => { return () => {
el.removeEventListener("scroll", onScroll); listNode.removeEventListener("scroll", onScroll);
}; };
}, [updateScrollState]); }, [listNode, updateScrollState]);
const onNewItems = useCallback((count: number) => { const onNewItems = useCallback((count: number) => {
if (count <= 0) { if (count <= 0) {
@ -1212,6 +1218,7 @@ const useListScroll = (): ListScrollState => {
return { return {
listRef, listRef,
setListRef,
isAtTop, isAtTop,
isAtTopRef, isAtTopRef,
missed, missed,
@ -1846,6 +1853,8 @@ type PausableTapeViewConfig<T extends SortableItem & { seq: number }> = {
captureScroll?: () => void; captureScroll?: () => void;
getItemTs?: (item: T) => number; getItemTs?: (item: T) => number;
retentionLimit?: number; retentionLimit?: number;
shouldHold?: () => boolean;
resumeSignal?: number;
}; };
const usePausableTapeView = <T extends SortableItem & { seq: number }>( const usePausableTapeView = <T extends SortableItem & { seq: number }>(
@ -1872,11 +1881,12 @@ const usePausableTapeView = <T extends SortableItem & { seq: number }>(
return; return;
} }
const holdForScroll = config.shouldHold ? config.shouldHold() : false;
setData((current) => { setData((current) => {
const next = reducePausableTapeData( const next = reducePausableTapeData(
current, current,
config.sourceItems, config.sourceItems,
paused, paused || holdForScroll,
config.retentionLimit ?? LIVE_HOT_WINDOW config.retentionLimit ?? LIVE_HOT_WINDOW
); );
if (next === current) { if (next === current) {
@ -1897,6 +1907,7 @@ const usePausableTapeView = <T extends SortableItem & { seq: number }>(
config.onNewItems, config.onNewItems,
config.captureScroll, config.captureScroll,
config.retentionLimit, config.retentionLimit,
config.shouldHold,
paused paused
]); ]);
@ -1905,6 +1916,11 @@ const usePausableTapeView = <T extends SortableItem & { seq: number }>(
return; return;
} }
const holdForScroll = config.shouldHold ? config.shouldHold() : false;
if (holdForScroll) {
return;
}
setData((current) => { setData((current) => {
const next = flushPausableTapeData(current, config.retentionLimit ?? LIVE_HOT_WINDOW); const next = flushPausableTapeData(current, config.retentionLimit ?? LIVE_HOT_WINDOW);
if (next === current) { if (next === current) {
@ -1918,7 +1934,15 @@ const usePausableTapeView = <T extends SortableItem & { seq: number }>(
return next; 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(() => { const togglePause = useCallback(() => {
setPaused((current) => !current); setPaused((current) => !current);
@ -2841,7 +2865,9 @@ const TapeControls = ({ paused, onTogglePause, isAtTop, missed, onJump }: TapeCo
<button className="jump-button" type="button" onClick={onJump} disabled={isAtTop}> <button className="jump-button" type="button" onClick={onJump} disabled={isAtTop}>
Jump to top Jump to top
</button> </button>
<span className="missed-count">{active ? `+${missed} new` : ""}</span> <span className={`missed-count${active ? " missed-count-visible" : ""}`} aria-hidden={!active}>
+{missed} new
</span>
</div> </div>
); );
}; };
@ -4233,7 +4259,9 @@ const useTerminalState = () => {
freshnessMs: LIVE_OPTIONS_STALE_MS, freshnessMs: LIVE_OPTIONS_STALE_MS,
retentionLimit: LIVE_HOT_WINDOW_OPTIONS, retentionLimit: LIVE_HOT_WINDOW_OPTIONS,
captureScroll: optionsAnchor.capture, captureScroll: optionsAnchor.capture,
onNewItems: optionsScroll.onNewItems onNewItems: optionsScroll.onNewItems,
shouldHold: () => !optionsScroll.isAtTopRef.current,
resumeSignal: optionsScroll.resumeTick
}); });
const liveEquities = usePausableTapeView<EquityPrint>({ const liveEquities = usePausableTapeView<EquityPrint>({
enabled: mode === "live", enabled: mode === "live",
@ -4242,7 +4270,9 @@ const useTerminalState = () => {
lastUpdate: liveSession.lastUpdate, lastUpdate: liveSession.lastUpdate,
freshnessMs: LIVE_EQUITIES_STALE_MS, freshnessMs: LIVE_EQUITIES_STALE_MS,
captureScroll: equitiesAnchor.capture, captureScroll: equitiesAnchor.capture,
onNewItems: equitiesScroll.onNewItems onNewItems: equitiesScroll.onNewItems,
shouldHold: () => !equitiesScroll.isAtTopRef.current,
resumeSignal: equitiesScroll.resumeTick
}); });
const liveFlow = usePausableTapeView<FlowPacket>({ const liveFlow = usePausableTapeView<FlowPacket>({
enabled: mode === "live", enabled: mode === "live",
@ -4252,6 +4282,8 @@ const useTerminalState = () => {
freshnessMs: LIVE_FLOW_STALE_MS, freshnessMs: LIVE_FLOW_STALE_MS,
captureScroll: flowAnchor.capture, captureScroll: flowAnchor.capture,
onNewItems: flowScroll.onNewItems, onNewItems: flowScroll.onNewItems,
shouldHold: () => !flowScroll.isAtTopRef.current,
resumeSignal: flowScroll.resumeTick,
getItemTs: (item) => item.source_ts getItemTs: (item) => item.source_ts
}); });
@ -5494,7 +5526,7 @@ const OptionsPane = ({ limit }: OptionsPaneProps) => {
: "Replay queue empty. Ensure ClickHouse has data."} : "Replay queue empty. Ensure ClickHouse has data."}
</div> </div>
) : ( ) : (
<div className="data-table-wrap" ref={state.optionsScroll.listRef}> <div className="data-table-wrap" ref={state.optionsScroll.setListRef}>
<div className="data-table data-table-options" role="table" aria-label="Options tape"> <div className="data-table data-table-options" role="table" aria-label="Options tape">
<div className="data-table-head" role="row"> <div className="data-table-head" role="row">
<span className="data-table-cell">TIME</span> <span className="data-table-cell">TIME</span>
@ -5660,7 +5692,7 @@ const EquitiesPane = ({ limit }: EquitiesPaneProps) => {
: "Replay queue empty. Ensure ClickHouse has data."} : "Replay queue empty. Ensure ClickHouse has data."}
</div> </div>
) : ( ) : (
<div className="data-table-wrap" ref={state.equitiesScroll.listRef}> <div className="data-table-wrap" ref={state.equitiesScroll.setListRef}>
<div className="data-table data-table-equities" role="table" aria-label="Equity prints"> <div className="data-table data-table-equities" role="table" aria-label="Equity prints">
<div className="data-table-head" role="row"> <div className="data-table-head" role="row">
<span className="data-table-cell">TIME</span> <span className="data-table-cell">TIME</span>
@ -5753,7 +5785,7 @@ const FlowPane = ({ limit, title = "Flow" }: FlowPaneProps) => {
: "Replay queue empty. Ensure ClickHouse has data."} : "Replay queue empty. Ensure ClickHouse has data."}
</div> </div>
) : ( ) : (
<div className="data-table-wrap" ref={state.flowScroll.listRef}> <div className="data-table-wrap" ref={state.flowScroll.setListRef}>
<div className="data-table data-table-flow" role="table" aria-label="Flow packets"> <div className="data-table data-table-flow" role="table" aria-label="Flow packets">
<div className="data-table-head" role="row"> <div className="data-table-head" role="row">
<span className="data-table-cell">TIME</span> <span className="data-table-cell">TIME</span>
@ -5892,7 +5924,7 @@ const AlertsPane = ({ limit, withStrip = false, className }: AlertsPaneProps) =>
: "Replay queue empty. Ensure ClickHouse has data."} : "Replay queue empty. Ensure ClickHouse has data."}
</div> </div>
) : ( ) : (
<div className="data-table-wrap" ref={state.alertsScroll.listRef}> <div className="data-table-wrap" ref={state.alertsScroll.setListRef}>
<div className="data-table data-table-alerts" role="table" aria-label="Alerts"> <div className="data-table data-table-alerts" role="table" aria-label="Alerts">
<div className="data-table-head" role="row"> <div className="data-table-head" role="row">
<span className="data-table-cell">TIME</span> <span className="data-table-cell">TIME</span>
@ -5988,7 +6020,7 @@ const ClassifierPane = ({ limit, className }: ClassifierPaneProps) => {
: "Replay queue empty. Ensure ClickHouse has data."} : "Replay queue empty. Ensure ClickHouse has data."}
</div> </div>
) : ( ) : (
<div className="data-table-wrap" ref={state.classifierScroll.listRef}> <div className="data-table-wrap" ref={state.classifierScroll.setListRef}>
<div className="data-table data-table-classifier" role="table" aria-label="Classifier hits"> <div className="data-table data-table-classifier" role="table" aria-label="Classifier hits">
<div className="data-table-head" role="row"> <div className="data-table-head" role="row">
<span className="data-table-cell">TIME</span> <span className="data-table-cell">TIME</span>
@ -6073,7 +6105,7 @@ const DarkPane = ({ limit, className }: DarkPaneProps) => {
: "Replay queue empty. Ensure ClickHouse has data."} : "Replay queue empty. Ensure ClickHouse has data."}
</div> </div>
) : ( ) : (
<div className="data-table-wrap" ref={state.darkScroll.listRef}> <div className="data-table-wrap" ref={state.darkScroll.setListRef}>
<div className="data-table data-table-dark" role="table" aria-label="Dark events"> <div className="data-table data-table-dark" role="table" aria-label="Dark events">
<div className="data-table-head" role="row"> <div className="data-table-head" role="row">
<span className="data-table-cell">TIME</span> <span className="data-table-cell">TIME</span>