feat(timeline): add ship-then-rip virtualizer debug overlay

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 <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-06-15 20:49:55 -07:00
co-authored by Taylor Ho
parent 374c829619
commit 98005ccd57
2 changed files with 163 additions and 0 deletions
@@ -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({
</Button>
</div>
) : null}
{showMessageList ? (
<TimelineDebugOverlay
highlightedMessageId={highlightedMessageId}
isAtBottom={isAtBottom}
newMessageCount={newMessageCount}
overscan={overscan}
rows={rows}
scrollContainerRef={scrollContainerRef}
searchActiveMessageId={searchActiveMessageId}
targetMessageId={targetMessageId}
virtualizer={virtualizer}
/>
) : null}
</div>
</TooltipProvider>
);
@@ -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 `<TimelineDebugOverlay … />` 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<HTMLDivElement, Element>;
rows: VirtualTimelineRow[];
overscan: number;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
isAtBottom: boolean;
newMessageCount: number;
highlightedMessageId: string | null;
searchActiveMessageId: string | null;
targetMessageId: string | null;
};
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-3 tabular-nums">
<span className="text-[10px] uppercase tracking-wide text-emerald-300/60">
{label}
</span>
<span className="font-mono text-[11px] text-emerald-100">{value}</span>
</div>
);
}
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 (
<div
aria-hidden
className="pointer-events-none absolute right-2 top-2 z-50 w-56 select-none rounded-lg border border-emerald-400/30 bg-black/80 px-3 py-2 font-mono shadow-lg backdrop-blur-sm"
data-testid="timeline-debug-overlay"
>
<div className="mb-1.5 flex items-center justify-between border-b border-emerald-400/20 pb-1 text-[10px] font-semibold uppercase tracking-wider text-emerald-300">
<span>▚ virtualizer</span>
<span className="text-emerald-300/50">debug</span>
</div>
<div className="flex flex-col gap-0.5">
<Stat label="rendered" value={`${renderedCount} / ${rows.length}`} />
<Stat label="msg · div" value={`${messageRows} · ${dividerRows}`} />
<Stat label="overscan" value={overscan} />
<Stat
label="visible idx"
value={firstIndex < 0 ? "—" : `${firstIndex}–${lastIndex}`}
/>
<Stat
label="row h (min–max)"
value={sizes.length ? `${minSize}–${maxSize}px` : "—"}
/>
<Stat label="totalSize" value={`${totalSize}px`} />
<Stat label="scrollTop" value={`${scrollTop}px`} />
<Stat label="clientH" value={`${Math.round(clientHeight)}px`} />
<Stat
label="atBottom"
value={
<span
className={isAtBottom ? "text-emerald-300" : "text-amber-300"}
>
{isAtBottom ? "yes" : "no"}
</span>
}
/>
<Stat
label="newCount"
value={
<span
className={newMessageCount > 0 ? "text-amber-300" : undefined}
>
{newMessageCount}
</span>
}
/>
<Stat label={`jump · ${jumpKind}`} value={truncate(activeJump)} />
</div>
</div>
);
}
function truncate(value: string): string {
if (value.length <= 10) {
return value;
}
return `${value.slice(0, 6)}…${value.slice(-3)}`;
}