| ... | ... | @@ -24,7 +24,11 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat |
| 24 | 24 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 25 | 25 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 26 | 26 | import { useNavigate, useSearch } from "@tanstack/react-router"; |
| 27 | | import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual"; |
| 27 | import { |
| 28 | measureElement as measureVirtualElement, |
| 29 | type VirtualItem, |
| 30 | useVirtualizer, |
| 31 | } from "@tanstack/react-virtual"; |
| 28 | 32 | import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; |
| 29 | 33 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; |
| 30 | 34 | import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; |
| ... | ... | @@ -47,7 +51,7 @@ import { |
| 47 | 51 | formatElapsed, |
| 48 | 52 | formatTimestamp, |
| 49 | 53 | } from "../session-logic"; |
| 50 | | import { isScrollContainerNearBottom } from "../chat-scroll"; |
| 54 | import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "../chat-scroll"; |
| 51 | 55 | import { useStore } from "../store"; |
| 52 | 56 | import { truncateTitle } from "../truncateTitle"; |
| 53 | 57 | import { |
| ... | ... | @@ -130,6 +134,8 @@ function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 130 | 134 | const LAST_EDITOR_KEY = "t3code:last-editor"; |
| 131 | 135 | const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; |
| 132 | 136 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; |
| 137 | const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; |
| 138 | const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; |
| 133 | 139 | const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; |
| 134 | 140 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = |
| 135 | 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 | 257 | } |
| 252 | 258 | } |
| 253 | 259 | |
| 260 | function 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 | |
| 254 | 273 | type ComposerCommandItem = |
| 255 | 274 | | { |
| 256 | 275 | id: string; |
| ... | ... | @@ -485,14 +504,22 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 485 | 504 | const [nowTick, setNowTick] = useState(() => Date.now()); |
| 486 | 505 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| 487 | 506 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); |
| 507 | const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< |
| 508 | Record<string, string[]> |
| 509 | >({}); |
| 488 | 510 | const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState< |
| 489 | 511 | Record<string, string> |
| 490 | 512 | >(() => readLastInvokedScriptByProjectFromStorage()); |
| 491 | 513 | const messagesScrollRef = useRef<HTMLDivElement>(null); |
| 492 | 514 | const shouldAutoScrollRef = useRef(true); |
| 515 | const pendingAutoScrollFrameRef = useRef<number | null>(null); |
| 493 | 516 | const textareaRef = useRef<HTMLTextAreaElement>(null); |
| 517 | const composerFormRef = useRef<HTMLFormElement>(null); |
| 518 | const composerFormHeightRef = useRef(0); |
| 494 | 519 | const composerCommandInputRef = useRef<HTMLInputElement>(null); |
| 495 | 520 | const composerImagesRef = useRef<ComposerImageAttachment[]>([]); |
| 521 | const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({}); |
| 522 | const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef<Record<string, number>>({}); |
| 496 | 523 | const sendInFlightRef = useRef(false); |
| 497 | 524 | const dragDepthRef = useRef(0); |
| 498 | 525 | const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); |
| ... | ... | @@ -614,18 +641,100 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 614 | 641 | () => derivePendingApprovals(threadActivities), |
| 615 | 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 | 698 | const timelineMessages = useMemo(() => { |
| 618 | 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 | 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 | 732 | const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id)); |
| 624 | 733 | if (pendingMessages.length === 0) { |
| 625 | | return serverMessages; |
| 734 | return serverMessagesWithPreviewHandoff; |
| 626 | 735 | } |
| 627 | | return [...serverMessages, ...pendingMessages]; |
| 628 | | }, [activeThread?.messages, optimisticUserMessages]); |
| 736 | return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; |
| 737 | }, [activeThread?.messages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); |
| 629 | 738 | const timelineEntries = useMemo( |
| 630 | 739 | () => deriveTimelineEntries(timelineMessages, workLogEntries), |
| 631 | 740 | [timelineMessages, workLogEntries], |
| ... | ... | @@ -1177,24 +1286,79 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1177 | 1286 | scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior }); |
| 1178 | 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 | 1296 | const onMessagesScroll = useCallback(() => { |
| 1181 | 1297 | const scrollContainer = messagesScrollRef.current; |
| 1182 | 1298 | if (!scrollContainer) return; |
| 1183 | 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 | 1309 | useLayoutEffect(() => { |
| 1186 | 1310 | 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]); |
| 1189 | 1352 | useEffect(() => { |
| 1353 | if (phase !== "running") return; |
| 1190 | 1354 | if (!shouldAutoScrollRef.current) return; |
| 1191 | | scrollMessagesToBottom("smooth"); |
| 1192 | | }, [messageCount, scrollMessagesToBottom]); |
| 1355 | scheduleStickToBottom(); |
| 1356 | }, [phase, scheduleStickToBottom, workLogCount]); |
| 1193 | 1357 | useEffect(() => { |
| 1194 | 1358 | if (phase !== "running") return; |
| 1195 | 1359 | if (!shouldAutoScrollRef.current) return; |
| 1196 | | scrollMessagesToBottom("smooth"); |
| 1197 | | }, [phase, workLogCount, scrollMessagesToBottom]); |
| 1360 | scheduleStickToBottom(); |
| 1361 | }, [phase, scheduleStickToBottom, timelineEntries]); |
| 1198 | 1362 | |
| 1199 | 1363 | useEffect(() => { |
| 1200 | 1364 | setExpandedWorkGroups({}); |
| ... | ... | @@ -1230,6 +1394,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1230 | 1394 | |
| 1231 | 1395 | useEffect(() => { |
| 1232 | 1396 | if (!activeThread?.id) { |
| 1397 | clearAttachmentPreviewHandoffs(); |
| 1233 | 1398 | setOptimisticUserMessages((existing) => { |
| 1234 | 1399 | for (const message of existing) { |
| 1235 | 1400 | revokeUserMessagePreviewUrls(message); |
| ... | ... | @@ -1242,21 +1407,33 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1242 | 1407 | return; |
| 1243 | 1408 | } |
| 1244 | 1409 | 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; |
| 1249 | 1420 | } |
| 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 | ]); |
| 1254 | 1430 | |
| 1255 | 1431 | useEffect(() => { |
| 1256 | 1432 | promptRef.current = prompt; |
| 1257 | 1433 | }, [prompt]); |
| 1258 | 1434 | |
| 1259 | 1435 | useEffect(() => { |
| 1436 | clearAttachmentPreviewHandoffs(); |
| 1260 | 1437 | setOptimisticUserMessages((existing) => { |
| 1261 | 1438 | for (const message of existing) { |
| 1262 | 1439 | revokeUserMessagePreviewUrls(message); |
| ... | ... | @@ -1268,7 +1445,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1268 | 1445 | dragDepthRef.current = 0; |
| 1269 | 1446 | setIsDragOverComposer(false); |
| 1270 | 1447 | setExpandedImage(null); |
| 1271 | | }, [threadId]); |
| 1448 | }, [clearAttachmentPreviewHandoffs, threadId]); |
| 1272 | 1449 | |
| 1273 | 1450 | useEffect(() => { |
| 1274 | 1451 | let cancelled = false; |
| ... | ... | @@ -1760,6 +1937,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1760 | 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(); |
| 1763 | 1943 | |
| 1764 | 1944 | setThreadError(threadIdForSend, null); |
| 1765 | 1945 | promptRef.current = ""; |
| ... | ... | @@ -2185,6 +2365,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2185 | 2365 | onScroll={onMessagesScroll} |
| 2186 | 2366 | > |
| 2187 | 2367 | <MessagesTimeline |
| 2368 | key={activeThread.id} |
| 2188 | 2369 | hasMessages={timelineMessages.length > 0} |
| 2189 | 2370 | isWorking={isWorking} |
| 2190 | 2371 | activeTurnInProgress={!latestTurnSettled} |
| ... | ... | @@ -2209,6 +2390,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2209 | 2390 | {/* Input bar */} |
| 2210 | 2391 | <div className={cn("px-3 pt-1.5 sm:px-5 sm:pt-2", isGitRepo ? "pb-1" : "pb-3 sm:pb-4")}> |
| 2211 | 2392 | <form |
| 2393 | ref={composerFormRef} |
| 2212 | 2394 | onSubmit={onSend} |
| 2213 | 2395 | className="mx-auto w-full min-w-fit max-w-3xl" |
| 2214 | 2396 | data-chat-composer-form="true" |
| ... | ... | @@ -2804,6 +2986,20 @@ type TimelineRow = |
| 2804 | 2986 | } |
| 2805 | 2987 | | { kind: "working"; id: string; createdAt: string | null }; |
| 2806 | 2988 | |
| 2989 | function 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 | |
| 2807 | 3003 | const MessagesTimeline = memo(function MessagesTimeline({ |
| 2808 | 3004 | hasMessages, |
| 2809 | 3005 | isWorking, |
| ... | ... | @@ -2875,7 +3071,8 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2875 | 3071 | }, [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt]); |
| 2876 | 3072 | |
| 2877 | 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; |
| 2879 | 3076 | |
| 2880 | 3077 | const turnStartedAtMs = |
| 2881 | 3078 | typeof activeTurnStartedAt === "string" ? Date.parse(activeTurnStartedAt) : Number.NaN; |
| ... | ... | @@ -2895,22 +3092,20 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2895 | 3092 | ); |
| 2896 | 3093 | } |
| 2897 | 3094 | |
| 2898 | | if (firstCurrentTurnRowIndex < 0) { |
| 2899 | | return rows.length; |
| 2900 | | } |
| 3095 | if (firstCurrentTurnRowIndex < 0) return firstTailRowIndex; |
| 2901 | 3096 | |
| 2902 | 3097 | for (let index = firstCurrentTurnRowIndex - 1; index >= 0; index -= 1) { |
| 2903 | 3098 | const previousRow = rows[index]; |
| 2904 | 3099 | if (!previousRow || previousRow.kind !== "message") continue; |
| 2905 | 3100 | if (previousRow.message.role === "user") { |
| 2906 | | return index; |
| 3101 | return Math.min(index, firstTailRowIndex); |
| 2907 | 3102 | } |
| 2908 | 3103 | if (previousRow.message.role === "assistant" && !previousRow.message.streaming) { |
| 2909 | 3104 | break; |
| 2910 | 3105 | } |
| 2911 | 3106 | } |
| 2912 | 3107 | |
| 2913 | | return firstCurrentTurnRowIndex; |
| 3108 | return Math.min(firstCurrentTurnRowIndex, firstTailRowIndex); |
| 2914 | 3109 | }, [activeTurnInProgress, activeTurnStartedAt, rows]); |
| 2915 | 3110 | |
| 2916 | 3111 | const virtualizedRowCount = clamp(firstUnvirtualizedRowIndex, { |
| ... | ... | @@ -2921,16 +3116,46 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2921 | 3116 | const rowVirtualizer = useVirtualizer({ |
| 2922 | 3117 | count: virtualizedRowCount, |
| 2923 | 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 | 3121 | estimateSize: (index: number) => { |
| 2925 | 3122 | const row = rows[index]; |
| 2926 | 3123 | if (!row) return 96; |
| 2927 | 3124 | if (row.kind === "work") return 112; |
| 2928 | 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 | 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 | }, []); |
| 2934 | 3159 | |
| 2935 | 3160 | const virtualRows = rowVirtualizer.getVirtualItems(); |
| 2936 | 3161 | const nonVirtualizedRows = rows.slice(virtualizedRowCount); |
| ... | ... | @@ -3022,6 +3247,8 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 3022 | 3247 | src={image.previewUrl} |
| 3023 | 3248 | alt={image.name} |
| 3024 | 3249 | className="h-full max-h-[220px] w-full cursor-zoom-in object-cover" |
| 3250 | onLoad={onTimelineImageLoad} |
| 3251 | onError={onTimelineImageLoad} |
| 3025 | 3252 | onClick={() => { |
| 3026 | 3253 | const preview = buildExpandedImagePreview(userImages, image.id); |
| 3027 | 3254 | if (!preview) return; |