| ... | @@ -517,6 +517,10 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -517,6 +517,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 517 | const messagesScrollRef = useRef<HTMLDivElement>(null); | 517 | const messagesScrollRef = useRef<HTMLDivElement>(null); |
| 518 | const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null); | 518 | const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null); |
| 519 | const shouldAutoScrollRef = useRef(true); | 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 | const pendingAutoScrollFrameRef = useRef<number | null>(null); | 524 | const pendingAutoScrollFrameRef = useRef<number | null>(null); |
| 521 | const composerEditorRef = useRef<ComposerPromptEditorHandle>(null); | 525 | const composerEditorRef = useRef<ComposerPromptEditorHandle>(null); |
| 522 | const composerCommandInputRef = useRef<HTMLInputElement>(null); | 526 | const composerCommandInputRef = useRef<HTMLInputElement>(null); |
| ... | @@ -1324,8 +1328,15 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1324,8 +1328,15 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1324 | const scrollContainer = messagesScrollRef.current; | 1328 | const scrollContainer = messagesScrollRef.current; |
| 1325 | if (!scrollContainer) return; | 1329 | if (!scrollContainer) return; |
| 1326 | scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior }); | 1330 | scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior }); |
| | 1331 | lastKnownScrollTopRef.current = scrollContainer.scrollTop; |
| 1327 | shouldAutoScrollRef.current = true; | 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 | const scheduleStickToBottom = useCallback(() => { | 1340 | const scheduleStickToBottom = useCallback(() => { |
| 1330 | if (pendingAutoScrollFrameRef.current !== null) return; | 1341 | if (pendingAutoScrollFrameRef.current !== null) return; |
| 1331 | pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => { | 1342 | pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => { |
| ... | @@ -1333,19 +1344,77 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1333,19 +1344,77 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1333 | scrollMessagesToBottom(); | 1344 | scrollMessagesToBottom(); |
| 1334 | }); | 1345 | }); |
| 1335 | }, [scrollMessagesToBottom]); | 1346 | }, [scrollMessagesToBottom]); |
| | 1347 | const forceStickToBottom = useCallback(() => { |
| | 1348 | cancelPendingStickToBottom(); |
| | 1349 | scrollMessagesToBottom(); |
| | 1350 | scheduleStickToBottom(); |
| | 1351 | }, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom]); |
| 1336 | const onMessagesScroll = useCallback(() => { | 1352 | const onMessagesScroll = useCallback(() => { |
| 1337 | const scrollContainer = messagesScrollRef.current; | 1353 | const scrollContainer = messagesScrollRef.current; |
| 1338 | if (!scrollContainer) return; | 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 | useEffect(() => { | 1413 | useEffect(() => { |
| 1342 | return () => { | 1414 | return () => { |
| 1343 | const frame = pendingAutoScrollFrameRef.current; | 1415 | cancelPendingStickToBottom(); |
| 1344 | if (frame !== null) { | | |
| 1345 | window.cancelAnimationFrame(frame); | | |
| 1346 | } | | |
| 1347 | }; | 1416 | }; |
| 1348 | }, []); | 1417 | }, [cancelPendingStickToBottom]); |
| 1349 | useLayoutEffect(() => { | 1418 | useLayoutEffect(() => { |
| 1350 | if (!activeThread?.id) return; | 1419 | if (!activeThread?.id) return; |
| 1351 | shouldAutoScrollRef.current = true; | 1420 | shouldAutoScrollRef.current = true; |
| ... | @@ -1922,7 +1991,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1922,7 +1991,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1922 | ]); | 1991 | ]); |
| 1923 | // Sending a message should always bring the latest user turn into view. | 1992 | // Sending a message should always bring the latest user turn into view. |
| 1924 | shouldAutoScrollRef.current = true; | 1993 | shouldAutoScrollRef.current = true; |
| 1925 | scheduleStickToBottom(); | 1994 | forceStickToBottom(); |
| 1926 | | 1995 | |
| 1927 | setThreadError(threadIdForSend, null); | 1996 | setThreadError(threadIdForSend, null); |
| 1928 | promptRef.current = ""; | 1997 | promptRef.current = ""; |
| ... | @@ -2442,6 +2511,14 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2442,6 +2511,14 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2442 | ref={setMessagesScrollContainerRef} | 2511 | ref={setMessagesScrollContainerRef} |
| 2443 | 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" | 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 | onScroll={onMessagesScroll} | 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 | <MessagesTimeline | 2523 | <MessagesTimeline |
| 2447 | key={activeThread.id} | 2524 | key={activeThread.id} |
| ... | @@ -3602,6 +3679,11 @@ const OpenInPicker = memo(function OpenInPicker({ | ... | @@ -3602,6 +3679,11 @@ const OpenInPicker = memo(function OpenInPicker({ |
| 3602 | Icon: CursorIcon, | 3679 | Icon: CursorIcon, |
| 3603 | value: "cursor", | 3680 | value: "cursor", |
| 3604 | }, | 3681 | }, |
| | 3682 | { |
| | 3683 | label: "VS Code", |
| | 3684 | Icon: FileIcon, |
| | 3685 | value: "vscode", |
| | 3686 | }, |
| 3605 | { | 3687 | { |
| 3606 | label: isMacPlatform(navigator.platform) | 3688 | label: isMacPlatform(navigator.platform) |
| 3607 | ? "Finder" | 3689 | ? "Finder" |