fix live tape scroll stability

This commit is contained in:
dirtydishes 2026-05-17 03:33:06 -04:00
parent 1424a2716f
commit d334e16874
5 changed files with 298 additions and 14 deletions

View file

@ -1,3 +1,4 @@
{"_type":"issue","id":"islandflow-9dg","title":"Fix live tape scroll stability","description":"Live tape rows can shift while a user is scrolled away from the hot head because newer live prints and ClickHouse history are merged into the displayed segment. Implement held-history freezing so only truly older rows append below the current tail, resync on jump-to-top, and tune virtualization/background rendering to reduce fast-scroll blank gaps.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-17T07:28:52Z","created_by":"dirtydishes","updated_at":"2026-05-17T07:32:53Z","started_at":"2026-05-17T07:29:00Z","closed_at":"2026-05-17T07:32:53Z","close_reason":"Implemented held live tape history freezing, older-only held history append, jump-to-top resync behavior, virtualizer overscan tuning, and stable row-lane table background. Validated with scoped Bun tests, web production build, and local /tape HTTP smoke check.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-qso","title":"Fix durable options tape history routing","description":"Implement the fix-tape plan: make same-origin history routing durable, add deployment/public smoke checks for required API routes, expose tape history loading failures in the UI, document the work, and track api.flow.deltaisland.io migration separately.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-17T01:53:22Z","created_by":"dirtydishes","updated_at":"2026-05-17T02:00:04Z","started_at":"2026-05-17T01:53:25Z","closed_at":"2026-05-17T02:00:04Z","close_reason":"Implemented durable same-origin history routing, public route smoke checks, tape history diagnostics, docs, validation, and follow-up tracking for api.flow.deltaisland.io.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-200","title":"Implement durable options tape history","description":"Implement the plan from docs/plans/2026-05-16-1711-durable-options-tape-history.html: durable ClickHouse-backed options history, signal/all prints view selection, preserved execution context, stale semantics limited to live health, reset runbook, tests, and turn documentation.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-16T21:21:30Z","created_by":"dirtydishes","updated_at":"2026-05-16T21:26:51Z","started_at":"2026-05-16T21:21:33Z","closed_at":"2026-05-16T21:26:51Z","close_reason":"Implemented durable options tape history, signal/raw view selection, reset runbook, tests, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-k4f","title":"Gate deploy script on docker workspace snapshot sync","description":"Prevent frozen-lockfile build failures during deploy by adding a local preflight in scripts/deploy.ts that runs bun run check:docker-workspace and aborts with a clear sync+commit remediation message when stale.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-15T23:01:44Z","created_by":"dirtydishes","updated_at":"2026-05-15T23:04:11Z","started_at":"2026-05-15T23:01:48Z","closed_at":"2026-05-15T23:04:11Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}

View file

@ -1039,11 +1039,27 @@ h3 {
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
background-color: oklch(0.12 0.01 250);
}
.data-table-body {
position: relative;
min-width: 100%;
--tape-row-height: 36px;
--tape-row-double-height: 72px;
background:
repeating-linear-gradient(
to bottom,
oklch(0.98 0.008 250 / 0.01) 0,
oklch(0.98 0.008 250 / 0.01) calc(var(--tape-row-height) - 1px),
oklch(0.72 0.012 250 / 0.08) calc(var(--tape-row-height) - 1px),
oklch(0.72 0.012 250 / 0.08) var(--tape-row-height),
oklch(0.98 0.008 250 / 0.018) var(--tape-row-height),
oklch(0.98 0.008 250 / 0.018) calc(var(--tape-row-double-height) - 1px),
oklch(0.72 0.012 250 / 0.08) calc(var(--tape-row-double-height) - 1px),
oklch(0.72 0.012 250 / 0.08) var(--tape-row-double-height)
),
oklch(0.12 0.01 250);
}
.data-table-options {
@ -1137,6 +1153,14 @@ h3 {
height: 44px;
}
.data-table-flow .data-table-body,
.data-table-alerts .data-table-body,
.data-table-classifier .data-table-body,
.data-table-dark .data-table-body {
--tape-row-height: 44px;
--tape-row-double-height: 88px;
}
.data-table-row-classified {
background:
linear-gradient(90deg, rgba(var(--classifier-rgb, 192, 200, 210), calc(0.012 + var(--classifier-intensity, 0) * 0.06)), transparent 62%),

View file

@ -24,6 +24,7 @@ import {
getLiveManifest,
getRouteFeatures,
getTapeVirtualConfig,
mergeHeldTapeHistory,
mergeNewestWithOverflow,
normalizeAlertSeverity,
normalizeTickerFilterInput,
@ -394,12 +395,12 @@ describe("route feature map", () => {
describe("fixed tape virtualization config", () => {
it("uses expected fixed row heights and overscan by table", () => {
expect(getTapeVirtualConfig("options")).toEqual({ rowHeight: 36, overscan: 24, debugLabel: "options" });
expect(getTapeVirtualConfig("equities")).toEqual({ rowHeight: 36, overscan: 20, debugLabel: "equities" });
expect(getTapeVirtualConfig("flow")).toEqual({ rowHeight: 44, overscan: 16, debugLabel: "flow" });
expect(getTapeVirtualConfig("alerts")).toEqual({ rowHeight: 44, overscan: 16, debugLabel: "alerts" });
expect(getTapeVirtualConfig("classifier")).toEqual({ rowHeight: 44, overscan: 16, debugLabel: "classifier" });
expect(getTapeVirtualConfig("dark")).toEqual({ rowHeight: 44, overscan: 16, debugLabel: "dark" });
expect(getTapeVirtualConfig("options")).toEqual({ rowHeight: 36, overscan: 44, debugLabel: "options" });
expect(getTapeVirtualConfig("equities")).toEqual({ rowHeight: 36, overscan: 36, debugLabel: "equities" });
expect(getTapeVirtualConfig("flow")).toEqual({ rowHeight: 44, overscan: 24, debugLabel: "flow" });
expect(getTapeVirtualConfig("alerts")).toEqual({ rowHeight: 44, overscan: 24, debugLabel: "alerts" });
expect(getTapeVirtualConfig("classifier")).toEqual({ rowHeight: 44, overscan: 24, debugLabel: "classifier" });
expect(getTapeVirtualConfig("dark")).toEqual({ rowHeight: 44, overscan: 24, debugLabel: "dark" });
});
});
@ -683,6 +684,53 @@ describe("live tape history helpers", () => {
const nextKeys = ["anchor", "after-1", "after-2", "older-1", "older-2"];
expect(findAnchorRestoreIndex(nextKeys, "anchor", ["anchor", "after-1", "after-2"])).toBe(0);
});
it("keeps held ClickHouse history stable when newer live overflow arrives", () => {
const frozenLive = [makeItem("hot-5", 5, 500), makeItem("hot-4", 4, 400)];
const displayed = [makeItem("hist-3", 3, 300), makeItem("hist-2", 2, 200)];
const incoming = [
makeItem("overflow-newer", 6, 600),
makeItem("hot-4", 4, 400),
makeItem("hist-3", 3, 300),
makeItem("hist-2", 2, 200)
];
expect(mergeHeldTapeHistory(displayed, incoming, frozenLive).map((item) => item.trace_id)).toEqual([
"hist-3",
"hist-2"
]);
});
it("appends truly older lazy-loaded rows to the held history tail", () => {
const frozenLive = [makeItem("hot-5", 5, 500), makeItem("hot-4", 4, 400)];
const displayed = [makeItem("hist-3", 3, 300), makeItem("hist-2", 2, 200)];
const incoming = [
makeItem("hist-3", 3, 300),
makeItem("hist-2", 2, 200),
makeItem("older-1", 1, 100),
makeItem("older-0", 0, 50)
];
expect(mergeHeldTapeHistory(displayed, incoming, frozenLive).map((item) => item.trace_id)).toEqual([
"hist-3",
"hist-2",
"older-1",
"older-0"
]);
});
it("resyncs buffered live history by replacing the held segment after resume", () => {
const frozenLive = [makeItem("hot-5", 5, 500), makeItem("hot-4", 4, 400)];
const held = mergeHeldTapeHistory(
[makeItem("hist-3", 3, 300), makeItem("hist-2", 2, 200)],
[makeItem("overflow-newer", 6, 600), makeItem("hist-3", 3, 300), makeItem("older-1", 1, 100)],
frozenLive
);
const resynced = appendHistoryTail([], [makeItem("overflow-newer", 6, 600), ...held], [], 0);
expect(held.map((item) => item.trace_id)).toEqual(["hist-3", "hist-2", "older-1"]);
expect(resynced.map((item) => item.trace_id)).toEqual(["overflow-newer", "hist-3", "hist-2", "older-1"]);
});
});
describe("options display formatters", () => {

View file

@ -142,12 +142,12 @@ type TapeVirtualListConfig = {
};
const TAPE_VIRTUAL_CONFIG: Record<TapeVirtualPane, TapeVirtualListConfig> = {
options: { rowHeight: 36, overscan: 24, debugLabel: "options" },
equities: { rowHeight: 36, overscan: 20, debugLabel: "equities" },
flow: { rowHeight: 44, overscan: 16, debugLabel: "flow" },
alerts: { rowHeight: 44, overscan: 16, debugLabel: "alerts" },
classifier: { rowHeight: 44, overscan: 16, debugLabel: "classifier" },
dark: { rowHeight: 44, overscan: 16, debugLabel: "dark" }
options: { rowHeight: 36, overscan: 44, debugLabel: "options" },
equities: { rowHeight: 36, overscan: 36, debugLabel: "equities" },
flow: { rowHeight: 44, overscan: 24, debugLabel: "flow" },
alerts: { rowHeight: 44, overscan: 24, debugLabel: "alerts" },
classifier: { rowHeight: 44, overscan: 24, debugLabel: "classifier" },
dark: { rowHeight: 44, overscan: 24, debugLabel: "dark" }
};
export const getTapeVirtualConfig = (pane: TapeVirtualPane): TapeVirtualListConfig =>
@ -844,6 +844,30 @@ export const appendHistoryTail = <T extends SortableItem>(
return cap > 0 ? combined.slice(0, cap) : combined;
};
export const mergeHeldTapeHistory = <T extends SortableItem>(
displayedHistory: T[],
incomingHistory: T[],
frozenLiveHead: T[]
): T[] => {
if (displayedHistory.length === 0) {
return appendHistoryTail([], incomingHistory, frozenLiveHead, 0);
}
const sortedDisplayed = appendHistoryTail([], displayedHistory, frozenLiveHead, 0);
const tail = sortedDisplayed.at(-1);
const tailTs = tail ? extractSortTs(tail) : Number.POSITIVE_INFINITY;
const tailSeq = tail ? extractSortSeq(tail) : Number.POSITIVE_INFINITY;
const olderIncoming = incomingHistory.filter((item) => {
const itemTs = extractSortTs(item);
if (itemTs < tailTs) {
return true;
}
return itemTs === tailTs && extractSortSeq(item) < tailSeq;
});
return appendHistoryTail(sortedDisplayed, olderIncoming, frozenLiveHead, 0);
};
export const getLiveHistoryRetentionCap = (subscription: LiveSubscription): number => {
switch (subscription.channel) {
case "options":
@ -2491,6 +2515,7 @@ const usePausableTapeView = <T extends SortableItem & { seq: number }>(
config: PausableTapeViewConfig<T>
): TapeState<T> => {
const [data, setData] = useState<PausableTapeData<T>>(EMPTY_PAUSABLE_TAPE);
const displayedHistoryRef = useRef<T[]>([]);
const holdForScroll = config.enabled ? (config.shouldHold ? config.shouldHold() : false) : false;
useEffect(() => {
@ -2557,13 +2582,31 @@ const usePausableTapeView = <T extends SortableItem & { seq: number }>(
const status = config.enabled ? config.sourceStatus : "disconnected";
const projected = projectPausableTapeState(data.visible, status, config.lastUpdate);
const historyItems = config.historyTail ?? [];
const items = useMemo(() => composeTapeItems([], projected.items, historyItems), [projected.items, historyItems]);
const displayedHistoryItems = useMemo(() => {
if (!config.enabled) {
displayedHistoryRef.current = [];
return [];
}
if (!holdForScroll) {
displayedHistoryRef.current = historyItems;
return historyItems;
}
const next = mergeHeldTapeHistory(displayedHistoryRef.current, historyItems, projected.items);
displayedHistoryRef.current = next;
return next;
}, [config.enabled, historyItems, holdForScroll, projected.items]);
const items = useMemo(
() => composeTapeItems([], projected.items, displayedHistoryItems),
[projected.items, displayedHistoryItems]
);
return {
status,
items,
liveItems: projected.items,
historyItems,
historyItems: displayedHistoryItems,
lastUpdate: projected.lastUpdate,
replayTime: null,
replayComplete: false,

View file

@ -0,0 +1,168 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Fix Live Tape Scroll Stability</title>
<style>
:root {
color-scheme: dark;
--bg: oklch(0.14 0.012 250);
--panel: oklch(0.18 0.014 250);
--text: oklch(0.92 0.012 250);
--muted: oklch(0.72 0.018 250);
--accent: oklch(0.76 0.12 74);
--border: oklch(0.72 0.012 250 / 0.18);
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 15px/1.6 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
main {
max-width: 920px;
margin: 0 auto;
padding: 48px 24px 64px;
}
header {
margin-bottom: 28px;
padding-bottom: 18px;
border-bottom: 1px solid var(--border);
}
h1 {
margin: 0 0 8px;
font-size: 2rem;
line-height: 1.15;
}
h2 {
margin: 28px 0 10px;
color: var(--accent);
font-size: 1rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
p,
li {
color: var(--muted);
}
code {
border: 1px solid var(--border);
border-radius: 5px;
padding: 1px 5px;
background: var(--panel);
color: var(--text);
}
pre {
overflow: auto;
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
background: var(--panel);
}
pre code {
border: 0;
padding: 0;
background: transparent;
}
</style>
</head>
<body>
<main>
<header>
<h1>Fix Live Tape Scroll Stability</h1>
<p>
Completed on 2026-05-17 at 03:31 America/New_York for Beads issue
<code>islandflow-9dg</code>.
</p>
</header>
<section>
<h2>Summary</h2>
<p>
The live tape now keeps the visible scrolled segment stable while new prints arrive. When
the user is away from the top, the view freezes both the hot live head and the displayed
history segment, only allowing genuinely older history to append below the current tail.
</p>
</section>
<section>
<h2>Changes Made</h2>
<ul>
<li>Added <code>mergeHeldTapeHistory</code> to filter held history updates by the visible tail.</li>
<li>Updated <code>usePausableTapeView</code> to keep a displayed history ref while scroll-held.</li>
<li>Resynced displayed history automatically when the user jumps back to the top or otherwise resumes.</li>
<li>Increased tape virtualizer overscan for options, equities, flow, alerts, classifier, and dark panes.</li>
<li>Added a fixed row-lane table background so fast scrolling shows a stable substrate instead of blank holes.</li>
</ul>
</section>
<section>
<h2>Context</h2>
<p>
Live session history receives both ClickHouse history and hot-window overflow from new live
prints. Before this change, the pausable view froze live rows during scroll hold but still
composed against the mutating history array, so newer overflow rows could insert above the
user's current viewport.
</p>
</section>
<section>
<h2>Important Implementation Details</h2>
<p>
The stable merge compares incoming history with the current displayed history tail. Rows
newer than that tail are withheld during hold, duplicates from the frozen live head are
removed, and older lazy-loaded rows remain eligible to append.
</p>
<pre><code>const next = mergeHeldTapeHistory(displayedHistoryRef.current, historyItems, projected.items);</code></pre>
<p>
When hold ends, <code>displayedHistoryRef</code> is replaced with the latest live session
history, so buffered overflow catches up cleanly on jump-to-top.
</p>
</section>
<section>
<h2>Expected Impact for End-Users</h2>
<p>
Users can scroll into older options or equities prints without the rows shifting under them
as new live prints arrive. The <code>+N new</code> counter can continue accumulating until
they jump back to the top, where the tape catches up.
</p>
</section>
<section>
<h2>Validation</h2>
<ul>
<li><code>bun test apps/web/app/terminal.test.ts services/api/tests/live.test.ts</code>: passed, 90 tests.</li>
<li><code>bun --cwd=apps/web run build</code>: passed.</li>
<li><code>curl -I http://localhost:3000/tape</code> against the local dev server: returned 200 OK.</li>
</ul>
</section>
<section>
<h2>Issues, Limitations, and Mitigations</h2>
<p>
This change preserves row stability in the frontend view model. It does not alter backend
history pagination or wire protocols. The fixed table substrate mitigates visual blanking
during fast scrolls, while actual row rendering remains virtualized. Browser automation was
attempted, but the local Node automation runtime did not have Playwright installed, so the
handoff relies on unit tests, production build, and the local HTTP smoke check.
</p>
</section>
<section>
<h2>Follow-up Work</h2>
<p>No follow-up Beads issues were needed for this turn.</p>
</section>
</main>
</body>
</html>