Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ vi.mock("@/hooks/queries/system-queries", () => ({
editMessages: false,
mobileApp: false,
providerSessionReaping: false,
timelineWindowing: false,
},
},
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ vi.mock("@/hooks/queries/system-queries", () => ({
editMessages: false,
mobileApp: false,
providerSessionReaping: false,
timelineWindowing: false,
},
},
}),
Expand Down
272 changes: 215 additions & 57 deletions apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -444,6 +457,8 @@ const StreamingAssistantMessageIdContext = createContext<string | null>(null);
const EMPTY_ROW_ID_SET: ReadonlySet<string> = new Set<string>();
const TimelineSearchExpansionContext =
createContext<ReadonlySet<string>>(EMPTY_ROW_ID_SET);
const TimelineWindowingEnabledContext = createContext(false);
const TIMELINE_TERMINAL_EXPANSION_RETENTION = 24;
const SKILL_FILE_NAME = "SKILL.md";

function useTimelineRendererStaticContext(): TimelineRendererStaticContextValue {
Expand Down Expand Up @@ -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<HTMLDivElement>(null);
useArmTopLevelTimelineRowContainment(wrapperRef);
const composedRef = useComposedRefs(wrapperRef, windowedState.itemRef);
const isTopLevel = spacing === "top-level";
useArmTopLevelTimelineRowContainment(
wrapperRef,
isTopLevel && !windowedState.windowingEnabled,
);
return (
<div
ref={wrapperRef}
ref={composedRef}
data-timeline-row-id={row.id}
className={TOP_LEVEL_TIMELINE_ROW_INTRINSIC_SIZE_CLASS_NAME}
style={timelineRowContainmentStyle(row)}
data-timeline-window-key={row.id}
data-index={windowedState.itemIndex}
data-timeline-windowed-realized={
windowedState.windowingEnabled
? String(windowedState.isRealized)
: undefined
}
className={
isTopLevel && !windowedState.windowingEnabled
? TOP_LEVEL_TIMELINE_ROW_INTRINSIC_SIZE_CLASS_NAME
: undefined
}
style={
windowedState.itemStyle ??
(isTopLevel && !windowedState.windowingEnabled
? timelineRowContainmentStyle(row)
: undefined)
}
>
{children}
</div>
);
}

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,
Expand All @@ -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<string, number>());
const measurements = inheritedMeasurements ?? standaloneMeasurements;
const searchExpandedRowIds = useTimelineSearchExpansionRowIds(rows);
const stableSearchExpandedRowIds = useStableReadonlySet(searchExpandedRowIds);
useScrollToSearchedMessage(rows, threadId, {
Expand All @@ -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<string>();
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 (
<TimelineSearchExpansionContext.Provider value={stableSearchExpandedRowIds}>
<div
Expand All @@ -2042,39 +2141,70 @@ function TimelineRowsList({
)}
data-timeline-row-list={spacing}
>
{items.map((item) => {
if (item.kind === "unread-divider") {
return (
<TimelineUnreadDivider
key={item.id}
autoScroll={unreadDividerAutoScroll}
/>
);
<TimelineWindowedItemsLoader
enabled={timelineWindowingEnabled}
alwaysMountedKeys={alwaysMountedKeys}
estimateItemHeight={(index) => {
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 = (
<MemoizedTimelineRowView
activeLatestBundleId={activeLatestBundleId}
row={item.row}
scopeActive={scopeActive}
showAssistantMessageActions={showAssistantMessageActions}
spacing={spacing}
compactActivityIntents={compactActivityIntents}
/>
);
if (spacing === "top-level") {
renderItem={(index, windowedState) => {
const item = items[index];
if (item === undefined) {
return null;
}
if (item.kind === "unread-divider") {
return (
<div
key={item.id}
ref={windowedState.itemRef}
data-index={windowedState.itemIndex}
data-timeline-window-key={`divider:${item.id}`}
data-timeline-windowed-realized={
windowedState.windowingEnabled
? String(windowedState.isRealized)
: undefined
}
style={windowedState.itemStyle}
>
{windowedState.isRealized ? (
<TimelineUnreadDivider
autoScroll={unreadDividerAutoScroll}
/>
) : null}
</div>
);
}
return (
<TopLevelTimelineRowWrapper key={item.row.id} row={item.row}>
{rowView}
</TopLevelTimelineRowWrapper>
<TimelineRowItemWrapper
key={item.row.id}
row={item.row}
spacing={spacing}
windowedState={windowedState}
>
{windowedState.isRealized ? (
<MemoizedTimelineRowView
activeLatestBundleId={activeLatestBundleId}
row={item.row}
scopeActive={scopeActive}
showAssistantMessageActions={showAssistantMessageActions}
spacing={spacing}
compactActivityIntents={compactActivityIntents}
/>
) : null}
</TimelineRowItemWrapper>
);
}
return (
<div key={item.row.id} data-timeline-row-id={item.row.id}>
{rowView}
</div>
);
})}
}}
/>
</div>
</TimelineSearchExpansionContext.Provider>
);
Expand All @@ -2090,6 +2220,7 @@ function ThreadTimelineRowsComponent(props: ThreadTimelineRowsProps) {

function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) {
const getViewRows = useTimelineViewRowsCache();
const [windowingMeasurements] = useState(() => new Map<string, number>());
const rows = useMemo(
() => getViewRows(props.timelineRows),
[getViewRows, props.timelineRows],
Expand Down Expand Up @@ -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<string>());
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,
Expand Down Expand Up @@ -2335,26 +2485,34 @@ function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) {
<TimelineTurnStateContext.Provider
value={turnStateContextValue}
>
<AutoHeightContainer snapRevision={heightSnapRevision}>
<TimelineRowsList
hasOlderTimelineRows={props.hasOlderTimelineRows}
isLoadingOlderTimelineRows={
props.isLoadingOlderTimelineRows
}
onLoadOlderRows={props.onLoadOlderRows}
rows={rows}
scopeActive={scopeActive}
showAssistantMessageActions={true}
compactActivityIntents={false}
spacing="top-level"
unreadDividerAutoScroll={
props.unreadDividerAutoScroll ?? true
}
unreadDividerPlacement={
props.unreadDividerPlacement ?? null
}
/>
</AutoHeightContainer>
<TimelineWindowingMeasurementsContext.Provider
value={windowingMeasurements}
>
<TimelineWindowingEnabledContext.Provider
value={props.timelineWindowingEnabled ?? false}
>
<AutoHeightContainer snapRevision={heightSnapRevision}>
<TimelineRowsList
hasOlderTimelineRows={props.hasOlderTimelineRows}
isLoadingOlderTimelineRows={
props.isLoadingOlderTimelineRows
}
onLoadOlderRows={props.onLoadOlderRows}
rows={rows}
scopeActive={scopeActive}
showAssistantMessageActions={true}
compactActivityIntents={false}
spacing="top-level"
unreadDividerAutoScroll={
props.unreadDividerAutoScroll ?? true
}
unreadDividerPlacement={
props.unreadDividerPlacement ?? null
}
/>
</AutoHeightContainer>
</TimelineWindowingEnabledContext.Provider>
</TimelineWindowingMeasurementsContext.Provider>
{hasSelectionActions ? (
<TimelineSelectionMenu
selection={activeSelection?.selection ?? null}
Expand Down
Loading
Loading