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
2424import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2525import { useDebouncedValue } from "@tanstack/react-pacer";
2626import { 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";
2832import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
2933import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
3034import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
......@@ -47,7 +51,7 @@ import {
4751 formatElapsed,
4852 formatTimestamp,
4953} from "../session-logic";
50import { isScrollContainerNearBottom } from "../chat-scroll";
54import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "../chat-scroll";
5155import { useStore } from "../store";
5256import { truncateTitle } from "../truncateTitle";
5357import {
......@@ -130,6 +134,8 @@ function formatMessageMeta(createdAt: string, duration: string | null): string {
130134const LAST_EDITOR_KEY = "t3code:last-editor";
131135const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
132136const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
137const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8;
138const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000;
133139const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
134140const IMAGE_ONLY_BOOTSTRAP_PROMPT =
135141 "[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 {
251257 }
252258}
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
254273type ComposerCommandItem =
255274 | {
256275 id: string;
......@@ -485,14 +504,22 @@ export default function ChatView({ threadId }: ChatViewProps) {
485504 const [nowTick, setNowTick] = useState(() => Date.now());
486505 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
487506 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
507 const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState<
508 Record<string, string[]>
509 >({});
488510 const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState<
489511 Record<string, string>
490512 >(() => readLastInvokedScriptByProjectFromStorage());
491513 const messagesScrollRef = useRef<HTMLDivElement>(null);
492514 const shouldAutoScrollRef = useRef(true);
515 const pendingAutoScrollFrameRef = useRef<number | null>(null);
493516 const textareaRef = useRef<HTMLTextAreaElement>(null);
517 const composerFormRef = useRef<HTMLFormElement>(null);
518 const composerFormHeightRef = useRef(0);
494519 const composerCommandInputRef = useRef<HTMLInputElement>(null);
495520 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
521 const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});
522 const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef<Record<string, number>>({});
496523 const sendInFlightRef = useRef(false);
497524 const dragDepthRef = useRef(0);
498525 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
......@@ -614,18 +641,100 @@ export default function ChatView({ threadId }: ChatViewProps) {
614641 () => derivePendingApprovals(threadActivities),
615642 [threadActivities],
616643 );
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 }, []);
617698 const timelineMessages = useMemo(() => {
618699 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
619728 if (optimisticUserMessages.length === 0) {
620 return serverMessages;
729 return serverMessagesWithPreviewHandoff;
621730 }
622 const serverIds = new Set(serverMessages.map((message) => message.id));
731 const serverIds = new Set(serverMessagesWithPreviewHandoff.map((message) => message.id));
623732 const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id));
624733 if (pendingMessages.length === 0) {
625 return serverMessages;
734 return serverMessagesWithPreviewHandoff;
626735 }
627 return [...serverMessages, ...pendingMessages];
628 }, [activeThread?.messages, optimisticUserMessages]);
736 return [...serverMessagesWithPreviewHandoff, ...pendingMessages];
737 }, [activeThread?.messages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]);
629738 const timelineEntries = useMemo(
630739 () => deriveTimelineEntries(timelineMessages, workLogEntries),
631740 [timelineMessages, workLogEntries],
......@@ -1177,24 +1286,79 @@ export default function ChatView({ threadId }: ChatViewProps) {
11771286 scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior });
11781287 shouldAutoScrollRef.current = true;
11791288 }, []);
1289 const scheduleStickToBottom = useCallback(() => {
1290 if (pendingAutoScrollFrameRef.current !== null) return;
1291 pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
1292 pendingAutoScrollFrameRef.current = null;
1293 scrollMessagesToBottom();
1294 });
1295 }, [scrollMessagesToBottom]);
11801296 const onMessagesScroll = useCallback(() => {
11811297 const scrollContainer = messagesScrollRef.current;
11821298 if (!scrollContainer) return;
11831299 shouldAutoScrollRef.current = isScrollContainerNearBottom(scrollContainer);
11841300 }, []);
1301 useEffect(() => {
1302 return () => {
1303 const frame = pendingAutoScrollFrameRef.current;
1304 if (frame !== null) {
1305 window.cancelAnimationFrame(frame);
1306 }
1307 };
1308 }, []);
11851309 useLayoutEffect(() => {
11861310 if (!activeThread?.id) return;
1187 scrollMessagesToBottom();
1188 }, [activeThread?.id, scrollMessagesToBottom]);
1311 shouldAutoScrollRef.current = true;
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]);
11891352 useEffect(() => {
1353 if (phase !== "running") return;
11901354 if (!shouldAutoScrollRef.current) return;
1191 scrollMessagesToBottom("smooth");
1192 }, [messageCount, scrollMessagesToBottom]);
1355 scheduleStickToBottom();
1356 }, [phase, scheduleStickToBottom, workLogCount]);
11931357 useEffect(() => {
11941358 if (phase !== "running") return;
11951359 if (!shouldAutoScrollRef.current) return;
1196 scrollMessagesToBottom("smooth");
1197 }, [phase, workLogCount, scrollMessagesToBottom]);
1360 scheduleStickToBottom();
1361 }, [phase, scheduleStickToBottom, timelineEntries]);
11981362
11991363 useEffect(() => {
12001364 setExpandedWorkGroups({});
......@@ -1230,6 +1394,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
12301394
12311395 useEffect(() => {
12321396 if (!activeThread?.id) {
1397 clearAttachmentPreviewHandoffs();
12331398 setOptimisticUserMessages((existing) => {
12341399 for (const message of existing) {
12351400 revokeUserMessagePreviewUrls(message);
......@@ -1242,21 +1407,33 @@ export default function ChatView({ threadId }: ChatViewProps) {
12421407 return;
12431408 }
12441409 const serverIds = new Set(activeThread.messages.map((message) => message.id));
1245 setOptimisticUserMessages((existing) => {
1246 const removed = existing.filter((message) => serverIds.has(message.id));
1247 for (const message of removed) {
1248 revokeUserMessagePreviewUrls(message);
1410 const removedMessages = optimisticUserMessages.filter((message) => serverIds.has(message.id));
1411 if (removedMessages.length === 0) {
1412 return;
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;
12491420 }
1250 const next = existing.filter((message) => !serverIds.has(message.id));
1251 return next.length === existing.length ? existing : next;
1252 });
1253 }, [activeThread?.id, activeThread?.messages]);
1421 revokeUserMessagePreviewUrls(removedMessage);
1422 }
1423 }, [
1424 activeThread?.id,
1425 activeThread?.messages,
1426 clearAttachmentPreviewHandoffs,
1427 handoffAttachmentPreviews,
1428 optimisticUserMessages,
1429 ]);
12541430
12551431 useEffect(() => {
12561432 promptRef.current = prompt;
12571433 }, [prompt]);
12581434
12591435 useEffect(() => {
1436 clearAttachmentPreviewHandoffs();
12601437 setOptimisticUserMessages((existing) => {
12611438 for (const message of existing) {
12621439 revokeUserMessagePreviewUrls(message);
......@@ -1268,7 +1445,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
12681445 dragDepthRef.current = 0;
12691446 setIsDragOverComposer(false);
12701447 setExpandedImage(null);
1271 }, [threadId]);
1448 }, [clearAttachmentPreviewHandoffs, threadId]);
12721449
12731450 useEffect(() => {
12741451 let cancelled = false;
......@@ -1760,6 +1937,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
17601937 streaming: false,
17611938 },
17621939 ]);
1940 // Sending a message should always bring the latest user turn into view.
1941 shouldAutoScrollRef.current = true;
1942 scheduleStickToBottom();
17631943
17641944 setThreadError(threadIdForSend, null);
17651945 promptRef.current = "";
......@@ -2185,6 +2365,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
21852365 onScroll={onMessagesScroll}
21862366 >
21872367 <MessagesTimeline
2368 key={activeThread.id}
21882369 hasMessages={timelineMessages.length > 0}
21892370 isWorking={isWorking}
21902371 activeTurnInProgress={!latestTurnSettled}
......@@ -2209,6 +2390,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
22092390 {/* Input bar */}
22102391 <div className={cn("px-3 pt-1.5 sm:px-5 sm:pt-2", isGitRepo ? "pb-1" : "pb-3 sm:pb-4")}>
22112392 <form
2393 ref={composerFormRef}
22122394 onSubmit={onSend}
22132395 className="mx-auto w-full min-w-fit max-w-3xl"
22142396 data-chat-composer-form="true"
......@@ -2804,6 +2986,20 @@ type TimelineRow =
28042986 }
28052987 | { 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
28073003const MessagesTimeline = memo(function MessagesTimeline({
28083004 hasMessages,
28093005 isWorking,
......@@ -2875,7 +3071,8 @@ const MessagesTimeline = memo(function MessagesTimeline({
28753071 }, [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt]);
28763072
28773073 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
28803077 const turnStartedAtMs =
28813078 typeof activeTurnStartedAt === "string" ? Date.parse(activeTurnStartedAt) : Number.NaN;
......@@ -2895,22 +3092,20 @@ const MessagesTimeline = memo(function MessagesTimeline({
28953092 );
28963093 }
28973094
2898 if (firstCurrentTurnRowIndex < 0) {
2899 return rows.length;
2900 }
3095 if (firstCurrentTurnRowIndex < 0) return firstTailRowIndex;
29013096
29023097 for (let index = firstCurrentTurnRowIndex - 1; index >= 0; index -= 1) {
29033098 const previousRow = rows[index];
29043099 if (!previousRow || previousRow.kind !== "message") continue;
29053100 if (previousRow.message.role === "user") {
2906 return index;
3101 return Math.min(index, firstTailRowIndex);
29073102 }
29083103 if (previousRow.message.role === "assistant" && !previousRow.message.streaming) {
29093104 break;
29103105 }
29113106 }
29123107
2913 return firstCurrentTurnRowIndex;
3108 return Math.min(firstCurrentTurnRowIndex, firstTailRowIndex);
29143109 }, [activeTurnInProgress, activeTurnStartedAt, rows]);
29153110
29163111 const virtualizedRowCount = clamp(firstUnvirtualizedRowIndex, {
......@@ -2921,16 +3116,46 @@ const MessagesTimeline = memo(function MessagesTimeline({
29213116 const rowVirtualizer = useVirtualizer({
29223117 count: virtualizedRowCount,
29233118 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,
29243121 estimateSize: (index: number) => {
29253122 const row = rows[index];
29263123 if (!row) return 96;
29273124 if (row.kind === "work") return 112;
29283125 if (row.kind === "working") return 40;
2929 return row.message.role === "assistant" ? 220 : 170;
3126 return estimateTimelineMessageHeight(row.message);
29303127 },
2931 measureElement: (element: HTMLElement) => element.getBoundingClientRect().height,
3128 measureElement: measureVirtualElement,
3129 useAnimationFrameWithResizeObserver: true,
29323130 overscan: 8,
29333131 });
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
29353160 const virtualRows = rowVirtualizer.getVirtualItems();
29363161 const nonVirtualizedRows = rows.slice(virtualizedRowCount);
......@@ -3022,6 +3247,8 @@ const MessagesTimeline = memo(function MessagesTimeline({
30223247 src={image.previewUrl}
30233248 alt={image.name}
30243249 className="h-full max-h-[220px] w-full cursor-zoom-in object-cover"
3250 onLoad={onTimelineImageLoad}
3251 onError={onTimelineImageLoad}
30253252 onClick={() => {
30263253 const preview = buildExpandedImagePreview(userImages, image.id);
30273254 if (!preview) return;