diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.actions.test.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.actions.test.tsx index e94d2285b6..4bd84423b8 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineRows.actions.test.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.actions.test.tsx @@ -12,6 +12,12 @@ import { renderToStaticMarkup } from "react-dom/server"; import { afterEach, describe, expect, it, vi } from "vitest"; import { useState, type ComponentProps, type ReactElement } from "react"; import { MemoryRouter, useNavigate } from "react-router-dom"; +import { CompactViewportOverrideProvider } from "@bb/shared-ui/hooks/use-compact-viewport"; +import { getDefaultStore } from "jotai"; +import { + BottomAnchorContext, + type BottomAnchorContextValue, +} from "@/components/ui/bottom-anchored-scroll-body"; import { COMPACT_VIEWPORT_QUERY } from "@bb/shared-ui/hooks/use-compact-viewport"; import { POINTER_COARSE_QUERY } from "@bb/shared-ui/hooks/use-pointer-coarse"; import type { PluginMessageActionRegistration } from "@bb/plugin-sdk"; @@ -25,6 +31,7 @@ import { setPluginSlotRegistrations, type PluginRegistrationSet, } from "@/lib/plugin-slots"; +import { threadTimelineScrollAnchorAtomFamily } from "@/lib/thread-timeline-scroll-anchor"; import { ThreadTimelineRows } from "./ThreadTimelineRows"; function messageActionRegistrationSet( @@ -268,11 +275,892 @@ function mockSelectionMenuMedia({ afterEach(() => { cleanup(); + getDefaultStore().set( + threadTimelineScrollAnchorAtomFamily("thr_large"), + null, + ); resetPluginSlotStoreForTest(); vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); describe("ThreadTimelineRows actions", () => { + it("keeps measured placeholders and preserves the visible row", async () => { + let intersectionCallback: IntersectionObserverCallback | null = null; + vi.stubGlobal( + "IntersectionObserver", + class IntersectionObserverMock { + constructor(callback: IntersectionObserverCallback) { + intersectionCallback = callback; + } + observe() {} + unobserve() {} + disconnect() {} + }, + ); + const rows = Array.from({ length: 80 }, (_, index) => + conversationRow({ + id: `message_${index}`, + role: index % 2 === 0 ? "user" : "assistant", + text: `Timeline message ${index}`, + sourceSeqStart: index + 1, + sourceSeqEnd: index + 1, + threadId: "thr_large", + }), + ); + const scrollElement = document.createElement("div"); + let scrollTop = 17; + const setScrollTop = vi.fn((value: number) => { + scrollTop = value; + }); + Object.defineProperty(scrollElement, "clientHeight", { + configurable: true, + value: 0, + }); + Object.defineProperty(scrollElement, "scrollTop", { + configurable: true, + get: () => scrollTop, + set: setScrollTop, + }); + Object.defineProperty(scrollElement, "scrollHeight", { + configurable: true, + value: 16_000, + }); + scrollElement.getBoundingClientRect = () => + ({ top: 0, bottom: 800 }) as DOMRect; + + const bottomAnchor: BottomAnchorContextValue = { + captureScrollAnchor: vi.fn(), + getScrollElement: () => scrollElement, + isAtBottom: true, + scrollElementIntoView: vi.fn(), + scrollElementIntoViewClampedToMaxScroll: vi.fn(), + scrollToBottom: vi.fn(), + }; + + const { container } = renderWithRouter( + + + + + , + ); + + const windowedList = container.querySelector( + '[data-timeline-windowed="true"]', + ); + expect(windowedList).not.toBeNull(); + expect(windowedList?.closest('[style*="overflow-y: clip"]')).toBeNull(); + expect(container.querySelectorAll("[data-timeline-row-id]").length).toBe( + rows.length, + ); + expect( + container.querySelectorAll('[data-timeline-row-realized="true"]').length, + ).toBeLessThan(rows.length); + + const firstWrapper = container.querySelector( + '[data-timeline-row-id="message_0"]', + ); + const lastWrapper = container.querySelector( + '[data-timeline-row-id="message_79"]', + ); + lastWrapper!.getBoundingClientRect = () => { + const top = + firstWrapper?.dataset.timelineRowRealized === "true" ? 320 : 200; + return { top, bottom: top + 100 } as DOMRect; + }; + expect(firstWrapper?.dataset.timelineRowRealized).toBe("false"); + expect(lastWrapper?.dataset.timelineRowRealized).toBe("true"); + + act(() => { + intersectionCallback?.( + [ + { + target: lastWrapper!, + isIntersecting: false, + boundingClientRect: { height: 144 }, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ); + }); + await waitFor(() => + expect(lastWrapper?.dataset.timelineRowRealized).toBe("false"), + ); + + await act(async () => { + intersectionCallback?.( + [ + { + target: firstWrapper!, + isIntersecting: true, + boundingClientRect: { height: 120 }, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ); + }); + await waitFor(() => + expect(firstWrapper?.dataset.timelineRowRealized).toBe("true"), + ); + expect(scrollTop).toBe(137); + + act(() => { + intersectionCallback?.( + [ + { + target: firstWrapper!, + isIntersecting: false, + boundingClientRect: { height: 212 }, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ); + }); + await waitFor(() => + expect(firstWrapper?.dataset.timelineRowRealized).toBe("false"), + ); + expect(firstWrapper?.style.height).toBe("212px"); + expect(scrollTop).toBe(17); + expect(setScrollTop).toHaveBeenCalledTimes(2); + + // While a scroll is active, a placeholder fully below the viewport + // realizes immediately: its height change cannot shift visible content, + // so no compensating scrollTop write happens. + scrollElement.dataset.scrollbarScrolling = "true"; + const belowViewportWrapper = container.querySelector( + '[data-timeline-row-id="message_40"]', + ); + expect(belowViewportWrapper?.dataset.timelineRowRealized).toBe("false"); + await act(async () => { + intersectionCallback?.( + [ + { + target: belowViewportWrapper!, + isIntersecting: true, + boundingClientRect: { top: 900, height: 120 }, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ); + }); + await waitFor(() => + expect(belowViewportWrapper?.dataset.timelineRowRealized).toBe("true"), + ); + expect(scrollTop).toBe(17); + expect(setScrollTop).toHaveBeenCalledTimes(2); + + // The first row has no donor placeholders above it, so its scroll-time + // realization reverts to the unchanged placeholder: nothing on screen + // moves, and no scrollTop write can kill momentum mid-gesture. + await act(async () => { + intersectionCallback?.( + [ + { + target: firstWrapper!, + isIntersecting: true, + boundingClientRect: { top: -400, height: 212 }, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ); + }); + expect(firstWrapper?.dataset.timelineRowRealized).toBe("false"); + expect(scrollTop).toBe(17); + expect(setScrollTop).toHaveBeenCalledTimes(2); + + // The idle pass mounts it with anchor compensation once the scroll + // stops: the visible row keeps its on-screen position. + scrollElement.removeAttribute("data-scrollbar-scrolling"); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 450)); + }); + expect(firstWrapper?.dataset.timelineRowRealized).toBe("true"); + expect(scrollTop).toBe(137); + expect(setScrollTop).toHaveBeenCalledTimes(3); + }); + + it("realizes above-viewport rows during a scroll by shrinking donor placeholders", () => { + let intersectionCallback: IntersectionObserverCallback | null = null; + vi.stubGlobal( + "IntersectionObserver", + class IntersectionObserverMock { + constructor(callback: IntersectionObserverCallback) { + intersectionCallback = callback; + } + observe() {} + unobserve() {} + disconnect() {} + }, + ); + const rows = Array.from({ length: 80 }, (_, index) => + conversationRow({ + id: `message_${index}`, + role: index % 2 === 0 ? "user" : "assistant", + text: `Timeline message ${index}`, + sourceSeqStart: index + 1, + sourceSeqEnd: index + 1, + threadId: "thr_large", + }), + ); + const scrollElement = document.createElement("div"); + const setScrollTop = vi.fn(); + Object.defineProperty(scrollElement, "scrollTop", { + configurable: true, + get: () => 500, + set: setScrollTop, + }); + Object.defineProperty(scrollElement, "scrollHeight", { + configurable: true, + value: 16_000, + }); + Object.defineProperty(scrollElement, "clientHeight", { + configurable: true, + value: 0, + }); + scrollElement.getBoundingClientRect = () => + ({ top: 0, bottom: 800 }) as DOMRect; + const bottomAnchor: BottomAnchorContextValue = { + captureScrollAnchor: vi.fn(), + getScrollElement: () => scrollElement, + isAtBottom: false, + scrollElementIntoView: vi.fn(), + scrollElementIntoViewClampedToMaxScroll: vi.fn(), + scrollToBottom: vi.fn(), + }; + + const { container } = renderWithRouter( + + + + + , + ); + + const wrapperOf = (id: string) => + container.querySelector(`[data-timeline-row-id="${id}"]`); + const target = wrapperOf("message_30"); + expect(target?.dataset.timelineRowRealized).toBe("false"); + // The realized content measures 500px against a 120px placeholder, so a + // 380px delta must come out of donor placeholders above it. + target!.getBoundingClientRect = () => ({ height: 500 }) as DOMRect; + + scrollElement.dataset.scrollbarScrolling = "true"; + act(() => { + intersectionCallback?.( + [ + { + target: target!, + isIntersecting: true, + boundingClientRect: { top: -600, height: 120 }, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ); + }); + + expect(target?.dataset.timelineRowRealized).toBe("true"); + // Donors shrink topmost-first: 120 + 120 + 120 + 20 covers the delta. + expect(wrapperOf("message_0")?.style.height).toBe("0px"); + expect(wrapperOf("message_1")?.style.height).toBe("0px"); + expect(wrapperOf("message_2")?.style.height).toBe("0px"); + expect(wrapperOf("message_3")?.style.height).toBe("100px"); + expect(wrapperOf("message_4")?.style.height).toBe("120px"); + // No scrollTop write happened, so momentum scrolling survives. + expect(setScrollTop).not.toHaveBeenCalled(); + }); + + it("grows a donor placeholder for short content and re-balances late growth", () => { + let intersectionCallback: IntersectionObserverCallback | null = null; + vi.stubGlobal( + "IntersectionObserver", + class IntersectionObserverMock { + constructor(callback: IntersectionObserverCallback) { + intersectionCallback = callback; + } + observe() {} + unobserve() {} + disconnect() {} + }, + ); + let resizeCallback: ResizeObserverCallback | null = null; + vi.stubGlobal( + "ResizeObserver", + class ResizeObserverMock { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback; + } + observe() {} + unobserve() {} + disconnect() {} + }, + ); + const rows = Array.from({ length: 80 }, (_, index) => + conversationRow({ + id: `message_${index}`, + role: index % 2 === 0 ? "user" : "assistant", + text: `Timeline message ${index}`, + sourceSeqStart: index + 1, + sourceSeqEnd: index + 1, + threadId: "thr_large", + }), + ); + const scrollElement = document.createElement("div"); + const setScrollTop = vi.fn(); + Object.defineProperty(scrollElement, "scrollTop", { + configurable: true, + get: () => 500, + set: setScrollTop, + }); + Object.defineProperty(scrollElement, "scrollHeight", { + configurable: true, + value: 16_000, + }); + Object.defineProperty(scrollElement, "clientHeight", { + configurable: true, + value: 0, + }); + scrollElement.getBoundingClientRect = () => + ({ top: 0, bottom: 800 }) as DOMRect; + const bottomAnchor: BottomAnchorContextValue = { + captureScrollAnchor: vi.fn(), + getScrollElement: () => scrollElement, + isAtBottom: false, + scrollElementIntoView: vi.fn(), + scrollElementIntoViewClampedToMaxScroll: vi.fn(), + scrollToBottom: vi.fn(), + }; + + const { container } = renderWithRouter( + + + + + , + ); + + const wrapperOf = (id: string) => + container.querySelector(`[data-timeline-row-id="${id}"]`); + const target = wrapperOf("message_30"); + expect(target?.dataset.timelineRowRealized).toBe("false"); + // The realized content measures 0px (the jsdom default) against a 120px + // placeholder, so the topmost donor absorbs the 120px of slack. + + scrollElement.dataset.scrollbarScrolling = "true"; + act(() => { + intersectionCallback?.( + [ + { + target: target!, + isIntersecting: true, + boundingClientRect: { top: -600, height: 120 }, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ); + }); + + expect(target?.dataset.timelineRowRealized).toBe("true"); + expect(wrapperOf("message_0")?.style.height).toBe("240px"); + expect(wrapperOf("message_1")?.style.height).toBe("120px"); + expect(setScrollTop).not.toHaveBeenCalled(); + + // Late growth while the scroll is active only updates the baseline — + // mutating geometry mid-gesture is what reads as snapping. + act(() => { + resizeCallback?.( + [ + { + target: target!, + borderBoxSize: [{ blockSize: 90, inlineSize: 390 }], + contentRect: { height: 90 }, + } as unknown as ResizeObserverEntry, + ], + {} as ResizeObserver, + ); + }); + expect(wrapperOf("message_0")?.style.height).toBe("240px"); + expect(setScrollTop).not.toHaveBeenCalled(); + + // At idle, further growth above the viewport compensates with a direct + // scrollTop nudge: 150 − 90 = 60 on top of the current 500. + scrollElement.removeAttribute("data-scrollbar-scrolling"); + act(() => { + resizeCallback?.( + [ + { + target: target!, + borderBoxSize: [{ blockSize: 150, inlineSize: 390 }], + contentRect: { height: 150 }, + } as unknown as ResizeObserverEntry, + ], + {} as ResizeObserver, + ); + }); + expect(target?.dataset.timelineRowRealized).toBe("true"); + expect(wrapperOf("message_0")?.style.height).toBe("240px"); + expect(setScrollTop).toHaveBeenCalledWith(560); + }); + + it("re-budgets estimate placeholders to the measured average at idle", async () => { + let intersectionCallback: IntersectionObserverCallback | null = null; + vi.stubGlobal( + "IntersectionObserver", + class IntersectionObserverMock { + constructor(callback: IntersectionObserverCallback) { + intersectionCallback = callback; + } + observe() {} + unobserve() {} + disconnect() {} + }, + ); + const rows = Array.from({ length: 80 }, (_, index) => + conversationRow({ + id: `message_${index}`, + role: index % 2 === 0 ? "user" : "assistant", + text: `Timeline message ${index}`, + sourceSeqStart: index + 1, + sourceSeqEnd: index + 1, + threadId: "thr_large", + }), + ); + const scrollElement = document.createElement("div"); + const setScrollTop = vi.fn(); + Object.defineProperty(scrollElement, "scrollTop", { + configurable: true, + get: () => 500, + set: setScrollTop, + }); + Object.defineProperty(scrollElement, "scrollHeight", { + configurable: true, + value: 16_000, + }); + Object.defineProperty(scrollElement, "clientHeight", { + configurable: true, + value: 0, + }); + scrollElement.getBoundingClientRect = () => + ({ top: 0, bottom: 800 }) as DOMRect; + const bottomAnchor: BottomAnchorContextValue = { + captureScrollAnchor: vi.fn(), + getScrollElement: () => scrollElement, + isAtBottom: false, + scrollElementIntoView: vi.fn(), + scrollElementIntoViewClampedToMaxScroll: vi.fn(), + scrollToBottom: vi.fn(), + }; + + const { container } = renderWithRouter( + + + + + , + ); + + const wrapperOf = (id: string) => + container.querySelector(`[data-timeline-row-id="${id}"]`); + + // Realize eight 500px rows above the viewport in one scroll-time batch: + // 8 × (500 − 120) = 3,040px of donor demand against the 3,600px pool in + // message_0..29 — solvent, so every row realizes with no scrollTop + // write. + scrollElement.dataset.scrollbarScrolling = "true"; + const targets = Array.from({ length: 8 }, (_, offset) => { + const wrapper = wrapperOf(`message_${30 + offset}`); + wrapper!.getBoundingClientRect = () => ({ height: 500 }) as DOMRect; + return wrapper!; + }); + act(() => { + intersectionCallback?.( + targets.map( + (target) => + ({ + target, + isIntersecting: true, + boundingClientRect: { top: -600, height: 120 }, + }) as unknown as IntersectionObserverEntry, + ), + {} as IntersectionObserver, + ); + }); + expect(wrapperOf("message_30")?.dataset.timelineRowRealized).toBe("true"); + expect(wrapperOf("message_0")?.style.height).toBe("0px"); + expect(wrapperOf("message_26")?.style.height).toBe("120px"); + expect(setScrollTop).not.toHaveBeenCalled(); + + // Once the scroll idles, never-realized placeholders re-seed to the + // measured 500px average, refilling the drained donor pool. + scrollElement.removeAttribute("data-scrollbar-scrolling"); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 450)); + }); + expect(wrapperOf("message_0")?.style.height).toBe("500px"); + expect(wrapperOf("message_26")?.style.height).toBe("500px"); + expect(wrapperOf("message_50")?.style.height).toBe("500px"); + expect(setScrollTop).not.toHaveBeenCalled(); + }); + + it("releases the oldest interaction pin once the cap is exceeded", async () => { + let intersectionCallback: IntersectionObserverCallback | null = null; + vi.stubGlobal( + "IntersectionObserver", + class IntersectionObserverMock { + constructor(callback: IntersectionObserverCallback) { + intersectionCallback = callback; + } + observe() {} + unobserve() {} + disconnect() {} + }, + ); + const rows = Array.from({ length: 80 }, (_, index) => + conversationRow({ + id: `message_${index}`, + role: index % 2 === 0 ? "user" : "assistant", + text: `Timeline message ${index}`, + sourceSeqStart: index + 1, + sourceSeqEnd: index + 1, + threadId: "thr_large", + }), + ); + const scrollElement = document.createElement("div"); + const bottomAnchor: BottomAnchorContextValue = { + captureScrollAnchor: vi.fn(), + getScrollElement: () => scrollElement, + isAtBottom: true, + scrollElementIntoView: vi.fn(), + scrollElementIntoViewClampedToMaxScroll: vi.fn(), + scrollToBottom: vi.fn(), + }; + + const { container } = renderWithRouter( + + + + + , + ); + + const wrapperOf = (id: string) => + container.querySelector(`[data-timeline-row-id="${id}"]`); + expect(wrapperOf("message_66")?.dataset.timelineRowRealized).toBe("true"); + expect(wrapperOf("message_65")?.dataset.timelineRowRealized).toBe("true"); + + // Pin message_66 first, then message_65, then 23 more rows. The 25th pin + // exceeds the cap of 24, so the oldest pin (message_66) releases. + fireEvent.click(wrapperOf("message_66")!); + fireEvent.click(wrapperOf("message_65")!); + for (let index = 0; index < 23; index += 1) { + fireEvent.click(wrapperOf(`message_${index}`)!); + } + + const exitEntry = (id: string) => + ({ + target: wrapperOf(id)!, + isIntersecting: false, + boundingClientRect: { height: 100 }, + }) as unknown as IntersectionObserverEntry; + act(() => { + intersectionCallback?.( + [exitEntry("message_66"), exitEntry("message_65")], + {} as IntersectionObserver, + ); + }); + await waitFor(() => + expect(wrapperOf("message_66")?.dataset.timelineRowRealized).toBe( + "false", + ), + ); + // The still-pinned row survives the same exit. + expect(wrapperOf("message_65")?.dataset.timelineRowRealized).toBe("true"); + }); + + it("keeps an interacted row mounted after it leaves the window", async () => { + let intersectionCallback: IntersectionObserverCallback | null = null; + vi.stubGlobal( + "IntersectionObserver", + class IntersectionObserverMock { + constructor(callback: IntersectionObserverCallback) { + intersectionCallback = callback; + } + observe() {} + unobserve() {} + disconnect() {} + }, + ); + const rows = [ + turnRow({ + id: "expandable_turn", + children: [ + conversationRow({ + id: "expanded_child", + role: "assistant", + text: "Expanded row state stays mounted.", + threadId: "thr_large", + }), + ], + threadId: "thr_large", + }), + ...Array.from({ length: 79 }, (_, index) => + conversationRow({ + id: `message_${index + 1}`, + role: index % 2 === 0 ? "user" : "assistant", + text: `Timeline message ${index + 1}`, + sourceSeqStart: index + 20, + sourceSeqEnd: index + 20, + threadId: "thr_large", + }), + ), + ]; + const scrollElement = document.createElement("div"); + const bottomAnchor: BottomAnchorContextValue = { + captureScrollAnchor: vi.fn(), + getScrollElement: () => scrollElement, + isAtBottom: true, + scrollElementIntoView: vi.fn(), + scrollElementIntoViewClampedToMaxScroll: vi.fn(), + scrollToBottom: vi.fn(), + }; + + const { container } = renderWithRouter( + + + + + , + ); + + const wrapper = container.querySelector( + '[data-timeline-row-id="expandable_turn"]', + ); + expect(wrapper?.dataset.timelineRowRealized).toBe("false"); + + await act(async () => { + intersectionCallback?.( + [ + { + target: wrapper!, + isIntersecting: true, + boundingClientRect: { height: 120 }, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ); + }); + const toggle = await waitFor(() => { + const element = wrapper?.querySelector( + 'button[aria-expanded="false"]', + ); + if (element === null || element === undefined) { + throw new Error("The realized row toggle was not rendered"); + } + return element; + }); + fireEvent.click(toggle); + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + expect(screen.getByText("Expanded row state stays mounted.")).toBeTruthy(); + + act(() => { + intersectionCallback?.( + [ + { + target: wrapper!, + isIntersecting: false, + boundingClientRect: { height: 212 }, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ); + }); + await waitFor(() => + expect(wrapper?.dataset.timelineRowRealized).toBe("true"), + ); + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + expect(screen.getByText("Expanded row state stays mounted.")).toBeTruthy(); + }); + + it("uses a saved anchor when search state belongs to another thread", () => { + vi.stubGlobal( + "IntersectionObserver", + class IntersectionObserverMock { + constructor(_callback: IntersectionObserverCallback) {} + observe() {} + unobserve() {} + disconnect() {} + }, + ); + getDefaultStore().set(threadTimelineScrollAnchorAtomFamily("thr_large"), { + rowId: "message_60", + offsetWithinRow: 0, + atBottom: false, + }); + const rows = Array.from({ length: 80 }, (_, index) => + conversationRow({ + id: `message_${index}`, + role: index % 2 === 0 ? "user" : "assistant", + text: `Timeline message ${index}`, + sourceSeqStart: index + 1, + sourceSeqEnd: index + 1, + threadId: "thr_large", + }), + ); + const scrollElement = document.createElement("div"); + const bottomAnchor: BottomAnchorContextValue = { + captureScrollAnchor: vi.fn(), + getScrollElement: () => scrollElement, + isAtBottom: false, + scrollElementIntoView: vi.fn(), + scrollElementIntoViewClampedToMaxScroll: vi.fn(), + scrollToBottom: vi.fn(), + }; + + const { container } = renderWithRouter( + + + + + , + [ + { + pathname: "/thread", + state: { searchMessageSeq: 11, searchThreadId: "thr_other" }, + }, + ], + ); + + expect( + container.querySelector( + '[data-timeline-row-id="message_60"]', + )?.dataset.timelineRowRealized, + ).toBe("true"); + expect( + container.querySelector( + '[data-timeline-row-id="message_10"]', + )?.dataset.timelineRowRealized, + ).toBe("false"); + }); + + it("realizes a search target before it reveals a windowed timeline row", async () => { + vi.stubGlobal( + "IntersectionObserver", + class IntersectionObserverMock { + constructor(_callback: IntersectionObserverCallback) {} + observe() {} + unobserve() {} + disconnect() {} + }, + ); + vi.stubGlobal( + "ResizeObserver", + class ResizeObserverMock { + constructor(_callback: ResizeObserverCallback) {} + observe() {} + unobserve() {} + disconnect() {} + }, + ); + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + callback(performance.now()); + return 1; + }); + vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {}); + + const rows = Array.from({ length: 80 }, (_, index) => + conversationRow({ + id: `search_message_${index}`, + role: index % 2 === 0 ? "user" : "assistant", + text: `Search timeline message ${index}`, + sourceSeqStart: index + 1, + sourceSeqEnd: index + 1, + threadId: "thr_large_search", + }), + ); + const scrollElement = document.createElement("div"); + Object.defineProperty(scrollElement, "clientHeight", { + configurable: true, + value: 800, + }); + const scrollElementIntoView = vi.fn(); + const bottomAnchor: BottomAnchorContextValue = { + captureScrollAnchor: vi.fn(), + getScrollElement: () => scrollElement, + isAtBottom: false, + scrollElementIntoView, + scrollElementIntoViewClampedToMaxScroll: vi.fn(), + scrollToBottom: vi.fn(), + }; + + const { container } = renderWithRouter( + + + + + , + [ + { + pathname: "/thread", + state: { + searchMessageSeq: 11, + searchThreadId: "thr_large_search", + }, + }, + ], + ); + + const target = container.querySelector( + '[data-timeline-row-id="search_message_10"]', + ); + expect(target?.dataset.timelineRowRealized).toBe("true"); + await waitFor(() => + expect(scrollElementIntoView).toHaveBeenCalledWith({ + element: target, + options: { block: "center" }, + }), + ); + }); + it("uses inline mobile actions only for the last assistant message", () => { const { container } = renderWithRouter( void; + realize: () => void; + /** Revert a just-realized item to its unchanged placeholder. */ + derealize: () => void; + /** The current placeholder height, or null while the item is realized. */ + peekPlaceholderHeight: () => number | null; + /** Grow or shrink the placeholder (clamped at zero) to absorb a height delta. */ + adjustPlaceholderHeight: (delta: number) => void; + /** + * Whether the item has ever mounted its real content. A false value means + * its placeholder height is still a pure estimate, eligible for + * re-budgeting; a true value means the placeholder carries a measurement. + */ + hasEverRealized: () => boolean; + /** Release an interaction pin so the row can derealize on its next exit. */ + unpin: () => void; +} + +interface TimelineVisibleAnchor { + element: HTMLDivElement; + top: number; +} + +interface TimelineWindowedListItemProps { + alwaysRealized: boolean; + children: ReactNode; + estimatedHeight: number; + initiallyRealized: boolean; + itemKey: string; + registerWrapper: (key: string, node: HTMLDivElement | null) => void; + registerController: ( + key: string, + controller: TimelineWindowItemController | null, + ) => void; + registerInteractionPin: (key: string) => void; + rowId: string | undefined; +} + interface TimelineUnreadDividerProps { autoScroll: boolean; } @@ -401,6 +445,86 @@ type TimelineRowsListItem = id: "thread-unread-divider"; }; +const TIMELINE_WINDOWING_MIN_ITEM_COUNT = 40; +const TIMELINE_WINDOW_MARGIN_PX = 1_000; +const TIMELINE_WINDOW_FALLBACK_VIEWPORT_HEIGHT_PX = 800; +// Interaction pins keep rows mounted so their local state (expansion, +// long-message reveal) survives eviction. Cap them so a long session of taps +// cannot keep every heavy row realized forever: the oldest pin releases +// first, and its row derealizes on its next window exit. +const TIMELINE_WINDOW_MAX_INTERACTION_PINS = 24; +// Re-budgeting replaces never-measured placeholder estimates with the running +// average of measured rows once the scroll idles, so the donor pool tracks +// the timeline's real heights instead of draining monotonically. +const TIMELINE_WINDOW_REBUDGET_MIN_SAMPLES = 8; +const TIMELINE_WINDOW_REBUDGET_DRIFT_PX = 24; +const TIMELINE_WINDOW_REBUDGET_IDLE_DELAY_MS = 300; +const TIMELINE_WINDOW_REBUDGET_MAX_ESTIMATE_PX = 1_000; + +function timelineListItemKey(item: TimelineRowsListItem): string { + return item.kind === "row" ? item.row.id : item.id; +} + +function estimateTimelineListItemHeight(item: TimelineRowsListItem): number { + if (item.kind === "unread-divider") { + return 24; + } + if (item.row.kind === "conversation") { + return 120; + } + return 40; +} + +function collectTimelineWindowKeys({ + centerIndex, + items, +}: { + centerIndex: number; + items: readonly TimelineRowsListItem[]; +}): ReadonlySet { + if (items.length === 0) { + return EMPTY_ROW_ID_SET; + } + + const keys = new Set(); + const boundedCenterIndex = Math.max( + 0, + Math.min(centerIndex, items.length - 1), + ); + const coverage = + TIMELINE_WINDOW_MARGIN_PX + TIMELINE_WINDOW_FALLBACK_VIEWPORT_HEIGHT_PX; + + let beforeHeight = 0; + for ( + let index = boundedCenterIndex; + index >= 0 && beforeHeight <= coverage; + index -= 1 + ) { + const item = items[index]; + if (item === undefined) { + break; + } + keys.add(timelineListItemKey(item)); + beforeHeight += estimateTimelineListItemHeight(item); + } + + let afterHeight = 0; + for ( + let index = boundedCenterIndex + 1; + index < items.length && afterHeight <= coverage; + index += 1 + ) { + const item = items[index]; + if (item === undefined) { + break; + } + keys.add(timelineListItemKey(item)); + afterHeight += estimateTimelineListItemHeight(item); + } + + return keys; +} + interface ConversationRowProps { row: TimelineConversationViewRow; showAssistantMessageActions: boolean; @@ -1843,6 +1967,191 @@ function buildTimelineRowsListItems({ return items; } +function TimelineWindowedListItem({ + alwaysRealized, + children, + estimatedHeight, + initiallyRealized, + itemKey, + registerWrapper, + registerController, + registerInteractionPin, + rowId, +}: TimelineWindowedListItemProps) { + const [locallyRealized, setLocallyRealized] = useState(initiallyRealized); + const [placeholderHeight, setPlaceholderHeight] = useState(estimatedHeight); + // Mirrors the placeholder-height state so the window controller can read + // and adjust it synchronously between two flushSync commits in one task. + const placeholderHeightRef = useRef(estimatedHeight); + const locallyRealizedRef = useRef(locallyRealized); + const alwaysRealizedRef = useRef(alwaysRealized); + const everRealizedRef = useRef(initiallyRealized || alwaysRealized); + const interactionPinnedRef = useRef(false); + const lastIntersectionRef = useRef(null); + useLayoutEffect(() => { + locallyRealizedRef.current = locallyRealized; + alwaysRealizedRef.current = alwaysRealized; + }, [alwaysRealized, locallyRealized]); + + const updateLocallyRealized = useCallback((next: boolean) => { + if (next) { + everRealizedRef.current = true; + } + if (locallyRealizedRef.current === next) { + return; + } + locallyRealizedRef.current = next; + setLocallyRealized(next); + }, []); + const applyPlaceholderHeight = useCallback((next: number) => { + placeholderHeightRef.current = next; + setPlaceholderHeight(next); + }, []); + const controller = useMemo( + () => ({ + handleIntersection: (entry) => { + lastIntersectionRef.current = entry.isIntersecting; + if (entry.isIntersecting) { + updateLocallyRealized(true); + return; + } + if (entry.boundingClientRect.height > 0) { + applyPlaceholderHeight(entry.boundingClientRect.height); + } + if (!alwaysRealizedRef.current && !interactionPinnedRef.current) { + updateLocallyRealized(false); + } + }, + // Applies a realization outside an intersection entry (scroll-time + // realization or the idle flush). The item is intersecting when this + // runs, so the last-intersection state matches what handleIntersection + // would have set. + realize: () => { + lastIntersectionRef.current = true; + updateLocallyRealized(true); + }, + derealize: () => { + updateLocallyRealized(false); + }, + peekPlaceholderHeight: () => + alwaysRealizedRef.current || locallyRealizedRef.current + ? null + : placeholderHeightRef.current, + adjustPlaceholderHeight: (delta) => { + applyPlaceholderHeight( + Math.max(0, placeholderHeightRef.current + delta), + ); + }, + hasEverRealized: () => everRealizedRef.current, + unpin: () => { + interactionPinnedRef.current = false; + }, + }), + [applyPlaceholderHeight, updateLocallyRealized], + ); + useLayoutEffect(() => { + registerController(itemKey, controller); + return () => registerController(itemKey, null); + }, [controller, itemKey, registerController]); + useLayoutEffect(() => { + if ( + !alwaysRealized && + lastIntersectionRef.current === false && + !interactionPinnedRef.current + ) { + const frame = requestAnimationFrame(() => updateLocallyRealized(false)); + return () => cancelAnimationFrame(frame); + } + }, [alwaysRealized, updateLocallyRealized]); + + const pinInteractedItem = useCallback(() => { + interactionPinnedRef.current = true; + // Reporting every interaction (not just the first) keeps the list-level + // pin order in recency order, so the least-recently-touched pin evicts. + registerInteractionPin(itemKey); + }, [itemKey, registerInteractionPin]); + const handleWrapperRef = useCallback( + (node: HTMLDivElement | null) => registerWrapper(itemKey, node), + [itemKey, registerWrapper], + ); + const isRealized = alwaysRealized || locallyRealized; + + return ( +
+ {isRealized ? children : null} +
+ ); +} + +function captureTimelineVisibleAnchor({ + orderedKeys, + scrollElement, + wrapperByKey, +}: { + orderedKeys: readonly string[]; + scrollElement: HTMLElement; + wrapperByKey: ReadonlyMap; +}): TimelineVisibleAnchor | null { + const scrollRect = scrollElement.getBoundingClientRect(); + let low = 0; + let high = orderedKeys.length - 1; + let firstVisibleIndex = orderedKeys.length; + while (low <= high) { + const middle = Math.floor((low + high) / 2); + const key = orderedKeys[middle]; + const element = key === undefined ? undefined : wrapperByKey.get(key); + if (element === undefined) { + return null; + } + if (element.getBoundingClientRect().bottom > scrollRect.top) { + firstVisibleIndex = middle; + high = middle - 1; + } else { + low = middle + 1; + } + } + const key = orderedKeys[firstVisibleIndex]; + const element = key === undefined ? undefined : wrapperByKey.get(key); + if (element === undefined) { + return null; + } + const rect = element.getBoundingClientRect(); + return rect.top < scrollRect.bottom ? { element, top: rect.top } : null; +} + +function restoreTimelineVisibleAnchor({ + anchor, + scrollElement, + wasAtBottom, +}: { + anchor: TimelineVisibleAnchor | null; + scrollElement: HTMLElement; + wasAtBottom: boolean; +}): void { + if (wasAtBottom) { + scrollElement.scrollTop = Math.max( + 0, + scrollElement.scrollHeight - scrollElement.clientHeight, + ); + return; + } + if (anchor === null || !anchor.element.isConnected) { + return; + } + const topDelta = anchor.element.getBoundingClientRect().top - anchor.top; + if (Math.abs(topDelta) > 0.5) { + scrollElement.scrollTop += topDelta; + } +} + function TimelineRowsList({ compactActivityIntents, hasOlderTimelineRows, @@ -1857,13 +2166,12 @@ function TimelineRowsList({ unreadDividerPlacement, }: TimelineRowsListProps) { const { threadId } = useTimelineRendererStaticContext(); + const isCompactViewport = useIsCompactViewport(); + const bottomAnchor = useBottomAnchoredScroll(); + const store = useStore(); + const location = useLocation(); const searchExpandedRowIds = useTimelineSearchExpansionRowIds(rows); const stableSearchExpandedRowIds = useStableReadonlySet(searchExpandedRowIds); - useScrollToSearchedMessage(rows, threadId, { - hasOlderRows: hasOlderTimelineRows, - isLoadingOlderRows: isLoadingOlderTimelineRows, - onLoadOlderRows, - }); const activeLatestBundleId = useMemo( () => findActiveLatestBundleId(rows), [rows], @@ -1872,7 +2180,643 @@ function TimelineRowsList({ () => buildTimelineRowsListItems({ rows, unreadDividerPlacement }), [rows, unreadDividerPlacement], ); - return ( + const itemKeys = useMemo(() => items.map(timelineListItemKey), [items]); + const shouldWindow = + spacing === "top-level" && + isCompactViewport && + bottomAnchor !== null && + typeof IntersectionObserver !== "undefined" && + items.length >= TIMELINE_WINDOWING_MIN_ITEM_COUNT; + const searchTarget = useMemo( + () => readSearchMessageTarget(location.state), + [location.state], + ); + const searchTargetsTimeline = + searchTarget !== null && + (threadId === undefined || + searchTarget.threadId === null || + searchTarget.threadId === threadId); + const initialWindowCenterIndex = useMemo(() => { + if (searchTarget !== null && searchTargetsTimeline) { + const searchIndex = items.findIndex( + (item) => + item.kind === "row" && + item.row.sourceSeqStart <= searchTarget.seq && + searchTarget.seq <= item.row.sourceSeqEnd, + ); + if (searchIndex >= 0) { + return searchIndex; + } + } + + if (unreadDividerAutoScroll) { + const dividerIndex = items.findIndex( + (item) => item.kind === "unread-divider", + ); + if (dividerIndex >= 0) { + return dividerIndex; + } + } + + if (threadId !== undefined) { + const anchor = store.get(threadTimelineScrollAnchorAtomFamily(threadId)); + if (anchor !== null && !anchor.atBottom && anchor.rowId.length > 0) { + const anchorIndex = items.findIndex( + (item) => item.kind === "row" && item.row.id === anchor.rowId, + ); + if (anchorIndex >= 0) { + return anchorIndex; + } + } + } + + return Math.max(0, items.length - 1); + }, [ + items, + searchTarget, + searchTargetsTimeline, + store, + threadId, + unreadDividerAutoScroll, + ]); + const initiallyRealizedKeys = useMemo( + () => + shouldWindow + ? collectTimelineWindowKeys({ + centerIndex: initialWindowCenterIndex, + items, + }) + : EMPTY_ROW_ID_SET, + [initialWindowCenterIndex, items, shouldWindow], + ); + const wrapperByKeyRef = useRef(new Map()); + const keyByWrapperRef = useRef(new Map()); + const controllerByKeyRef = useRef( + new Map(), + ); + const intersectionObserverRef = useRef(null); + const resizeObserverRef = useRef(null); + // Last committed content height of each realized wrapper. Late growth + // (lazy images, async rendering) diffs against this baseline so it can be + // compensated the same way as the mount-time delta. + const realizedHeightByKeyRef = useRef(new Map()); + // Interaction-pinned keys in recency order, capped so pins cannot + // accumulate without bound over a long session. + const pinnedKeysRef = useRef([]); + // The observers outlive row streaming, so their callbacks read the current + // key order through this ref instead of re-creating per rows change. + const itemKeysRef = useRef(itemKeys); + useLayoutEffect(() => { + itemKeysRef.current = itemKeys; + }, [itemKeys]); + const alwaysRealizedKeys = useMemo(() => { + if (!shouldWindow) { + return EMPTY_ROW_ID_SET; + } + const keys = new Set(); + if (searchTarget !== null && searchTargetsTimeline) { + const searchIndex = items.findIndex( + (item) => + item.kind === "row" && + item.row.sourceSeqStart <= searchTarget.seq && + searchTarget.seq <= item.row.sourceSeqEnd, + ); + const searchItem = items[searchIndex]; + if (searchItem !== undefined) { + keys.add(timelineListItemKey(searchItem)); + } + } + if (unreadDividerAutoScroll) { + const dividerIndex = items.findIndex( + (item) => item.kind === "unread-divider", + ); + const divider = items[dividerIndex]; + if (divider !== undefined) { + keys.add(timelineListItemKey(divider)); + } + } + return keys; + }, [ + items, + searchTarget, + searchTargetsTimeline, + shouldWindow, + unreadDividerAutoScroll, + ]); + + const registerWrapper = useCallback( + (key: string, node: HTMLDivElement | null) => { + const previous = wrapperByKeyRef.current.get(key); + if (previous !== undefined && previous !== node) { + keyByWrapperRef.current.delete(previous); + intersectionObserverRef.current?.unobserve(previous); + resizeObserverRef.current?.unobserve(previous); + } + if (node === null) { + wrapperByKeyRef.current.delete(key); + realizedHeightByKeyRef.current.delete(key); + return; + } + wrapperByKeyRef.current.set(key, node); + keyByWrapperRef.current.set(node, key); + intersectionObserverRef.current?.observe(node); + resizeObserverRef.current?.observe(node); + }, + [], + ); + const registerController = useCallback( + (key: string, controller: TimelineWindowItemController | null) => { + if (controller === null) { + controllerByKeyRef.current.delete(key); + return; + } + controllerByKeyRef.current.set(key, controller); + }, + [], + ); + const registerInteractionPin = useCallback((key: string) => { + const pinned = pinnedKeysRef.current; + const existing = pinned.indexOf(key); + if (existing >= 0) { + pinned.splice(existing, 1); + } + pinned.push(key); + while (pinned.length > TIMELINE_WINDOW_MAX_INTERACTION_PINS) { + const evicted = pinned.shift(); + if (evicted !== undefined) { + controllerByKeyRef.current.get(evicted)?.unpin(); + } + } + }, []); + + const getScrollElement = bottomAnchor?.getScrollElement; + useEffect(() => { + if (!shouldWindow || typeof IntersectionObserver === "undefined") { + return; + } + const scrollElement = getScrollElement?.() ?? null; + if (scrollElement === null) { + return; + } + const realizedHeightByKey = realizedHeightByKeyRef.current; + + const applyWithScrollCompensation = (apply: () => void) => { + const maxScrollTop = Math.max( + 0, + scrollElement.scrollHeight - scrollElement.clientHeight, + ); + const wasAtBottom = maxScrollTop - scrollElement.scrollTop <= 2; + const anchor = wasAtBottom + ? null + : captureTimelineVisibleAnchor({ + scrollElement, + orderedKeys: itemKeysRef.current, + wrapperByKey: wrapperByKeyRef.current, + }); + flushSync(apply); + restoreTimelineVisibleAnchor({ anchor, scrollElement, wasAtBottom }); + }; + + // Distributes one above-viewport height delta into placeholder donors + // that sit earlier in the list (and therefore also fully above the + // viewport). Positive deltas shrink donors topmost-first so placeholders + // nearest the viewport keep accurate heights; negative deltas hand the + // slack to the topmost donor. All-or-nothing: donors change only when + // the whole delta is covered, so the swap is exactly net zero. Must run + // inside flushSync so donor commits paint atomically with the change + // they balance. + const tryCompensateAboveViewportDelta = ( + candidateIndex: number, + delta: number, + ): boolean => { + const keys = itemKeysRef.current; + if (delta < 0) { + for (let index = 0; index < candidateIndex; index += 1) { + const key = keys[index]; + if (key === undefined) { + continue; + } + const controller = controllerByKeyRef.current.get(key); + if ((controller?.peekPlaceholderHeight() ?? null) !== null) { + controller?.adjustPlaceholderHeight(-delta); + return true; + } + } + return false; + } + let remaining = delta; + const takes: Array<[TimelineWindowItemController, number]> = []; + for ( + let index = 0; + index < candidateIndex && remaining > 0.5; + index += 1 + ) { + const key = keys[index]; + if (key === undefined) { + continue; + } + const controller = controllerByKeyRef.current.get(key); + if (controller === undefined) { + continue; + } + const available = controller.peekPlaceholderHeight(); + if (available === null || available <= 0) { + continue; + } + const take = Math.min(available, remaining); + takes.push([controller, take]); + remaining -= take; + } + if (remaining > 0.5) { + return false; + } + for (const [controller, take] of takes) { + controller.adjustPlaceholderHeight(-take); + } + return true; + }; + + // Realizations that found no donor capacity while scrolling. They keep + // their unchanged placeholders (nothing on screen moves) and mount at + // the next idle pass, where a compensating scrollTop write is free. + // During an active scroll the ONLY geometry this component changes is + // the exact net-zero donor swap — no scrollTop writes, no tolerated + // drift. Anything that cannot satisfy that invariant waits for idle. + const pendingRealizeKeys = new Set(); + + // Running average of first-measured item heights. Never-realized + // placeholders start from a static estimate that real content usually + // exceeds, so donor capacity would otherwise drain monotonically during + // upward scrolling. Re-budgeting those estimates to the measured average + // at idle keeps the donor pool solvent, which keeps insolvency reverts + // (and their brief near-top blanks) rare. + const measuredSampleKeys = new Set(); + let measuredSampleSum = 0; + let lastRebudgetAverage: number | null = null; + const computeRebudgetAdjustments = (): Array< + [TimelineWindowItemController, number] + > => { + if (measuredSampleKeys.size < TIMELINE_WINDOW_REBUDGET_MIN_SAMPLES) { + return []; + } + const average = Math.min( + TIMELINE_WINDOW_REBUDGET_MAX_ESTIMATE_PX, + measuredSampleSum / measuredSampleKeys.size, + ); + if ( + lastRebudgetAverage !== null && + Math.abs(average - lastRebudgetAverage) < + TIMELINE_WINDOW_REBUDGET_DRIFT_PX + ) { + return []; + } + lastRebudgetAverage = average; + const adjustments: Array<[TimelineWindowItemController, number]> = []; + for (const key of itemKeysRef.current) { + const controller = controllerByKeyRef.current.get(key); + if (controller === undefined || controller.hasEverRealized()) { + continue; + } + const current = controller.peekPlaceholderHeight(); + if (current === null) { + continue; + } + const delta = average - current; + if (Math.abs(delta) < 1) { + continue; + } + adjustments.push([controller, delta]); + } + return adjustments; + }; + + // One idle pass covers both deferred works: mount the insolvent + // realizations and re-seed estimate placeholders, in a single + // anchor-compensated commit. + const runIdlePass = () => { + const pending = [...pendingRealizeKeys]; + pendingRealizeKeys.clear(); + const adjustments = computeRebudgetAdjustments(); + if (pending.length === 0 && adjustments.length === 0) { + return; + } + applyWithScrollCompensation(() => { + for (const key of pending) { + controllerByKeyRef.current.get(key)?.realize(); + } + for (const [controller, delta] of adjustments) { + controller.adjustPlaceholderHeight(delta); + } + }); + }; + let idlePassTimeout: number | null = null; + const scheduleIdlePass = () => { + if (idlePassTimeout !== null) { + window.clearTimeout(idlePassTimeout); + } + idlePassTimeout = window.setTimeout(() => { + idlePassTimeout = null; + if (scrollElement.dataset.scrollbarScrolling === "true") { + scheduleIdlePass(); + return; + } + runIdlePass(); + }, TIMELINE_WINDOW_REBUDGET_IDLE_DELAY_MS); + }; + const recordMeasuredSample = (key: string, height: number) => { + if (height <= 0 || measuredSampleKeys.has(key)) { + return; + } + measuredSampleKeys.add(key); + measuredSampleSum += height; + scheduleIdlePass(); + }; + + // Realize items whose top sits above the viewport while a scroll is + // active. Mount their content in one synchronous commit, measure each + // realized height, then balance every height delta against placeholder + // donors in a second commit in the same task (so nothing paints in + // between). The net height change above the viewport stays zero, which + // keeps visible content still without a momentum-killing scrollTop + // write. Donor placeholders self-correct: they re-measure whenever they + // realize or derealize later. + const realizeAboveViewportDuringScroll = (keys: readonly string[]) => { + const itemKeysNow = itemKeysRef.current; + const candidates = keys.flatMap((key) => { + const controller = controllerByKeyRef.current.get(key); + const wrapper = wrapperByKeyRef.current.get(key); + const index = itemKeysNow.indexOf(key); + const placeholderHeight = controller?.peekPlaceholderHeight() ?? null; + if ( + controller === undefined || + wrapper === undefined || + index < 0 || + placeholderHeight === null + ) { + return []; + } + return [{ controller, index, key, placeholderHeight, wrapper }]; + }); + if (candidates.length === 0) { + return; + } + + flushSync(() => { + for (const candidate of candidates) { + candidate.controller.realize(); + } + }); + + flushSync(() => { + for (const candidate of candidates) { + const measured = candidate.wrapper.getBoundingClientRect().height; + recordMeasuredSample(candidate.key, measured); + const delta = measured - candidate.placeholderHeight; + if ( + delta !== 0 && + !tryCompensateAboveViewportDelta(candidate.index, delta) + ) { + // No donor capacity (near the top of the timeline): revert to + // the unchanged placeholder — net zero, nothing painted in + // between — and mount at the idle pass instead. + candidate.controller.derealize(); + pendingRealizeKeys.add(candidate.key); + scheduleIdlePass(); + continue; + } + realizedHeightByKey.set(candidate.key, measured); + } + }); + }; + + const observer = new IntersectionObserver( + (entries) => { + // A programmatic scrollTop write stops WebKit's native momentum + // scrolling, and WebKit — the only engine this compact-viewport list + // runs on for mobile — has no scroll anchoring to absorb layout + // shifts. While a scroll is active (the scroll owner keeps this + // marker present through the inertial tail), apply derealizations + // and at-or-below-viewport realizations directly: derealizations + // swap in their just-measured height, and an item whose top edge is + // at or below the viewport top only pushes layout below that edge + // when it grows. Items whose top is above the viewport realize with + // donor compensation instead. + if (scrollElement.dataset.scrollbarScrolling === "true") { + let viewportTop: number | null = null; + const growUpKeys: string[] = []; + for (const entry of entries) { + const key = keyByWrapperRef.current.get(entry.target); + if (key === undefined) { + continue; + } + const controller = controllerByKeyRef.current.get(key); + if (controller === undefined) { + continue; + } + if (!entry.isIntersecting) { + realizedHeightByKey.delete(key); + pendingRealizeKeys.delete(key); + controller.handleIntersection(entry); + continue; + } + const alreadyRealized = + wrapperByKeyRef.current.get(key)?.dataset + .timelineRowRealized === "true"; + if (alreadyRealized) { + controller.handleIntersection(entry); + continue; + } + // rootBounds already includes the rootMargin expansion; strip it + // to recover the true viewport edge without forcing a layout. + const rootTop = entry.rootBounds?.top; + viewportTop ??= + rootTop !== undefined + ? rootTop + TIMELINE_WINDOW_MARGIN_PX + : scrollElement.getBoundingClientRect().top; + if (entry.boundingClientRect.top >= viewportTop) { + controller.handleIntersection(entry); + continue; + } + growUpKeys.push(key); + } + if (growUpKeys.length > 0) { + realizeAboveViewportDuringScroll(growUpKeys); + } + return; + } + + applyWithScrollCompensation(() => { + for (const entry of entries) { + const key = keyByWrapperRef.current.get(entry.target); + if (key === undefined) { + continue; + } + if (entry.isIntersecting) { + pendingRealizeKeys.delete(key); + } else { + realizedHeightByKey.delete(key); + pendingRealizeKeys.delete(key); + } + controllerByKeyRef.current.get(key)?.handleIntersection(entry); + } + }); + }, + { + root: scrollElement, + rootMargin: `${TIMELINE_WINDOW_MARGIN_PX}px 0px`, + }, + ); + intersectionObserverRef.current = observer; + + // Late content growth in a realized row above the viewport (a lazy image + // decoding, async rendering settling) shifts visible content, because + // WebKit has no scroll anchoring. Compensate with a direct scrollTop + // nudge — but only while the scroll is idle, where the write is free. + // During an active scroll only the baseline updates: mutating geometry + // mid-gesture is exactly what reads as snapping. Growth in or below the + // viewport stays uncompensated: a visible image expanding in place is + // expected content behavior. + let contentGrowthObserver: ResizeObserver | null = null; + if (typeof ResizeObserver !== "undefined") { + contentGrowthObserver = new ResizeObserver((entries) => { + let viewportTop: number | null = null; + let idleAdjustment = 0; + const scrolling = + scrollElement.dataset.scrollbarScrolling === "true"; + for (const entry of entries) { + const key = keyByWrapperRef.current.get(entry.target); + if (key === undefined) { + continue; + } + const wrapper = wrapperByKeyRef.current.get(key); + if (wrapper === undefined) { + continue; + } + if (wrapper.dataset.timelineRowRealized !== "true") { + // Placeholder height changes are this component's own writes + // (donor adjustments, derealization measurements) — already + // balanced, never compensated again. + realizedHeightByKey.delete(key); + continue; + } + const height = + entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height; + const previous = realizedHeightByKey.get(key); + realizedHeightByKey.set(key, height); + recordMeasuredSample(key, height); + if (previous === undefined || scrolling) { + // First observation after an already-balanced realization, or a + // mid-scroll change that must not mutate geometry. + continue; + } + const delta = height - previous; + if (Math.abs(delta) < 0.5) { + continue; + } + viewportTop ??= scrollElement.getBoundingClientRect().top; + if (wrapper.getBoundingClientRect().bottom > viewportTop) { + continue; + } + idleAdjustment += delta; + } + if (idleAdjustment !== 0) { + scrollElement.scrollTop = Math.max( + 0, + scrollElement.scrollTop + idleAdjustment, + ); + } + }); + } + resizeObserverRef.current = contentGrowthObserver; + + for (const wrapper of wrapperByKeyRef.current.values()) { + observer.observe(wrapper); + contentGrowthObserver?.observe(wrapper); + } + // Scroll events push the pending idle pass out to the next quiet moment. + scrollElement.addEventListener("scroll", scheduleIdlePass, { + passive: true, + }); + + return () => { + intersectionObserverRef.current = null; + resizeObserverRef.current = null; + observer.disconnect(); + contentGrowthObserver?.disconnect(); + scrollElement.removeEventListener("scroll", scheduleIdlePass); + if (idlePassTimeout !== null) { + window.clearTimeout(idlePassTimeout); + } + pendingRealizeKeys.clear(); + realizedHeightByKey.clear(); + }; + }, [getScrollElement, shouldWindow]); + + const renderedRowsKey = shouldWindow + ? [...alwaysRealizedKeys].sort().join("\u0000") + : "all"; + + useScrollToSearchedMessage(rows, threadId, { + hasOlderRows: hasOlderTimelineRows, + isLoadingOlderRows: isLoadingOlderTimelineRows, + onLoadOlderRows, + renderedRowsKey, + }); + + const renderItem = (item: TimelineRowsListItem) => { + if (item.kind === "unread-divider") { + return ; + } + return ( + + ); + }; + + if (shouldWindow) { + return ( + +
+ {items.map((item) => { + const itemKey = timelineListItemKey(item); + return ( + + {renderItem(item)} + + ); + })} +
+
+ ); + } + + const list = (
- +
+ {renderItem(item)}
); })}
); + return spacing === "top-level" ? ( + {list} + ) : ( + list + ); } function ThreadTimelineRowsComponent(props: ThreadTimelineRowsProps) { @@ -1948,8 +2894,22 @@ function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) { const liveAutoExpandedRowIds = useStableReadonlySet( computedAutoExpansionRowIds.liveFrontierRowIds, ); + // Terminal auto-expansion is a one-shot latch per row, and the latch lives + // in row-local state that windowed eviction unmounts. Accumulate every id + // that has ever latched so an evicted frontier-error row re-expands when it + // remounts. A row the user manually collapses stays collapsed: collapsing + // is an interaction, which pins the row against eviction, so its manual + // override survives. + const accumulatedTerminalRowIdsRef = useRef(new Set()); + const accumulatedTerminalRowIds = useMemo(() => { + const accumulated = accumulatedTerminalRowIdsRef.current; + for (const id of computedAutoExpansionRowIds.terminalFrontierRowIds) { + accumulated.add(id); + } + return new Set(accumulated); + }, [computedAutoExpansionRowIds.terminalFrontierRowIds]); const terminalAutoExpandedRowIds = useStableReadonlySet( - computedAutoExpansionRowIds.terminalFrontierRowIds, + accumulatedTerminalRowIds, ); const initialAutoExpandedRowIds = useStableReadonlySet( props.initialExpanded ?? EMPTY_ROW_ID_SET, @@ -2158,26 +3118,20 @@ function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) { value={latestActionableUserMessageId} > - - - + {hasSelectionActions ? ( Promise | void; + /** Changes when a virtual list mounts a different row range. */ + renderedRowsKey?: string; } interface SeqRange { @@ -195,6 +197,7 @@ export function useScrollToSearchedMessage( hasOlderRows = false, isLoadingOlderRows = false, onLoadOlderRows, + renderedRowsKey, }: SearchMessagePaginationOptions = {}, ): void { const location = useLocation(); @@ -247,7 +250,11 @@ export function useScrollToSearchedMessage( return; } const selector = `[data-timeline-row-id="${escapeTimelineRowId(targetLeafRow.id)}"]`; - if (document.querySelector(selector) === null) { + const renderedTarget = document.querySelector(selector); + if ( + renderedTarget === null || + renderedTarget.dataset.timelineRowRealized === "false" + ) { return; } handledKeyRef.current = location.key; @@ -291,6 +298,7 @@ export function useScrollToSearchedMessage( isLoadingOlderRows, location.key, onLoadOlderRows, + renderedRowsKey, rows, targetSeq, targetThreadId,