fix live tape scroll stability
This commit is contained in:
parent
1424a2716f
commit
d334e16874
5 changed files with 298 additions and 14 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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%),
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
168
docs/turns/2026-05-17-0331-fix-live-tape-scroll-stability.html
Normal file
168
docs/turns/2026-05-17-0331-fix-live-tape-scroll-stability.html
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue