From 5beb592105f4b3fa7661c445a7f050e812a2f8f2 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Wed, 19 Aug 2026 04:55:05 +0000 Subject: [PATCH 1/2] Window the top-level timeline on phones and evict far pages Every loaded timeline page stayed mounted: `TimelineRowsList` mapped each row to a wrapper, `prependOlderTimelineRows` concatenated pages without a cap, and auto-load pulled the next page 600px before the top. On a phone, reading back through a long thread left hundreds of markdown bodies and action bars in the DOM, so every style/layout pass (keyboard, orientation, streaming growth) and the memory footprint grew with how far the user had scrolled. Step 1 (`content-visibility: auto`) skips their layout and paint; this step bounds the DOM itself. The top-level list is now windowed on compact viewports inside a bottom-anchored scroll body (the same gate as row containment). Rows around the viewport stay mounted; every maximal run of far rows becomes one spacer sized from the rows' last measured heights (one ResizeObserver over the mounted wrappers) or, for rows that never mounted, from the same estimate `content-visibility` uses, so swapping a spacer for skipped rows is height-neutral. The window keeps two viewports (>= 1600px) of overscan on each side and only re-ranges once the viewport is within half of that of an edge, so scrolling extends the near side and evicts the far side as a by-product; unrelated re-renders (streaming) reuse the committed range. Rows above the viewport that re-mount taller than their spacer share are compensated in the same layout pass by moving scrollTop with the first visible mounted row (idempotent under Chromium's native scroll anchoring, does the whole job on WebKit, which has none), and the auto-height wrapper is snapped so the swap does not ease. Preserved on purpose: - The viewport is anchored to a row id, so a prepended older page keeps the same rows mounted and lands as a spacer above; the scroll body's prepend restore then sees the spacer's height as before. - The last row (and the running turn's top-level rows) is always mounted, so the bottom sentinel and the streaming row keep their state. - The unread divider is always mounted (it scrolls itself into view once) and the search target's ancestors are pinned, so the reveal finds them. - The saved per-thread scroll anchor seeds the first window, so the mount restore finds its row; a row that holds focus, or a live text selection, is never swapped away. - The user's manual expand/collapse choices are remembered per row for the timeline's lifetime, so a turn opened far up does not come back collapsed. - Desktop, nested lists and surfaces without a scroll body render exactly as before (no windowing, same DOM). Windowing that switches on later (viewport crossing the compact breakpoint) renders everything until the first sample reads the real viewport, and a window whose anchored row was replaced keeps what is mounted and re-anchors from the DOM instead of jumping to the bottom. Co-Authored-By: Claude --- .../thread/timeline/ExpandableTimelineRow.tsx | 15 +- .../thread/timeline/ThreadTimelineRows.tsx | 306 ++++++-- .../ThreadTimelineRows.windowing.test.tsx | 726 ++++++++++++++++++ .../timeline/timeline-row-containment.ts | 19 + .../timeline/timeline-windowing.test.ts | 341 ++++++++ .../thread/timeline/timeline-windowing.ts | 337 ++++++++ .../thread/timeline/useTimelineWindow.ts | 659 ++++++++++++++++ .../src/components/ui/height-transition.tsx | 29 +- 8 files changed, 2371 insertions(+), 61 deletions(-) create mode 100644 apps/app/src/components/thread/timeline/ThreadTimelineRows.windowing.test.tsx create mode 100644 apps/app/src/components/thread/timeline/timeline-windowing.test.ts create mode 100644 apps/app/src/components/thread/timeline/timeline-windowing.ts create mode 100644 apps/app/src/components/thread/timeline/useTimelineWindow.ts diff --git a/apps/app/src/components/thread/timeline/ExpandableTimelineRow.tsx b/apps/app/src/components/thread/timeline/ExpandableTimelineRow.tsx index aa48ea3eb4..22ff31a221 100644 --- a/apps/app/src/components/thread/timeline/ExpandableTimelineRow.tsx +++ b/apps/app/src/components/thread/timeline/ExpandableTimelineRow.tsx @@ -36,6 +36,14 @@ export interface ExpandableTimelineRowProps { * state until the user toggles the row or the row unmounts. */ terminalAutoExpanded?: boolean; + /** + * Seed for the user's manual expand/collapse choice, and where to report + * changes to it. Lets a host that unmounts far-away rows (the windowed + * timeline) restore the choice when the row mounts again; the state itself + * stays local to the row. + */ + initialManualExpansionOverride?: boolean | null; + onManualExpansionOverrideChange?: (override: boolean) => void; onBeforeExpand?: () => void; renderBody: () => ReactNode; title: TimelineTitle; @@ -86,8 +94,10 @@ function ExpandableTimelineRowComponent({ expandable = true, forceExpanded = false, horizontalPadding = "default", + initialManualExpansionOverride = null, leadingIcon, onBeforeExpand, + onManualExpansionOverrideChange, onTitleAction, renderBody, resolveSegmentLinkHref, @@ -97,7 +107,7 @@ function ExpandableTimelineRowComponent({ titleContent, }: ExpandableTimelineRowProps) { const [manualExpansionOverride, setManualExpansionOverride] = - useState(null); + useState(initialManualExpansionOverride); const [terminalAutoExpandedLatch, setTerminalAutoExpandedLatch] = useState(terminalAutoExpanded); const [collapsedPreviewActive, setCollapsedPreviewActive] = useState(false); @@ -123,7 +133,8 @@ function ExpandableTimelineRowComponent({ onBeforeExpand?.(); } setManualExpansionOverride(!isExpanded); - }, [isExpanded, onBeforeExpand]); + onManualExpansionOverrideChange?.(!isExpanded); + }, [isExpanded, onBeforeExpand, onManualExpansionOverrideChange]); const handleCollapsedPreviewClick = useCallback( (event: CollapsedPreviewClickEvent): void => { if ( diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx index a75b30a1b3..1abfb26037 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx @@ -9,7 +9,7 @@ import { useState, useSyncExternalStore, } from "react"; -import type { ReactNode } from "react"; +import type { ReactNode, RefCallback } from "react"; import { useLocation } from "react-router-dom"; import { isBackgroundAgentTaskType, @@ -98,9 +98,13 @@ import { } from "./timelineRowSignatures.js"; import { TOP_LEVEL_TIMELINE_ROW_INTRINSIC_SIZE_CLASS_NAME, + estimateSkippedTimelineRowBlockSizePx, timelineRowContainmentStyle, useArmTopLevelTimelineRowContainment, } from "./timeline-row-containment.js"; +import { useTimelineWindow } from "./useTimelineWindow.js"; +import type { TimelineWindowEntry } from "./timeline-windowing.js"; +import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; import { NESTED_TIMELINE_GROUP_LINE_CLASS_NAME } from "./timeline-nested-group-line.js"; import { getThreadRoutePath } from "@/lib/route-paths"; import { useThreadTimelineTurnSummaryDetails } from "@/hooks/queries/thread-queries"; @@ -396,6 +400,7 @@ type GetTimelineViewRows = ( rows: TimelineRawRows, options?: BuildTimelineViewRowsOptions, ) => ThreadTimelineViewRow[]; +const UNREAD_DIVIDER_ID = "thread-unread-divider"; type TimelineRowsListItem = | { kind: "row"; @@ -403,7 +408,7 @@ type TimelineRowsListItem = } | { kind: "unread-divider"; - id: "thread-unread-divider"; + id: typeof UNREAD_DIVIDER_ID; }; interface ConversationRowProps { @@ -447,6 +452,13 @@ const StreamingAssistantMessageIdContext = createContext(null); const EMPTY_ROW_ID_SET: ReadonlySet = new Set(); const TimelineSearchExpansionContext = createContext>(EMPTY_ROW_ID_SET); +// The user's manual expand/collapse choices, by row id, for the lifetime of the +// timeline instance. The windowed top-level list unmounts far-away rows; a turn +// the user opened must not come back collapsed when it re-mounts. +const TimelineManualExpansionMemoryContext = createContext | null>(null); const SKILL_FILE_NAME = "SKILL.md"; function useTimelineRendererStaticContext(): TimelineRendererStaticContextValue { @@ -1858,6 +1870,16 @@ function TimelineExpandableRowView({ terminalAutoExpandedRowIds, } = useTimelineTurnStateContext(); const searchExpandedRowIds = useContext(TimelineSearchExpansionContext); + const manualExpansionMemory = useContext( + TimelineManualExpansionMemoryContext, + ); + const rowId = row.id; + const handleManualExpansionOverrideChange = useCallback( + (override: boolean) => { + manualExpansionMemory?.set(rowId, override); + }, + [manualExpansionMemory, rowId], + ); const renderBody = useCallback( () => ( ; row: ThreadTimelineViewRow; }) { const wrapperRef = useRef(null); useArmTopLevelTimelineRowContainment(wrapperRef); + const ref = useCallback( + (node: HTMLDivElement | null) => { + wrapperRef.current = node; + return entryRef(node); + }, + [entryRef], + ); return (
@@ -1997,6 +2032,77 @@ function TopLevelTimelineRowWrapper({ ); } +// `py-1` around a 10px label: the divider is never measured before it mounts, +// and it is always mounted, so the estimate only sizes the initial layout. +const UNREAD_DIVIDER_ESTIMATED_BLOCK_SIZE_PX = 22; +/** + * The live tail is bounded so a turn with many top-level rows cannot pin an + * unbounded number of them; nested rows of the pending turn live inside its + * turn row and are not windowed individually. + */ +const MAX_LIVE_TAIL_ITEMS = 32; + +function timelineRowsListItemId(item: TimelineRowsListItem): string { + return item.kind === "row" ? item.row.id : item.id; +} + +function buildTimelineWindowEntries( + items: readonly TimelineRowsListItem[], +): TimelineWindowEntry[] { + return items.map((item) => + item.kind === "row" + ? { + id: item.row.id, + estimatedHeightPx: estimateSkippedTimelineRowBlockSizePx(item.row), + } + : { + id: item.id, + estimatedHeightPx: UNREAD_DIVIDER_ESTIMATED_BLOCK_SIZE_PX, + }, + ); +} + +/** + * Index of the first item that must stay mounted regardless of scroll + * position: the last item always (the bottom sentinel geometry and the + * streaming assistant row hang off it) and, while the thread runs, every + * top-level row of the turn in progress, so streaming rows keep their state + * (settled markdown prefix, expansion latches, live timers) when the user + * scrolls far up. + */ +function findLiveTailStartIndex({ + items, + scopeActive, +}: { + items: readonly TimelineRowsListItem[]; + scopeActive: boolean; +}): number { + if (items.length === 0) { + return 0; + } + let start = items.length - 1; + const last = items[start]; + if (!scopeActive || last === undefined || last.kind !== "row") { + return start; + } + const liveTurnId = last.row.turnId; + if (liveTurnId === null) { + return start; + } + while (start > 0 && items.length - start < MAX_LIVE_TAIL_ITEMS) { + const previous = items[start - 1]; + if ( + previous === undefined || + previous.kind !== "row" || + previous.row.turnId !== liveTurnId + ) { + break; + } + start -= 1; + } + return start; +} + function TimelineRowsList({ compactActivityIntents, hasOlderTimelineRows, @@ -2026,9 +2132,91 @@ function TimelineRowsList({ () => buildTimelineRowsListItems({ rows, unreadDividerPlacement }), [rows, unreadDividerPlacement], ); + // Only the top-level list is windowed, and only on compact viewports inside + // a bottom-anchored scroll body (the same gate as row containment: phones + // are where every loaded page staying mounted hurts, and the scroll body is + // what the window samples and corrects). Nested lists live inside an + // expandable body that animates its own height. + const isCompactViewport = useIsCompactViewport(); + const bottomAnchor = useBottomAnchoredScroll(); + const isTopLevel = spacing === "top-level"; + const windowEntries = useMemo( + () => + isTopLevel ? buildTimelineWindowEntries(items) : EMPTY_WINDOW_ENTRIES, + [isTopLevel, items], + ); + const liveTailStartIndex = useMemo( + () => (isTopLevel ? findLiveTailStartIndex({ items, scopeActive }) : 0), + [isTopLevel, items, scopeActive], + ); + // The unread divider scrolls itself into view once on mount, and the search + // target's ancestors must be in the DOM for the reveal to find them. + const pinnedWindowEntryIds = useMemo(() => { + const ids = new Set(stableSearchExpandedRowIds); + ids.add(UNREAD_DIVIDER_ID); + return ids; + }, [stableSearchExpandedRowIds]); + const timelineWindow = useTimelineWindow({ + enabled: isTopLevel && isCompactViewport && bottomAnchor !== null, + entries: windowEntries, + pinnedEntryIds: pinnedWindowEntryIds, + pinnedTailStartIndex: liveTailStartIndex, + scrollAnchorThreadId: isTopLevel ? threadId : undefined, + }); + const renderItem = (item: TimelineRowsListItem): ReactNode => { + if (item.kind === "unread-divider") { + const divider = ( + + ); + if (!isTopLevel) { + return divider; + } + return ( +
+ {divider} +
+ ); + } + + const rowView = ( + + ); + if (isTopLevel) { + return ( + + {rowView} + + ); + } + return ( +
+ {rowView} +
+ ); + }; + const segments = timelineWindow.segments; return (
- {items.map((item) => { - if (item.kind === "unread-divider") { - return ( - - ); - } - - const rowView = ( - - ); - if (spacing === "top-level") { - return ( - - {rowView} - - ); - } - return ( -
- {rowView} -
- ); - })} + {segments === null + ? items.map(renderItem) + : segments.map((segment) => { + if (segment.kind === "entry") { + const item = items[segment.index]; + return item === undefined ? null : renderItem(item); + } + const firstItem = items[segment.startIndex]; + const spacerKey = + firstItem === undefined + ? `spacer:${segment.startIndex}` + : `spacer:${timelineRowsListItemId(firstItem)}`; + // Never a scroll-anchor candidate: a spacer's own height is what + // changes on a swap, so anchoring to it would let the rows below + // shift. Excluded, the browser anchors to the first mounted row, + // which is also what the hook's own correction preserves. + return ( +
+ ); + })}
); } +const EMPTY_WINDOW_ENTRIES: readonly TimelineWindowEntry[] = []; + function ThreadTimelineRowsComponent(props: ThreadTimelineRowsProps) { const ownerKey = timelineRowsOwnerKey({ threadId: props.threadId, @@ -2165,6 +2352,7 @@ function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) { selection: MessageProseSelection; message: ThreadChatMessageReference; } | null>(null); + const [manualExpansionMemory] = useState(() => new Map()); // Only hand a reporter to the messages when an action exists; otherwise the // wrapper stays inert and the floating menu never mounts. const reportProseSelection = useMemo< @@ -2333,24 +2521,28 @@ function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) { value={turnStateContextValue} > - + + + {hasSelectionActions ? ( void>; +} + +const frames: FrameQueue = { callbacks: [] }; + +function flushFrames(): void { + act(() => { + // Callbacks scheduled while flushing run in the next flush. + const pending = frames.callbacks; + frames.callbacks = []; + for (const callback of pending) callback(); + }); +} + +interface ObservedTarget { + callback: ResizeObserverCallback; + observer: ResizeObserver; +} + +const observedTargets = new Map(); + +class ResizeObserverStub implements ResizeObserver { + constructor(private readonly callback: ResizeObserverCallback) {} + observe(target: Element): void { + observedTargets.set(target, { callback: this.callback, observer: this }); + } + unobserve(target: Element): void { + observedTargets.delete(target); + } + disconnect(): void { + for (const [target, observed] of observedTargets) { + if (observed.observer === this) observedTargets.delete(target); + } + } +} + +/** Real heights per row id; rows without one take the estimate. */ +const rowHeights = new Map(); + +function reportMeasuredHeights(): void { + act(() => { + for (const [target, observed] of observedTargets) { + if (!(target instanceof HTMLElement)) continue; + const id = target.dataset.timelineWindowEntry; + if (id === undefined) continue; + const height = elementHeight(target); + observed.callback( + [ + { + target, + contentRect: new DOMRect(0, 0, 320, height), + borderBoxSize: [{ blockSize: height, inlineSize: 320 }], + contentBoxSize: [{ blockSize: height, inlineSize: 320 }], + devicePixelContentBoxSize: [{ blockSize: height, inlineSize: 320 }], + }, + ], + observed.observer, + ); + } + }); +} + +function elementHeight(element: HTMLElement): number { + if (element.dataset.timelineRowSpacer !== undefined) { + return Number.parseFloat(element.style.height) || 0; + } + const id = element.dataset.timelineWindowEntry; + if (id !== undefined) { + return rowHeights.get(id) ?? TURN_ROW_ESTIMATE_PX; + } + return 0; +} + +let scrollArea: HTMLDivElement; +let scrollTopValue = 0; + +function listElement(): HTMLElement | null { + return document.querySelector( + '[data-timeline-row-list="top-level"]', + ); +} + +/** Offset of a list child from the top of the list: previous siblings + gaps. */ +function contentOffsetOf(element: HTMLElement): number { + let offset = 0; + const parent = element.parentElement; + if (!parent) return 0; + for (const sibling of Array.from(parent.children)) { + if (sibling === element) break; + if (sibling instanceof HTMLElement) { + offset += elementHeight(sibling) + GAP_PX; + } + } + return offset; +} + +function listHeight(): number { + const list = listElement(); + if (!list) return 0; + let height = 0; + const children = Array.from(list.children); + for (const child of children) { + if (child instanceof HTMLElement) height += elementHeight(child); + } + return height + Math.max(0, children.length - 1) * GAP_PX; +} + +function rect(top: number, height: number): DOMRect { + return new DOMRect(0, top, 320, height); +} + +function installLayoutSimulation(): void { + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( + function mockRect(this: HTMLElement) { + if (this === scrollArea) { + return rect(0, VIEWPORT_HEIGHT_PX); + } + if (this.dataset.timelineRowList === "top-level") { + return rect(-scrollTopValue, listHeight()); + } + if ( + this.dataset.timelineWindowEntry !== undefined || + this.dataset.timelineRowSpacer !== undefined + ) { + return rect( + contentOffsetOf(this) - scrollTopValue, + elementHeight(this), + ); + } + return rect(0, 0); + }, + ); +} + +function createScrollArea(): HTMLDivElement { + const element = document.createElement("div"); + Object.defineProperty(element, "clientHeight", { + configurable: true, + get: () => VIEWPORT_HEIGHT_PX, + }); + Object.defineProperty(element, "scrollHeight", { + configurable: true, + get: () => listHeight(), + }); + Object.defineProperty(element, "scrollTop", { + configurable: true, + get: () => scrollTopValue, + set: (value: number) => { + scrollTopValue = Math.max( + 0, + Math.min(value, Math.max(0, listHeight() - VIEWPORT_HEIGHT_PX)), + ); + }, + }); + return element; +} + +function scrollTo(top: number): void { + scrollArea.scrollTop = top; + act(() => { + scrollArea.dispatchEvent(new Event("scroll")); + }); + flushFrames(); +} + +let bottomAnchor: BottomAnchorContextValue; + +function createBottomAnchor(): BottomAnchorContextValue { + return { + getScrollElement: () => scrollArea, + isAtBottom: false, + scrollToBottom: vi.fn(), + scrollElementIntoView: vi.fn(), + scrollElementIntoViewClampedToMaxScroll: vi.fn(), + captureScrollAnchor: vi.fn(), + }; +} + +function buildRows({ + count = ROW_COUNT, + firstIndex = 0, + withLiveTurn = false, +}: { + count?: number; + firstIndex?: number; + withLiveTurn?: boolean; +} = {}): TimelineRow[] { + const rows: TimelineRow[] = []; + for (let index = firstIndex; index < firstIndex + count; index += 1) { + const seq = index * 10 + 10; + rows.push( + turnRow({ + id: `turn_${index}`, + turnId: `turn_${index}`, + seq, + sourceSeqStart: seq, + sourceSeqEnd: seq + 5, + status: "completed", + threadId: THREAD_ID, + children: [ + commandRow({ + id: `turn_${index}_command`, + turnId: `turn_${index}`, + command: `pnpm test --filter ${index}`, + seq: seq + 1, + threadId: THREAD_ID, + }), + ], + }), + ); + } + // A running turn: several top-level rows sharing one turn id at the tail. + if (withLiveTurn) { + const seq = (firstIndex + count) * 10 + 10; + rows.push( + conversationRow({ + id: "live_user", + role: "user", + text: "Keep going", + turnId: "turn_live", + seq, + threadId: THREAD_ID, + }), + turnRow({ + id: "live_turn", + turnId: "turn_live", + seq: seq + 1, + sourceSeqStart: seq + 1, + sourceSeqEnd: seq + 3, + status: "pending", + threadId: THREAD_ID, + children: [ + commandRow({ + id: "live_command", + turnId: "turn_live", + command: "pnpm test", + seq: seq + 2, + status: "pending", + threadId: THREAD_ID, + }), + ], + }), + conversationRow({ + id: "live_assistant", + role: "assistant", + text: "Running the tests now.", + turnId: "turn_live", + seq: seq + 4, + threadId: THREAD_ID, + }), + ); + } + return rows; +} + +function Providers({ + children, + compact, + withAnchor, + routerState, +}: { + children: ReactNode; + compact: boolean; + withAnchor: boolean; + routerState?: unknown; +}) { + const inner = ( + + + + {children} + + + + ); + return withAnchor ? ( + + {inner} + + ) : ( + inner + ); +} + +function renderTimeline({ + compact = true, + rows = buildRows(), + routerState, + status = "idle" as const, + withAnchor = true, +}: { + compact?: boolean; + rows?: TimelineRow[]; + routerState?: unknown; + status?: "idle" | "active"; + withAnchor?: boolean; +} = {}) { + const view = render( + + + , + ); + const rerender = (nextRows: TimelineRow[], nextStatus = status) => { + view.rerender( + + + , + ); + }; + return { ...view, rerender }; +} + +function mountedRowIds(): string[] { + return Array.from( + document.querySelectorAll( + '[data-timeline-row-list="top-level"] > [data-timeline-row-id]', + ), + ).map((element) => element.dataset.timelineRowId ?? ""); +} + +function spacers(): HTMLElement[] { + return Array.from( + document.querySelectorAll("[data-timeline-row-spacer]"), + ); +} + +function spacerHeight(element: HTMLElement): number { + return Number.parseFloat(element.style.height); +} + +function spacerRowCount(element: HTMLElement): number { + return Number(element.dataset.timelineRowSpacer); +} + +beforeEach(() => { + frames.callbacks = []; + observedTargets.clear(); + rowHeights.clear(); + scrollTopValue = 0; + scrollArea = createScrollArea(); + bottomAnchor = createBottomAnchor(); + vi.stubGlobal("ResizeObserver", ResizeObserverStub); + vi.stubGlobal( + "requestAnimationFrame", + vi.fn((callback: FrameRequestCallback) => { + frames.callbacks.push(() => callback(performance.now())); + return frames.callbacks.length; + }), + ); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + installLayoutSimulation(); + getDefaultStore().set(threadTimelineScrollAnchorAtomFamily(THREAD_ID), null); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("ThreadTimelineRows windowing", () => { + it("renders every row when the viewport is not compact or there is no scroll body", () => { + renderTimeline({ compact: false }); + expect(mountedRowIds()).toHaveLength(ROW_COUNT); + expect(spacers()).toHaveLength(0); + cleanup(); + + renderTimeline({ compact: true, withAnchor: false }); + expect(mountedRowIds()).toHaveLength(ROW_COUNT); + expect(spacers()).toHaveLength(0); + }); + + it("mounts only the trailing rows at first and stands the rest in for one spacer", () => { + renderTimeline(); + const mounted = mountedRowIds(); + // Bounded well below the page: the initial window covers the assumed + // viewport plus overscan of never-measured rows. + expect(mounted.length).toBeGreaterThan(20); + expect(mounted.length).toBeLessThan(ROW_COUNT / 2); + expect(mounted.at(-1)).toBe(`turn_${ROW_COUNT - 1}`); + // Contiguous trailing block. + const firstMountedIndex = ROW_COUNT - mounted.length; + expect(mounted[0]).toBe(`turn_${firstMountedIndex}`); + const [topSpacer, ...rest] = spacers(); + expect(rest).toHaveLength(0); + expect(topSpacer).toBeDefined(); + expect(spacerRowCount(topSpacer!)).toBe(firstMountedIndex); + // Estimated height per skipped row plus the gaps between them. + expect(spacerHeight(topSpacer!)).toBe( + firstMountedIndex * TURN_ROW_ESTIMATE_PX + + (firstMountedIndex - 1) * GAP_PX, + ); + // The spacer precedes the first mounted row in the DOM. + const list = listElement()!; + expect(list.firstElementChild).toBe(topSpacer); + }); + + it("follows the viewport up the list, evicting far rows below except the last one", () => { + renderTimeline(); + // Scroll to the very top of the (estimated) list. + scrollTo(0); + const mounted = mountedRowIds(); + expect(mounted[0]).toBe("turn_0"); + // Rows within viewport + overscan stay; the middle of the list is gone. + const overscanRows = Math.ceil( + (VIEWPORT_HEIGHT_PX + TIMELINE_WINDOW_MIN_OVERSCAN_PX) / + (TURN_ROW_ESTIMATE_PX + GAP_PX), + ); + expect(mounted).toContain(`turn_${overscanRows - 2}`); + expect(mounted).not.toContain("turn_150"); + // The last row is always mounted (bottom sentinel geometry). + expect(mounted.at(-1)).toBe(`turn_${ROW_COUNT - 1}`); + const [bottomSpacer, ...rest] = spacers(); + expect(rest).toHaveLength(0); + expect(bottomSpacer).toBeDefined(); + // Spacer sits between the mounted head and the pinned last row. + const list = listElement()!; + expect(list.lastElementChild?.getAttribute("data-timeline-row-id")).toBe( + `turn_${ROW_COUNT - 1}`, + ); + expect(list.lastElementChild?.previousElementSibling).toBe(bottomSpacer); + // DOM growth stays bounded: mounted rows ≈ viewport + overscan on one side. + expect(mounted.length).toBeLessThan(overscanRows + 5); + expect(mounted.length + spacerRowCount(bottomSpacer!)).toBe(ROW_COUNT); + }); + + it("does not re-render the window while the viewport stays inside the slack", () => { + renderTimeline(); + scrollTo(0); + const before = mountedRowIds(); + const [spacerBefore] = spacers(); + // A small scroll within the overscan slack changes nothing. + scrollTo(300); + expect(mountedRowIds()).toEqual(before); + expect(spacers()[0]).toBe(spacerBefore); + }); + + it("uses measured heights for evicted rows and re-mounts them in place", () => { + renderTimeline(); + scrollTo(0); + // The rows near the top rendered taller than the estimate. Report that + // through the ResizeObserver, then scroll away so they are evicted. + for (let index = 0; index < 10; index += 1) { + rowHeights.set(`turn_${index}`, 100); + } + reportMeasuredHeights(); + // Far enough down that the head is evicted: 100 rows * 28px. + scrollTo(100 * (TURN_ROW_ESTIMATE_PX + GAP_PX)); + const [topSpacer] = spacers(); + expect(topSpacer).toBeDefined(); + const evictedCount = spacerRowCount(topSpacer!); + expect(evictedCount).toBeGreaterThanOrEqual(10); + // 10 measured rows at 100px, the rest at the estimate, gaps between all. + expect(spacerHeight(topSpacer!)).toBe( + 10 * 100 + + (evictedCount - 10) * TURN_ROW_ESTIMATE_PX + + (evictedCount - 1) * GAP_PX, + ); + expect(mountedRowIds()).not.toContain("turn_0"); + // Back to the top: the head re-mounts and the spacer disappears. + scrollTo(0); + expect(mountedRowIds()[0]).toBe("turn_0"); + expect(spacers().every((spacer) => spacerRowCount(spacer) > 0)).toBe(true); + expect( + listElement()!.firstElementChild?.getAttribute("data-timeline-row-id"), + ).toBe("turn_0"); + }); + + it("corrects scrollTop when re-mounted rows above the viewport are taller than their spacer share", () => { + renderTimeline(); + scrollTo(0); + // Evict the head at the estimate (never measured). + scrollTo(100 * (TURN_ROW_ESTIMATE_PX + GAP_PX)); + const [topSpacer] = spacers(); + const evictedCount = spacerRowCount(topSpacer!); + expect(evictedCount).toBeGreaterThan(20); + expect(mountedRowIds()).not.toContain("turn_0"); + // Those rows are "really" 60px each: when they re-mount, everything below + // them (including the row at the top of the viewport) shifts down by 40px + // per row, and the window must move scrollTop with it. + for (let index = 0; index < evictedCount; index += 1) { + rowHeights.set(`turn_${index}`, 60); + } + // Scroll to a mounted row just below the spacer, close enough to the head + // that the whole spacer re-mounts. + const referenceIndex = evictedCount + 8; + const targetScrollTop = referenceIndex * (TURN_ROW_ESTIMATE_PX + GAP_PX); + scrollTo(targetScrollTop); + const mounted = mountedRowIds(); + expect(mounted[0]).toBe("turn_0"); + // The reference row (top of the viewport before the swap) is now preceded + // by `evictedCount` rows of 60px instead of 20px, so its content offset + // grew by 40px per row and scrollTop followed. + expect(scrollArea.scrollTop).toBe(targetScrollTop + evictedCount * 40); + }); + + it("keeps the same rows mounted when an older page is prepended", () => { + const view = renderTimeline(); + scrollTo(0); + scrollTo(120 * (TURN_ROW_ESTIMATE_PX + GAP_PX)); + const before = mountedRowIds(); + const [topSpacerBefore] = spacers(); + const spacerBeforeHeight = spacerHeight(topSpacerBefore!); + const spacerBeforeCount = spacerRowCount(topSpacerBefore!); + // Prepend 100 older rows (an older page landing above everything). + const older = buildRows({ count: 100, firstIndex: -100 }); + view.rerender([...older, ...buildRows()]); + expect(mountedRowIds()).toEqual(before); + const [topSpacerAfter] = spacers(); + expect(spacerRowCount(topSpacerAfter!)).toBe(spacerBeforeCount + 100); + expect(spacerHeight(topSpacerAfter!)).toBe( + spacerBeforeHeight + 100 * (TURN_ROW_ESTIMATE_PX + GAP_PX), + ); + }); + + it("re-anchors from the DOM instead of jumping when the anchored row is replaced", () => { + const view = renderTimeline(); + scrollTo(0); + const scrollTop = 100 * (TURN_ROW_ESTIMATE_PX + GAP_PX); + scrollTo(scrollTop); + // The viewport now starts on turn_100; the window is anchored to it. + const before = mountedRowIds(); + expect(before).toContain("turn_100"); + // Rows are replaced under the anchor (its id changes, e.g. a projection + // rebuilt the row) while the user does not scroll. + const rows = buildRows().map((row) => + row.id === "turn_100" ? { ...row, id: "turn_100_replaced" } : row, + ); + view.rerender(rows); + const after = mountedRowIds(); + expect(after).toContain("turn_100_replaced"); + expect(after).toContain("turn_99"); + expect(after).toContain("turn_101"); + // Not re-windowed around the bottom. + expect(after).not.toContain(`turn_${ROW_COUNT - 2}`); + expect(scrollArea.scrollTop).toBe(scrollTop); + // And a following scroll inside the slack still changes nothing. + scrollTo(scrollTop + 200); + expect(mountedRowIds()).toEqual(after); + }); + + it("keeps the running turn's rows mounted while the user reads far above", () => { + renderTimeline({ + rows: buildRows({ withLiveTurn: true }), + status: "active", + }); + scrollTo(0); + const mounted = mountedRowIds(); + expect(mounted[0]).toBe("turn_0"); + expect(mounted.slice(-3)).toEqual([ + "live_user", + "live_turn", + "live_assistant", + ]); + // The completed turn just before the live one is not part of the tail. + expect(mounted).not.toContain(`turn_${ROW_COUNT - 1}`); + }); + + it("keeps the search target's top-level row mounted so the reveal can find it", () => { + // Search target is turn_10's command (seq 111), which the initial bottom + // window would otherwise leave in the spacer. + renderTimeline({ + routerState: { searchMessageSeq: 111, searchThreadId: THREAD_ID }, + }); + const mounted = mountedRowIds(); + expect(mounted).toContain("turn_10"); + expect(mounted).not.toContain("turn_11"); + expect(mounted).not.toContain("turn_9"); + // It sits between two spacers. + const [above, below] = spacers(); + expect(above && below).toBeTruthy(); + expect(spacerRowCount(above!)).toBe(10); + }); + + it("seeds the first window from the saved per-thread scroll anchor", () => { + getDefaultStore().set(threadTimelineScrollAnchorAtomFamily(THREAD_ID), { + rowId: "turn_50", + offsetWithinRow: 4, + atBottom: false, + }); + renderTimeline(); + const mounted = mountedRowIds(); + expect(mounted).toContain("turn_50"); + expect(mounted).toContain("turn_40"); + expect(mounted).toContain("turn_60"); + // Not the trailing block: the bottom is a spacer plus the pinned last row. + expect(mounted).not.toContain(`turn_${ROW_COUNT - 2}`); + expect(mounted.at(-1)).toBe(`turn_${ROW_COUNT - 1}`); + }); + + it("windows around the current viewport when it switches on after mount", () => { + const rows = buildRows(); + const timeline = (compact: boolean) => ( + + + + ); + const view = render(timeline(false)); + expect(mountedRowIds()).toHaveLength(ROW_COUNT); + // The user is reading at the top of the fully mounted list when the + // viewport crosses into compact (rotation, split-pane resize). + scrollArea.scrollTop = 0; + view.rerender(timeline(true)); + // Until the scroll area is sampled nothing is evicted. + expect(mountedRowIds()).toHaveLength(ROW_COUNT); + flushFrames(); + const mounted = mountedRowIds(); + expect(mounted[0]).toBe("turn_0"); + expect(mounted.length).toBeLessThan(ROW_COUNT / 2); + expect(spacers()).toHaveLength(1); + expect(scrollArea.scrollTop).toBe(0); + }); + + it("remembers a manually expanded row across eviction and re-mount", () => { + renderTimeline(); + scrollTo(0); + const rowWrapper = (id: string) => + document.querySelector(`[data-timeline-row-id="${id}"]`); + const toggleOf = (id: string) => + rowWrapper(id)?.querySelector( + "button[aria-expanded]", + ) ?? null; + expect(toggleOf("turn_2")?.getAttribute("aria-expanded")).toBe("false"); + act(() => { + toggleOf("turn_2")?.click(); + }); + expect(toggleOf("turn_2")?.getAttribute("aria-expanded")).toBe("true"); + // Scroll far enough to evict the head, then come back. + scrollTo(100 * (TURN_ROW_ESTIMATE_PX + GAP_PX)); + expect(rowWrapper("turn_2")).toBeNull(); + scrollTo(0); + expect(toggleOf("turn_2")?.getAttribute("aria-expanded")).toBe("true"); + // Neighbours were not touched. + expect(toggleOf("turn_3")?.getAttribute("aria-expanded")).toBe("false"); + }); + + it("wraps and pins the unread divider", () => { + const view = render( + + + , + ); + const divider = view.getByTestId("thread-unread-divider"); + const wrapper = divider.parentElement!; + expect(wrapper.dataset.timelineWindowEntry).toBe("thread-unread-divider"); + // Placed before turn_5 (createdAt 60 > 55) which is far above the initial + // window, so it renders between two spacers. + expect( + wrapper.previousElementSibling?.hasAttribute("data-timeline-row-spacer"), + ).toBe(true); + expect( + wrapper.nextElementSibling?.hasAttribute("data-timeline-row-spacer"), + ).toBe(true); + expect(mountedRowIds()).not.toContain("turn_5"); + }); +}); diff --git a/apps/app/src/components/thread/timeline/timeline-row-containment.ts b/apps/app/src/components/thread/timeline/timeline-row-containment.ts index 145f0a374e..3b62aa4cb8 100644 --- a/apps/app/src/components/thread/timeline/timeline-row-containment.ts +++ b/apps/app/src/components/thread/timeline/timeline-row-containment.ts @@ -110,3 +110,22 @@ export function timelineRowContainmentStyle( } return { containIntrinsicBlockSize: `auto ${estimate}px` }; } + +/** + * Height a top-level row wrapper occupies while it is skipped by + * `content-visibility: auto` and has never rendered: the class default above + * (`1.25rem`) for work / turn / system rows, the per-row estimate for + * conversation rows. The timeline window uses the same number for rows it has + * never measured, so a spacer and the skipped rows it stands in for take the + * same space and swapping one for the other does not move the content below. + */ +const SKIPPED_ROW_DEFAULT_BLOCK_SIZE_PX = 20; + +export function estimateSkippedTimelineRowBlockSizePx( + row: ThreadTimelineViewRow, +): number { + return ( + estimateTimelineRowIntrinsicBlockSizePx(row) ?? + SKIPPED_ROW_DEFAULT_BLOCK_SIZE_PX + ); +} diff --git a/apps/app/src/components/thread/timeline/timeline-windowing.test.ts b/apps/app/src/components/thread/timeline/timeline-windowing.test.ts new file mode 100644 index 0000000000..2f1d6132ca --- /dev/null +++ b/apps/app/src/components/thread/timeline/timeline-windowing.test.ts @@ -0,0 +1,341 @@ +import { describe, expect, it } from "vitest"; +import { + buildTimelineWindowLayout, + buildTimelineWindowSegments, + computeTimelineWindowRange, + findTimelineWindowEntryAtOffset, + resolveTimelineViewportTop, + resolveTimelineWindowRangeIds, + timelineWindowEntryHeightPx, + timelineWindowOverscanPx, + timelineWindowRangeCoversViewport, + timelineWindowRangeToIds, + TIMELINE_WINDOW_MIN_OVERSCAN_PX, + type TimelineWindowEntry, +} from "./timeline-windowing"; + +function entries( + count: number, + estimatedHeightPx = 100, +): TimelineWindowEntry[] { + return Array.from({ length: count }, (_, index) => ({ + id: `row_${index}`, + estimatedHeightPx, + })); +} + +function indexById(list: readonly TimelineWindowEntry[]): Map { + return new Map(list.map((entry, index) => [entry.id, index])); +} + +describe("buildTimelineWindowLayout", () => { + it("prefers measured heights over estimates and accounts for the flex gap", () => { + const list = entries(3, 100); + const layout = buildTimelineWindowLayout({ + entries: list, + gapPx: 8, + measuredHeightsById: new Map([["row_1", 250]]), + }); + expect(Array.from(layout.starts)).toEqual([0, 108, 366, 474]); + expect(layout.totalHeightPx).toBe(466); + expect(timelineWindowEntryHeightPx(layout, 0)).toBe(100); + expect(timelineWindowEntryHeightPx(layout, 1)).toBe(250); + expect(timelineWindowEntryHeightPx(layout, 2)).toBe(100); + }); + + it("handles an empty list", () => { + const layout = buildTimelineWindowLayout({ + entries: [], + gapPx: 8, + measuredHeightsById: new Map(), + }); + expect(layout.totalHeightPx).toBe(0); + expect(findTimelineWindowEntryAtOffset(layout, 10)).toBe(-1); + expect( + computeTimelineWindowRange({ + layout, + overscanPx: 100, + viewportHeightPx: 100, + viewportTopPx: 0, + }), + ).toEqual({ start: 0, end: 0 }); + expect( + buildTimelineWindowSegments({ + layout, + range: { start: 0, end: 0 }, + isPinned: () => false, + }), + ).toEqual([]); + }); +}); + +describe("findTimelineWindowEntryAtOffset", () => { + const layout = buildTimelineWindowLayout({ + entries: entries(5, 100), + gapPx: 8, + measuredHeightsById: new Map(), + }); + + it("maps offsets to the entry whose extent (plus trailing gap) contains them", () => { + expect(findTimelineWindowEntryAtOffset(layout, -50)).toBe(0); + expect(findTimelineWindowEntryAtOffset(layout, 0)).toBe(0); + expect(findTimelineWindowEntryAtOffset(layout, 99)).toBe(0); + // Inside the gap after row 0 still resolves to row 0. + expect(findTimelineWindowEntryAtOffset(layout, 104)).toBe(0); + expect(findTimelineWindowEntryAtOffset(layout, 108)).toBe(1); + expect(findTimelineWindowEntryAtOffset(layout, 432)).toBe(4); + // Past the end clamps to the last entry. + expect(findTimelineWindowEntryAtOffset(layout, 10_000)).toBe(4); + }); +}); + +describe("computeTimelineWindowRange", () => { + it("covers the viewport plus overscan on both sides and clamps to the list", () => { + const layout = buildTimelineWindowLayout({ + entries: entries(100, 100), + gapPx: 0, + measuredHeightsById: new Map(), + }); + // Viewport rows 40..47 (800px) with 300px overscan → rows 37..50. + expect( + computeTimelineWindowRange({ + layout, + overscanPx: 300, + viewportHeightPx: 800, + viewportTopPx: 4_000, + }), + ).toEqual({ start: 37, end: 51 }); + expect( + computeTimelineWindowRange({ + layout, + overscanPx: 300, + viewportHeightPx: 800, + viewportTopPx: 0, + }), + ).toEqual({ start: 0, end: 11 }); + expect( + computeTimelineWindowRange({ + layout, + overscanPx: 300, + viewportHeightPx: 800, + viewportTopPx: 9_200, + }), + ).toEqual({ start: 89, end: 100 }); + }); + + it("scales overscan with the viewport but never below the floor", () => { + expect(timelineWindowOverscanPx(300)).toBe(TIMELINE_WINDOW_MIN_OVERSCAN_PX); + expect(timelineWindowOverscanPx(1_000)).toBe(2_000); + }); +}); + +describe("timelineWindowRangeCoversViewport", () => { + const layout = buildTimelineWindowLayout({ + entries: entries(100, 100), + gapPx: 0, + measuredHeightsById: new Map(), + }); + + it("requires the margin on both sides unless the range reaches a list edge", () => { + const range = { start: 30, end: 60 }; // 3000px..6000px + const covers = (viewportTopPx: number) => + timelineWindowRangeCoversViewport({ + layout, + marginPx: 500, + range, + viewportHeightPx: 800, + viewportTopPx, + }); + expect(covers(3_500)).toBe(true); + expect(covers(4_700)).toBe(true); + // Top slack shrinks below the margin. + expect(covers(3_400)).toBe(false); + // Bottom slack shrinks below the margin. + expect(covers(4_800)).toBe(false); + // A range touching the list start needs no top slack. + expect( + timelineWindowRangeCoversViewport({ + layout, + marginPx: 500, + range: { start: 0, end: 30 }, + viewportHeightPx: 800, + viewportTopPx: 0, + }), + ).toBe(true); + // A range touching the list end needs no bottom slack. + expect( + timelineWindowRangeCoversViewport({ + layout, + marginPx: 500, + range: { start: 70, end: 100 }, + viewportHeightPx: 800, + viewportTopPx: 9_200, + }), + ).toBe(true); + }); + + it("rejects ranges that no longer fit the list", () => { + expect( + timelineWindowRangeCoversViewport({ + layout, + marginPx: 0, + range: { start: 90, end: 120 }, + viewportHeightPx: 800, + viewportTopPx: 9_000, + }), + ).toBe(false); + }); +}); + +describe("resolveTimelineViewportTop", () => { + const list = entries(50, 100); + const layout = buildTimelineWindowLayout({ + entries: list, + gapPx: 0, + measuredHeightsById: new Map(), + }); + + it("resolves a bottom anchor to the end of the list", () => { + expect( + resolveTimelineViewportTop({ + anchor: null, + entryIndexById: indexById(list), + layout, + viewportHeightPx: 800, + }), + ).toBe(4_200); + }); + + it("follows an entry anchor across a prepend", () => { + const anchor = { entryId: "row_10", offsetWithinEntryPx: 40 }; + expect( + resolveTimelineViewportTop({ + anchor, + entryIndexById: indexById(list), + layout, + viewportHeightPx: 800, + }), + ).toBe(1_040); + const prepended = [ + ...Array.from({ length: 20 }, (_, index) => ({ + id: `older_${index}`, + estimatedHeightPx: 100, + })), + ...list, + ]; + const prependedLayout = buildTimelineWindowLayout({ + entries: prepended, + gapPx: 0, + measuredHeightsById: new Map(), + }); + expect( + resolveTimelineViewportTop({ + anchor, + entryIndexById: indexById(prepended), + layout: prependedLayout, + viewportHeightPx: 800, + }), + ).toBe(3_040); + }); + + it("falls back to the bottom when the anchored entry is gone", () => { + expect( + resolveTimelineViewportTop({ + anchor: { entryId: "missing", offsetWithinEntryPx: 0 }, + entryIndexById: indexById(list), + layout, + viewportHeightPx: 800, + }), + ).toBe(4_200); + }); +}); + +describe("range ids", () => { + it("round-trips through ids and survives an index shift", () => { + const list = entries(10); + const ids = timelineWindowRangeToIds(list, { start: 2, end: 5 }); + expect(ids).toEqual({ startId: "row_2", endId: "row_4" }); + const shifted = [{ id: "older", estimatedHeightPx: 1 }, ...list]; + expect( + resolveTimelineWindowRangeIds({ + entryIndexById: indexById(shifted), + ids, + }), + ).toEqual({ start: 3, end: 6 }); + expect( + resolveTimelineWindowRangeIds({ + entryIndexById: indexById(list.slice(0, 3)), + ids, + }), + ).toBeNull(); + expect( + resolveTimelineWindowRangeIds({ + entryIndexById: indexById(list), + ids: null, + }), + ).toBeNull(); + expect(timelineWindowRangeToIds(list, { start: 0, end: 0 })).toBeNull(); + }); +}); + +describe("buildTimelineWindowSegments", () => { + it("collapses every run of unmounted entries into a spacer sized to its extent", () => { + const list = entries(10, 100); + const layout = buildTimelineWindowLayout({ + entries: list, + gapPx: 8, + measuredHeightsById: new Map([ + ["row_0", 50], + ["row_1", 70], + ]), + }); + const segments = buildTimelineWindowSegments({ + layout, + range: { start: 3, end: 6 }, + isPinned: (index) => index === 9, + }); + expect(segments).toEqual([ + // rows 0..2: 50 + 8 + 70 + 8 + 100 = 236 (no trailing gap) + { kind: "spacer", startIndex: 0, endIndex: 3, heightPx: 236 }, + { kind: "entry", index: 3 }, + { kind: "entry", index: 4 }, + { kind: "entry", index: 5 }, + // rows 6..8: 3 * 100 + 2 * 8 + { kind: "spacer", startIndex: 6, endIndex: 9, heightPx: 316 }, + { kind: "entry", index: 9 }, + ]); + // Spacers plus mounted entries plus the gaps between segments add up to + // the un-windowed list height, so the scroll range is unchanged. + const mountedHeight = segments.reduce((sum, segment) => { + return ( + sum + + (segment.kind === "spacer" + ? segment.heightPx + : timelineWindowEntryHeightPx(layout, segment.index)) + ); + }, 0); + expect(mountedHeight + (segments.length - 1) * layout.gapPx).toBe( + layout.totalHeightPx, + ); + }); + + it("mounts everything when the range spans the list", () => { + const layout = buildTimelineWindowLayout({ + entries: entries(4), + gapPx: 8, + measuredHeightsById: new Map(), + }); + expect( + buildTimelineWindowSegments({ + layout, + range: { start: 0, end: 4 }, + isPinned: () => false, + }), + ).toEqual([ + { kind: "entry", index: 0 }, + { kind: "entry", index: 1 }, + { kind: "entry", index: 2 }, + { kind: "entry", index: 3 }, + ]); + }); +}); diff --git a/apps/app/src/components/thread/timeline/timeline-windowing.ts b/apps/app/src/components/thread/timeline/timeline-windowing.ts new file mode 100644 index 0000000000..6bc64b7bf5 --- /dev/null +++ b/apps/app/src/components/thread/timeline/timeline-windowing.ts @@ -0,0 +1,337 @@ +/** + * Pure geometry for windowing the top-level timeline list. + * + * The list is modelled as a column of entries (rows and the unread divider) + * separated by a fixed flex gap. Each entry has a height: the last measured + * height of its wrapper when it has been mounted, or a per-kind estimate when + * it never has. Entries outside the mounted range are replaced by spacers + * whose height is the sum of the entry heights they stand in for (plus the + * gaps between them), so the scroll range and the offsets of mounted entries + * match the un-windowed list as closely as the measurements allow. + * + * Everything here is deterministic and DOM-free so the range/segment math is + * testable on its own; `useTimelineWindow` wires it to scroll and resize. + */ + +export interface TimelineWindowEntry { + id: string; + /** Height used while the entry has never been measured. */ + estimatedHeightPx: number; +} + +export interface TimelineWindowLayout { + count: number; + gapPx: number; + /** + * `starts[i]` is the offset of entry `i` from the top of the list; + * `starts[count]` is one gap past the bottom of the last entry (so + * `starts[i + 1] - starts[i] - gapPx` is entry `i`'s height). + */ + starts: Float64Array; + /** Height of the whole list (no trailing gap). */ + totalHeightPx: number; +} + +/** Half-open index range `[start, end)` of mounted entries. */ +export interface TimelineWindowRange { + start: number; + end: number; +} + +/** + * Where the viewport sits, expressed against an entry so the position survives + * prepends (older pages), appends and in-place merges of the row list. `null` + * means "pinned to the bottom". + */ +export interface TimelineViewportAnchor { + entryId: string; + /** Distance from the entry's top to the viewport's top (>= 0). */ + offsetWithinEntryPx: number; +} + +export type TimelineWindowSegment = + | { kind: "entry"; index: number } + | { + kind: "spacer"; + /** First entry the spacer stands in for. */ + startIndex: number; + /** One past the last entry the spacer stands in for. */ + endIndex: number; + heightPx: number; + }; + +interface BuildTimelineWindowLayoutArgs { + entries: readonly TimelineWindowEntry[]; + gapPx: number; + measuredHeightsById: ReadonlyMap; +} + +interface ComputeTimelineWindowRangeArgs { + layout: TimelineWindowLayout; + overscanPx: number; + viewportHeightPx: number; + viewportTopPx: number; +} + +interface TimelineWindowRangeCoversViewportArgs { + layout: TimelineWindowLayout; + marginPx: number; + range: TimelineWindowRange; + viewportHeightPx: number; + viewportTopPx: number; +} + +interface ResolveTimelineViewportTopArgs { + anchor: TimelineViewportAnchor | null; + entryIndexById: ReadonlyMap; + layout: TimelineWindowLayout; + viewportHeightPx: number; +} + +interface BuildTimelineWindowSegmentsArgs { + isPinned: (index: number) => boolean; + layout: TimelineWindowLayout; + range: TimelineWindowRange; +} + +interface ResolveTimelineWindowRangeIdsArgs { + entryIndexById: ReadonlyMap; + ids: TimelineWindowRangeIds | null; +} + +/** A committed range remembered by entry id so it survives index shifts. */ +export interface TimelineWindowRangeIds { + endId: string; + startId: string; +} + +/** + * Rows this far beyond the viewport stay mounted on each side. Two viewports + * keeps a fling from reaching a spacer before the next range lands while + * bounding the DOM to roughly five viewports of rows; the floor covers short + * (keyboard-shrunk) viewports. + */ +export const TIMELINE_WINDOW_OVERSCAN_VIEWPORTS = 2; +export const TIMELINE_WINDOW_MIN_OVERSCAN_PX = 1_600; +/** + * A committed range is reused until the viewport comes within this fraction of + * the overscan of one of its edges. Re-ranging then re-centres the window, so + * eviction on the far side happens as a by-product of extending the near side. + */ +export const TIMELINE_WINDOW_SLACK_FRACTION = 0.5; + +export function timelineWindowOverscanPx(viewportHeightPx: number): number { + return Math.max( + TIMELINE_WINDOW_MIN_OVERSCAN_PX, + Math.ceil(viewportHeightPx * TIMELINE_WINDOW_OVERSCAN_VIEWPORTS), + ); +} + +export function buildTimelineWindowLayout({ + entries, + gapPx, + measuredHeightsById, +}: BuildTimelineWindowLayoutArgs): TimelineWindowLayout { + const starts = new Float64Array(entries.length + 1); + let offset = 0; + for (const [index, entry] of entries.entries()) { + starts[index] = offset; + const height = measuredHeightsById.get(entry.id) ?? entry.estimatedHeightPx; + offset += Math.max(0, height) + gapPx; + } + starts[entries.length] = offset; + return { + count: entries.length, + gapPx, + starts, + totalHeightPx: entries.length === 0 ? 0 : offset - gapPx, + }; +} + +export function timelineWindowEntryHeightPx( + layout: TimelineWindowLayout, + index: number, +): number { + const start = layout.starts[index]; + const next = layout.starts[index + 1]; + if (start === undefined || next === undefined) { + return 0; + } + return Math.max(0, next - start - layout.gapPx); +} + +/** + * Index of the entry whose extent (including the gap that follows it) contains + * `offsetPx`, clamped to the list. Binary search over the monotonic starts. + */ +export function findTimelineWindowEntryAtOffset( + layout: TimelineWindowLayout, + offsetPx: number, +): number { + if (layout.count === 0) { + return -1; + } + if (offsetPx <= 0) { + return 0; + } + let low = 0; + let high = layout.count - 1; + while (low < high) { + const middle = low + Math.ceil((high - low) / 2); + const start = layout.starts[middle] ?? Number.POSITIVE_INFINITY; + if (start <= offsetPx) { + low = middle; + } else { + high = middle - 1; + } + } + return low; +} + +/** + * Entries overlapping the viewport extended by `overscanPx` on both sides. + * The result always covers the visible entries and is never empty for a + * non-empty list. + */ +export function computeTimelineWindowRange({ + layout, + overscanPx, + viewportHeightPx, + viewportTopPx, +}: ComputeTimelineWindowRangeArgs): TimelineWindowRange { + if (layout.count === 0) { + return { start: 0, end: 0 }; + } + const start = findTimelineWindowEntryAtOffset( + layout, + viewportTopPx - overscanPx, + ); + const bottomEdgePx = viewportTopPx + viewportHeightPx + overscanPx; + let last = findTimelineWindowEntryAtOffset(layout, bottomEdgePx); + // An entry that starts exactly on the edge lies outside the range. + if (last > start && (layout.starts[last] ?? 0) >= bottomEdgePx) { + last -= 1; + } + return { start, end: Math.max(start + 1, last + 1) }; +} + +/** + * Whether `range` still extends at least `marginPx` beyond both viewport + * edges (or reaches the corresponding end of the list). Used as hysteresis so + * the mounted set only changes when the viewport nears an edge of it. + */ +export function timelineWindowRangeCoversViewport({ + layout, + marginPx, + range, + viewportHeightPx, + viewportTopPx, +}: TimelineWindowRangeCoversViewportArgs): boolean { + if (range.start < 0 || range.end > layout.count || range.start >= range.end) { + return layout.count === 0 && range.start === 0 && range.end === 0; + } + const rangeTop = layout.starts[range.start] ?? 0; + const rangeBottom = + (layout.starts[range.end] ?? layout.totalHeightPx) - layout.gapPx; + const topCovered = range.start === 0 || rangeTop <= viewportTopPx - marginPx; + const bottomCovered = + range.end === layout.count || + rangeBottom >= viewportTopPx + viewportHeightPx + marginPx; + return topCovered && bottomCovered; +} + +/** + * The viewport's top offset within the list for an anchor. A bottom anchor + * (`null`) or an anchor whose entry is gone resolves to the bottom of the list. + */ +export function resolveTimelineViewportTop({ + anchor, + entryIndexById, + layout, + viewportHeightPx, +}: ResolveTimelineViewportTopArgs): number { + const bottomTop = Math.max(0, layout.totalHeightPx - viewportHeightPx); + if (anchor === null) { + return bottomTop; + } + const index = entryIndexById.get(anchor.entryId); + if (index === undefined) { + return bottomTop; + } + const start = layout.starts[index] ?? 0; + return Math.max(0, start + Math.max(0, anchor.offsetWithinEntryPx)); +} + +export function resolveTimelineWindowRangeIds({ + entryIndexById, + ids, +}: ResolveTimelineWindowRangeIdsArgs): TimelineWindowRange | null { + if (ids === null) { + return null; + } + const start = entryIndexById.get(ids.startId); + const endInclusive = entryIndexById.get(ids.endId); + if ( + start === undefined || + endInclusive === undefined || + endInclusive < start + ) { + return null; + } + return { start, end: endInclusive + 1 }; +} + +export function timelineWindowRangeToIds( + entries: readonly TimelineWindowEntry[], + range: TimelineWindowRange, +): TimelineWindowRangeIds | null { + const first = entries[range.start]; + const last = entries[range.end - 1]; + if (first === undefined || last === undefined) { + return null; + } + return { startId: first.id, endId: last.id }; +} + +/** + * Renders the list as mounted entries and spacers. An entry is mounted when it + * falls inside `range` or `isPinned` says so (streaming tail, unread divider, + * search target, focused row); every maximal run of unmounted entries becomes + * one spacer sized to the run's total extent. + */ +export function buildTimelineWindowSegments({ + isPinned, + layout, + range, +}: BuildTimelineWindowSegmentsArgs): TimelineWindowSegment[] { + const segments: TimelineWindowSegment[] = []; + let spacerStart = -1; + const flushSpacer = (endIndex: number) => { + if (spacerStart === -1) { + return; + } + const top = layout.starts[spacerStart] ?? 0; + const bottom = (layout.starts[endIndex] ?? top) - layout.gapPx; + segments.push({ + kind: "spacer", + startIndex: spacerStart, + endIndex, + heightPx: Math.max(0, bottom - top), + }); + spacerStart = -1; + }; + for (let index = 0; index < layout.count; index += 1) { + const mounted = + (index >= range.start && index < range.end) || isPinned(index); + if (mounted) { + flushSpacer(index); + segments.push({ kind: "entry", index }); + continue; + } + if (spacerStart === -1) { + spacerStart = index; + } + } + flushSpacer(layout.count); + return segments; +} diff --git a/apps/app/src/components/thread/timeline/useTimelineWindow.ts b/apps/app/src/components/thread/timeline/useTimelineWindow.ts new file mode 100644 index 0000000000..5c1fea2bc2 --- /dev/null +++ b/apps/app/src/components/thread/timeline/useTimelineWindow.ts @@ -0,0 +1,659 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type RefCallback, +} from "react"; +import { useStore } from "jotai"; +import { useBottomAnchoredScroll } from "@/components/ui/bottom-anchored-scroll-body.js"; +import { useAutoHeightSnap } from "@/components/ui/height-transition.js"; +import { threadTimelineScrollAnchorAtomFamily } from "@/lib/thread-timeline-scroll-anchor.js"; +import { + buildTimelineWindowLayout, + buildTimelineWindowSegments, + computeTimelineWindowRange, + findTimelineWindowEntryAtOffset, + resolveTimelineViewportTop, + resolveTimelineWindowRangeIds, + timelineWindowOverscanPx, + timelineWindowRangeCoversViewport, + timelineWindowRangeToIds, + TIMELINE_WINDOW_SLACK_FRACTION, + type TimelineViewportAnchor, + type TimelineWindowEntry, + type TimelineWindowLayout, + type TimelineWindowRange, + type TimelineWindowRangeIds, + type TimelineWindowSegment, +} from "./timeline-windowing.js"; + +/** + * Windows the top-level timeline list: keeps the entries around the viewport + * mounted, replaces far entries with height-preserving spacers, and re-mounts + * them as the viewport approaches. Rows are measured through one + * ResizeObserver so an evicted row's spacer share is its last real height. + * + * Interplay with the surrounding scroll body (see + * `bottom-anchored-scroll-body.tsx`), all of which keeps working because the + * window only ever swaps entries that are at least `overscan / 2` away from + * the viewport and compensates `scrollTop` for any resulting shift: + * + * - The bottom sentinel and stick-to-bottom restore see the same trailing + * content: the last entry (and the live turn while the thread runs) is + * always mounted, and a swap above the viewport is followed by a scrollTop + * correction in the same layout pass, so the bottom stays pinned. + * - Older-page prepends: the viewport is anchored to an entry id, so the same + * entries stay mounted while the new page lands as a spacer above (or as + * rows, if within overscan). The scroll body's own prepend restore then sees + * a height delta equal to the spacer's height, exactly as before. + * - Per-thread scroll restore and search reveal find their row in the DOM: the + * saved anchor seeds the initial window, and the search target's ancestors + * are pinned by the caller. + * - Native scroll anchoring (Chromium) and this hook agree: both preserve the + * first visible mounted entry, so the correction is idempotent there and does + * the whole job on WebKit, which has no scroll anchoring. + */ +export interface UseTimelineWindowArgs { + enabled: boolean; + /** One entry per list item, in list order (memoised by the caller). */ + entries: readonly TimelineWindowEntry[]; + /** Entry ids that stay mounted wherever they are (divider, search target). */ + pinnedEntryIds: ReadonlySet; + /** Entries at or after this index stay mounted (the live tail). */ + pinnedTailStartIndex: number; + /** + * Thread whose saved scroll anchor seeds the first window so the scroll + * body's mount-time restore finds its row mounted. + */ + scrollAnchorThreadId: string | undefined; +} + +export interface TimelineWindow { + /** + * Ref for every mounted entry wrapper. The wrapper must carry + * `data-timeline-window-entry=""`. + */ + entryRef: RefCallback; + listRef: RefCallback; + /** `null` while inactive: render every entry as before. */ + segments: readonly TimelineWindowSegment[] | null; +} + +interface TimelineWindowState { + anchor: TimelineViewportAnchor | null; + focusedEntryId: string | null; + /** + * Serial of the sample this state came from (0 before any sample). Ties a + * pending scrollTop correction to the commit that renders its window. + */ + sampleSerial: number; + /** `null` until the scroll area has been sampled (render everything). */ + viewportHeightPx: number | null; +} + +interface PendingScrollCompensation { + contentOffsetPx: number; + entryId: string; + sampleSerial: number; +} + +interface LayoutCache { + entries: readonly TimelineWindowEntry[]; + gapPx: number; + heightsVersion: number; + layout: TimelineWindowLayout; +} + +/** + * Mutable, render-independent bookkeeping: measurements, mounted elements and + * the last committed range. Held in a ref; the render only reads it to build + * the layout, and every decision that changes what is mounted goes through + * React state. + */ +class TimelineWindowController { + committedRangeIds: TimelineWindowRangeIds | null = null; + committedSignature: string | null = null; + readonly elementsById = new Map(); + entries: readonly TimelineWindowEntry[] = []; + entryIndexById: ReadonlyMap = new Map(); + gapPx = DEFAULT_LIST_GAP_PX; + readonly heightsById = new Map(); + heightsVersion = 0; + listElement: HTMLDivElement | null = null; + pendingCompensation: PendingScrollCompensation | null = null; + sampleSerial = 0; + private layoutCache: LayoutCache | null = null; + private resizeObserver: ResizeObserver | null | undefined; + + getLayout(entries: readonly TimelineWindowEntry[]): TimelineWindowLayout { + const cache = this.layoutCache; + if ( + cache !== null && + cache.entries === entries && + cache.gapPx === this.gapPx && + cache.heightsVersion === this.heightsVersion + ) { + return cache.layout; + } + // Ids leave the list when turns are summarised; drop their measurements + // once they clearly dominate so the map does not grow with thread history. + if (this.heightsById.size > entries.length * 2 + 64) { + const liveIds = new Set(entries.map((entry) => entry.id)); + for (const id of this.heightsById.keys()) { + if (!liveIds.has(id)) { + this.heightsById.delete(id); + } + } + } + const layout = buildTimelineWindowLayout({ + entries, + gapPx: this.gapPx, + measuredHeightsById: this.heightsById, + }); + this.layoutCache = { + entries, + gapPx: this.gapPx, + heightsVersion: this.heightsVersion, + layout, + }; + return layout; + } + + getResizeObserver(): ResizeObserver | null { + if (this.resizeObserver === undefined) { + this.resizeObserver = + typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver((observed) => { + for (const entry of observed) { + const target = entry.target; + if (!(target instanceof HTMLElement)) { + continue; + } + const id = target.dataset.timelineWindowEntry; + if (id === undefined) { + continue; + } + const height = + entry.borderBoxSize?.[0]?.blockSize ?? + entry.contentRect.height; + if (this.heightsById.get(id) !== height) { + this.heightsById.set(id, height); + this.heightsVersion += 1; + } + } + }); + } + return this.resizeObserver; + } + + /** + * (Re)observe every mounted wrapper. Effects can be torn down and re-run + * without the ref callbacks running again (StrictMode), so the observer is + * rebuilt from the element map rather than relying on the refs. + */ + ensureObserving(): void { + const resizeObserver = this.getResizeObserver(); + if (resizeObserver === null) { + return; + } + for (const element of this.elementsById.values()) { + resizeObserver.observe(element); + } + } + + attachEntry(id: string, element: HTMLElement): void { + this.elementsById.set(id, element); + this.getResizeObserver()?.observe(element); + } + + detachEntry(id: string, element: HTMLElement): void { + if (this.elementsById.get(id) === element) { + this.elementsById.delete(id); + } + // Only the current observer can be watching it; a replaced one was + // disconnected wholesale. + if (this.resizeObserver) { + this.resizeObserver.unobserve(element); + } + } + + disconnect(): void { + this.resizeObserver?.disconnect(); + this.resizeObserver = undefined; + } +} + +const DEFAULT_LIST_GAP_PX = 8; +/** + * Viewport height assumed for the very first render, before the scroll area + * can be measured. Generous so the first window is a superset of what the + * real sample keeps; only the trailing (or anchored) entries mount at all. + */ +const INITIAL_VIEWPORT_HEIGHT_PX = 900; +const BOTTOM_THRESHOLD_PX = 4; +/** Below this the scrollTop correction is sub-pixel noise; skip it. */ +const MIN_SCROLL_COMPENSATION_PX = 0.5; + +function readListGapPx(list: HTMLElement): number { + if ( + typeof window === "undefined" || + typeof window.getComputedStyle !== "function" + ) { + return DEFAULT_LIST_GAP_PX; + } + const gap = Number.parseFloat(window.getComputedStyle(list).rowGap); + return Number.isFinite(gap) && gap >= 0 ? gap : DEFAULT_LIST_GAP_PX; +} + +function hasActiveSelectionInside(list: HTMLElement): boolean { + const selection = document.getSelection(); + if ( + selection === null || + selection.isCollapsed || + selection.rangeCount === 0 + ) { + return false; + } + return ( + (selection.anchorNode !== null && list.contains(selection.anchorNode)) || + (selection.focusNode !== null && list.contains(selection.focusNode)) + ); +} + +function focusedEntryIdInside(list: HTMLElement): string | null { + const active = document.activeElement; + if (!(active instanceof HTMLElement) || !list.contains(active)) { + return null; + } + const wrapper = active.closest("[data-timeline-window-entry]"); + return wrapper?.dataset.timelineWindowEntry ?? null; +} + +function segmentsSignature( + segments: readonly TimelineWindowSegment[], + entries: readonly TimelineWindowEntry[], +): string { + const parts: string[] = []; + for (const segment of segments) { + if (segment.kind === "entry") { + parts.push(entries[segment.index]?.id ?? "?"); + } else { + parts.push( + `s:${entries[segment.startIndex]?.id ?? "?"}-${entries[segment.endIndex - 1]?.id ?? "?"}`, + ); + } + } + return parts.join("|"); +} + +function contentOffsetOf( + element: HTMLElement, + scrollArea: HTMLElement, +): number { + return ( + element.getBoundingClientRect().top - + scrollArea.getBoundingClientRect().top + + scrollArea.scrollTop + ); +} + +interface WindowPlan { + /** + * The state's anchor entry left the list (rows replaced under it), so the + * viewport position had to be guessed; a fresh sample after commit re-reads + * it from the DOM. + */ + anchorLost: boolean; + range: TimelineWindowRange; + segments: TimelineWindowSegment[]; +} + +export function useTimelineWindow({ + enabled, + entries, + pinnedEntryIds, + pinnedTailStartIndex, + scrollAnchorThreadId, +}: UseTimelineWindowArgs): TimelineWindow { + const bottomAnchor = useBottomAnchoredScroll(); + const snapAutoHeight = useAutoHeightSnap(); + const store = useStore(); + // Mutable bookkeeping that outlives renders. The render does read it (to + // size spacers from the latest measurements), which the compiler flags; that + // read is deliberate: measurements only ever change for mounted rows, whose + // spacer share is not rendered until they are evicted by a state change. + const controllerRef = useRef(null); + if (controllerRef.current === null) { + controllerRef.current = new TimelineWindowController(); + } + const controller = controllerRef.current; + const [state, setState] = useState(() => { + const savedAnchor = + scrollAnchorThreadId === undefined + ? null + : store.get(threadTimelineScrollAnchorAtomFamily(scrollAnchorThreadId)); + return { + anchor: + savedAnchor === null || savedAnchor.atBottom + ? null + : { + entryId: savedAnchor.rowId, + offsetWithinEntryPx: savedAnchor.offsetWithinRow, + }, + focusedEntryId: null, + sampleSerial: 0, + viewportHeightPx: enabled ? INITIAL_VIEWPORT_HEIGHT_PX : null, + }; + }); + // Windowing that switches on later (viewport crossed the compact breakpoint, + // scroll body appeared) must not evict what the user is looking at: fall + // back to rendering everything until the first sample reads the real + // viewport and picks the range from it. + const [wasEnabled, setWasEnabled] = useState(enabled); + if (wasEnabled !== enabled) { + setWasEnabled(enabled); + setState((current) => ({ + ...current, + anchor: null, + viewportHeightPx: null, + })); + } + + const entryIndexById = useMemo(() => { + const indexById = new Map(); + for (const [index, entry] of entries.entries()) { + indexById.set(entry.id, index); + } + return indexById; + }, [entries]); + + const active = + enabled && + bottomAnchor !== null && + state.viewportHeightPx !== null && + entries.length > 0; + + let plan: WindowPlan | null = null; + if (active && state.viewportHeightPx !== null) { + const viewportHeightPx = state.viewportHeightPx; + const layout = controller.getLayout(entries); + const anchorLost = + state.anchor !== null && !entryIndexById.has(state.anchor.entryId); + const viewportTopPx = resolveTimelineViewportTop({ + anchor: state.anchor, + entryIndexById, + layout, + viewportHeightPx, + }); + const overscanPx = timelineWindowOverscanPx(viewportHeightPx); + const committedRange = resolveTimelineWindowRangeIds({ + entryIndexById, + ids: controller.committedRangeIds, + }); + // Reuse the committed range while the viewport keeps its slack inside it, + // so unrelated re-renders (streaming) do not churn the edges. With the + // anchor gone the resolved position is a guess, so keep what is mounted + // rather than jump; the post-commit sample re-anchors from the DOM. + const range = + committedRange !== null && + (anchorLost || + timelineWindowRangeCoversViewport({ + layout, + marginPx: overscanPx * TIMELINE_WINDOW_SLACK_FRACTION, + range: committedRange, + viewportHeightPx, + viewportTopPx, + })) + ? committedRange + : computeTimelineWindowRange({ + layout, + overscanPx, + viewportHeightPx, + viewportTopPx, + }); + const focusedEntryId = state.focusedEntryId; + const segments = buildTimelineWindowSegments({ + layout, + range, + isPinned: (index) => { + if (index >= pinnedTailStartIndex) { + return true; + } + const id = entries[index]?.id; + return ( + id !== undefined && (pinnedEntryIds.has(id) || id === focusedEntryId) + ); + }, + }); + plan = { anchorLost, range, segments }; + } + + const sample = useCallback( + (force = false) => { + const scrollArea = bottomAnchor?.getScrollElement() ?? null; + const list = controller.listElement; + if (scrollArea === null || list === null) { + return; + } + const viewportHeightPx = scrollArea.clientHeight; + const currentEntries = controller.entries; + if (viewportHeightPx <= 0 || currentEntries.length === 0) { + return; + } + if (hasActiveSelectionInside(list)) { + // Swapping rows under a live selection would drop its anchor node. + return; + } + const layout = controller.getLayout(currentEntries); + const scrollAreaRect = scrollArea.getBoundingClientRect(); + const scrollTop = scrollArea.scrollTop; + const listTopPx = + list.getBoundingClientRect().top - scrollAreaRect.top + scrollTop; + const viewportTopPx = scrollTop - listTopPx; + const overscanPx = timelineWindowOverscanPx(viewportHeightPx); + const committedRange = resolveTimelineWindowRangeIds({ + entryIndexById: controller.entryIndexById, + ids: controller.committedRangeIds, + }); + if ( + !force && + committedRange !== null && + timelineWindowRangeCoversViewport({ + layout, + marginPx: overscanPx * TIMELINE_WINDOW_SLACK_FRACTION, + range: committedRange, + viewportHeightPx, + viewportTopPx, + }) + ) { + return; + } + const nearBottom = + scrollArea.scrollHeight - viewportHeightPx - scrollTop <= + BOTTOM_THRESHOLD_PX; + const anchorIndex = findTimelineWindowEntryAtOffset( + layout, + viewportTopPx, + ); + const anchorEntry = currentEntries[anchorIndex]; + const anchor: TimelineViewportAnchor | null = + nearBottom || anchorEntry === undefined + ? null + : { + entryId: anchorEntry.id, + offsetWithinEntryPx: Math.max( + 0, + viewportTopPx - (layout.starts[anchorIndex] ?? 0), + ), + }; + // Reference for the post-commit scrollTop correction: the first mounted + // entry that is (at least partly) in the viewport. It stays mounted across + // the swap because the new range covers the viewport. + controller.sampleSerial += 1; + const sampleSerial = controller.sampleSerial; + let pendingCompensation: PendingScrollCompensation | null = null; + const viewportBottomPx = viewportTopPx + viewportHeightPx; + for ( + let index = Math.max(0, anchorIndex); + index < layout.count && (layout.starts[index] ?? 0) < viewportBottomPx; + index += 1 + ) { + const entry = currentEntries[index]; + const element = + entry === undefined + ? undefined + : controller.elementsById.get(entry.id); + if (entry !== undefined && element !== undefined) { + pendingCompensation = { + entryId: entry.id, + contentOffsetPx: contentOffsetOf(element, scrollArea), + sampleSerial, + }; + break; + } + } + controller.pendingCompensation = pendingCompensation; + const focusedEntryId = focusedEntryIdInside(list); + setState({ + anchor, + focusedEntryId, + sampleSerial, + viewportHeightPx, + }); + }, + [bottomAnchor, controller], + ); + + // Commit bookkeeping: remember what is mounted for the next sample, then + // correct scrollTop for whatever the swap shifted and snap the auto-height + // wrapper so the swap does not ease. + useLayoutEffect(() => { + controller.entries = entries; + controller.entryIndexById = entryIndexById; + if (plan === null) { + controller.committedRangeIds = null; + controller.committedSignature = null; + controller.pendingCompensation = null; + return; + } + controller.committedRangeIds = timelineWindowRangeToIds( + entries, + plan.range, + ); + const signature = segmentsSignature(plan.segments, entries); + // A correction belongs to the commit that renders the sample's window; a + // rows-driven commit that lands in between leaves it pending, and a + // superseded one is dropped. + let pending = controller.pendingCompensation; + if (pending !== null && pending.sampleSerial > state.sampleSerial) { + pending = null; + } else { + controller.pendingCompensation = null; + } + if (plan.anchorLost) { + sample(true); + } + if (signature === controller.committedSignature) { + return; + } + controller.committedSignature = signature; + const scrollArea = bottomAnchor?.getScrollElement() ?? null; + if ( + pending !== null && + pending.sampleSerial === state.sampleSerial && + scrollArea !== null + ) { + const element = controller.elementsById.get(pending.entryId); + if (element !== undefined) { + const delta = + contentOffsetOf(element, scrollArea) - pending.contentOffsetPx; + if (Math.abs(delta) >= MIN_SCROLL_COMPENSATION_PX) { + scrollArea.scrollTop += delta; + } + } + } + snapAutoHeight?.(); + }); + + useEffect(() => { + if (!enabled || bottomAnchor === null) { + return; + } + const scrollArea = bottomAnchor.getScrollElement(); + if (scrollArea === null) { + return; + } + let frame: number | null = null; + const schedule = () => { + if (frame !== null) { + return; + } + frame = window.requestAnimationFrame(() => { + frame = null; + sample(); + }); + }; + scrollArea.addEventListener("scroll", schedule, { passive: true }); + const resizeObserver = + typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(schedule); + resizeObserver?.observe(scrollArea); + schedule(); + return () => { + scrollArea.removeEventListener("scroll", schedule); + resizeObserver?.disconnect(); + if (frame !== null) { + window.cancelAnimationFrame(frame); + } + }; + }, [bottomAnchor, enabled, sample]); + + useEffect(() => { + if (!enabled) { + return; + } + controller.ensureObserving(); + return () => controller.disconnect(); + }, [controller, enabled]); + + const listRef = useCallback>( + (node) => { + controller.listElement = node; + if (node !== null) { + controller.gapPx = readListGapPx(node); + } + }, + [controller], + ); + + // Identity changes with `enabled` on purpose: React re-runs the ref for every + // mounted wrapper, so entries that mounted while windowing was off get + // observed once it switches on. + const entryRef = useCallback>( + (node) => { + if (!enabled || node === null) { + return; + } + const id = node.dataset.timelineWindowEntry; + if (id === undefined) { + return; + } + controller.attachEntry(id, node); + return () => { + controller.detachEntry(id, node); + }; + }, + [controller, enabled], + ); + + return { + entryRef, + listRef, + segments: plan?.segments ?? null, + }; +} diff --git a/apps/app/src/components/ui/height-transition.tsx b/apps/app/src/components/ui/height-transition.tsx index 8bfc5e442c..d9ad0d1304 100644 --- a/apps/app/src/components/ui/height-transition.tsx +++ b/apps/app/src/components/ui/height-transition.tsx @@ -1,5 +1,12 @@ import { useStore } from "jotai"; -import { useLayoutEffect, useRef, type ReactNode } from "react"; +import { + createContext, + useCallback, + useContext, + useLayoutEffect, + useRef, + type ReactNode, +} from "react"; import { cn } from "@bb/shared-ui/lib/utils"; import { usePrefersReducedMotion } from "@bb/shared-ui/hooks/use-media-query"; import { usePointerCoarse } from "@bb/shared-ui/hooks/use-pointer-coarse"; @@ -249,6 +256,19 @@ export function HeightTransition({ ); } +/** + * Lets content inside an `AutoHeightContainer` snap the wrapper to its current + * height without a transition. The timeline window uses it when it swaps rows + * for spacers (or back) in one commit: that replacement is a layout swap, not + * growth, and easing through it would leave the scroll range short of the new + * content for the transition's duration. Null outside a container. + */ +const AutoHeightSnapContext = createContext<(() => void) | null>(null); + +export function useAutoHeightSnap(): (() => void) | null { + return useContext(AutoHeightSnapContext); +} + export interface AutoHeightContainerProps { children: ReactNode; className?: string; @@ -314,6 +334,9 @@ export function AutoHeightContainer({ const snapToCurrentHeightRef = useRef<(() => void) | null>(null); const previousSnapRevisionRef = useRef(snapRevision); const store = useStore(); + const snapToCurrentHeight = useCallback(() => { + snapToCurrentHeightRef.current?.(); + }, []); useLayoutEffect(() => { const wrapper = wrapperRef.current; const inner = innerRef.current; @@ -422,7 +445,9 @@ export function AutoHeightContainer({ }} >
- {children} + + {children} +
); From 16bfd083c60fb3b0f4a80b1ef2ccf5695af1bdd7 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Wed, 19 Aug 2026 05:33:01 +0000 Subject: [PATCH 2/2] Re-sample the timeline window from the DOM on rows-driven range changes A rows-driven commit (streaming rows appended, a turn summarised) recomputed the mounted range from the state's viewport anchor, which is only as fresh as the last sample that changed the window: a scroll inside the slack updates nothing, so a user who scrolled up a little from the bottom still had a "bottom" anchor, and once the live turn grew by more than the overscan the range re-centred on the new bottom and evicted the rows in the viewport into a spacer (blank screen until the next scroll). Only a render driven by a fresh sample now moves the range from the state's anchor. A rows-driven commit that finds the committed range no longer covering (or its edge rows gone) keeps what is mounted and forces a sample after commit, which re-anchors from the real scroll position and lands the new range with its scrollTop correction. Adds a windowing test for the stale-bottom-anchor case (fails before, passes after). Co-Authored-By: Claude --- .../ThreadTimelineRows.windowing.test.tsx | 54 ++++++++++++++++++ .../thread/timeline/useTimelineWindow.ts | 57 +++++++++++++------ 2 files changed, 94 insertions(+), 17 deletions(-) diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.windowing.test.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.windowing.test.tsx index d951db741b..ec16e8be73 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineRows.windowing.test.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.windowing.test.tsx @@ -614,6 +614,60 @@ describe("ThreadTimelineRows windowing", () => { expect(mounted).not.toContain(`turn_${ROW_COUNT - 1}`); }); + it("keeps the rows the user is reading mounted when the live turn grows below a stale bottom anchor", () => { + const rows = buildRows({ withLiveTurn: true }); + const view = renderTimeline({ rows, status: "active" }); + // Read at the bottom, then scroll up a little: inside the slack, so the + // window is not re-sampled and its anchor still says "bottom". + scrollTo(listHeight()); + const readingScrollTop = listHeight() - VIEWPORT_HEIGHT_PX - 700; + scrollTo(readingScrollTop); + const visibleBefore = mountedRowIds().filter((id) => { + const wrapper = document.querySelector( + `[data-timeline-row-id="${id}"]`, + ); + const rowRect = wrapper?.getBoundingClientRect(); + return ( + rowRect !== undefined && + rowRect.bottom > 0 && + rowRect.top < VIEWPORT_HEIGHT_PX + ); + }); + expect(visibleBefore.length).toBeGreaterThan(3); + // The streaming assistant row grows by 3000px (measured), then a rows + // commit lands: another top-level row of the live turn. + rowHeights.set("live_assistant", 3000); + reportMeasuredHeights(); + view.rerender( + [ + ...rows, + conversationRow({ + id: "live_assistant_2", + role: "assistant", + text: "Still running.", + turnId: "turn_live", + seq: ROW_COUNT * 10 + 15, + threadId: THREAD_ID, + }), + ], + "active", + ); + // The rows in the viewport must not be evicted into a spacer because the + // resolved "bottom" moved 3000px below the user; the window re-samples + // from the DOM instead and keeps them, plus the live tail. + const after = mountedRowIds(); + for (const id of visibleBefore) { + expect(after).toContain(id); + } + expect(after.slice(-4)).toEqual([ + "live_user", + "live_turn", + "live_assistant", + "live_assistant_2", + ]); + expect(scrollArea.scrollTop).toBe(readingScrollTop); + }); + it("keeps the search target's top-level row mounted so the reveal can find it", () => { // Search target is turn_10's command (seq 111), which the initial bottom // window would otherwise leave in the spacer. diff --git a/apps/app/src/components/thread/timeline/useTimelineWindow.ts b/apps/app/src/components/thread/timeline/useTimelineWindow.ts index 5c1fea2bc2..030000b1a0 100644 --- a/apps/app/src/components/thread/timeline/useTimelineWindow.ts +++ b/apps/app/src/components/thread/timeline/useTimelineWindow.ts @@ -115,6 +115,8 @@ interface LayoutCache { */ class TimelineWindowController { committedRangeIds: TimelineWindowRangeIds | null = null; + /** `sampleSerial` of the state the last committed plan was built from. */ + committedSampleSerial = -1; committedSignature: string | null = null; readonly elementsById = new Map(); entries: readonly TimelineWindowEntry[] = []; @@ -303,11 +305,15 @@ function contentOffsetOf( interface WindowPlan { /** - * The state's anchor entry left the list (rows replaced under it), so the - * viewport position had to be guessed; a fresh sample after commit re-reads - * it from the DOM. + * The committed range was kept although the state's view of the viewport + * says it should change, because that view cannot be trusted: the anchor + * entry left the list (rows replaced under it), or the commit is rows-driven + * and the anchor is only as fresh as the last sample that changed the window + * (a scroll inside the slack updates nothing, and content growing below a + * bottom anchor moves the resolved viewport away from the real one). A + * forced sample after commit re-reads the DOM and re-ranges from that. */ - anchorLost: boolean; + needsResample: boolean; range: TimelineWindowRange; segments: TimelineWindowSegment[]; } @@ -395,19 +401,27 @@ export function useTimelineWindow({ ids: controller.committedRangeIds, }); // Reuse the committed range while the viewport keeps its slack inside it, - // so unrelated re-renders (streaming) do not churn the edges. With the - // anchor gone the resolved position is a guess, so keep what is mounted - // rather than jump; the post-commit sample re-anchors from the DOM. + // so unrelated re-renders (streaming) do not churn the edges. Only a render + // driven by a fresh sample may move the range from the state's anchor; a + // rows-driven commit that finds the range no longer covering (or the + // anchor gone) keeps what is mounted rather than jump, and asks for a + // forced sample after commit, which re-anchors from the DOM and lands the + // new range with its scrollTop correction. + const isSampleRender = + state.sampleSerial !== controller.committedSampleSerial; + const committedRangeCovers = + committedRange !== null && + !anchorLost && + timelineWindowRangeCoversViewport({ + layout, + marginPx: overscanPx * TIMELINE_WINDOW_SLACK_FRACTION, + range: committedRange, + viewportHeightPx, + viewportTopPx, + }); const range = committedRange !== null && - (anchorLost || - timelineWindowRangeCoversViewport({ - layout, - marginPx: overscanPx * TIMELINE_WINDOW_SLACK_FRACTION, - range: committedRange, - viewportHeightPx, - viewportTopPx, - })) + (committedRangeCovers || anchorLost || !isSampleRender) ? committedRange : computeTimelineWindowRange({ layout, @@ -415,6 +429,13 @@ export function useTimelineWindow({ viewportHeightPx, viewportTopPx, }); + // A committed range whose edge rows left the list (turn summarised) cannot + // be kept, so it is recomputed from the state's anchor; that guess is + // checked against the DOM the same way. + const needsResample = + !committedRangeCovers && + (anchorLost || + (!isSampleRender && controller.committedRangeIds !== null)); const focusedEntryId = state.focusedEntryId; const segments = buildTimelineWindowSegments({ layout, @@ -429,7 +450,7 @@ export function useTimelineWindow({ ); }, }); - plan = { anchorLost, range, segments }; + plan = { needsResample, range, segments }; } const sample = useCallback( @@ -536,6 +557,7 @@ export function useTimelineWindow({ controller.entryIndexById = entryIndexById; if (plan === null) { controller.committedRangeIds = null; + controller.committedSampleSerial = -1; controller.committedSignature = null; controller.pendingCompensation = null; return; @@ -544,6 +566,7 @@ export function useTimelineWindow({ entries, plan.range, ); + controller.committedSampleSerial = state.sampleSerial; const signature = segmentsSignature(plan.segments, entries); // A correction belongs to the commit that renders the sample's window; a // rows-driven commit that lands in between leaves it pending, and a @@ -554,7 +577,7 @@ export function useTimelineWindow({ } else { controller.pendingCompensation = null; } - if (plan.anchorLost) { + if (plan.needsResample) { sample(true); } if (signature === controller.committedSignature) {