From 300015e27f98bcd11575278e1b5aa7a0bfc6e298 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Wed, 19 Aug 2026 22:44:43 -0700 Subject: [PATCH] Add experiment-gated timeline row windowing --- .../AppLayout.plugin-panel-header.test.tsx | 1 + .../AppLayout.root-compose-project.test.tsx | 1 + .../thread/timeline/ThreadTimelineRows.tsx | 272 +++++++++++--- .../ThreadTimelineRows.windowing.test.tsx | 239 ++++++++++++ .../thread/timeline/ThreadTimelineSurface.tsx | 5 + .../thread/timeline/TimelineDetailScroll.tsx | 27 +- .../timeline/TimelineWindowedItems.test.tsx | 231 ++++++++++++ .../thread/timeline/TimelineWindowedItems.tsx | 346 ++++++++++++++++++ .../timeline/TimelineWindowedItemsLoader.tsx | 82 +++++ .../timeline/timeline-row-containment.ts | 5 +- .../timeline/useScrollToSearchedMessage.ts | 21 +- ...d-scroll-body.scroll-preservation.test.tsx | 52 ++- .../ui/bottom-anchored-scroll-body.tsx | 120 +++--- apps/app/src/lib/system-config-atoms.ts | 1 + .../views/SettingsView.experiments.test.tsx | 12 + apps/app/src/views/SettingsView.stories.tsx | 7 + apps/app/src/views/SettingsView.tsx | 24 ++ .../__tests__/command-output/settings.test.ts | 20 + apps/desktop/test/preload-build.test.ts | 1 + .../skills/builtin-skills/bb-cli/SKILL.md | 3 + .../bb-cli/references/app-settings.md | 7 + apps/server/test/system/experiments.test.ts | 7 + docs/configuration.md | 5 + packages/db/test/experiments.test.ts | 1 + packages/domain/src/experiments.ts | 2 + .../src/templates/bb-guide-customization.md | 4 + 26 files changed, 1370 insertions(+), 126 deletions(-) create mode 100644 apps/app/src/components/thread/timeline/ThreadTimelineRows.windowing.test.tsx create mode 100644 apps/app/src/components/thread/timeline/TimelineWindowedItems.test.tsx create mode 100644 apps/app/src/components/thread/timeline/TimelineWindowedItems.tsx create mode 100644 apps/app/src/components/thread/timeline/TimelineWindowedItemsLoader.tsx diff --git a/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx b/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx index ebe6ff7454..fb0586c829 100644 --- a/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx +++ b/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx @@ -31,6 +31,7 @@ vi.mock("@/hooks/queries/system-queries", () => ({ editMessages: false, mobileApp: false, providerSessionReaping: false, + timelineWindowing: false, }, }, }), diff --git a/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx b/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx index 5deb06e07d..b8816a93f3 100644 --- a/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx +++ b/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx @@ -36,6 +36,7 @@ vi.mock("@/hooks/queries/system-queries", () => ({ editMessages: false, mobileApp: false, providerSessionReaping: false, + timelineWindowing: false, }, }, }), diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx index 69c7cd4475..b88e028a89 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx @@ -10,6 +10,7 @@ import { useSyncExternalStore, } from "react"; import type { ReactNode } from "react"; +import { useComposedRefs } from "@radix-ui/react-compose-refs"; import { useLocation } from "react-router-dom"; import { isBackgroundAgentTaskType, @@ -44,6 +45,7 @@ import { type TimelineViewWorkRow, } from "@bb/thread-view"; import { cn } from "@bb/shared-ui/lib/utils"; +import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; import { collectTimelineAutoExpansionRowIds, isNonExpandableSummary, @@ -84,7 +86,10 @@ import { Button } from "@bb/shared-ui/button"; import { AutoHeightContainer } from "../../ui/height-transition.js"; import { Icon, type IconName } from "@bb/shared-ui/icon"; import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/prompt-mention-link"; -import { useBottomAnchoredScroll } from "@/components/ui/bottom-anchored-scroll-body.js"; +import { + TimelineScrollRestoreRowIdContext, + useBottomAnchoredScroll, +} from "@/components/ui/bottom-anchored-scroll-body.js"; import { collectSearchedMessageAncestorRowIds, readSearchMessageTarget, @@ -120,8 +125,16 @@ import { buildMessageDirectiveRegistry, MessageDirectiveRegistryProvider, } from "@/components/ui/markdown-message-directives.js"; +import { + TimelineWindowedItemsLoader, + TimelineWindowingMeasurementsContext, + TimelineWindowingScrollRootContext, + type TimelineWindowedItemRenderState, +} from "./TimelineWindowedItemsLoader.js"; export interface ThreadTimelineRowsProps { + /** Enable the opt-in timeline row virtualizer. */ + timelineWindowingEnabled?: boolean; /** * Row ids to start expanded on first render. Non-recursive: an id only * applies to the row it names — bundle/step/turn children are unaffected. @@ -444,6 +457,8 @@ const StreamingAssistantMessageIdContext = createContext(null); const EMPTY_ROW_ID_SET: ReadonlySet = new Set(); const TimelineSearchExpansionContext = createContext>(EMPTY_ROW_ID_SET); +const TimelineWindowingEnabledContext = createContext(false); +const TIMELINE_TERMINAL_EXPANSION_RETENTION = 24; const SKILL_FILE_NAME = "SKILL.md"; function useTimelineRendererStaticContext(): TimelineRendererStaticContextValue { @@ -1982,27 +1997,71 @@ function buildTimelineRowsListItems({ * `useArmTopLevelTimelineRowContainment`) and the per-row intrinsic size * estimate. */ -function TopLevelTimelineRowWrapper({ +function TimelineRowItemWrapper({ children, row, + spacing, + windowedState, }: { children: ReactNode; row: ThreadTimelineViewRow; + spacing: TimelineRowsListSpacing; + windowedState: TimelineWindowedItemRenderState; }) { const wrapperRef = useRef(null); - useArmTopLevelTimelineRowContainment(wrapperRef); + const composedRef = useComposedRefs(wrapperRef, windowedState.itemRef); + const isTopLevel = spacing === "top-level"; + useArmTopLevelTimelineRowContainment( + wrapperRef, + isTopLevel && !windowedState.windowingEnabled, + ); return (
{children}
); } +function estimateTimelineWindowedRowHeight( + row: ThreadTimelineViewRow, + spacing: TimelineRowsListSpacing, +): number { + if (row.kind !== "conversation") { + return spacing === "top-level" ? 20 : spacing === "bundle" ? 24 : 28; + } + // Estimates only seed never-realized placeholders. ResizeObserver replaces + // them with exact stable-id measurements as soon as a row enters overscan. + const charsPerLine = + spacing === "top-level" ? (row.role === "user" ? 76 : 95) : 64; + let lineCount = Math.max(1, Math.ceil(row.text.length / charsPerLine)); + if (row.role === "user") { + lineCount = Math.min(lineCount, 15); + return 50 + lineCount * 23; + } + return 20 + lineCount * 23; +} + function TimelineRowsList({ compactActivityIntents, hasOlderTimelineRows, @@ -2017,6 +2076,16 @@ function TimelineRowsList({ unreadDividerPlacement, }: TimelineRowsListProps) { const { threadId } = useTimelineRendererStaticContext(); + const isCompactViewport = useIsCompactViewport(); + const bottomAnchor = useBottomAnchoredScroll(); + const scrollRestoreRowId = useContext(TimelineScrollRestoreRowIdContext); + const detailScrollRoot = useContext(TimelineWindowingScrollRootContext); + const timelineWindowingEnabled = useContext(TimelineWindowingEnabledContext); + const inheritedMeasurements = useContext( + TimelineWindowingMeasurementsContext, + ); + const [standaloneMeasurements] = useState(() => new Map()); + const measurements = inheritedMeasurements ?? standaloneMeasurements; const searchExpandedRowIds = useTimelineSearchExpansionRowIds(rows); const stableSearchExpandedRowIds = useStableReadonlySet(searchExpandedRowIds); useScrollToSearchedMessage(rows, threadId, { @@ -2032,6 +2101,36 @@ function TimelineRowsList({ () => buildTimelineRowsListItems({ rows, unreadDividerPlacement }), [rows, unreadDividerPlacement], ); + const itemKeys = useMemo( + () => + items.map((item) => + item.kind === "row" ? item.row.id : `divider:${item.id}`, + ), + [items], + ); + const alwaysMountedKeys = useMemo(() => { + const keys = new Set(); + const lastRow = rows.at(-1); + if (lastRow !== undefined) { + keys.add(lastRow.id); + } + for (const item of items) { + if (item.kind === "unread-divider") { + keys.add(`divider:${item.id}`); + } + } + for (const rowId of stableSearchExpandedRowIds) { + keys.add(rowId); + } + if (spacing === "top-level" && scrollRestoreRowId !== null) { + keys.add(scrollRestoreRowId); + } + return keys; + }, [items, rows, scrollRestoreRowId, spacing, stableSearchExpandedRowIds]); + const getWindowingScrollElement = + detailScrollRoot?.getScrollElement ?? + bottomAnchor?.getScrollElement ?? + null; return (
- {items.map((item) => { - if (item.kind === "unread-divider") { - return ( - - ); + { + const item = items[index]; + return item?.kind === "row" + ? estimateTimelineWindowedRowHeight(item.row, spacing) + : 28; + }} + gap={spacing === "bundle" ? 0 : 8} + getScrollElement={getWindowingScrollElement} + itemKeys={itemKeys} + measurements={measurements} + minItemCount={ + spacing === "top-level" ? (isCompactViewport ? 40 : 60) : 20 } - - const rowView = ( - - ); - if (spacing === "top-level") { + renderItem={(index, windowedState) => { + const item = items[index]; + if (item === undefined) { + return null; + } + if (item.kind === "unread-divider") { + return ( +
+ {windowedState.isRealized ? ( + + ) : null} +
+ ); + } return ( - - {rowView} - + + {windowedState.isRealized ? ( + + ) : null} + ); - } - return ( -
- {rowView} -
- ); - })} + }} + />
); @@ -2090,6 +2220,7 @@ function ThreadTimelineRowsComponent(props: ThreadTimelineRowsProps) { function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) { const getViewRows = useTimelineViewRowsCache(); + const [windowingMeasurements] = useState(() => new Map()); const rows = useMemo( () => getViewRows(props.timelineRows), [getViewRows, props.timelineRows], @@ -2122,8 +2253,27 @@ function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) { const liveAutoExpandedRowIds = useStableReadonlySet( computedAutoExpansionRowIds.liveFrontierRowIds, ); + // Terminal expansion is a one-shot latch stored in an individual row. Keep + // a bounded recent set at the owner so windowed eviction cannot immediately + // erase it without growing state forever in a long-lived streaming client. + const accumulatedTerminalRowIdsRef = useRef(new Set()); + const accumulatedTerminalRowIds = useMemo(() => { + const accumulated = accumulatedTerminalRowIdsRef.current; + for (const id of computedAutoExpansionRowIds.terminalFrontierRowIds) { + accumulated.delete(id); + accumulated.add(id); + } + while (accumulated.size > TIMELINE_TERMINAL_EXPANSION_RETENTION) { + const oldestId = accumulated.values().next().value; + if (oldestId === undefined) { + break; + } + accumulated.delete(oldestId); + } + return new Set(accumulated); + }, [computedAutoExpansionRowIds.terminalFrontierRowIds]); const terminalAutoExpandedRowIds = useStableReadonlySet( - computedAutoExpansionRowIds.terminalFrontierRowIds, + accumulatedTerminalRowIds, ); const initialAutoExpandedRowIds = useStableReadonlySet( props.initialExpanded ?? EMPTY_ROW_ID_SET, @@ -2335,26 +2485,34 @@ function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) { - - - + + + + + + + {hasSelectionActions ? ( ({}), + }; +} + +class IntersectionObserverStub implements IntersectionObserver { + readonly root = null; + readonly rootMargin = "0px"; + readonly scrollMargin = "0px"; + readonly thresholds = [0]; + disconnect = vi.fn(); + observe = vi.fn(); + takeRecords = vi.fn(() => []); + unobserve = vi.fn(); +} + +class ResizeObserverStub implements ResizeObserver { + disconnect = vi.fn(); + observe = vi.fn(); + unobserve = vi.fn(); +} + +const nestedRows = Array.from({ length: 30 }, (_, index) => + conversationRow({ + id: `nested-${index}`, + role: index % 2 === 0 ? "assistant" : "user", + seq: index + 2, + text: `Nested message ${index}`, + turnId: `turn-${index}`, + }), +); + +function renderDelegation(timelineWindowingEnabled: boolean) { + const queryClient = new QueryClient(); + return render( + + + + + , + ); +} + +beforeEach(() => { + vi.stubGlobal("IntersectionObserver", IntersectionObserverStub); + vi.stubGlobal("ResizeObserver", ResizeObserverStub); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: vi.fn(), + }); + vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockImplementation( + function (this: HTMLElement) { + if (this.hasAttribute("data-detail-scroll-area")) return 200; + if (this.hasAttribute("data-test-main-scroll")) return 800; + return 0; + }, + ); + vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation( + function (this: HTMLElement) { + if (this.hasAttribute("data-detail-scroll-area")) return 200; + if (this.hasAttribute("data-test-main-scroll")) return 800; + return 0; + }, + ); + vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(600); + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( + function (this: HTMLElement) { + if (this.hasAttribute("data-detail-scroll-area")) { + return rect(0, 200); + } + if (this.hasAttribute("data-test-main-scroll")) { + return rect(0, 800); + } + const rowId = this.dataset.timelineRowId; + const match = rowId?.match(/^nested-(\d+)$/); + if (match?.[1] !== undefined) { + const index = Number(match[1]); + return index < 4 ? rect(index * 40, 32) : rect(2_000, 32); + } + const searchMatch = rowId?.match(/^search-message-(\d+)$/); + if (searchMatch?.[1] !== undefined) { + return rect(Number(searchMatch[1]) * 100, 32); + } + return rect(0, 32); + }, + ); +}); + +afterEach(() => { + cleanup(); + Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView"); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("ThreadTimelineRows windowing experiment", () => { + it("keeps the control timeline fully mounted", () => { + const view = renderDelegation(false); + const nestedList = view.container.querySelector( + '[data-timeline-row-list="nested"]', + ); + + for (let index = 0; index < 30; index += 1) { + expect(nestedList?.textContent).toContain(`Nested message ${index}`); + } + }); + + it("windows the rows inside a large expanded detail", async () => { + const view = renderDelegation(true); + const detailScroll = view.container.querySelector( + "[data-detail-scroll-area]", + ); + + expect(detailScroll?.clientHeight).toBe(200); + await waitFor(() => { + const nestedList = view.container.querySelector( + '[data-timeline-row-list="nested"]', + ); + const wrappers = nestedList?.querySelectorAll( + ":scope > [data-timeline-virtual-spacer] > [data-timeline-row-id]", + ); + expect(wrappers?.length).toBeGreaterThan(0); + expect(wrappers?.length).toBeLessThan(30); + expect(nestedList?.textContent).toContain("Nested message 0"); + expect(nestedList?.textContent).not.toContain("Nested message 20"); + }); + }); + + it("keeps an offscreen search target realized and reveals it", async () => { + const scrollElement = document.createElement("div"); + scrollElement.setAttribute("data-test-main-scroll", ""); + Object.defineProperty(scrollElement, "clientHeight", { value: 800 }); + Object.defineProperty(scrollElement, "scrollHeight", { value: 8_000 }); + const scrollElementIntoView = vi.fn(); + const bottomAnchor: BottomAnchorContextValue = { + captureScrollAnchor: vi.fn(), + getScrollElement: () => scrollElement, + isAtBottom: false, + scrollElementIntoView, + scrollElementIntoViewClampedToMaxScroll: vi.fn(), + scrollToBottom: vi.fn(), + }; + const rows = Array.from({ length: 80 }, (_, index) => + conversationRow({ + id: `search-message-${index}`, + role: index % 2 === 0 ? "user" : "assistant", + sourceSeqEnd: index + 1, + sourceSeqStart: index + 1, + text: `Search message ${index}`, + threadId: "thr_large_search", + }), + ); + const queryClient = new QueryClient(); + expect( + collectSearchedMessageAncestorRowIds(buildTimelineViewRows(rows), 21), + ).toContain("search-message-20"); + const view = render( + + + + + + + + + , + ); + const target = view.container.querySelector( + '[data-timeline-row-id="search-message-20"]', + ); + + expect(target?.dataset.timelineWindowedRealized).toBe("true"); + expect(target?.textContent).toContain("Search message 20"); + await waitFor(() => + expect(scrollElementIntoView).toHaveBeenCalledWith({ + element: target, + options: { block: "center" }, + }), + ); + }); +}); diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx index 8f04924ba9..f506227f63 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx @@ -11,6 +11,7 @@ import { ConversationTimeline } from "@/components/ui/conversation.js"; import { HeightTransition } from "@/components/ui/height-transition.js"; import { Icon } from "@bb/shared-ui/icon"; import { Skeleton } from "@bb/shared-ui/skeleton"; +import { useSystemConfig } from "@/hooks/queries/system-queries"; import { toUserAttachmentImageSrc } from "@/lib/user-attachment-images"; import { ThreadTimelineRows } from "./ThreadTimelineRows.js"; import { useAutoLoadOlderRows } from "./useAutoLoadOlderRows.js"; @@ -177,6 +178,9 @@ export function ThreadTimelineSurface({ unreadDividerPlacement, workspaceRootPath, }: ThreadTimelineSurfaceProps) { + const systemConfigQuery = useSystemConfig(); + const timelineWindowingEnabled = + systemConfigQuery.data?.experiments.timelineWindowing ?? false; const showActiveThinking = activeThinking !== null && ongoingIndicatorLabel === undefined; const activeThinkingText = activeThinking?.text.trim() ?? ""; @@ -240,6 +244,7 @@ export function ThreadTimelineSurface({ isLoadingOlderTimelineRows={isLoadingOlderTimelineRows} onLoadOlderRows={onLoadOlderRows} timelineRows={timelineRowsWithPendingStop} + timelineWindowingEnabled={timelineWindowingEnabled} threadId={threadId} threadRuntimeDisplayStatus={threadRuntimeDisplayStatus} unreadDividerAutoScroll={unreadDividerAutoScroll} diff --git a/apps/app/src/components/thread/timeline/TimelineDetailScroll.tsx b/apps/app/src/components/thread/timeline/TimelineDetailScroll.tsx index eef2c3b0bf..e450d18c3f 100644 --- a/apps/app/src/components/thread/timeline/TimelineDetailScroll.tsx +++ b/apps/app/src/components/thread/timeline/TimelineDetailScroll.tsx @@ -1,4 +1,4 @@ -import { useCallback, type ReactNode, type UIEvent } from "react"; +import { useMemo, type ReactNode } from "react"; import { useComposedRefs } from "@radix-ui/react-compose-refs"; import { cn } from "@bb/shared-ui/lib/utils"; import { @@ -7,6 +7,10 @@ import { } from "../../ui/detail-scroll-size.js"; import { useStickyBottomScroll } from "./useStickyBottomScroll.js"; import { useScrollOverflowState } from "./useScrollOverflowState.js"; +import { + TimelineWindowingScrollRootContext, + type TimelineWindowingScrollRoot, +} from "./TimelineWindowedItemsLoader.js"; export interface TimelineDetailScrollProps { size: DetailScrollSize; @@ -63,17 +67,14 @@ export function TimelineDetailScroll({ const maxHeightClassName = getDetailScrollMaxHeightClass(size); const { aboveOverflow, belowOverflow } = overflow; - const handleScroll = useCallback( - (event: UIEvent) => { - sticky.onScroll(event); - }, - [sticky], - ); - const refCallback = useComposedRefs( sticky.ref, overflow.scrollRef, ); + const windowingScrollRoot = useMemo( + () => ({ getScrollElement: () => sticky.ref.current }), + [sticky.ref], + ); return (
{children}
+
+ + {children} + +
`row-${index}`); + +let scrollElement: HTMLDivElement; +let itemHeights = new Map(); + +function rect(top: number, height: number): DOMRect { + return { + bottom: top + height, + height, + left: 0, + right: 320, + top, + width: 320, + x: 0, + y: top, + toJSON: () => ({}), + }; +} + +class ResizeObserverStub implements ResizeObserver { + disconnect(): void {} + observe(): void {} + unobserve(): void {} +} + +function renderWindowedItems(options?: { + alwaysMountedKeys?: ReadonlySet; + clientHeight?: number; + enabled?: boolean; + measurements?: Map; +}) { + const measurements = options?.measurements ?? new Map(); + Object.defineProperty(scrollElement, "clientHeight", { + configurable: true, + value: options?.clientHeight ?? 96, + }); + Object.defineProperty(scrollElement, "offsetHeight", { + configurable: true, + value: options?.clientHeight ?? 96, + }); + return { + ...render( + 32} + gap={0} + getScrollElement={() => scrollElement} + itemKeys={ITEM_KEYS} + measurements={measurements} + renderItem={(index: number, state: TimelineWindowedItemRenderState) => ( +
+ {state.isRealized ? ( + + ) : null} +
+ )} + />, + { container: scrollElement }, + ), + measurements, + }; +} + +beforeEach(() => { + itemHeights = new Map(); + scrollElement = document.createElement("div"); + document.body.append(scrollElement); + Object.defineProperty(scrollElement, "clientWidth", { + configurable: true, + value: 320, + }); + Object.defineProperty(scrollElement, "offsetWidth", { + configurable: true, + value: 320, + }); + Object.defineProperty(scrollElement, "scrollHeight", { + configurable: true, + value: 3_200, + }); + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( + function (this: HTMLElement) { + if (this === scrollElement) return rect(0, scrollElement.clientHeight); + if (this.hasAttribute("data-timeline-virtual-spacer")) { + return rect( + -scrollElement.scrollTop, + Number.parseFloat(this.style.height) || 0, + ); + } + const index = Number(this.dataset.index); + if (Number.isInteger(index)) { + return rect( + index * 32 - scrollElement.scrollTop, + itemHeights.get(index) ?? 32, + ); + } + return rect(0, Number.parseFloat(this.style.height) || 0); + }, + ); + vi.stubGlobal("ResizeObserver", ResizeObserverStub); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("TimelineWindowedItems", () => { + it("keeps the control path fully mounted when the experiment is off", () => { + renderWindowedItems({ enabled: false }); + + expect(screen.getAllByTestId(/^content-/)).toHaveLength(100); + expect( + scrollElement.querySelector("[data-timeline-virtual-spacer]"), + ).toBeNull(); + }); + + it("mounts only the visible TanStack range and removes offscreen wrappers", async () => { + renderWindowedItems(); + + await waitFor(() => expect(screen.getByTestId("content-0")).toBeTruthy()); + expect(screen.getAllByTestId(/^wrapper-/).length).toBeLessThan(30); + expect(screen.queryByTestId("wrapper-60")).toBeNull(); + expect( + scrollElement.querySelector("[data-timeline-virtual-spacer]") + ?.style.height, + ).toBe("3200px"); + }); + + it("changes ranges on scroll without retaining the old rich rows", async () => { + renderWindowedItems(); + await waitFor(() => expect(screen.getByTestId("content-0")).toBeTruthy()); + + scrollElement.scrollTop = 1_600; + fireEvent.scroll(scrollElement); + + await waitFor(() => expect(screen.getByTestId("content-50")).toBeTruthy()); + expect(screen.queryByTestId("wrapper-0")).toBeNull(); + }); + + it("preserves an existing scroll offset when a nested virtualizer mounts", async () => { + scrollElement.scrollTop = 1_600; + + renderWindowedItems(); + + await waitFor(() => expect(screen.getByTestId("content-50")).toBeTruthy()); + expect(scrollElement.scrollTop).toBe(1_600); + }); + + it("keeps search and interacted rows mounted outside the visible range", async () => { + renderWindowedItems({ alwaysMountedKeys: new Set(["row-80"]) }); + await waitFor(() => expect(screen.getByTestId("content-80")).toBeTruthy()); + fireEvent.click(screen.getByTestId("content-0")); + + scrollElement.scrollTop = 1_600; + fireEvent.scroll(scrollElement); + + await waitFor(() => expect(screen.getByTestId("content-50")).toBeTruthy()); + expect(screen.getByTestId("content-0")).toBeTruthy(); + expect(screen.getByTestId("content-80")).toBeTruthy(); + }); + + it("defers rich transient rows during a fast traversal until scroll idle", async () => { + vi.useFakeTimers(); + renderWindowedItems(); + await act(async () => {}); + + scrollElement.scrollTop = 1_600; + fireEvent.scroll(scrollElement); + await act(async () => {}); + + expect( + scrollElement.querySelectorAll( + '[data-timeline-windowed-realized="false"]', + ).length, + ).toBeGreaterThan(0); + + await act(async () => { + vi.advanceTimersByTime(300); + }); + expect(screen.getByTestId("content-50")).toBeTruthy(); + }); + + it("seeds its size model from measurements retained by the thread", async () => { + const measurements = new Map([["row-50", 64]]); + renderWindowedItems({ measurements }); + await waitFor(() => + expect( + scrollElement.querySelector( + "[data-timeline-virtual-spacer]", + )?.style.height, + ).toBe("3232px"), + ); + }); + + it("renders everything when its scrollport has no usable geometry", async () => { + renderWindowedItems({ clientHeight: 0 }); + + await waitFor(() => + expect(screen.getAllByTestId(/^content-/)).toHaveLength(100), + ); + }); +}); diff --git a/apps/app/src/components/thread/timeline/TimelineWindowedItems.tsx b/apps/app/src/components/thread/timeline/TimelineWindowedItems.tsx new file mode 100644 index 0000000000..a16a627560 --- /dev/null +++ b/apps/app/src/components/thread/timeline/TimelineWindowedItems.tsx @@ -0,0 +1,346 @@ +import { + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, + type SyntheticEvent, +} from "react"; +import { useComposedRefs } from "@radix-ui/react-compose-refs"; +import { + defaultRangeExtractor, + useVirtualizer, + type Range, + type Virtualizer, +} from "@tanstack/react-virtual"; +import type { TimelineWindowedItemsProps } from "./TimelineWindowedItemsLoader.js"; + +export type { TimelineWindowedItemRenderState } from "./TimelineWindowedItemsLoader.js"; + +/** Rich rows retained on each side of the visible range. */ +const TIMELINE_WINDOW_OVERSCAN_ITEMS = 8; +/** TanStack's scroll-idle boundary also drives rich-content realization. */ +const TIMELINE_WINDOW_IDLE_DELAY_MS = 300; +/** Bound row-local interaction state retained across a long-lived session. */ +const TIMELINE_WINDOW_MAX_INTERACTION_PINS = 24; +/** Bound exact heights that survive nested-list unmounts. */ +const TIMELINE_WINDOW_MAX_MEASUREMENTS = 2_000; +/** Short lists cost less to keep mounted than to virtualize. */ +export const TIMELINE_WINDOWING_MIN_ITEM_COUNT = 20; + +const EMPTY_KEY_SET: ReadonlySet = new Set(); +const GET_NO_SCROLL_ELEMENT = () => null; +const NOOP_ITEM_REF = () => {}; + +function recordTimelineMeasurement( + measurements: Map, + key: string, + height: number, +): void { + measurements.delete(key); + measurements.set(key, height); + while (measurements.size > TIMELINE_WINDOW_MAX_MEASUREMENTS) { + const oldestKey = measurements.keys().next().value; + if (oldestKey === undefined) break; + measurements.delete(oldestKey); + } +} + +interface ScrollSample { + at: number; + fast: boolean; + offset: number; +} + +function measureBorderBox( + element: HTMLElement, + entry: ResizeObserverEntry | undefined, +): number { + const observedHeight = entry?.borderBoxSize[0]?.blockSize; + return observedHeight ?? element.getBoundingClientRect().height; +} + +function findOwnedWindowKey( + target: EventTarget | null, + container: HTMLElement, + indexByKey: ReadonlyMap, +): string | null { + let element = target instanceof Element ? target : null; + while (element !== null && element !== container) { + const key = element.getAttribute("data-timeline-window-key"); + if (key !== null && indexByKey.has(key)) return key; + element = element.parentElement; + } + return null; +} + +/** + * Timeline adapter around TanStack Virtual. + * + * TanStack owns range calculation, dynamic measurement, scroll correction, + * and iOS momentum safety. This adapter only retains product policy: stable + * row keys, nested scroll offsets, search/interaction pins, and cheap + * placeholders during a synthetic or high-velocity traversal. + */ +export function TimelineWindowedItems({ + enabled, + alwaysMountedKeys = EMPTY_KEY_SET, + estimateItemHeight, + gap, + getScrollElement, + itemKeys, + measurements, + minItemCount = TIMELINE_WINDOWING_MIN_ITEM_COUNT, + renderItem, +}: TimelineWindowedItemsProps) { + const configured = + enabled && itemKeys.length >= minItemCount && getScrollElement !== null; + const [scrollRootUsable, setScrollRootUsable] = useState(true); + const [scrollMargin, setScrollMargin] = useState(0); + const [interactionPins, setInteractionPins] = useState([]); + const containerElementRef = useRef(null); + const scrollSampleRef = useRef({ + at: 0, + fast: false, + offset: 0, + }); + const windowingEnabled = configured && scrollRootUsable; + const resolvedGetScrollElement = getScrollElement ?? GET_NO_SCROLL_ELEMENT; + + const indexByKey = useMemo( + () => new Map(itemKeys.map((key, index) => [key, index])), + [itemKeys], + ); + const forcedIndexes = useMemo(() => { + const indexes = new Set(); + for (const key of alwaysMountedKeys) { + const index = indexByKey.get(key); + if (index !== undefined) indexes.add(index); + } + for (const key of interactionPins) { + const index = indexByKey.get(key); + if (index !== undefined) indexes.add(index); + } + return indexes; + }, [alwaysMountedKeys, indexByKey, interactionPins]); + + const getItemKey = useCallback( + (index: number) => itemKeys[index] ?? index, + [itemKeys], + ); + const estimateSize = useCallback( + (index: number) => { + const key = itemKeys[index]; + return key === undefined + ? Math.max(1, estimateItemHeight(index)) + : (measurements.get(key) ?? Math.max(1, estimateItemHeight(index))); + }, + [estimateItemHeight, itemKeys, measurements], + ); + const measureElement = useCallback( + ( + element: HTMLDivElement, + entry: ResizeObserverEntry | undefined, + ): number => { + const index = Number(element.dataset.index); + const height = measureBorderBox(element, entry); + const key = Number.isInteger(index) ? itemKeys[index] : undefined; + if ( + key !== undefined && + height > 0 && + element.dataset.timelineWindowedRealized === "true" + ) { + recordTimelineMeasurement(measurements, key, height); + } + return height > 0 ? height : estimateSize(index); + }, + [estimateSize, itemKeys, measurements], + ); + const rangeExtractor = useCallback( + (range: Range) => { + const indexes = new Set(defaultRangeExtractor(range)); + for (const index of forcedIndexes) indexes.add(index); + return [...indexes].sort((left, right) => left - right); + }, + [forcedIndexes], + ); + const handleVirtualizerChange = useCallback( + ( + instance: Virtualizer, + scrolling: boolean, + ) => { + const sample = scrollSampleRef.current; + if (!scrolling) { + sample.fast = false; + sample.at = 0; + sample.offset = instance.scrollOffset ?? sample.offset; + return; + } + const now = performance.now(); + const offset = instance.scrollOffset ?? 0; + const elapsed = sample.at === 0 ? 0 : now - sample.at; + const distance = Math.abs(offset - sample.offset); + const viewportSize = instance.scrollRect?.height ?? 0; + sample.fast = + (sample.at === 0 || elapsed <= 100) && + distance >= Math.max(200, viewportSize * 0.5); + sample.at = now; + sample.offset = offset; + }, + [], + ); + const initialOffset = useCallback( + () => resolvedGetScrollElement()?.scrollTop ?? 0, + [resolvedGetScrollElement], + ); + + const virtualizer = useVirtualizer({ + count: itemKeys.length, + directDomUpdates: true, + directDomUpdatesMode: "position", + enabled: windowingEnabled, + estimateSize, + gap, + getItemKey, + getScrollElement: resolvedGetScrollElement, + initialOffset, + isScrollingResetDelay: TIMELINE_WINDOW_IDLE_DELAY_MS, + measureElement, + onChange: handleVirtualizerChange, + overscan: TIMELINE_WINDOW_OVERSCAN_ITEMS, + rangeExtractor, + scrollMargin, + useFlushSync: false, + }); + const containerRef = useComposedRefs( + containerElementRef, + virtualizer.containerRef, + ); + + const updateScrollGeometry = useCallback(() => { + if (!configured) return; + const container = containerElementRef.current; + const scrollElement = resolvedGetScrollElement(); + if (container === null || scrollElement === null) return; + const nextMargin = + container.getBoundingClientRect().top - + scrollElement.getBoundingClientRect().top + + scrollElement.scrollTop - + scrollElement.clientTop; + setScrollMargin((previous) => + Math.abs(previous - nextMargin) < 0.5 ? previous : nextMargin, + ); + }, [configured, resolvedGetScrollElement]); + + useLayoutEffect(() => { + if (!configured) return; + const updateRootUsability = () => { + const scrollElement = resolvedGetScrollElement(); + if (scrollElement !== null) { + setScrollRootUsable(scrollElement.clientHeight > 0); + } + }; + const scrollElement = resolvedGetScrollElement(); + if (scrollElement === null) { + const frame = requestAnimationFrame(() => { + updateRootUsability(); + updateScrollGeometry(); + }); + return () => cancelAnimationFrame(frame); + } + updateRootUsability(); + updateScrollGeometry(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => { + updateRootUsability(); + updateScrollGeometry(); + }); + observer.observe(scrollElement); + const containerParent = containerElementRef.current?.parentElement; + if (containerParent !== null && containerParent !== undefined) { + observer.observe(containerParent); + } + return () => observer.disconnect(); + }, [configured, resolvedGetScrollElement, updateScrollGeometry]); + + // A nested list's offset can change when its owning row expands or reflows + // without resizing the scroll root itself. Re-read after those React commits. + useLayoutEffect(updateScrollGeometry); + + const retainInteractedItem = useCallback( + (event: SyntheticEvent) => { + const container = containerElementRef.current; + if (container === null) return; + const key = findOwnedWindowKey(event.target, container, indexByKey); + if (key === null) return; + setInteractionPins((previous) => { + const next = previous.filter((candidate) => candidate !== key); + next.push(key); + return next.slice(-TIMELINE_WINDOW_MAX_INTERACTION_PINS); + }); + }, + [indexByKey], + ); + + if (!windowingEnabled) { + return ( + <> + {itemKeys.map((key, index) => + renderItem(index, { + isRealized: true, + itemIndex: undefined, + itemRef: NOOP_ITEM_REF, + itemStyle: undefined, + windowingEnabled: false, + }), + )} + + ); + } + + const fastScrolling = scrollSampleRef.current.fast; + const virtualItemsByIndex = new Map( + virtualizer.getVirtualItems().map((item) => [item.index, item]), + ); + // Range calculation starts after the scroll element is measured. Product- + // pinned rows must exist in the first commit so navigation search and saved + // scroll restoration can find their DOM ids immediately. + for (const index of forcedIndexes) { + const item = virtualizer.measurementsCache[index]; + if (item !== undefined) virtualItemsByIndex.set(index, item); + } + const virtualItems = [...virtualItemsByIndex.values()].sort( + (left, right) => left.index - right.index, + ); + return ( +
+ {virtualItems.map((item) => { + const isRealized = !fastScrolling || forcedIndexes.has(item.index); + return renderItem(item.index, { + isRealized, + itemIndex: item.index, + itemRef: virtualizer.measureElement, + itemStyle: { + position: "absolute", + left: 0, + width: "100%", + ...(isRealized + ? undefined + : { + height: item.size, + minHeight: item.size, + overflow: "hidden", + }), + }, + windowingEnabled: true, + }); + })} +
+ ); +} diff --git a/apps/app/src/components/thread/timeline/TimelineWindowedItemsLoader.tsx b/apps/app/src/components/thread/timeline/TimelineWindowedItemsLoader.tsx new file mode 100644 index 0000000000..c710d7aebe --- /dev/null +++ b/apps/app/src/components/thread/timeline/TimelineWindowedItemsLoader.tsx @@ -0,0 +1,82 @@ +import { + createContext, + lazy, + Suspense, + type CSSProperties, + type ReactNode, +} from "react"; + +const DEFAULT_WINDOWING_MIN_ITEM_COUNT = 20; +const NOOP_ITEM_REF = () => {}; + +export interface TimelineWindowingScrollRoot { + getScrollElement: () => HTMLElement | null; +} + +/** Nested capped details virtualize against their own scroll element. */ +export const TimelineWindowingScrollRootContext = + createContext(null); + +/** Exact heights survive while a virtualized parent unmounts a nested list. */ +export const TimelineWindowingMeasurementsContext = createContext | null>(null); + +export interface TimelineWindowedItemRenderState { + isRealized: boolean; + itemIndex: number | undefined; + itemRef: (node: HTMLDivElement | null) => void; + itemStyle: CSSProperties | undefined; + windowingEnabled: boolean; +} + +export interface TimelineWindowedItemsProps { + enabled: boolean; + alwaysMountedKeys?: ReadonlySet; + estimateItemHeight: (index: number) => number; + gap: number; + getScrollElement: (() => HTMLElement | null) | null; + itemKeys: readonly string[]; + measurements: Map; + minItemCount?: number; + renderItem: ( + index: number, + state: TimelineWindowedItemRenderState, + ) => ReactNode; +} + +const LazyTimelineWindowedItems = lazy(async () => { + const module = await import("./TimelineWindowedItems.js"); + return { default: module.TimelineWindowedItems }; +}); + +function TimelineWindowedItemsControl({ + itemKeys, + renderItem, +}: TimelineWindowedItemsProps) { + return itemKeys.map((_key, index) => + renderItem(index, { + isRealized: true, + itemIndex: undefined, + itemRef: NOOP_ITEM_REF, + itemStyle: undefined, + windowingEnabled: false, + }), + ); +} + +/** Keep TanStack Virtual out of the route bundle until the experiment is on. */ +export function TimelineWindowedItemsLoader(props: TimelineWindowedItemsProps) { + const configured = + props.enabled && + props.getScrollElement !== null && + props.itemKeys.length >= + (props.minItemCount ?? DEFAULT_WINDOWING_MIN_ITEM_COUNT); + if (!configured) return ; + return ( + }> + + + ); +} 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 69c2902646..e03aac9bc9 100644 --- a/apps/app/src/components/thread/timeline/timeline-row-containment.ts +++ b/apps/app/src/components/thread/timeline/timeline-row-containment.ts @@ -49,10 +49,11 @@ export const TOP_LEVEL_TIMELINE_ROW_CLASS_NAME = `${CONTENT_VISIBILITY_CLASS_NAM */ export function useArmTopLevelTimelineRowContainment( wrapperRef: RefObject, + enabled = true, ): void { useEffect(() => { const wrapper = wrapperRef.current; - if (wrapper === null || !supportsScrollAnchoring()) { + if (!enabled || wrapper === null || !supportsScrollAnchoring()) { return; } let cancelled = false; @@ -74,7 +75,7 @@ export function useArmTopLevelTimelineRowContainment( cancelAnimationFrame(secondFrame); } }; - }, [wrapperRef]); + }, [enabled, wrapperRef]); } /** diff --git a/apps/app/src/components/thread/timeline/useScrollToSearchedMessage.ts b/apps/app/src/components/thread/timeline/useScrollToSearchedMessage.ts index e02892f08f..6c6aa338a8 100644 --- a/apps/app/src/components/thread/timeline/useScrollToSearchedMessage.ts +++ b/apps/app/src/components/thread/timeline/useScrollToSearchedMessage.ts @@ -30,6 +30,7 @@ interface SeqRange { const FLASH_CLASS_NAME = "bb-search-flash"; const FLASH_DURATION_MS = 1700; +const POST_WINDOW_SETTLE_REVEAL_MS = 800; function escapeTimelineRowId(rowId: string): string { if (typeof CSS !== "undefined" && typeof CSS.escape === "function") { @@ -129,7 +130,7 @@ function collectSearchedMessageAncestorRowIdsInRows({ continue; } const nestedRows = getNestedRows(row); - if (nestedRows === null) { + if (nestedRows === null || nestedRows.length === 0) { ancestorIds.add(row.id); return true; } @@ -201,6 +202,8 @@ export function useScrollToSearchedMessage( const bottomAnchor = useBottomAnchoredScroll(); const handledKeyRef = useRef(null); const olderLoadAttemptKeyRef = useRef(null); + const locationKeyRef = useRef(location.key); + locationKeyRef.current = location.key; const target = readSearchMessageTarget(location.state); const targetSeq = target?.seq ?? null; const targetThreadId = target?.threadId ?? null; @@ -247,13 +250,20 @@ 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.timelineWindowedRealized === "false" + ) { return; } handledKeyRef.current = location.key; let flashed = false; const revealTarget = () => { + if (locationKeyRef.current !== location.key) { + return; + } const element = document.querySelector(selector); if (element === null) { return; @@ -277,13 +287,12 @@ export function useScrollToSearchedMessage( } }; - // Reveal on the next frame, then once more after layout settles, so a late - // scroll-anchor restore can't leave the target off-screen. + // Reveal after initial layout and again after idle placeholder correction. const frame = requestAnimationFrame(revealTarget); - const settle = window.setTimeout(revealTarget, 320); + window.setTimeout(revealTarget, 320); + window.setTimeout(revealTarget, POST_WINDOW_SETTLE_REVEAL_MS); return () => { cancelAnimationFrame(frame); - window.clearTimeout(settle); }; }, [ bottomAnchor, diff --git a/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx b/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx index d2ad8fb515..ab5b1a1985 100644 --- a/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx +++ b/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx @@ -97,6 +97,7 @@ interface RenderArgs { rowIds: string[]; showCapturePrependAnchorControl?: boolean; showScrollToBottomControl?: boolean; + virtualized?: boolean; } function CapturePrependAnchorControl() { @@ -122,7 +123,13 @@ function renderTimeline({ rowIds, showCapturePrependAnchorControl = false, showScrollToBottomControl = false, + virtualized = false, }: RenderArgs) { + const rows = rowIds.map((rowId) => ( +
+ {rowId} +
+ )); const view = render( Footer
} @@ -132,11 +139,13 @@ function renderTimeline({ > {showCapturePrependAnchorControl ? : null} {showScrollToBottomControl ? : null} - {rowIds.map((rowId) => ( -
- {rowId} + {virtualized ? ( +
+
{rows}
- ))} + ) : ( + rows + )} , ); @@ -240,6 +249,41 @@ describe("BottomAnchoredScrollBody scroll preservation", () => { }); }); + it("captures rows nested in a virtualizer spacer", () => { + const { scrollArea, rowElements } = renderTimeline({ + threadId: "thread-a", + rowIds: ["row-a", "row-b", "row-c"], + virtualized: true, + }); + mockScrollAreaRect(scrollArea); + mockRowRect(requireHTMLElement(rowElements.get("row-a")!), { + top: -120, + bottom: -20, + }); + mockRowRect(requireHTMLElement(rowElements.get("row-b")!), { + top: -20, + bottom: 80, + }); + mockRowRect(requireHTMLElement(rowElements.get("row-c")!), { + top: 80, + bottom: 180, + }); + setScrollMetrics(scrollArea, { + scrollHeight: 400, + clientHeight: 100, + scrollTop: 150, + }); + + fireEvent.wheel(scrollArea); + fireEvent.scroll(scrollArea); + + expect(readAnchor("thread-a")).toEqual({ + rowId: "row-b", + offsetWithinRow: 20, + atBottom: false, + }); + }); + it("finds the visible anchor with logarithmic row measurements", () => { const rowIds = Array.from({ length: 128 }, (_, index) => `row-${index}`); const { scrollArea, rowElements } = renderTimeline({ diff --git a/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx b/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx index 6f939a22f3..b64b4131cf 100644 --- a/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx +++ b/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx @@ -98,7 +98,10 @@ const SCROLL_ANCHOR_RESTORE_MAX_ATTEMPTS = 8; const TIMELINE_ROW_ID_SELECTOR = "[data-timeline-row-id]"; const TOP_LEVEL_TIMELINE_ROW_LIST_SELECTOR = '[data-timeline-row-list="top-level"]'; -const DIRECT_TIMELINE_ROW_SELECTOR = `:scope > ${TIMELINE_ROW_ID_SELECTOR}`; +const DIRECT_TIMELINE_ROW_SELECTOR = [ + `:scope > ${TIMELINE_ROW_ID_SELECTOR}`, + `:scope > [data-timeline-virtual-spacer] > ${TIMELINE_ROW_ID_SELECTOR}`, +].join(", "); const SCROLL_INTENT_KEYS = new Set([ "ArrowDown", "ArrowUp", @@ -112,6 +115,16 @@ const SCROLL_INTENT_KEYS = new Set([ export const BottomAnchorContext = createContext(null); +/** + * A virtualized timeline pins this one row during initial navigation restore; + * otherwise the saved row would not exist in the DOM for the scroll body to + * measure. It remains separate from BottomAnchorContext so embedded/test + * consumers do not need to implement virtualizer policy. + */ +export const TimelineScrollRestoreRowIdContext = createContext( + null, +); + export function useBottomAnchoredScroll(): BottomAnchorContextValue | null { return useContext(BottomAnchorContext); } @@ -303,6 +316,15 @@ export function BottomAnchoredScrollBody({ }>({ lastWriteAt: 0, trailingTimeout: null }); const userDetachedFromBottomRef = useRef(false); const [isAtBottom, setIsAtBottom] = useState(true); + const initialScrollRestoreRowId = useMemo(() => { + if (scrollAnchorThreadId === undefined) return null; + const anchor = store.get( + threadTimelineScrollAnchorAtomFamily(scrollAnchorThreadId), + ); + return anchor !== null && anchor !== undefined && !anchor.atBottom + ? anchor.rowId + : null; + }, [scrollAnchorThreadId, store]); const getScrollElement = useCallback(() => scrollAreaRef.current, []); @@ -827,65 +849,69 @@ export function BottomAnchoredScrollBody({ return ( -
-
+ +
- {/* `.scroll-bottom-anchor-content` sets `overflow-anchor: none` on +
+ {/* `.scroll-bottom-anchor-content` sets `overflow-anchor: none` on this wrapper only. Scroll anchoring skips an excluded element's whole subtree, so one class on one element redirects anchoring to the trailing sentinel without a descendant rule that would restyle every timeline node each time the bottom attaches or detaches. Browsers without scroll anchoring (WebKit) never get the class: the toggle would be a pure invalidation cost. */} -
- {children} -
-
- {footer ? ( - // The sticky footer is excluded from anchor selection outright: - // it moves with the scrollport, so anchoring to it (or to a - // control inside it) would turn its own height changes into - // scroll jumps. Static exclusion keeps the previous - // `.scroll-bottom-anchor-content *` coverage of this subtree - // without a toggling class; while the wrapper is not excluded - // it always wins selection anyway, so nothing else changes.
- {footer} + {children}
- ) : null} +
+ {footer ? ( + // The sticky footer is excluded from anchor selection outright: + // it moves with the scrollport, so anchoring to it (or to a + // control inside it) would turn its own height changes into + // scroll jumps. Static exclusion keeps the previous + // `.scroll-bottom-anchor-content *` coverage of this subtree + // without a toggling class; while the wrapper is not excluded + // it always wins selection anyway, so nothing else changes. +
+ {footer} +
+ ) : null} +
+ {scrollOverlay ? ( +
+
{scrollOverlay}
+
+ ) : null}
- {scrollOverlay ? ( -
-
{scrollOverlay}
-
- ) : null} -
+ ); } diff --git a/apps/app/src/lib/system-config-atoms.ts b/apps/app/src/lib/system-config-atoms.ts index cba450490c..7bb6b1fdc7 100644 --- a/apps/app/src/lib/system-config-atoms.ts +++ b/apps/app/src/lib/system-config-atoms.ts @@ -25,6 +25,7 @@ const unavailableSystemConfig: SystemConfigResponse = { editMessages: false, mobileApp: false, providerSessionReaping: false, + timelineWindowing: false, }, appearance: defaultAppTheme, customThemes: [], diff --git a/apps/app/src/views/SettingsView.experiments.test.tsx b/apps/app/src/views/SettingsView.experiments.test.tsx index 06a82bba53..037c808cdf 100644 --- a/apps/app/src/views/SettingsView.experiments.test.tsx +++ b/apps/app/src/views/SettingsView.experiments.test.tsx @@ -9,6 +9,7 @@ function renderSection(overrides?: { onChangelogPreviewEnabledChange?: (enabled: boolean) => void; onMobileAppEnabledChange?: (enabled: boolean) => void; onProviderSessionReapingEnabledChange?: (enabled: boolean) => void; + onTimelineWindowingEnabledChange?: (enabled: boolean) => void; }) { return render( , ); } @@ -50,4 +55,11 @@ describe("ExperimentsSettingsSection", () => { fireEvent.click(screen.getByLabelText("Idle provider session release")); expect(onChange).toHaveBeenCalledWith(true); }); + + it("reports timeline windowing changes", () => { + const onChange = vi.fn(); + renderSection({ onTimelineWindowingEnabledChange: onChange }); + fireEvent.click(screen.getByLabelText("Timeline windowing")); + expect(onChange).toHaveBeenCalledWith(true); + }); }); diff --git a/apps/app/src/views/SettingsView.stories.tsx b/apps/app/src/views/SettingsView.stories.tsx index 48476fb150..0e51e3deb1 100644 --- a/apps/app/src/views/SettingsView.stories.tsx +++ b/apps/app/src/views/SettingsView.stories.tsx @@ -351,6 +351,7 @@ function ExperimentsStory() { editMessagesEnabled={state.experiments.editMessages} mobileAppEnabled={state.experiments.mobileApp} providerSessionReapingEnabled={state.experiments.providerSessionReaping} + timelineWindowingEnabled={state.experiments.timelineWindowing} onChangelogPreviewEnabledChange={(enabled) => state.setExperiments((current) => ({ ...current, @@ -375,6 +376,12 @@ function ExperimentsStory() { providerSessionReaping: enabled, })) } + onTimelineWindowingEnabledChange={(enabled) => + state.setExperiments((current) => ({ + ...current, + timelineWindowing: enabled, + })) + } /> ); } diff --git a/apps/app/src/views/SettingsView.tsx b/apps/app/src/views/SettingsView.tsx index 093881992b..f49dcc515b 100644 --- a/apps/app/src/views/SettingsView.tsx +++ b/apps/app/src/views/SettingsView.tsx @@ -213,10 +213,12 @@ export interface ExperimentsSettingsSectionProps { editMessagesEnabled: boolean; mobileAppEnabled: boolean; providerSessionReapingEnabled: boolean; + timelineWindowingEnabled: boolean; onChangelogPreviewEnabledChange: (enabled: boolean) => void; onEditMessagesEnabledChange: (enabled: boolean) => void; onMobileAppEnabledChange: (enabled: boolean) => void; onProviderSessionReapingEnabledChange: (enabled: boolean) => void; + onTimelineWindowingEnabledChange: (enabled: boolean) => void; } const THEME_PREFERENCE_OPTIONS: ReadonlyArray = [ @@ -969,16 +971,19 @@ const EDIT_MESSAGES_EXPERIMENT_LABEL = "Edit messages"; const MOBILE_APP_EXPERIMENT_LABEL = "Mobile app"; const PROVIDER_SESSION_REAPING_EXPERIMENT_LABEL = "Idle provider session release"; +const TIMELINE_WINDOWING_EXPERIMENT_LABEL = "Timeline windowing"; export function ExperimentsSettingsSection({ changelogPreviewEnabled, disabled, editMessagesEnabled, mobileAppEnabled, providerSessionReapingEnabled, + timelineWindowingEnabled, onChangelogPreviewEnabledChange, onEditMessagesEnabledChange, onMobileAppEnabledChange, onProviderSessionReapingEnabledChange, + onTimelineWindowingEnabledChange, }: ExperimentsSettingsSectionProps) { return ( + + + +
); @@ -1219,6 +1236,13 @@ export function SettingsView() { providerSessionReaping: enabled, }) } + timelineWindowingEnabled={experiments.timelineWindowing} + onTimelineWindowingEnabledChange={(enabled) => + updateExperimentsMutation.mutate({ + ...experiments, + timelineWindowing: enabled, + }) + } /> ); } else if (activeSection === "marketplaces") { diff --git a/apps/cli/src/__tests__/command-output/settings.test.ts b/apps/cli/src/__tests__/command-output/settings.test.ts index 1785960b45..6f37e46f61 100644 --- a/apps/cli/src/__tests__/command-output/settings.test.ts +++ b/apps/cli/src/__tests__/command-output/settings.test.ts @@ -110,6 +110,26 @@ describe("bb settings commands", () => { }); }); + it("updates timeline windowing while preserving every experiment", async () => { + const updateExperiments = vi.fn(async ({ json }) => json); + stubServerApi({ + "v1.system.config.$get": vi.fn(async () => ({ + generalSettings: defaultAppSettings, + experiments: defaultExperiments, + })), + "v1.settings.experiments.$put": updateExperiments, + }); + + await runCommand( + ["settings", "experiment", "timelineWindowing", "true"], + register, + ); + + expect(updateExperiments).toHaveBeenCalledWith({ + json: { ...defaultExperiments, timelineWindowing: true }, + }); + }); + it("reads usage from a selected machine", async () => { const getUsage = vi.fn(async () => ({ codex: { status: "unauthenticated" }, diff --git a/apps/desktop/test/preload-build.test.ts b/apps/desktop/test/preload-build.test.ts index 6cbe09808e..fd3c17762a 100644 --- a/apps/desktop/test/preload-build.test.ts +++ b/apps/desktop/test/preload-build.test.ts @@ -131,6 +131,7 @@ async function startDesktopSmokeServer( editMessages: false, mobileApp: false, providerSessionReaping: false, + timelineWindowing: false, }, featureFlags: { placeholder: false, diff --git a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md index 335bcf708a..e4f52f53c9 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md @@ -117,6 +117,9 @@ message agents, or inspect projects, providers, and environments. failed or incomplete turns. Submitting an edit to a running thread stops and settles the current turn first. Change it with: `bb settings experiment editMessages `. +- The default-off `timelineWindowing` experiment mounts only nearby rows in + long timelines and large expanded timeline details. Change it with + `bb settings experiment timelineWindowing `. - Thread timeline windows are capped by event count as well as by user-message count (`BB_FF_TIMELINE_WINDOW_EVENT_BUDGET`, default 1500), because a thread with few user messages but many events would otherwise reproject its whole diff --git a/apps/server/src/services/skills/builtin-skills/bb-cli/references/app-settings.md b/apps/server/src/services/skills/builtin-skills/bb-cli/references/app-settings.md index 5e4ccd2e48..28e60f7e95 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/references/app-settings.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/references/app-settings.md @@ -63,3 +63,10 @@ every window and client sees the same value. - The `changelogPreview` experiment defaults to false. - Enable it with `bb settings experiment changelogPreview true` to show the latest release notes on Settings → Updates. + +## Timeline windowing + +- The `timelineWindowing` experiment defaults to false. +- Enable it with `bb settings experiment timelineWindowing true`. +- It keeps stable timeline wrappers while mounting only rows near the active + main or nested detail scrollport. diff --git a/apps/server/test/system/experiments.test.ts b/apps/server/test/system/experiments.test.ts index 4a7d520cea..23748a98e8 100644 --- a/apps/server/test/system/experiments.test.ts +++ b/apps/server/test/system/experiments.test.ts @@ -18,6 +18,7 @@ describe("experiments settings", () => { editMessages: true, mobileApp: false, providerSessionReaping: false, + timelineWindowing: false, }); }); }); @@ -32,6 +33,7 @@ describe("experiments settings", () => { editMessages: true, mobileApp: true, providerSessionReaping: true, + timelineWindowing: true, }), }); expect(put.status).toBe(200); @@ -40,12 +42,14 @@ describe("experiments settings", () => { editMessages: true, mobileApp: true, providerSessionReaping: true, + timelineWindowing: true, }); expect(getExperiments(harness.db)).toEqual({ changelogPreview: true, editMessages: true, mobileApp: true, providerSessionReaping: true, + timelineWindowing: true, }); const config = await harness.app.request("/api/v1/system/config"); @@ -56,6 +60,7 @@ describe("experiments settings", () => { editMessages: true, mobileApp: true, providerSessionReaping: true, + timelineWindowing: true, }); }); }); @@ -82,6 +87,7 @@ describe("experiments settings", () => { editMessages: true, mobileApp: false, providerSessionReaping: true, + timelineWindowing: false, }), }); const updated = await harness.app.request("/internal/runtime-policy", { @@ -106,6 +112,7 @@ describe("experiments settings", () => { editMessages: false, mobileApp: false, providerSessionReaping: false, + timelineWindowing: false, }), }); expect(put.status).toBe(200); diff --git a/docs/configuration.md b/docs/configuration.md index f9680a5103..08091272c4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -662,6 +662,11 @@ turns, commands, agents, workflows, and monitors keep their sessions loaded. The experiment does not gate release: BB releases idle Codex sessions with the experiment off, which is the behavior it had before this setting. +The `timelineWindowing` experiment is off by default. When enabled, long +timelines and large expanded timeline details retain stable height-preserving +wrappers while mounting only rows near their active scrollport. Toggle it with +`bb settings experiment timelineWindowing `. + ## Thread Timeline Window A thread-timeline window is bounded by segment (user-message) count _and_ by diff --git a/packages/db/test/experiments.test.ts b/packages/db/test/experiments.test.ts index e2d160d6e2..b4bb1d0ad3 100644 --- a/packages/db/test/experiments.test.ts +++ b/packages/db/test/experiments.test.ts @@ -40,6 +40,7 @@ describe("experiments", () => { "futureExperiment", "mobileApp", "providerSessionReaping", + "timelineWindowing", ]); } finally { db.$client.close(); diff --git a/packages/domain/src/experiments.ts b/packages/domain/src/experiments.ts index 752b7e5982..be91da5eac 100644 --- a/packages/domain/src/experiments.ts +++ b/packages/domain/src/experiments.ts @@ -15,6 +15,7 @@ export const experimentKeys = [ "editMessages", "mobileApp", "providerSessionReaping", + "timelineWindowing", ] as const; export const experimentKeySchema = z.enum(experimentKeys); export type ExperimentKey = z.infer; @@ -31,4 +32,5 @@ export const defaultExperiments: Experiments = { editMessages: true, mobileApp: false, providerSessionReaping: false, + timelineWindowing: false, }; diff --git a/packages/templates/src/templates/bb-guide-customization.md b/packages/templates/src/templates/bb-guide-customization.md index b9dab2f075..ae761f5d7d 100644 --- a/packages/templates/src/templates/bb-guide-customization.md +++ b/packages/templates/src/templates/bb-guide-customization.md @@ -115,6 +115,10 @@ The daemon applies a changed value within five minutes. Active turns, commands, agents, workflows, and monitors keep their sessions loaded. BB releases idle Codex sessions with the experiment off as well. +The default-off `timelineWindowing` experiment mounts only nearby rows in long +timelines and large expanded timeline details. Enable it with +`bb settings experiment timelineWindowing true`. + Thread timeline windows are bounded by event count as well as user-message count (`BB_FF_TIMELINE_WINDOW_EVENT_BUDGET`, default 1500), so a long thread stops reprojecting its whole history — and blocking the server event loop — on