diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx
index faecc7f51..f8e3401f8 100644
--- a/apps/web/src/components/ChatView.browser.tsx
+++ b/apps/web/src/components/ChatView.browser.tsx
@@ -2,12 +2,14 @@
import "../index.css";
import {
+ CheckpointRef,
ORCHESTRATION_WS_METHODS,
type MessageId,
type OrchestrationReadModel,
type ProjectId,
type ServerConfig,
type ThreadId,
+ type TurnId,
type WsWelcomePayload,
WS_CHANNELS,
WS_METHODS,
@@ -32,6 +34,7 @@ const PROJECT_ID = "project-1" as ProjectId;
const NOW_ISO = "2026-03-04T12:00:00.000Z";
const BASE_TIME_MS = Date.parse(NOW_ISO);
const ATTACHMENT_SVG = "";
+const BUTTON_POSITION_TOLERANCE_PX = 6;
interface WsRequestEnvelope {
id: string;
@@ -86,6 +89,7 @@ interface UserRowMeasurement {
interface MountedChatView {
cleanup: () => Promise;
+ host: HTMLElement;
measureUserRow: (targetMessageId: MessageId) => Promise;
setViewport: (viewport: ViewportSpec) => Promise;
router: ReturnType;
@@ -111,6 +115,18 @@ function createBaseServerConfig(): ServerConfig {
},
],
availableEditors: [],
+ telegram: {
+ transport: "threaded-private",
+ mode: "disabled",
+ hasBotToken: false,
+ chatId: null,
+ chatTitle: null,
+ botUsername: null,
+ hasTopicsEnabled: null,
+ allowsUserCreatedTopics: null,
+ setupExpiresAt: null,
+ errorMessage: null,
+ },
};
}
@@ -237,6 +253,120 @@ function createSnapshotForTargetUser(options: {
};
}
+function createSnapshotWithVirtualizedAssistantDiffSummary(options?: {
+ pairCount?: number;
+ overlapPairIndex?: number;
+}): {
+ assistantMessageId: MessageId;
+ snapshot: OrchestrationReadModel;
+ userMessageId: MessageId;
+} {
+ const pairCount = options?.pairCount ?? 5;
+ const overlapPairIndex = options?.overlapPairIndex ?? 0;
+ const turnId = "turn-overlap-virtualized" as TurnId;
+ const assistantMessageId = "msg-assistant-overlap-target" as MessageId;
+ const userMessageId = "msg-user-overlap-next" as MessageId;
+ const messages: Array = [];
+
+ for (let index = 0; index < pairCount; index += 1) {
+ const isOverlapPair = index === overlapPairIndex;
+ messages.push(
+ createUserMessage({
+ id: (isOverlapPair ? "msg-user-overlap-anchor" : `msg-user-overlap-${index}`) as MessageId,
+ text: isOverlapPair ? "anchor prompt" : `filler user message ${index}`,
+ offsetSeconds: messages.length * 3,
+ }),
+ );
+ messages.push(
+ createAssistantMessage({
+ id: isOverlapPair ? assistantMessageId : (`msg-assistant-overlap-${index}` as MessageId),
+ text: isOverlapPair
+ ? "assistant response with a large changed files summary"
+ : `assistant filler ${index}`,
+ offsetSeconds: messages.length * 3,
+ }),
+ );
+ }
+
+ const overlapNextUserMessageIndex = overlapPairIndex * 2 + 2;
+ messages[overlapNextUserMessageIndex] = createUserMessage({
+ id: userMessageId,
+ text: "user message immediately after the virtualized assistant row",
+ offsetSeconds: overlapNextUserMessageIndex * 3,
+ });
+
+ const files = Array.from({ length: 18 }, (_, index) => {
+ const directory = `packages/feature-${Math.floor(index / 3) + 1}`;
+ const nestedDirectory = `${directory}/deep-${(index % 3) + 1}`;
+ return {
+ path: `${nestedDirectory}/file-${index + 1}.ts`,
+ kind: "modified",
+ additions: 10 + index,
+ deletions: index % 4,
+ };
+ });
+
+ return {
+ assistantMessageId,
+ userMessageId,
+ snapshot: {
+ snapshotSequence: 1,
+ projects: [
+ {
+ id: PROJECT_ID,
+ title: "Project",
+ workspaceRoot: "/repo/project",
+ defaultModel: "gpt-5",
+ scripts: [],
+ createdAt: NOW_ISO,
+ updatedAt: NOW_ISO,
+ deletedAt: null,
+ },
+ ],
+ threads: [
+ {
+ id: THREAD_ID,
+ projectId: PROJECT_ID,
+ title: "Browser test thread",
+ model: "gpt-5",
+ interactionMode: "default",
+ runtimeMode: "full-access",
+ branch: "main",
+ worktreePath: null,
+ latestTurn: null,
+ createdAt: NOW_ISO,
+ updatedAt: NOW_ISO,
+ deletedAt: null,
+ messages,
+ activities: [],
+ proposedPlans: [],
+ checkpoints: [
+ {
+ turnId,
+ checkpointTurnCount: 1,
+ checkpointRef: CheckpointRef.makeUnsafe("checkpoint-overlap-1"),
+ status: "ready",
+ files,
+ assistantMessageId,
+ completedAt: isoAt(30),
+ },
+ ],
+ session: {
+ threadId: THREAD_ID,
+ status: "ready",
+ providerName: "codex",
+ runtimeMode: "full-access",
+ activeTurnId: null,
+ lastError: null,
+ updatedAt: NOW_ISO,
+ },
+ },
+ ],
+ updatedAt: NOW_ISO,
+ },
+ };
+}
+
function buildFixture(snapshot: OrchestrationReadModel): TestFixture {
return {
snapshot,
@@ -564,6 +694,118 @@ async function waitForImagesToLoad(scope: ParentNode): Promise {
await waitForLayout();
}
+async function waitForMessageRow(options: {
+ host: HTMLElement;
+ messageId: MessageId;
+ role: "assistant" | "user";
+}): Promise {
+ return waitForElement(
+ () =>
+ options.host.querySelector(
+ `[data-message-id="${options.messageId}"][data-message-role="${options.role}"]`,
+ ),
+ `Unable to locate ${options.role} message row ${options.messageId}.`,
+ );
+}
+
+async function scrollElementIntoView(element: HTMLElement): Promise {
+ element.scrollIntoView({ block: "center" });
+ await waitForLayout();
+}
+
+async function waitForMessageRowButton(options: {
+ host: HTMLElement;
+ messageId: MessageId;
+ role: "assistant" | "user";
+ label: string;
+}): Promise {
+ let button: HTMLButtonElement | null = null;
+ await vi.waitFor(
+ () => {
+ const row = options.host.querySelector(
+ `[data-message-id="${options.messageId}"][data-message-role="${options.role}"]`,
+ );
+ button =
+ row &&
+ (Array.from(row.querySelectorAll("button")).find(
+ (candidate) => candidate.textContent?.trim() === options.label,
+ ) as HTMLButtonElement | null);
+ expect(
+ button,
+ `Unable to locate "${options.label}" button in ${options.role} message row ${options.messageId}.`,
+ ).toBeTruthy();
+ },
+ {
+ timeout: 8_000,
+ interval: 16,
+ },
+ );
+ if (!button) {
+ throw new Error(
+ `Unable to locate "${options.label}" button in ${options.role} message row ${options.messageId}.`,
+ );
+ }
+ return button;
+}
+
+async function waitForNoVerticalOverlap(options: {
+ host: HTMLElement;
+ previousAssistantMessageId: MessageId;
+ nextUserMessageId: MessageId;
+}): Promise<{
+ assistantRenderedInVirtualizedRegion: boolean;
+ nextUserRenderedInVirtualizedRegion: boolean;
+}> {
+ let assistantRenderedInVirtualizedRegion = false;
+ let nextUserRenderedInVirtualizedRegion = false;
+
+ await vi.waitFor(
+ async () => {
+ await waitForLayout();
+ const assistantRow = await waitForMessageRow({
+ host: options.host,
+ messageId: options.previousAssistantMessageId,
+ role: "assistant",
+ });
+ const nextUserRow = await waitForMessageRow({
+ host: options.host,
+ messageId: options.nextUserMessageId,
+ role: "user",
+ });
+
+ const assistantRect = assistantRow.getBoundingClientRect();
+ const nextUserRect = nextUserRow.getBoundingClientRect();
+ assistantRenderedInVirtualizedRegion =
+ assistantRow.closest("[data-index]") instanceof HTMLElement;
+ nextUserRenderedInVirtualizedRegion =
+ nextUserRow.closest("[data-index]") instanceof HTMLElement;
+
+ expect(
+ assistantRect.bottom,
+ "Expected the previous assistant row to end before the following user row starts.",
+ ).toBeLessThanOrEqual(nextUserRect.top + 1);
+ },
+ {
+ timeout: 8_000,
+ interval: 16,
+ },
+ );
+
+ return { assistantRenderedInVirtualizedRegion, nextUserRenderedInVirtualizedRegion };
+}
+
+async function clickButtonByText(host: HTMLElement, label: string): Promise {
+ const button = await waitForElement(
+ () =>
+ Array.from(host.querySelectorAll("button")).find(
+ (candidate) => candidate.textContent?.trim() === label,
+ ) as HTMLButtonElement | null,
+ `Unable to find "${label}" button.`,
+ );
+ button.click();
+ await waitForLayout();
+}
+
async function measureUserRow(options: {
host: HTMLElement;
targetMessageId: MessageId;
@@ -664,6 +906,7 @@ async function mountChatView(options: {
await screen.unmount();
host.remove();
},
+ host,
measureUserRow: async (targetMessageId: MessageId) => measureUserRow({ host, targetMessageId }),
setViewport: async (viewport: ViewportSpec) => {
await setViewport(viewport);
@@ -889,6 +1132,164 @@ describe("ChatView timeline estimator parity (full app)", () => {
},
);
+ it("keeps the last virtualized assistant diff summary from overlapping the next user row", async () => {
+ const overlapFixture = createSnapshotWithVirtualizedAssistantDiffSummary();
+ const mounted = await mountChatView({
+ viewport: DEFAULT_VIEWPORT,
+ snapshot: overlapFixture.snapshot,
+ });
+
+ try {
+ const initialMeasurement = await waitForNoVerticalOverlap({
+ host: mounted.host,
+ previousAssistantMessageId: overlapFixture.assistantMessageId,
+ nextUserMessageId: overlapFixture.userMessageId,
+ });
+
+ expect(initialMeasurement.assistantRenderedInVirtualizedRegion).toBe(false);
+ expect(initialMeasurement.nextUserRenderedInVirtualizedRegion).toBe(false);
+
+ await clickButtonByText(mounted.host, "Collapse all");
+ await waitForNoVerticalOverlap({
+ host: mounted.host,
+ previousAssistantMessageId: overlapFixture.assistantMessageId,
+ nextUserMessageId: overlapFixture.userMessageId,
+ });
+
+ await clickButtonByText(mounted.host, "Expand all");
+ await waitForNoVerticalOverlap({
+ host: mounted.host,
+ previousAssistantMessageId: overlapFixture.assistantMessageId,
+ nextUserMessageId: overlapFixture.userMessageId,
+ });
+ } finally {
+ await mounted.cleanup();
+ }
+ });
+
+ it("keeps assistant diff-summary rows and following rows out of the virtualized region", async () => {
+ const overlapFixture = createSnapshotWithVirtualizedAssistantDiffSummary({
+ pairCount: 12,
+ overlapPairIndex: 2,
+ });
+ const mounted = await mountChatView({
+ viewport: DEFAULT_VIEWPORT,
+ snapshot: overlapFixture.snapshot,
+ });
+
+ try {
+ const measurement = await waitForNoVerticalOverlap({
+ host: mounted.host,
+ previousAssistantMessageId: overlapFixture.assistantMessageId,
+ nextUserMessageId: overlapFixture.userMessageId,
+ });
+
+ expect(measurement.assistantRenderedInVirtualizedRegion).toBe(false);
+ expect(measurement.nextUserRenderedInVirtualizedRegion).toBe(false);
+ } finally {
+ await mounted.cleanup();
+ }
+ });
+
+ it("keeps the diff-summary button position stable enough after collapse", async () => {
+ const overlapFixture = createSnapshotWithVirtualizedAssistantDiffSummary();
+ const mounted = await mountChatView({
+ viewport: DEFAULT_VIEWPORT,
+ snapshot: overlapFixture.snapshot,
+ });
+
+ try {
+ const measurement = await waitForNoVerticalOverlap({
+ host: mounted.host,
+ previousAssistantMessageId: overlapFixture.assistantMessageId,
+ nextUserMessageId: overlapFixture.userMessageId,
+ });
+
+ expect(measurement.assistantRenderedInVirtualizedRegion).toBe(false);
+
+ const collapseButton = await waitForMessageRowButton({
+ host: mounted.host,
+ messageId: overlapFixture.assistantMessageId,
+ role: "assistant",
+ label: "Collapse all",
+ });
+ await scrollElementIntoView(collapseButton);
+ const collapseButtonTop = collapseButton.getBoundingClientRect().top;
+
+ collapseButton.click();
+ await waitForLayout();
+
+ const expandButton = await waitForMessageRowButton({
+ host: mounted.host,
+ messageId: overlapFixture.assistantMessageId,
+ role: "assistant",
+ label: "Expand all",
+ });
+ await scrollElementIntoView(expandButton);
+ const expandButtonTop = expandButton.getBoundingClientRect().top;
+
+ expect(Math.abs(expandButtonTop - collapseButtonTop)).toBeLessThanOrEqual(
+ BUTTON_POSITION_TOLERANCE_PX,
+ );
+ } finally {
+ await mounted.cleanup();
+ }
+ });
+
+ it("keeps the diff-summary button position stable enough after expand", async () => {
+ const overlapFixture = createSnapshotWithVirtualizedAssistantDiffSummary();
+ const mounted = await mountChatView({
+ viewport: DEFAULT_VIEWPORT,
+ snapshot: overlapFixture.snapshot,
+ });
+
+ try {
+ const measurement = await waitForNoVerticalOverlap({
+ host: mounted.host,
+ previousAssistantMessageId: overlapFixture.assistantMessageId,
+ nextUserMessageId: overlapFixture.userMessageId,
+ });
+
+ expect(measurement.assistantRenderedInVirtualizedRegion).toBe(false);
+
+ const collapseButton = await waitForMessageRowButton({
+ host: mounted.host,
+ messageId: overlapFixture.assistantMessageId,
+ role: "assistant",
+ label: "Collapse all",
+ });
+ await scrollElementIntoView(collapseButton);
+ collapseButton.click();
+ await waitForLayout();
+
+ const expandButton = await waitForMessageRowButton({
+ host: mounted.host,
+ messageId: overlapFixture.assistantMessageId,
+ role: "assistant",
+ label: "Expand all",
+ });
+ await scrollElementIntoView(expandButton);
+ const expandButtonTop = expandButton.getBoundingClientRect().top;
+
+ expandButton.click();
+ await waitForLayout();
+
+ const nextCollapseButton = await waitForMessageRowButton({
+ host: mounted.host,
+ messageId: overlapFixture.assistantMessageId,
+ role: "assistant",
+ label: "Collapse all",
+ });
+ const nextCollapseButtonTop = nextCollapseButton.getBoundingClientRect().top;
+
+ expect(Math.abs(nextCollapseButtonTop - expandButtonTop)).toBeLessThanOrEqual(
+ BUTTON_POSITION_TOLERANCE_PX,
+ );
+ } finally {
+ await mounted.cleanup();
+ }
+ });
+
it("opens the project cwd for draft threads without a worktree path", async () => {
useComposerDraftStore.setState({
draftThreadsByThreadId: {
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 52637695e..ee867bb88 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -1530,6 +1530,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
);
if (!trigger || !scrollContainer.contains(trigger)) return;
if (trigger.closest("[data-scroll-anchor-ignore]")) return;
+ const virtualizedRow = trigger.closest("[data-index]");
+ if (virtualizedRow && virtualizedRow.closest('[data-timeline-root="true"]')) return;
pendingInteractionAnchorRef.current = {
element: trigger,
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
index e30801041..8e1054a0a 100644
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -181,8 +181,18 @@ export const MessagesTimeline = memo(function MessagesTimeline({
}, [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt]);
const firstUnvirtualizedRowIndex = useMemo(() => {
+ const firstDiffSummaryRowIndex = rows.findIndex(
+ (row) =>
+ row.kind === "message" &&
+ row.message.role === "assistant" &&
+ turnDiffSummaryByAssistantMessageId.has(row.message.id),
+ );
const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0);
- if (!activeTurnInProgress) return firstTailRowIndex;
+ let firstUnvirtualizedIndex = firstTailRowIndex;
+ if (firstDiffSummaryRowIndex >= 0) {
+ firstUnvirtualizedIndex = Math.min(firstUnvirtualizedIndex, firstDiffSummaryRowIndex);
+ }
+ if (!activeTurnInProgress) return firstUnvirtualizedIndex;
const turnStartedAtMs =
typeof activeTurnStartedAt === "string" ? Date.parse(activeTurnStartedAt) : Number.NaN;
@@ -202,26 +212,35 @@ export const MessagesTimeline = memo(function MessagesTimeline({
);
}
- if (firstCurrentTurnRowIndex < 0) return firstTailRowIndex;
+ if (firstCurrentTurnRowIndex < 0) return firstUnvirtualizedIndex;
for (let index = firstCurrentTurnRowIndex - 1; index >= 0; index -= 1) {
const previousRow = rows[index];
if (!previousRow || previousRow.kind !== "message") continue;
if (previousRow.message.role === "user") {
- return Math.min(index, firstTailRowIndex);
+ return Math.min(index, firstUnvirtualizedIndex);
}
if (previousRow.message.role === "assistant" && !previousRow.message.streaming) {
break;
}
}
- return Math.min(firstCurrentTurnRowIndex, firstTailRowIndex);
- }, [activeTurnInProgress, activeTurnStartedAt, rows]);
+ return Math.min(firstCurrentTurnRowIndex, firstUnvirtualizedIndex);
+ }, [activeTurnInProgress, activeTurnStartedAt, rows, turnDiffSummaryByAssistantMessageId]);
const virtualizedRowCount = clamp(firstUnvirtualizedRowIndex, {
minimum: 0,
maximum: rows.length,
});
+ const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState<
+ Record
+ >({});
+ const onToggleAllDirectories = useCallback((turnId: TurnId) => {
+ setAllDirectoriesExpandedByTurnId((current) => ({
+ ...current,
+ [turnId]: !(current[turnId] ?? true),
+ }));
+ }, []);
const rowVirtualizer = useVirtualizer({
count: virtualizedRowCount,
@@ -236,7 +255,22 @@ export const MessagesTimeline = memo(function MessagesTimeline({
if (row.kind === "working") return 40;
return estimateTimelineMessageHeight(row.message, { timelineWidthPx });
},
- measureElement: measureVirtualElement,
+ measureElement: (element, entry, instance) => {
+ const index = instance.indexFromElement(element);
+ const row = rows[index];
+ const nextSize = measureVirtualElement(element, entry, instance);
+ if (!row) {
+ return nextSize;
+ }
+
+ const previousSize = measuredHeightByRowIdRef.current.get(row.id);
+ if (previousSize !== undefined && Math.abs(nextSize - previousSize) <= 1) {
+ return previousSize;
+ }
+
+ measuredHeightByRowIdRef.current.set(row.id, nextSize);
+ return nextSize;
+ },
useAnimationFrameWithResizeObserver: true,
overscan: 8,
});
@@ -245,17 +279,66 @@ export const MessagesTimeline = memo(function MessagesTimeline({
rowVirtualizer.measure();
}, [rowVirtualizer, timelineWidthPx]);
useEffect(() => {
- rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => {
+ rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
const viewportHeight = instance.scrollRect?.height ?? 0;
const scrollOffset = instance.scrollOffset ?? 0;
const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight);
- return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX;
+ const changedItemStartsAboveViewport = item.start < scrollOffset;
+ return changedItemStartsAboveViewport && remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX;
};
return () => {
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined;
};
}, [rowVirtualizer]);
const pendingMeasureFrameRef = useRef(null);
+ const measuredHeightByRowIdRef = useRef(new Map());
+ const virtualRowElementsByIdRef = useRef(new Map());
+ const virtualRowRefCallbacksByIdRef = useRef(
+ new Map void>(),
+ );
+ const dirtyVirtualRowIdsRef = useRef(new Set());
+ const previousHeightSignatureByRowIdRef = useRef(new Map());
+
+ const clearVirtualRowMeasurementTracking = useCallback((rowId: string) => {
+ virtualRowElementsByIdRef.current.delete(rowId);
+ }, []);
+
+ const clearAllVirtualRowMeasurementTracking = useCallback(() => {
+ const trackedRowIds = new Set(virtualRowElementsByIdRef.current.keys());
+ for (const rowId of trackedRowIds) {
+ clearVirtualRowMeasurementTracking(rowId);
+ }
+ }, [clearVirtualRowMeasurementTracking]);
+
+ const getVirtualRowRef = useCallback(
+ (rowId: string) => {
+ const cachedCallback = virtualRowRefCallbacksByIdRef.current.get(rowId);
+ if (cachedCallback) {
+ return cachedCallback;
+ }
+
+ const callback = (element: HTMLDivElement | null) => {
+ const previousElement = virtualRowElementsByIdRef.current.get(rowId);
+ if (previousElement === element) {
+ return;
+ }
+
+ clearVirtualRowMeasurementTracking(rowId);
+
+ if (!element) {
+ return;
+ }
+
+ virtualRowElementsByIdRef.current.set(rowId, element);
+ rowVirtualizer.measureElement(element);
+ };
+
+ virtualRowRefCallbacksByIdRef.current.set(rowId, callback);
+ return callback;
+ },
+ [clearVirtualRowMeasurementTracking, rowVirtualizer],
+ );
+
const onTimelineImageLoad = useCallback(() => {
if (pendingMeasureFrameRef.current !== null) return;
pendingMeasureFrameRef.current = window.requestAnimationFrame(() => {
@@ -269,20 +352,111 @@ export const MessagesTimeline = memo(function MessagesTimeline({
if (frame !== null) {
window.cancelAnimationFrame(frame);
}
+ clearAllVirtualRowMeasurementTracking();
};
- }, []);
+ }, [clearAllVirtualRowMeasurementTracking]);
const virtualRows = rowVirtualizer.getVirtualItems();
const nonVirtualizedRows = rows.slice(virtualizedRowCount);
- const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState<
- Record
- >({});
- const onToggleAllDirectories = useCallback((turnId: TurnId) => {
- setAllDirectoriesExpandedByTurnId((current) => ({
- ...current,
- [turnId]: !(current[turnId] ?? true),
- }));
- }, []);
+
+ const virtualizedHeightSignatures = useMemo(() => {
+ const nextSignatures = new Map();
+ for (const row of rows.slice(0, virtualizedRowCount)) {
+ if (row.kind === "work") {
+ nextSignatures.set(row.id, `work:${row.id}:${row.groupedEntries.length}`);
+ continue;
+ }
+
+ if (row.kind === "proposed-plan") {
+ nextSignatures.set(
+ row.id,
+ `proposed-plan:${row.id}:${row.proposedPlan.planMarkdown.length}`,
+ );
+ continue;
+ }
+
+ if (row.kind === "working") {
+ nextSignatures.set(row.id, `working:${row.id}`);
+ continue;
+ }
+
+ const diffSummary =
+ row.message.role === "assistant"
+ ? turnDiffSummaryByAssistantMessageId.get(row.message.id)
+ : undefined;
+ const diffSummaryTurnId = diffSummary?.turnId ?? "";
+ const diffSummaryFileCount = diffSummary?.files.length ?? 0;
+ const allDirectoriesExpanded =
+ diffSummary === undefined
+ ? ""
+ : String(allDirectoriesExpandedByTurnId[diffSummary.turnId] ?? true);
+
+ nextSignatures.set(
+ row.id,
+ [
+ "message",
+ row.id,
+ row.message.role,
+ row.message.text.length,
+ row.message.streaming ? 1 : 0,
+ row.message.attachments?.length ?? 0,
+ row.showCompletionDivider ? 1 : 0,
+ diffSummary ? 1 : 0,
+ diffSummaryTurnId,
+ diffSummaryFileCount,
+ allDirectoriesExpanded,
+ ].join(":"),
+ );
+ }
+ return nextSignatures;
+ }, [
+ rows,
+ virtualizedRowCount,
+ turnDiffSummaryByAssistantMessageId,
+ allDirectoriesExpandedByTurnId,
+ ]);
+
+ useLayoutEffect(() => {
+ const previousSignatures = previousHeightSignatureByRowIdRef.current;
+ const dirtyRowIds = dirtyVirtualRowIdsRef.current;
+
+ for (const [rowId, signature] of virtualizedHeightSignatures) {
+ if (previousSignatures.get(rowId) !== signature) {
+ dirtyRowIds.add(rowId);
+ }
+ }
+
+ for (const rowId of previousSignatures.keys()) {
+ if (!virtualizedHeightSignatures.has(rowId)) {
+ previousSignatures.delete(rowId);
+ virtualRowElementsByIdRef.current.delete(rowId);
+ virtualRowRefCallbacksByIdRef.current.delete(rowId);
+ measuredHeightByRowIdRef.current.delete(rowId);
+ dirtyRowIds.delete(rowId);
+ }
+ }
+
+ previousHeightSignatureByRowIdRef.current = new Map(virtualizedHeightSignatures);
+ }, [virtualizedHeightSignatures]);
+
+ useLayoutEffect(() => {
+ if (virtualizedRowCount === 0) return;
+ rowVirtualizer.measure();
+ }, [rowVirtualizer, virtualizedRowCount]);
+
+ useLayoutEffect(() => {
+ const dirtyRowIds = Array.from(dirtyVirtualRowIdsRef.current);
+ if (dirtyRowIds.length === 0) return;
+
+ for (const rowId of dirtyRowIds) {
+ const element = virtualRowElementsByIdRef.current.get(rowId);
+ if (!element) {
+ continue;
+ }
+ rowVirtualizer.measureElement(element);
+ }
+ dirtyVirtualRowIdsRef.current.clear();
+ }, [rowVirtualizer, virtualizedHeightSignatures]);
const renderRowContent = (row: TimelineRow) => (