From 98005ccd57cdc65678bf760d3d9268faa819ca33 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Mon, 15 Jun 2026 20:49:55 -0700 Subject: [PATCH] feat(timeline): add ship-then-rip virtualizer debug overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 follow-on: a single self-contained TimelineDebugOverlay over the main timeline so behavior can be watched live while the virtualizer is young. Read-only by design: it only READS state the hook and virtualizer already expose — rendered-rows vs total, msg/divider split, overscan, visible index range, measured row-height min/max, totalSize, scrollTop, clientHeight, isAtBottom, newMessageCount, and the active jump target (deep-link / find / highlight). It never touches, wraps, or perturbs the scroll path, so there is zero risk to sticky-bottom / prepend-retention / deep-link. Ship-then-rip: removal is delete this one file plus its single import + single render line in MessageTimeline.tsx. No flag-gate hook, no localStorage toggle — nothing else references it, so it leaves zero residue. Main timeline only; thread pane untouched. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../features/messages/ui/MessageTimeline.tsx | 15 ++ .../messages/ui/TimelineDebugOverlay.tsx | 148 ++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 desktop/src/features/messages/ui/TimelineDebugOverlay.tsx diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 35f54401f..3fc6b2d55 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -16,6 +16,7 @@ import { SkeletonReveal } from "@/shared/ui/skeleton"; import { TooltipProvider } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton"; +import { TimelineDebugOverlay } from "./TimelineDebugOverlay"; import { renderTimelineEntry, type TimelineEntryRenderContext, @@ -530,6 +531,20 @@ export const MessageTimeline = React.memo(function MessageTimeline({ ) : null} + + {showMessageList ? ( + + ) : null} ); diff --git a/desktop/src/features/messages/ui/TimelineDebugOverlay.tsx b/desktop/src/features/messages/ui/TimelineDebugOverlay.tsx new file mode 100644 index 000000000..9d1394939 --- /dev/null +++ b/desktop/src/features/messages/ui/TimelineDebugOverlay.tsx @@ -0,0 +1,148 @@ +import * as React from "react"; +import type { Virtualizer } from "@tanstack/react-virtual"; + +import type { VirtualTimelineRow } from "@/features/messages/lib/buildVirtualTimelineRows"; + +// ───────────────────────────────────────────────────────────────────────────── +// SHIP-THEN-RIP debug overlay (Phase 2 virtualization). Read-only telemetry +// panel for watching the virtualizer behave in the running app. +// +// RIP-OUT = delete this file + the single `` line in +// MessageTimeline.tsx. Nothing else references it; it only READS state the hook +// and virtualizer already expose, so removal leaves zero residue and there is +// zero risk to the load-bearing scroll path. +// ───────────────────────────────────────────────────────────────────────────── + +type TimelineDebugOverlayProps = { + virtualizer: Virtualizer; + rows: VirtualTimelineRow[]; + overscan: number; + scrollContainerRef: React.RefObject; + isAtBottom: boolean; + newMessageCount: number; + highlightedMessageId: string | null; + searchActiveMessageId: string | null; + targetMessageId: string | null; +}; + +function Stat({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ + {label} + + {value} +
+ ); +} + +export function TimelineDebugOverlay({ + virtualizer, + rows, + overscan, + scrollContainerRef, + isAtBottom, + newMessageCount, + highlightedMessageId, + searchActiveMessageId, + targetMessageId, +}: TimelineDebugOverlayProps) { + // Re-render on scroll so scrollTop / visible range stay live. The virtualizer + // already re-renders the host on scroll, but a deferred snapshot can lag a + // raw scroll; this local tick keeps the panel honest without touching the + // scroll path. Listener is passive + read-only. + const [, force] = React.useReducer((n: number) => n + 1, 0); + React.useEffect(() => { + const el = scrollContainerRef.current; + if (!el) { + return; + } + const onScroll = () => force(); + el.addEventListener("scroll", onScroll, { passive: true }); + return () => el.removeEventListener("scroll", onScroll); + }, [scrollContainerRef]); + + const virtualItems = virtualizer.getVirtualItems(); + const totalSize = Math.round(virtualizer.getTotalSize()); + const scrollTop = Math.round(scrollContainerRef.current?.scrollTop ?? 0); + const clientHeight = scrollContainerRef.current?.clientHeight ?? 0; + + const firstIndex = virtualItems[0]?.index ?? -1; + const lastIndex = virtualItems[virtualItems.length - 1]?.index ?? -1; + const renderedCount = virtualItems.length; + const dividerRows = rows.filter((row) => row.kind === "day-divider").length; + const messageRows = rows.length - dividerRows; + + // Measured heights of the currently rendered window — exposes how far the + // estimate-vs-measured correction has settled (the variable-height path). + const sizes = virtualItems.map((item) => Math.round(item.size)); + const minSize = sizes.length ? Math.min(...sizes) : 0; + const maxSize = sizes.length ? Math.max(...sizes) : 0; + + const activeJump = + targetMessageId ?? searchActiveMessageId ?? highlightedMessageId ?? "—"; + const jumpKind = targetMessageId + ? "deep-link" + : searchActiveMessageId + ? "find" + : highlightedMessageId + ? "highlight" + : "none"; + + return ( +
+
+ ▚ virtualizer + debug +
+
+ + + + + + + + + + {isAtBottom ? "yes" : "no"} + + } + /> + 0 ? "text-amber-300" : undefined} + > + {newMessageCount} + + } + /> + +
+
+ ); +} + +function truncate(value: string): string { + if (value.length <= 10) { + return value; + } + return `${value.slice(0, 6)}…${value.slice(-3)}`; +}