authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-02 00:46:01-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-02 10:19:22-08:00
loga3a0cbf0f89ecc34168161f66c4901c922acb8d6
tree3aeec18e4f87abb01c22ce18b7e8f944a7d3a82d
parent2c7101092aca2ccd77b409e013f97020b23c2a8e

Stabilize chat scroll restoration across thread switches

- Keep auto-scroll pinned to bottom during thread changes, composer resize, and streaming updates - Improve virtualized timeline measurement/keying to prevent stale height reuse between threads - Hand off blob image preview URLs from optimistic to server messages to avoid flicker/revocation races

1 files changed, 257 insertions(+), 30 deletions(-)

apps/web/src/components/ChatView.tsx+257-30
...@@ -24,7 +24,11 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat...@@ -24,7 +24,11 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat
24import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";24import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
25import { useDebouncedValue } from "@tanstack/react-pacer";25import { useDebouncedValue } from "@tanstack/react-pacer";
26import { useNavigate, useSearch } from "@tanstack/react-router";26import { useNavigate, useSearch } from "@tanstack/react-router";
27import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual";27import {
28 measureElement as measureVirtualElement,
29 type VirtualItem,
30 useVirtualizer,
31} from "@tanstack/react-virtual";
28import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";32import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
29import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";33import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
30import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";34import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
...@@ -47,7 +51,7 @@ import {...@@ -47,7 +51,7 @@ import {
47 formatElapsed,51 formatElapsed,
48 formatTimestamp,52 formatTimestamp,
49} from "../session-logic";53} from "../session-logic";
50import { isScrollContainerNearBottom } from "../chat-scroll";54import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "../chat-scroll";
51import { useStore } from "../store";55import { useStore } from "../store";
52import { truncateTitle } from "../truncateTitle";56import { truncateTitle } from "../truncateTitle";
53import {57import {
...@@ -130,6 +134,8 @@ function formatMessageMeta(createdAt: string, duration: string | null): string {...@@ -130,6 +134,8 @@ function formatMessageMeta(createdAt: string, duration: string | null): string {
130const LAST_EDITOR_KEY = "t3code:last-editor";134const LAST_EDITOR_KEY = "t3code:last-editor";
131const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";135const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
132const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;136const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
137const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8;
138const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000;
133const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;139const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
134const IMAGE_ONLY_BOOTSTRAP_PROMPT =140const IMAGE_ONLY_BOOTSTRAP_PROMPT =
135 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";141 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
...@@ -251,6 +257,19 @@ function revokeUserMessagePreviewUrls(message: ChatMessage): void {...@@ -251,6 +257,19 @@ function revokeUserMessagePreviewUrls(message: ChatMessage): void {
251 }257 }
252}258}
253259
260function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[] {
261 if (message.role !== "user" || !message.attachments) {
262 return [];
263 }
264 const previewUrls: string[] = [];
265 for (const attachment of message.attachments) {
266 if (attachment.type !== "image") continue;
267 if (!attachment.previewUrl || !attachment.previewUrl.startsWith("blob:")) continue;
268 previewUrls.push(attachment.previewUrl);
269 }
270 return previewUrls;
271}
272
254type ComposerCommandItem =273type ComposerCommandItem =
255 | {274 | {
256 id: string;275 id: string;
...@@ -485,14 +504,22 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -485,14 +504,22 @@ export default function ChatView({ threadId }: ChatViewProps) {
485 const [nowTick, setNowTick] = useState(() => Date.now());504 const [nowTick, setNowTick] = useState(() => Date.now());
486 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);505 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
487 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);506 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
507 const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState<
508 Record<string, string[]>
509 >({});
488 const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState<510 const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState<
489 Record<string, string>511 Record<string, string>
490 >(() => readLastInvokedScriptByProjectFromStorage());512 >(() => readLastInvokedScriptByProjectFromStorage());
491 const messagesScrollRef = useRef<HTMLDivElement>(null);513 const messagesScrollRef = useRef<HTMLDivElement>(null);
492 const shouldAutoScrollRef = useRef(true);514 const shouldAutoScrollRef = useRef(true);
515 const pendingAutoScrollFrameRef = useRef<number | null>(null);
493 const textareaRef = useRef<HTMLTextAreaElement>(null);516 const textareaRef = useRef<HTMLTextAreaElement>(null);
517 const composerFormRef = useRef<HTMLFormElement>(null);
518 const composerFormHeightRef = useRef(0);
494 const composerCommandInputRef = useRef<HTMLInputElement>(null);519 const composerCommandInputRef = useRef<HTMLInputElement>(null);
495 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);520 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
521 const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});
522 const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef<Record<string, number>>({});
496 const sendInFlightRef = useRef(false);523 const sendInFlightRef = useRef(false);
497 const dragDepthRef = useRef(0);524 const dragDepthRef = useRef(0);
498 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});525 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
...@@ -614,18 +641,100 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -614,18 +641,100 @@ export default function ChatView({ threadId }: ChatViewProps) {
614 () => derivePendingApprovals(threadActivities),641 () => derivePendingApprovals(threadActivities),
615 [threadActivities],642 [threadActivities],
616 );643 );
644 useEffect(() => {
645 attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId;
646 }, [attachmentPreviewHandoffByMessageId]);
647 const clearAttachmentPreviewHandoffs = useCallback(() => {
648 for (const timeoutId of Object.values(attachmentPreviewHandoffTimeoutByMessageIdRef.current)) {
649 window.clearTimeout(timeoutId);
650 }
651 attachmentPreviewHandoffTimeoutByMessageIdRef.current = {};
652 for (const previewUrls of Object.values(attachmentPreviewHandoffByMessageIdRef.current)) {
653 for (const previewUrl of previewUrls) {
654 revokeBlobPreviewUrl(previewUrl);
655 }
656 }
657 attachmentPreviewHandoffByMessageIdRef.current = {};
658 setAttachmentPreviewHandoffByMessageId({});
659 }, []);
660 const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => {
661 if (previewUrls.length === 0) return;
662
663 const previousPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? [];
664 for (const previewUrl of previousPreviewUrls) {
665 if (!previewUrls.includes(previewUrl)) {
666 revokeBlobPreviewUrl(previewUrl);
667 }
668 }
669 setAttachmentPreviewHandoffByMessageId((existing) => {
670 const next = {
671 ...existing,
672 [messageId]: previewUrls,
673 };
674 attachmentPreviewHandoffByMessageIdRef.current = next;
675 return next;
676 });
677
678 const existingTimeout = attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId];
679 if (typeof existingTimeout === "number") {
680 window.clearTimeout(existingTimeout);
681 }
682 attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId] = window.setTimeout(() => {
683 const currentPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId];
684 if (currentPreviewUrls) {
685 for (const previewUrl of currentPreviewUrls) {
686 revokeBlobPreviewUrl(previewUrl);
687 }
688 }
689 setAttachmentPreviewHandoffByMessageId((existing) => {
690 if (!(messageId in existing)) return existing;
691 const { [messageId]: _removed, ...rest } = existing;
692 attachmentPreviewHandoffByMessageIdRef.current = rest;
693 return rest;
694 });
695 delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId];
696 }, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS);
697 }, []);
617 const timelineMessages = useMemo(() => {698 const timelineMessages = useMemo(() => {
618 const serverMessages = activeThread?.messages ?? [];699 const serverMessages = activeThread?.messages ?? [];
700 const serverMessagesWithPreviewHandoff =
701 Object.keys(attachmentPreviewHandoffByMessageId).length === 0
702 ? serverMessages
703 : serverMessages.map((message) => {
704 if (message.role !== "user" || !message.attachments || message.attachments.length === 0) {
705 return message;
706 }
707 const handoffPreviewUrls = attachmentPreviewHandoffByMessageId[message.id];
708 if (!handoffPreviewUrls || handoffPreviewUrls.length === 0) {
709 return message;
710 }
711
712 let changed = false;
713 const attachments = message.attachments.map((attachment, index) => {
714 const handoffPreviewUrl = handoffPreviewUrls[index];
715 if (!handoffPreviewUrl || attachment.previewUrl === handoffPreviewUrl) {
716 return attachment;
717 }
718 changed = true;
719 return {
720 ...attachment,
721 previewUrl: handoffPreviewUrl,
722 };
723 });
724
725 return changed ? { ...message, attachments } : message;
726 });
727
619 if (optimisticUserMessages.length === 0) {728 if (optimisticUserMessages.length === 0) {
620 return serverMessages;729 return serverMessagesWithPreviewHandoff;
621 }730 }
622 const serverIds = new Set(serverMessages.map((message) => message.id));731 const serverIds = new Set(serverMessagesWithPreviewHandoff.map((message) => message.id));
623 const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id));732 const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id));
624 if (pendingMessages.length === 0) {733 if (pendingMessages.length === 0) {
625 return serverMessages;734 return serverMessagesWithPreviewHandoff;
626 }735 }
627 return [...serverMessages, ...pendingMessages];736 return [...serverMessagesWithPreviewHandoff, ...pendingMessages];
628 }, [activeThread?.messages, optimisticUserMessages]);737 }, [activeThread?.messages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]);
629 const timelineEntries = useMemo(738 const timelineEntries = useMemo(
630 () => deriveTimelineEntries(timelineMessages, workLogEntries),739 () => deriveTimelineEntries(timelineMessages, workLogEntries),
631 [timelineMessages, workLogEntries],740 [timelineMessages, workLogEntries],
...@@ -1177,24 +1286,79 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1177,24 +1286,79 @@ export default function ChatView({ threadId }: ChatViewProps) {
1177 scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior });1286 scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior });
1178 shouldAutoScrollRef.current = true;1287 shouldAutoScrollRef.current = true;
1179 }, []);1288 }, []);
1289 const scheduleStickToBottom = useCallback(() => {
1290 if (pendingAutoScrollFrameRef.current !== null) return;
1291 pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
1292 pendingAutoScrollFrameRef.current = null;
1293 scrollMessagesToBottom();
1294 });
1295 }, [scrollMessagesToBottom]);
1180 const onMessagesScroll = useCallback(() => {1296 const onMessagesScroll = useCallback(() => {
1181 const scrollContainer = messagesScrollRef.current;1297 const scrollContainer = messagesScrollRef.current;
1182 if (!scrollContainer) return;1298 if (!scrollContainer) return;
1183 shouldAutoScrollRef.current = isScrollContainerNearBottom(scrollContainer);1299 shouldAutoScrollRef.current = isScrollContainerNearBottom(scrollContainer);
1184 }, []);1300 }, []);
1301 useEffect(() => {
1302 return () => {
1303 const frame = pendingAutoScrollFrameRef.current;
1304 if (frame !== null) {
1305 window.cancelAnimationFrame(frame);
1306 }
1307 };
1308 }, []);
1185 useLayoutEffect(() => {1309 useLayoutEffect(() => {
1186 if (!activeThread?.id) return;1310 if (!activeThread?.id) return;
1187 scrollMessagesToBottom();1311 shouldAutoScrollRef.current = true;
1188 }, [activeThread?.id, scrollMessagesToBottom]);1312 scheduleStickToBottom();
1313 const timeout = window.setTimeout(() => {
1314 const scrollContainer = messagesScrollRef.current;
1315 if (!scrollContainer) return;
1316 if (isScrollContainerNearBottom(scrollContainer)) return;
1317 scheduleStickToBottom();
1318 }, 96);
1319 return () => {
1320 window.clearTimeout(timeout);
1321 };
1322 }, [activeThread?.id, scheduleStickToBottom]);
1323 useLayoutEffect(() => {
1324 const composerForm = composerFormRef.current;
1325 if (!composerForm) return;
1326
1327 composerFormHeightRef.current = composerForm.getBoundingClientRect().height;
1328 if (typeof ResizeObserver === "undefined") return;
1329
1330 const observer = new ResizeObserver((entries) => {
1331 const [entry] = entries;
1332 if (!entry) return;
1333
1334 const nextHeight = entry.contentRect.height;
1335 const previousHeight = composerFormHeightRef.current;
1336 composerFormHeightRef.current = nextHeight;
1337
1338 if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) return;
1339 if (!shouldAutoScrollRef.current) return;
1340 scheduleStickToBottom();
1341 });
1342
1343 observer.observe(composerForm);
1344 return () => {
1345 observer.disconnect();
1346 };
1347 }, [activeThread?.id, scheduleStickToBottom]);
1348 useEffect(() => {
1349 if (!shouldAutoScrollRef.current) return;
1350 scheduleStickToBottom();
1351 }, [messageCount, scheduleStickToBottom]);
1189 useEffect(() => {1352 useEffect(() => {
1353 if (phase !== "running") return;
1190 if (!shouldAutoScrollRef.current) return;1354 if (!shouldAutoScrollRef.current) return;
1191 scrollMessagesToBottom("smooth");1355 scheduleStickToBottom();
1192 }, [messageCount, scrollMessagesToBottom]);1356 }, [phase, scheduleStickToBottom, workLogCount]);
1193 useEffect(() => {1357 useEffect(() => {
1194 if (phase !== "running") return;1358 if (phase !== "running") return;
1195 if (!shouldAutoScrollRef.current) return;1359 if (!shouldAutoScrollRef.current) return;
1196 scrollMessagesToBottom("smooth");1360 scheduleStickToBottom();
1197 }, [phase, workLogCount, scrollMessagesToBottom]);1361 }, [phase, scheduleStickToBottom, timelineEntries]);
11981362
1199 useEffect(() => {1363 useEffect(() => {
1200 setExpandedWorkGroups({});1364 setExpandedWorkGroups({});
...@@ -1230,6 +1394,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1230,6 +1394,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
12301394
1231 useEffect(() => {1395 useEffect(() => {
1232 if (!activeThread?.id) {1396 if (!activeThread?.id) {
1397 clearAttachmentPreviewHandoffs();
1233 setOptimisticUserMessages((existing) => {1398 setOptimisticUserMessages((existing) => {
1234 for (const message of existing) {1399 for (const message of existing) {
1235 revokeUserMessagePreviewUrls(message);1400 revokeUserMessagePreviewUrls(message);
...@@ -1242,21 +1407,33 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1242,21 +1407,33 @@ export default function ChatView({ threadId }: ChatViewProps) {
1242 return;1407 return;
1243 }1408 }
1244 const serverIds = new Set(activeThread.messages.map((message) => message.id));1409 const serverIds = new Set(activeThread.messages.map((message) => message.id));
1245 setOptimisticUserMessages((existing) => {1410 const removedMessages = optimisticUserMessages.filter((message) => serverIds.has(message.id));
1246 const removed = existing.filter((message) => serverIds.has(message.id));1411 if (removedMessages.length === 0) {
1247 for (const message of removed) {1412 return;
1248 revokeUserMessagePreviewUrls(message);1413 }
1414 setOptimisticUserMessages((existing) => existing.filter((message) => !serverIds.has(message.id)));
1415 for (const removedMessage of removedMessages) {
1416 const previewUrls = collectUserMessageBlobPreviewUrls(removedMessage);
1417 if (previewUrls.length > 0) {
1418 handoffAttachmentPreviews(removedMessage.id, previewUrls);
1419 continue;
1249 }1420 }
1250 const next = existing.filter((message) => !serverIds.has(message.id));1421 revokeUserMessagePreviewUrls(removedMessage);
1251 return next.length === existing.length ? existing : next;1422 }
1252 });1423 }, [
1253 }, [activeThread?.id, activeThread?.messages]);1424 activeThread?.id,
1425 activeThread?.messages,
1426 clearAttachmentPreviewHandoffs,
1427 handoffAttachmentPreviews,
1428 optimisticUserMessages,
1429 ]);
12541430
1255 useEffect(() => {1431 useEffect(() => {
1256 promptRef.current = prompt;1432 promptRef.current = prompt;
1257 }, [prompt]);1433 }, [prompt]);
12581434
1259 useEffect(() => {1435 useEffect(() => {
1436 clearAttachmentPreviewHandoffs();
1260 setOptimisticUserMessages((existing) => {1437 setOptimisticUserMessages((existing) => {
1261 for (const message of existing) {1438 for (const message of existing) {
1262 revokeUserMessagePreviewUrls(message);1439 revokeUserMessagePreviewUrls(message);
...@@ -1268,7 +1445,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1268,7 +1445,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1268 dragDepthRef.current = 0;1445 dragDepthRef.current = 0;
1269 setIsDragOverComposer(false);1446 setIsDragOverComposer(false);
1270 setExpandedImage(null);1447 setExpandedImage(null);
1271 }, [threadId]);1448 }, [clearAttachmentPreviewHandoffs, threadId]);
12721449
1273 useEffect(() => {1450 useEffect(() => {
1274 let cancelled = false;1451 let cancelled = false;
...@@ -1760,6 +1937,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1760,6 +1937,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
1760 streaming: false,1937 streaming: false,
1761 },1938 },
1762 ]);1939 ]);
1940 // Sending a message should always bring the latest user turn into view.
1941 shouldAutoScrollRef.current = true;
1942 scheduleStickToBottom();
17631943
1764 setThreadError(threadIdForSend, null);1944 setThreadError(threadIdForSend, null);
1765 promptRef.current = "";1945 promptRef.current = "";
...@@ -2185,6 +2365,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2185,6 +2365,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2185 onScroll={onMessagesScroll}2365 onScroll={onMessagesScroll}
2186 >2366 >
2187 <MessagesTimeline2367 <MessagesTimeline
2368 key={activeThread.id}
2188 hasMessages={timelineMessages.length > 0}2369 hasMessages={timelineMessages.length > 0}
2189 isWorking={isWorking}2370 isWorking={isWorking}
2190 activeTurnInProgress={!latestTurnSettled}2371 activeTurnInProgress={!latestTurnSettled}
...@@ -2209,6 +2390,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2209,6 +2390,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2209 {/* Input bar */}2390 {/* Input bar */}
2210 <div className={cn("px-3 pt-1.5 sm:px-5 sm:pt-2", isGitRepo ? "pb-1" : "pb-3 sm:pb-4")}>2391 <div className={cn("px-3 pt-1.5 sm:px-5 sm:pt-2", isGitRepo ? "pb-1" : "pb-3 sm:pb-4")}>
2211 <form2392 <form
2393 ref={composerFormRef}
2212 onSubmit={onSend}2394 onSubmit={onSend}
2213 className="mx-auto w-full min-w-fit max-w-3xl"2395 className="mx-auto w-full min-w-fit max-w-3xl"
2214 data-chat-composer-form="true"2396 data-chat-composer-form="true"
...@@ -2804,6 +2986,20 @@ type TimelineRow =...@@ -2804,6 +2986,20 @@ type TimelineRow =
2804 }2986 }
2805 | { kind: "working"; id: string; createdAt: string | null };2987 | { kind: "working"; id: string; createdAt: string | null };
28062988
2989function estimateTimelineMessageHeight(message: TimelineMessage): number {
2990 const textLength = message.text.length;
2991 if (message.role === "assistant") {
2992 const estimatedLines = Math.max(1, Math.ceil(textLength / 72));
2993 return 78 + Math.min(estimatedLines * 22, 820);
2994 }
2995
2996 const estimatedLines = Math.max(1, Math.ceil(textLength / 56));
2997 const attachmentCount = message.attachments?.length ?? 0;
2998 const attachmentRows = Math.ceil(attachmentCount / 2);
2999 const attachmentHeight = attachmentRows * 124;
3000 return 96 + Math.min(estimatedLines * 22, 620) + attachmentHeight;
3001}
3002
2807const MessagesTimeline = memo(function MessagesTimeline({3003const MessagesTimeline = memo(function MessagesTimeline({
2808 hasMessages,3004 hasMessages,
2809 isWorking,3005 isWorking,
...@@ -2875,7 +3071,8 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2875,7 +3071,8 @@ const MessagesTimeline = memo(function MessagesTimeline({
2875 }, [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt]);3071 }, [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt]);
28763072
2877 const firstUnvirtualizedRowIndex = useMemo(() => {3073 const firstUnvirtualizedRowIndex = useMemo(() => {
2878 if (!activeTurnInProgress) return rows.length;3074 const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0);
3075 if (!activeTurnInProgress) return firstTailRowIndex;
28793076
2880 const turnStartedAtMs =3077 const turnStartedAtMs =
2881 typeof activeTurnStartedAt === "string" ? Date.parse(activeTurnStartedAt) : Number.NaN;3078 typeof activeTurnStartedAt === "string" ? Date.parse(activeTurnStartedAt) : Number.NaN;
...@@ -2895,22 +3092,20 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2895,22 +3092,20 @@ const MessagesTimeline = memo(function MessagesTimeline({
2895 );3092 );
2896 }3093 }
28973094
2898 if (firstCurrentTurnRowIndex < 0) {3095 if (firstCurrentTurnRowIndex < 0) return firstTailRowIndex;
2899 return rows.length;
2900 }
29013096
2902 for (let index = firstCurrentTurnRowIndex - 1; index >= 0; index -= 1) {3097 for (let index = firstCurrentTurnRowIndex - 1; index >= 0; index -= 1) {
2903 const previousRow = rows[index];3098 const previousRow = rows[index];
2904 if (!previousRow || previousRow.kind !== "message") continue;3099 if (!previousRow || previousRow.kind !== "message") continue;
2905 if (previousRow.message.role === "user") {3100 if (previousRow.message.role === "user") {
2906 return index;3101 return Math.min(index, firstTailRowIndex);
2907 }3102 }
2908 if (previousRow.message.role === "assistant" && !previousRow.message.streaming) {3103 if (previousRow.message.role === "assistant" && !previousRow.message.streaming) {
2909 break;3104 break;
2910 }3105 }
2911 }3106 }
29123107
2913 return firstCurrentTurnRowIndex;3108 return Math.min(firstCurrentTurnRowIndex, firstTailRowIndex);
2914 }, [activeTurnInProgress, activeTurnStartedAt, rows]);3109 }, [activeTurnInProgress, activeTurnStartedAt, rows]);
29153110
2916 const virtualizedRowCount = clamp(firstUnvirtualizedRowIndex, {3111 const virtualizedRowCount = clamp(firstUnvirtualizedRowIndex, {
...@@ -2921,16 +3116,46 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2921,16 +3116,46 @@ const MessagesTimeline = memo(function MessagesTimeline({
2921 const rowVirtualizer = useVirtualizer({3116 const rowVirtualizer = useVirtualizer({
2922 count: virtualizedRowCount,3117 count: virtualizedRowCount,
2923 getScrollElement: () => scrollContainerRef.current,3118 getScrollElement: () => scrollContainerRef.current,
3119 // Use stable row ids so virtual measurements do not leak across thread switches.
3120 getItemKey: (index: number) => rows[index]?.id ?? index,
2924 estimateSize: (index: number) => {3121 estimateSize: (index: number) => {
2925 const row = rows[index];3122 const row = rows[index];
2926 if (!row) return 96;3123 if (!row) return 96;
2927 if (row.kind === "work") return 112;3124 if (row.kind === "work") return 112;
2928 if (row.kind === "working") return 40;3125 if (row.kind === "working") return 40;
2929 return row.message.role === "assistant" ? 220 : 170;3126 return estimateTimelineMessageHeight(row.message);
2930 },3127 },
2931 measureElement: (element: HTMLElement) => element.getBoundingClientRect().height,3128 measureElement: measureVirtualElement,
3129 useAnimationFrameWithResizeObserver: true,
2932 overscan: 8,3130 overscan: 8,
2933 });3131 });
3132 useEffect(() => {
3133 rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => {
3134 const viewportHeight = instance.scrollRect?.height ?? 0;
3135 const scrollOffset = instance.scrollOffset ?? 0;
3136 const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight);
3137 return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX;
3138 };
3139 return () => {
3140 rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined;
3141 };
3142 }, [rowVirtualizer]);
3143 const pendingMeasureFrameRef = useRef<number | null>(null);
3144 const onTimelineImageLoad = useCallback(() => {
3145 if (pendingMeasureFrameRef.current !== null) return;
3146 pendingMeasureFrameRef.current = window.requestAnimationFrame(() => {
3147 pendingMeasureFrameRef.current = null;
3148 rowVirtualizer.measure();
3149 });
3150 }, [rowVirtualizer]);
3151 useEffect(() => {
3152 return () => {
3153 const frame = pendingMeasureFrameRef.current;
3154 if (frame !== null) {
3155 window.cancelAnimationFrame(frame);
3156 }
3157 };
3158 }, []);
29343159
2935 const virtualRows = rowVirtualizer.getVirtualItems();3160 const virtualRows = rowVirtualizer.getVirtualItems();
2936 const nonVirtualizedRows = rows.slice(virtualizedRowCount);3161 const nonVirtualizedRows = rows.slice(virtualizedRowCount);
...@@ -3022,6 +3247,8 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -3022,6 +3247,8 @@ const MessagesTimeline = memo(function MessagesTimeline({
3022 src={image.previewUrl}3247 src={image.previewUrl}
3023 alt={image.name}3248 alt={image.name}
3024 className="h-full max-h-[220px] w-full cursor-zoom-in object-cover"3249 className="h-full max-h-[220px] w-full cursor-zoom-in object-cover"
3250 onLoad={onTimelineImageLoad}
3251 onError={onTimelineImageLoad}
3025 onClick={() => {3252 onClick={() => {
3026 const preview = buildExpandedImagePreview(userImages, image.id);3253 const preview = buildExpandedImagePreview(userImages, image.id);
3027 if (!preview) return;3254 if (!preview) return;