| ... | @@ -24,7 +24,11 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat | ... | @@ -24,7 +24,11 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat |
| 24 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | 24 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 25 | import { useDebouncedValue } from "@tanstack/react-pacer"; | 25 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 26 | import { useNavigate, useSearch } from "@tanstack/react-router"; | 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 | import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; | 32 | import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; |
| 29 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; | 33 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; |
| 30 | import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; | 34 | import { 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"; |
| 50 | import { isScrollContainerNearBottom } from "../chat-scroll"; | 54 | import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "../chat-scroll"; |
| 51 | import { useStore } from "../store"; | 55 | import { useStore } from "../store"; |
| 52 | import { truncateTitle } from "../truncateTitle"; | 56 | import { truncateTitle } from "../truncateTitle"; |
| 53 | import { | 57 | import { |
| ... | @@ -130,6 +134,8 @@ function formatMessageMeta(createdAt: string, duration: string | null): string { | ... | @@ -130,6 +134,8 @@ function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 130 | const LAST_EDITOR_KEY = "t3code:last-editor"; | 134 | const LAST_EDITOR_KEY = "t3code:last-editor"; |
| 131 | const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; | 135 | const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; |
| 132 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; | 136 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; |
| | 137 | const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; |
| | 138 | const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; |
| 133 | const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; | 139 | const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; |
| 134 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = | 140 | const 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 | } |
| 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 | type ComposerCommandItem = | 273 | type 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]); |
| 1198 | | 1362 | |
| 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) { |
| 1230 | | 1394 | |
| 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 | ]); |
| 1254 | | 1430 | |
| 1255 | useEffect(() => { | 1431 | useEffect(() => { |
| 1256 | promptRef.current = prompt; | 1432 | promptRef.current = prompt; |
| 1257 | }, [prompt]); | 1433 | }, [prompt]); |
| 1258 | | 1434 | |
| 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]); |
| 1272 | | 1449 | |
| 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(); |
| 1763 | | 1943 | |
| 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 | <MessagesTimeline | 2367 | <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 | <form | 2392 | <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 }; |
| 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 | const MessagesTimeline = memo(function MessagesTimeline({ | 3003 | const 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]); |
| 2876 | | 3072 | |
| 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; |
| 2879 | | 3076 | |
| 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 | } |
| 2897 | | 3094 | |
| 2898 | if (firstCurrentTurnRowIndex < 0) { | 3095 | if (firstCurrentTurnRowIndex < 0) return firstTailRowIndex; |
| 2899 | return rows.length; | | |
| 2900 | } | | |
| 2901 | | 3096 | |
| 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 | } |
| 2912 | | 3107 | |
| 2913 | return firstCurrentTurnRowIndex; | 3108 | return Math.min(firstCurrentTurnRowIndex, firstTailRowIndex); |
| 2914 | }, [activeTurnInProgress, activeTurnStartedAt, rows]); | 3109 | }, [activeTurnInProgress, activeTurnStartedAt, rows]); |
| 2915 | | 3110 | |
| 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 | }, []); |
| 2934 | | 3159 | |
| 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; |