| ... | ... | @@ -637,6 +637,11 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 637 | 637 | const lastTouchClientYRef = useRef<number | null>(null); |
| 638 | 638 | const pendingUserScrollUpIntentRef = useRef(false); |
| 639 | 639 | const pendingAutoScrollFrameRef = useRef<number | null>(null); |
| 640 | const pendingInteractionAnchorRef = useRef<{ |
| 641 | element: HTMLElement; |
| 642 | top: number; |
| 643 | } | null>(null); |
| 644 | const pendingInteractionAnchorFrameRef = useRef<number | null>(null); |
| 640 | 645 | const composerEditorRef = useRef<ComposerPromptEditorHandle>(null); |
| 641 | 646 | const composerFormRef = useRef<HTMLFormElement>(null); |
| 642 | 647 | const composerFormHeightRef = useRef(0); |
| ... | ... | @@ -1055,7 +1060,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1055 | 1060 | return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; |
| 1056 | 1061 | }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); |
| 1057 | 1062 | const timelineEntries = useMemo( |
| 1058 | | () => deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), |
| 1063 | () => |
| 1064 | deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), |
| 1059 | 1065 | [activeThread?.proposedPlans, timelineMessages, workLogEntries], |
| 1060 | 1066 | ); |
| 1061 | 1067 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| ... | ... | @@ -1675,6 +1681,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1675 | 1681 | pendingAutoScrollFrameRef.current = null; |
| 1676 | 1682 | window.cancelAnimationFrame(pendingFrame); |
| 1677 | 1683 | }, []); |
| 1684 | const cancelPendingInteractionAnchorAdjustment = useCallback(() => { |
| 1685 | const pendingFrame = pendingInteractionAnchorFrameRef.current; |
| 1686 | if (pendingFrame === null) return; |
| 1687 | pendingInteractionAnchorFrameRef.current = null; |
| 1688 | window.cancelAnimationFrame(pendingFrame); |
| 1689 | }, []); |
| 1678 | 1690 | const scheduleStickToBottom = useCallback(() => { |
| 1679 | 1691 | if (pendingAutoScrollFrameRef.current !== null) return; |
| 1680 | 1692 | pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => { |
| ... | ... | @@ -1682,6 +1694,40 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1682 | 1694 | scrollMessagesToBottom(); |
| 1683 | 1695 | }); |
| 1684 | 1696 | }, [scrollMessagesToBottom]); |
| 1697 | const onMessagesClickCapture = useCallback( |
| 1698 | (event: React.MouseEvent<HTMLDivElement>) => { |
| 1699 | const scrollContainer = messagesScrollRef.current; |
| 1700 | if (!scrollContainer || !(event.target instanceof Element)) return; |
| 1701 | |
| 1702 | const trigger = event.target.closest<HTMLElement>( |
| 1703 | "button, summary, [role='button'], [data-scroll-anchor-target]", |
| 1704 | ); |
| 1705 | if (!trigger || !scrollContainer.contains(trigger)) return; |
| 1706 | |
| 1707 | pendingInteractionAnchorRef.current = { |
| 1708 | element: trigger, |
| 1709 | top: trigger.getBoundingClientRect().top, |
| 1710 | }; |
| 1711 | |
| 1712 | cancelPendingInteractionAnchorAdjustment(); |
| 1713 | pendingInteractionAnchorFrameRef.current = window.requestAnimationFrame(() => { |
| 1714 | pendingInteractionAnchorFrameRef.current = null; |
| 1715 | const anchor = pendingInteractionAnchorRef.current; |
| 1716 | pendingInteractionAnchorRef.current = null; |
| 1717 | const activeScrollContainer = messagesScrollRef.current; |
| 1718 | if (!anchor || !activeScrollContainer) return; |
| 1719 | if (!anchor.element.isConnected || !activeScrollContainer.contains(anchor.element)) return; |
| 1720 | |
| 1721 | const nextTop = anchor.element.getBoundingClientRect().top; |
| 1722 | const delta = nextTop - anchor.top; |
| 1723 | if (Math.abs(delta) < 0.5) return; |
| 1724 | |
| 1725 | activeScrollContainer.scrollTop += delta; |
| 1726 | lastKnownScrollTopRef.current = activeScrollContainer.scrollTop; |
| 1727 | }); |
| 1728 | }, |
| 1729 | [cancelPendingInteractionAnchorAdjustment], |
| 1730 | ); |
| 1685 | 1731 | const forceStickToBottom = useCallback(() => { |
| 1686 | 1732 | cancelPendingStickToBottom(); |
| 1687 | 1733 | scrollMessagesToBottom(); |
| ... | ... | @@ -1751,8 +1797,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1751 | 1797 | useEffect(() => { |
| 1752 | 1798 | return () => { |
| 1753 | 1799 | cancelPendingStickToBottom(); |
| 1800 | cancelPendingInteractionAnchorAdjustment(); |
| 1754 | 1801 | }; |
| 1755 | | }, [cancelPendingStickToBottom]); |
| 1802 | }, [cancelPendingInteractionAnchorAdjustment, cancelPendingStickToBottom]); |
| 1756 | 1803 | useLayoutEffect(() => { |
| 1757 | 1804 | if (!activeThread?.id) return; |
| 1758 | 1805 | shouldAutoScrollRef.current = true; |
| ... | ... | @@ -2279,7 +2326,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2279 | 2326 | if (showPlanFollowUpPrompt && activeProposedPlan) { |
| 2280 | 2327 | const followUpText = |
| 2281 | 2328 | trimmed.length > 0 |
| 2282 | | ? trimmed |
| 2329 | ? trimmed |
| 2283 | 2330 | : buildPlanImplementationPrompt(activeProposedPlan.planMarkdown); |
| 2284 | 2331 | const nextInteractionMode = trimmed.length > 0 ? "plan" : "default"; |
| 2285 | 2332 | promptRef.current = ""; |
| ... | ... | @@ -3323,6 +3370,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3323 | 3370 | ref={setMessagesScrollContainerRef} |
| 3324 | 3371 | 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" |
| 3325 | 3372 | onScroll={onMessagesScroll} |
| 3373 | onClickCapture={onMessagesClickCapture} |
| 3326 | 3374 | onWheel={onMessagesWheel} |
| 3327 | 3375 | onPointerDown={onMessagesPointerDown} |
| 3328 | 3376 | onPointerUp={onMessagesPointerUp} |
| ... | ... | @@ -4270,15 +4318,14 @@ const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBanner({ |
| 4270 | 4318 | return ( |
| 4271 | 4319 | <div className="px-4 py-3.5 sm:px-5 sm:py-4"> |
| 4272 | 4320 | <div className="flex flex-wrap items-center gap-2"> |
| 4273 | | <Badge variant="secondary">Plan ready</Badge> |
| 4321 | <span className="uppercase text-sm tracking-[0.2em]">Plan ready</span> |
| 4274 | 4322 | {planTitle ? ( |
| 4275 | | <span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">{planTitle}</span> |
| 4323 | <span className="min-w-0 flex-1 truncate text-sm font-medium">{planTitle}</span> |
| 4276 | 4324 | ) : null} |
| 4277 | 4325 | </div> |
| 4278 | | <div className="mt-2 text-xs text-muted-foreground"> |
| 4279 | | Leave the composer empty to implement here, use the menu to implement in a new thread, or |
| 4280 | | type feedback to refine it. |
| 4281 | | </div> |
| 4326 | {/* <div className="mt-2 text-xs text-muted-foreground"> |
| 4327 | Review the plan |
| 4328 | </div> */} |
| 4282 | 4329 | </div> |
| 4283 | 4330 | ); |
| 4284 | 4331 | }); |