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-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-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}
|
{"_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;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
background-color: oklch(0.12 0.01 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-table-body {
|
.data-table-body {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 100%;
|
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 {
|
.data-table-options {
|
||||||
|
|
@ -1137,6 +1153,14 @@ h3 {
|
||||||
height: 44px;
|
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 {
|
.data-table-row-classified {
|
||||||
background:
|
background:
|
||||||
linear-gradient(90deg, rgba(var(--classifier-rgb, 192, 200, 210), calc(0.012 + var(--classifier-intensity, 0) * 0.06)), transparent 62%),
|
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,
|
getLiveManifest,
|
||||||
getRouteFeatures,
|
getRouteFeatures,
|
||||||
getTapeVirtualConfig,
|
getTapeVirtualConfig,
|
||||||
|
mergeHeldTapeHistory,
|
||||||
mergeNewestWithOverflow,
|
mergeNewestWithOverflow,
|
||||||
normalizeAlertSeverity,
|
normalizeAlertSeverity,
|
||||||
normalizeTickerFilterInput,
|
normalizeTickerFilterInput,
|
||||||
|
|
@ -394,12 +395,12 @@ describe("route feature map", () => {
|
||||||
|
|
||||||
describe("fixed tape virtualization config", () => {
|
describe("fixed tape virtualization config", () => {
|
||||||
it("uses expected fixed row heights and overscan by table", () => {
|
it("uses expected fixed row heights and overscan by table", () => {
|
||||||
expect(getTapeVirtualConfig("options")).toEqual({ rowHeight: 36, overscan: 24, debugLabel: "options" });
|
expect(getTapeVirtualConfig("options")).toEqual({ rowHeight: 36, overscan: 44, debugLabel: "options" });
|
||||||
expect(getTapeVirtualConfig("equities")).toEqual({ rowHeight: 36, overscan: 20, debugLabel: "equities" });
|
expect(getTapeVirtualConfig("equities")).toEqual({ rowHeight: 36, overscan: 36, debugLabel: "equities" });
|
||||||
expect(getTapeVirtualConfig("flow")).toEqual({ rowHeight: 44, overscan: 16, debugLabel: "flow" });
|
expect(getTapeVirtualConfig("flow")).toEqual({ rowHeight: 44, overscan: 24, debugLabel: "flow" });
|
||||||
expect(getTapeVirtualConfig("alerts")).toEqual({ rowHeight: 44, overscan: 16, debugLabel: "alerts" });
|
expect(getTapeVirtualConfig("alerts")).toEqual({ rowHeight: 44, overscan: 24, debugLabel: "alerts" });
|
||||||
expect(getTapeVirtualConfig("classifier")).toEqual({ rowHeight: 44, overscan: 16, debugLabel: "classifier" });
|
expect(getTapeVirtualConfig("classifier")).toEqual({ rowHeight: 44, overscan: 24, debugLabel: "classifier" });
|
||||||
expect(getTapeVirtualConfig("dark")).toEqual({ rowHeight: 44, overscan: 16, debugLabel: "dark" });
|
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"];
|
const nextKeys = ["anchor", "after-1", "after-2", "older-1", "older-2"];
|
||||||
expect(findAnchorRestoreIndex(nextKeys, "anchor", ["anchor", "after-1", "after-2"])).toBe(0);
|
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", () => {
|
describe("options display formatters", () => {
|
||||||
|
|
|
||||||
|
|
@ -142,12 +142,12 @@ type TapeVirtualListConfig = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const TAPE_VIRTUAL_CONFIG: Record<TapeVirtualPane, TapeVirtualListConfig> = {
|
const TAPE_VIRTUAL_CONFIG: Record<TapeVirtualPane, TapeVirtualListConfig> = {
|
||||||
options: { rowHeight: 36, overscan: 24, debugLabel: "options" },
|
options: { rowHeight: 36, overscan: 44, debugLabel: "options" },
|
||||||
equities: { rowHeight: 36, overscan: 20, debugLabel: "equities" },
|
equities: { rowHeight: 36, overscan: 36, debugLabel: "equities" },
|
||||||
flow: { rowHeight: 44, overscan: 16, debugLabel: "flow" },
|
flow: { rowHeight: 44, overscan: 24, debugLabel: "flow" },
|
||||||
alerts: { rowHeight: 44, overscan: 16, debugLabel: "alerts" },
|
alerts: { rowHeight: 44, overscan: 24, debugLabel: "alerts" },
|
||||||
classifier: { rowHeight: 44, overscan: 16, debugLabel: "classifier" },
|
classifier: { rowHeight: 44, overscan: 24, debugLabel: "classifier" },
|
||||||
dark: { rowHeight: 44, overscan: 16, debugLabel: "dark" }
|
dark: { rowHeight: 44, overscan: 24, debugLabel: "dark" }
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getTapeVirtualConfig = (pane: TapeVirtualPane): TapeVirtualListConfig =>
|
export const getTapeVirtualConfig = (pane: TapeVirtualPane): TapeVirtualListConfig =>
|
||||||
|
|
@ -844,6 +844,30 @@ export const appendHistoryTail = <T extends SortableItem>(
|
||||||
return cap > 0 ? combined.slice(0, cap) : combined;
|
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 => {
|
export const getLiveHistoryRetentionCap = (subscription: LiveSubscription): number => {
|
||||||
switch (subscription.channel) {
|
switch (subscription.channel) {
|
||||||
case "options":
|
case "options":
|
||||||
|
|
@ -2491,6 +2515,7 @@ const usePausableTapeView = <T extends SortableItem & { seq: number }>(
|
||||||
config: PausableTapeViewConfig<T>
|
config: PausableTapeViewConfig<T>
|
||||||
): TapeState<T> => {
|
): TapeState<T> => {
|
||||||
const [data, setData] = useState<PausableTapeData<T>>(EMPTY_PAUSABLE_TAPE);
|
const [data, setData] = useState<PausableTapeData<T>>(EMPTY_PAUSABLE_TAPE);
|
||||||
|
const displayedHistoryRef = useRef<T[]>([]);
|
||||||
const holdForScroll = config.enabled ? (config.shouldHold ? config.shouldHold() : false) : false;
|
const holdForScroll = config.enabled ? (config.shouldHold ? config.shouldHold() : false) : false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -2557,13 +2582,31 @@ const usePausableTapeView = <T extends SortableItem & { seq: number }>(
|
||||||
const status = config.enabled ? config.sourceStatus : "disconnected";
|
const status = config.enabled ? config.sourceStatus : "disconnected";
|
||||||
const projected = projectPausableTapeState(data.visible, status, config.lastUpdate);
|
const projected = projectPausableTapeState(data.visible, status, config.lastUpdate);
|
||||||
const historyItems = config.historyTail ?? [];
|
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 {
|
return {
|
||||||
status,
|
status,
|
||||||
items,
|
items,
|
||||||
liveItems: projected.items,
|
liveItems: projected.items,
|
||||||
historyItems,
|
historyItems: displayedHistoryItems,
|
||||||
lastUpdate: projected.lastUpdate,
|
lastUpdate: projected.lastUpdate,
|
||||||
replayTime: null,
|
replayTime: null,
|
||||||
replayComplete: false,
|
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