diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 2bf9d72..eb38e91 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -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} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index a0e1822..46f20bb 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -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%), diff --git a/apps/web/app/terminal.test.ts b/apps/web/app/terminal.test.ts index 03114c4..b6214eb 100644 --- a/apps/web/app/terminal.test.ts +++ b/apps/web/app/terminal.test.ts @@ -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", () => { diff --git a/apps/web/app/terminal.tsx b/apps/web/app/terminal.tsx index 1cd6f42..0dfc199 100644 --- a/apps/web/app/terminal.tsx +++ b/apps/web/app/terminal.tsx @@ -142,12 +142,12 @@ type TapeVirtualListConfig = { }; const TAPE_VIRTUAL_CONFIG: Record = { - 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 = ( return cap > 0 ? combined.slice(0, cap) : combined; }; +export const mergeHeldTapeHistory = ( + 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 = ( config: PausableTapeViewConfig ): TapeState => { const [data, setData] = useState>(EMPTY_PAUSABLE_TAPE); + const displayedHistoryRef = useRef([]); const holdForScroll = config.enabled ? (config.shouldHold ? config.shouldHold() : false) : false; useEffect(() => { @@ -2557,13 +2582,31 @@ const usePausableTapeView = ( 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, diff --git a/docs/turns/2026-05-17-0331-fix-live-tape-scroll-stability.html b/docs/turns/2026-05-17-0331-fix-live-tape-scroll-stability.html new file mode 100644 index 0000000..81b1576 --- /dev/null +++ b/docs/turns/2026-05-17-0331-fix-live-tape-scroll-stability.html @@ -0,0 +1,168 @@ + + + + + + Fix Live Tape Scroll Stability + + + +
+
+

Fix Live Tape Scroll Stability

+

+ Completed on 2026-05-17 at 03:31 America/New_York for Beads issue + islandflow-9dg. +

+
+ +
+

Summary

+

+ 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. +

+
+ +
+

Changes Made

+
    +
  • Added mergeHeldTapeHistory to filter held history updates by the visible tail.
  • +
  • Updated usePausableTapeView to keep a displayed history ref while scroll-held.
  • +
  • Resynced displayed history automatically when the user jumps back to the top or otherwise resumes.
  • +
  • Increased tape virtualizer overscan for options, equities, flow, alerts, classifier, and dark panes.
  • +
  • Added a fixed row-lane table background so fast scrolling shows a stable substrate instead of blank holes.
  • +
+
+ +
+

Context

+

+ 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. +

+
+ +
+

Important Implementation Details

+

+ 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. +

+
const next = mergeHeldTapeHistory(displayedHistoryRef.current, historyItems, projected.items);
+

+ When hold ends, displayedHistoryRef is replaced with the latest live session + history, so buffered overflow catches up cleanly on jump-to-top. +

+
+ +
+

Expected Impact for End-Users

+

+ Users can scroll into older options or equities prints without the rows shifting under them + as new live prints arrive. The +N new counter can continue accumulating until + they jump back to the top, where the tape catches up. +

+
+ +
+

Validation

+
    +
  • bun test apps/web/app/terminal.test.ts services/api/tests/live.test.ts: passed, 90 tests.
  • +
  • bun --cwd=apps/web run build: passed.
  • +
  • curl -I http://localhost:3000/tape against the local dev server: returned 200 OK.
  • +
+
+ +
+

Issues, Limitations, and Mitigations

+

+ 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. +

+
+ +
+

Follow-up Work

+

No follow-up Beads issues were needed for this turn.

+
+
+ +