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 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. + 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..030000b1a0 --- /dev/null +++ b/apps/app/src/components/thread/timeline/useTimelineWindow.ts @@ -0,0 +1,682 @@ +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; + /** `sampleSerial` of the state the last committed plan was built from. */ + committedSampleSerial = -1; + 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 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. + */ + needsResample: 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. 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 && + (committedRangeCovers || anchorLost || !isSampleRender) + ? committedRange + : computeTimelineWindowRange({ + layout, + overscanPx, + 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, + range, + isPinned: (index) => { + if (index >= pinnedTailStartIndex) { + return true; + } + const id = entries[index]?.id; + return ( + id !== undefined && (pinnedEntryIds.has(id) || id === focusedEntryId) + ); + }, + }); + plan = { needsResample, 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.committedSampleSerial = -1; + controller.committedSignature = null; + controller.pendingCompensation = null; + return; + } + controller.committedRangeIds = timelineWindowRangeToIds( + 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 + // superseded one is dropped. + let pending = controller.pendingCompensation; + if (pending !== null && pending.sampleSerial > state.sampleSerial) { + pending = null; + } else { + controller.pendingCompensation = null; + } + if (plan.needsResample) { + 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} +
);