Skip to content
Closed
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
7 changes: 7 additions & 0 deletions apps/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ const ProjectSettingsView = lazy(() =>
const splitWorkspaceRouteModule = import("./views/SplitWorkspaceRoute");
splitWorkspaceRouteModule.catch(() => {});
const SplitWorkspaceRoute = lazy(() => splitWorkspaceRouteModule);
// Same reasoning for the timeline windowing chunk: windowing defaults on for
// compact viewports, and until the chunk lands the loader's Suspense fallback
// mounts loaded rows without virtualization, so a first long-thread open on a
// cold connection would pay boot parse → route chunk → windowed chunk in
// series. Warming it here keeps it a separate chunk (nothing static imports
// it) while React.lazy resolves from the module cache.
import("./components/thread/timeline/TimelineWindowedItems").catch(() => {});

export function LegacyAutomationDetailRedirect() {
const location = useLocation();
Expand Down
16 changes: 16 additions & 0 deletions apps/app/src/components/thread/timeline/ExpandableTimelineRow.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import {
memo,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
type FocusEvent,
Expand Down Expand Up @@ -29,6 +32,7 @@ import {
type TimelineTitleActionResolver,
type TimelineTitleLinkResolver,
} from "./TimelineTitleView.js";
import { TimelineWindowingGeometryInvalidateContext } from "./TimelineWindowedItemsLoader.js";

interface ExpandableTimelineRowProps {
autoExpanded?: boolean;
Expand Down Expand Up @@ -125,6 +129,18 @@ function ExpandableTimelineRowComponent({
setCollapsedPreviewActive(false);
}
}, [isExpanded]);
const invalidateWindowingGeometry = useContext(
TimelineWindowingGeometryInvalidateContext,
);
const previousIsExpandedRef = useRef(isExpanded);
// Expanding or collapsing this row moves every windowed list below it (and
// any nested list inside it) within the shared scroll root without resizing
// the root; tell mounted windowed lists to re-read scroll geometry.
useLayoutEffect(() => {
if (previousIsExpandedRef.current === isExpanded) return;
previousIsExpandedRef.current = isExpanded;
invalidateWindowingGeometry();
}, [invalidateWindowingGeometry, isExpanded]);
const horizontalPaddingClass =
timelineRowHorizontalPaddingClassName(horizontalPadding);
const handleToggle = useCallback((): void => {
Expand Down
68 changes: 44 additions & 24 deletions apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
useContext,
useEffect,
useMemo,
useReducer,
useRef,
useState,
useSyncExternalStore,
Expand Down Expand Up @@ -133,6 +134,8 @@ import {
} from "@/components/ui/markdown-message-directives.js";
import {
TimelineWindowedItemsLoader,
TimelineWindowingGeometryInvalidateContext,
TimelineWindowingGeometryRevisionContext,
TimelineWindowingMeasurementsContext,
TimelineWindowingScrollRootContext,
type TimelineWindowedItemRenderState,
Expand Down Expand Up @@ -2168,7 +2171,7 @@ function TimelineRowsList({
itemKeys={itemKeys}
measurements={measurements}
minItemCount={
spacing === "top-level" ? (isCompactViewport ? 40 : 60) : 20
spacing === "top-level" ? (isCompactViewport ? 16 : 60) : 20
}
renderItem={(index, windowedState) => {
const item = items[index];
Expand Down Expand Up @@ -2234,6 +2237,13 @@ function ThreadTimelineRowsComponent(props: ThreadTimelineRowsProps) {
function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) {
const getViewRows = useTimelineViewRowsCache();
const [windowingMeasurements] = useState(() => new Map<string, number>());
// Expand/collapse commits can move a windowed list within its scroll root
// without resizing the root; the bumped revision tells every mounted
// windowed list (top-level and nested) to re-read scroll geometry.
const [windowingGeometryRevision, invalidateWindowingGeometry] = useReducer(
(revision: number) => revision + 1,
0,
);
const rows = useMemo(
() => getViewRows(props.timelineRows),
[getViewRows, props.timelineRows],
Expand Down Expand Up @@ -2504,29 +2514,39 @@ function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) {
<TimelineWindowingEnabledContext.Provider
value={props.timelineWindowingEnabled ?? false}
>
<AutoHeightContainer snapRevision={heightSnapRevision}>
<TimelineRowsList
hasOlderTimelineRows={props.hasOlderTimelineRows}
isLoadingOlderTimelineRows={
props.isLoadingOlderTimelineRows
}
navigationTargetRowId={
props.timelineNavigationTargetRowId
}
onLoadOlderRows={props.onLoadOlderRows}
rows={rows}
scopeActive={scopeActive}
showAssistantMessageActions={true}
compactActivityIntents={false}
spacing="top-level"
unreadDividerAutoScroll={
props.unreadDividerAutoScroll ?? true
}
unreadDividerPlacement={
props.unreadDividerPlacement ?? null
}
/>
</AutoHeightContainer>
<TimelineWindowingGeometryRevisionContext.Provider
value={windowingGeometryRevision}
>
<TimelineWindowingGeometryInvalidateContext.Provider
value={invalidateWindowingGeometry}
>
<AutoHeightContainer
snapRevision={heightSnapRevision}
>
<TimelineRowsList
hasOlderTimelineRows={props.hasOlderTimelineRows}
isLoadingOlderTimelineRows={
props.isLoadingOlderTimelineRows
}
navigationTargetRowId={
props.timelineNavigationTargetRowId
}
onLoadOlderRows={props.onLoadOlderRows}
rows={rows}
scopeActive={scopeActive}
showAssistantMessageActions={true}
compactActivityIntents={false}
spacing="top-level"
unreadDividerAutoScroll={
props.unreadDividerAutoScroll ?? true
}
unreadDividerPlacement={
props.unreadDividerPlacement ?? null
}
/>
</AutoHeightContainer>
</TimelineWindowingGeometryInvalidateContext.Provider>
</TimelineWindowingGeometryRevisionContext.Provider>
</TimelineWindowingEnabledContext.Provider>
</TimelineWindowingMeasurementsContext.Provider>
{hasSelectionActions ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @vitest-environment jsdom

import { cleanup, render, waitFor } from "@testing-library/react";
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
Expand Down Expand Up @@ -242,4 +242,122 @@ describe("ThreadTimelineRows windowing experiment", () => {
}),
);
});

it("windows a 20-row top-level timeline on a compact viewport", async () => {
const scrollElement = document.createElement("div");
scrollElement.setAttribute("data-test-main-scroll", "");
Object.defineProperty(scrollElement, "clientHeight", { value: 800 });
Object.defineProperty(scrollElement, "scrollHeight", { value: 2_000 });
const bottomAnchor: BottomAnchorContextValue = {
captureScrollAnchor: vi.fn(),
getScrollElement: () => scrollElement,
isAtBottom: false,
scrollElementIntoView: vi.fn(),
scrollElementIntoViewClampedToMaxScroll: vi.fn(),
scrollToBottom: vi.fn(),
};
const rows = Array.from({ length: 20 }, (_, index) =>
conversationRow({
id: `short-message-${index}`,
role: index % 2 === 0 ? "user" : "assistant",
sourceSeqEnd: index + 1,
sourceSeqStart: index + 1,
text: `Short message ${index}`,
}),
);
const queryClient = new QueryClient();
const view = render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<BottomAnchorContext.Provider value={bottomAnchor}>
<CompactViewportOverrideProvider isCompactViewport>
<ThreadTimelineRows
timelineRows={rows}
timelineWindowingEnabled
threadRuntimeDisplayStatus="idle"
workspaceRootPath={undefined}
/>
</CompactViewportOverrideProvider>
</BottomAnchorContext.Provider>
</QueryClientProvider>
</MemoryRouter>,
);

// Phones window from 16 top-level rows; 20 rows stayed fully mounted
// under the old 40-row floor.
await waitFor(() =>
expect(
view.container.querySelector("[data-timeline-virtual-spacer]"),
).not.toBeNull(),
);
});

it("re-reads windowing scroll geometry when a row expands", 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 bottomAnchor: BottomAnchorContextValue = {
captureScrollAnchor: vi.fn(),
getScrollElement: () => scrollElement,
isAtBottom: false,
scrollElementIntoView: vi.fn(),
scrollElementIntoViewClampedToMaxScroll: vi.fn(),
scrollToBottom: vi.fn(),
};
const rows = [
...Array.from({ length: 80 }, (_, index) =>
conversationRow({
id: `message-${index}`,
role: index % 2 === 0 ? "user" : "assistant",
sourceSeqEnd: index + 1,
sourceSeqStart: index + 1,
text: `Message ${index}`,
}),
),
delegationRow({
id: "expandable-delegation",
output: "Delegation output.",
sourceSeqEnd: 81,
sourceSeqStart: 81,
}),
];
const queryClient = new QueryClient();
const view = render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<BottomAnchorContext.Provider value={bottomAnchor}>
<ThreadTimelineRows
timelineRows={rows}
timelineWindowingEnabled
threadRuntimeDisplayStatus="idle"
workspaceRootPath={undefined}
/>
</BottomAnchorContext.Provider>
</QueryClientProvider>
</MemoryRouter>,
);
const toggle = view.container.querySelector<HTMLButtonElement>(
'[data-timeline-row-id="expandable-delegation"] button[aria-expanded]',
);
expect(toggle).not.toBeNull();

const boundingRectSpy = vi.mocked(
HTMLElement.prototype.getBoundingClientRect,
);
const scrollElementReads = () =>
boundingRectSpy.mock.contexts.filter(
(context) => context === scrollElement,
).length;
const settledReads = scrollElementReads();

// Expanding moves every windowed list below the row within the scroll
// root without resizing the root; the expansion path must bump the
// geometry revision so the windowed list re-reads its scroll margin.
fireEvent.click(toggle as HTMLButtonElement);

await waitFor(() =>
expect(scrollElementReads()).toBeGreaterThan(settledReads),
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport";
import { useSystemConfig } from "@/hooks/queries/system-queries";
import { toUserAttachmentImageSrc } from "@/lib/user-attachment-images";
import { ThreadTimelineRows } from "./ThreadTimelineRows.js";
Expand Down Expand Up @@ -180,8 +181,13 @@ export function ThreadTimelineSurface({
workspaceRootPath,
}: ThreadTimelineSurfaceProps) {
const systemConfigQuery = useSystemConfig();
const isCompactViewport = useIsCompactViewport();
// Compact viewports default timeline windowing on: phones are where the
// unwindowed tree hangs, and the server cannot own this default because it
// depends on the viewport. The experiment stays the kill switch — a served
// false still disables windowing here; desktop keeps the served value.
const timelineWindowingEnabled =
systemConfigQuery.data?.experiments.timelineWindowing ?? false;
systemConfigQuery.data?.experiments.timelineWindowing ?? isCompactViewport;
const showActiveThinking =
activeThinking !== null && ongoingIndicatorLabel === undefined;
const activeThinkingText = activeThinking?.text.trim() ?? "";
Expand Down
Loading
Loading