| ... | ... | @@ -517,6 +517,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 517 | 517 | const messagesScrollRef = useRef<HTMLDivElement>(null); |
| 518 | 518 | const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null); |
| 519 | 519 | const shouldAutoScrollRef = useRef(true); |
| 520 | const lastKnownScrollTopRef = useRef(0); |
| 521 | const isPointerScrollActiveRef = useRef(false); |
| 522 | const lastTouchClientYRef = useRef<number | null>(null); |
| 523 | const pendingUserScrollUpIntentRef = useRef(false); |
| 520 | 524 | const pendingAutoScrollFrameRef = useRef<number | null>(null); |
| 521 | 525 | const composerEditorRef = useRef<ComposerPromptEditorHandle>(null); |
| 522 | 526 | const composerCommandInputRef = useRef<HTMLInputElement>(null); |
| ... | ... | @@ -1324,8 +1328,15 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1324 | 1328 | const scrollContainer = messagesScrollRef.current; |
| 1325 | 1329 | if (!scrollContainer) return; |
| 1326 | 1330 | scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior }); |
| 1331 | lastKnownScrollTopRef.current = scrollContainer.scrollTop; |
| 1327 | 1332 | shouldAutoScrollRef.current = true; |
| 1328 | 1333 | }, []); |
| 1334 | const cancelPendingStickToBottom = useCallback(() => { |
| 1335 | const pendingFrame = pendingAutoScrollFrameRef.current; |
| 1336 | if (pendingFrame === null) return; |
| 1337 | pendingAutoScrollFrameRef.current = null; |
| 1338 | window.cancelAnimationFrame(pendingFrame); |
| 1339 | }, []); |
| 1329 | 1340 | const scheduleStickToBottom = useCallback(() => { |
| 1330 | 1341 | if (pendingAutoScrollFrameRef.current !== null) return; |
| 1331 | 1342 | pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => { |
| ... | ... | @@ -1333,19 +1344,77 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1333 | 1344 | scrollMessagesToBottom(); |
| 1334 | 1345 | }); |
| 1335 | 1346 | }, [scrollMessagesToBottom]); |
| 1347 | const forceStickToBottom = useCallback(() => { |
| 1348 | cancelPendingStickToBottom(); |
| 1349 | scrollMessagesToBottom(); |
| 1350 | scheduleStickToBottom(); |
| 1351 | }, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom]); |
| 1336 | 1352 | const onMessagesScroll = useCallback(() => { |
| 1337 | 1353 | const scrollContainer = messagesScrollRef.current; |
| 1338 | 1354 | if (!scrollContainer) return; |
| 1339 | | shouldAutoScrollRef.current = isScrollContainerNearBottom(scrollContainer); |
| 1355 | const currentScrollTop = scrollContainer.scrollTop; |
| 1356 | const isNearBottom = isScrollContainerNearBottom(scrollContainer); |
| 1357 | |
| 1358 | if (!shouldAutoScrollRef.current && isNearBottom) { |
| 1359 | shouldAutoScrollRef.current = true; |
| 1360 | pendingUserScrollUpIntentRef.current = false; |
| 1361 | } else if (shouldAutoScrollRef.current && pendingUserScrollUpIntentRef.current) { |
| 1362 | const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; |
| 1363 | if (scrolledUp) { |
| 1364 | shouldAutoScrollRef.current = false; |
| 1365 | } |
| 1366 | pendingUserScrollUpIntentRef.current = false; |
| 1367 | } else if (shouldAutoScrollRef.current && isPointerScrollActiveRef.current) { |
| 1368 | const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; |
| 1369 | if (scrolledUp) { |
| 1370 | shouldAutoScrollRef.current = false; |
| 1371 | } |
| 1372 | } else if (shouldAutoScrollRef.current && !isNearBottom) { |
| 1373 | // Catch-all for keyboard/assistive scroll interactions. |
| 1374 | const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; |
| 1375 | if (scrolledUp) { |
| 1376 | shouldAutoScrollRef.current = false; |
| 1377 | } |
| 1378 | } |
| 1379 | |
| 1380 | lastKnownScrollTopRef.current = currentScrollTop; |
| 1381 | }, []); |
| 1382 | const onMessagesWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => { |
| 1383 | if (event.deltaY < 0) { |
| 1384 | pendingUserScrollUpIntentRef.current = true; |
| 1385 | } |
| 1386 | }, []); |
| 1387 | const onMessagesPointerDown = useCallback((_event: React.PointerEvent<HTMLDivElement>) => { |
| 1388 | isPointerScrollActiveRef.current = true; |
| 1389 | }, []); |
| 1390 | const onMessagesPointerUp = useCallback((_event: React.PointerEvent<HTMLDivElement>) => { |
| 1391 | isPointerScrollActiveRef.current = false; |
| 1392 | }, []); |
| 1393 | const onMessagesPointerCancel = useCallback((_event: React.PointerEvent<HTMLDivElement>) => { |
| 1394 | isPointerScrollActiveRef.current = false; |
| 1395 | }, []); |
| 1396 | const onMessagesTouchStart = useCallback((event: React.TouchEvent<HTMLDivElement>) => { |
| 1397 | const touch = event.touches[0]; |
| 1398 | if (!touch) return; |
| 1399 | lastTouchClientYRef.current = touch.clientY; |
| 1400 | }, []); |
| 1401 | const onMessagesTouchMove = useCallback((event: React.TouchEvent<HTMLDivElement>) => { |
| 1402 | const touch = event.touches[0]; |
| 1403 | if (!touch) return; |
| 1404 | const previousTouchY = lastTouchClientYRef.current; |
| 1405 | if (previousTouchY !== null && touch.clientY > previousTouchY + 1) { |
| 1406 | pendingUserScrollUpIntentRef.current = true; |
| 1407 | } |
| 1408 | lastTouchClientYRef.current = touch.clientY; |
| 1409 | }, []); |
| 1410 | const onMessagesTouchEnd = useCallback((_event: React.TouchEvent<HTMLDivElement>) => { |
| 1411 | lastTouchClientYRef.current = null; |
| 1340 | 1412 | }, []); |
| 1341 | 1413 | useEffect(() => { |
| 1342 | 1414 | return () => { |
| 1343 | | const frame = pendingAutoScrollFrameRef.current; |
| 1344 | | if (frame !== null) { |
| 1345 | | window.cancelAnimationFrame(frame); |
| 1346 | | } |
| 1415 | cancelPendingStickToBottom(); |
| 1347 | 1416 | }; |
| 1348 | | }, []); |
| 1417 | }, [cancelPendingStickToBottom]); |
| 1349 | 1418 | useLayoutEffect(() => { |
| 1350 | 1419 | if (!activeThread?.id) return; |
| 1351 | 1420 | shouldAutoScrollRef.current = true; |
| ... | ... | @@ -1922,7 +1991,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1922 | 1991 | ]); |
| 1923 | 1992 | // Sending a message should always bring the latest user turn into view. |
| 1924 | 1993 | shouldAutoScrollRef.current = true; |
| 1925 | | scheduleStickToBottom(); |
| 1994 | forceStickToBottom(); |
| 1926 | 1995 | |
| 1927 | 1996 | setThreadError(threadIdForSend, null); |
| 1928 | 1997 | promptRef.current = ""; |
| ... | ... | @@ -2442,6 +2511,14 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2442 | 2511 | ref={setMessagesScrollContainerRef} |
| 2443 | 2512 | className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain px-3 py-3 sm:px-5 sm:py-4" |
| 2444 | 2513 | onScroll={onMessagesScroll} |
| 2514 | onWheel={onMessagesWheel} |
| 2515 | onPointerDown={onMessagesPointerDown} |
| 2516 | onPointerUp={onMessagesPointerUp} |
| 2517 | onPointerCancel={onMessagesPointerCancel} |
| 2518 | onTouchStart={onMessagesTouchStart} |
| 2519 | onTouchMove={onMessagesTouchMove} |
| 2520 | onTouchEnd={onMessagesTouchEnd} |
| 2521 | onTouchCancel={onMessagesTouchEnd} |
| 2445 | 2522 | > |
| 2446 | 2523 | <MessagesTimeline |
| 2447 | 2524 | key={activeThread.id} |
| ... | ... | @@ -3602,6 +3679,11 @@ const OpenInPicker = memo(function OpenInPicker({ |
| 3602 | 3679 | Icon: CursorIcon, |
| 3603 | 3680 | value: "cursor", |
| 3604 | 3681 | }, |
| 3682 | { |
| 3683 | label: "VS Code", |
| 3684 | Icon: FileIcon, |
| 3685 | value: "vscode", |
| 3686 | }, |
| 3605 | 3687 | { |
| 3606 | 3688 | label: isMacPlatform(navigator.platform) |
| 3607 | 3689 | ? "Finder" |