| ... | @@ -637,6 +637,11 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -637,6 +637,11 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 637 | const lastTouchClientYRef = useRef<number | null>(null); | 637 | const lastTouchClientYRef = useRef<number | null>(null); |
| 638 | const pendingUserScrollUpIntentRef = useRef(false); | 638 | const pendingUserScrollUpIntentRef = useRef(false); |
| 639 | const pendingAutoScrollFrameRef = useRef<number | null>(null); | 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 | const composerEditorRef = useRef<ComposerPromptEditorHandle>(null); | 645 | const composerEditorRef = useRef<ComposerPromptEditorHandle>(null); |
| 641 | const composerFormRef = useRef<HTMLFormElement>(null); | 646 | const composerFormRef = useRef<HTMLFormElement>(null); |
| 642 | const composerFormHeightRef = useRef(0); | 647 | const composerFormHeightRef = useRef(0); |
| ... | @@ -1055,7 +1060,8 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1055,7 +1060,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1055 | return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; | 1060 | return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; |
| 1056 | }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); | 1061 | }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); |
| 1057 | const timelineEntries = useMemo( | 1062 | const timelineEntries = useMemo( |
| 1058 | () => deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), | 1063 | () => |
| | 1064 | deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), |
| 1059 | [activeThread?.proposedPlans, timelineMessages, workLogEntries], | 1065 | [activeThread?.proposedPlans, timelineMessages, workLogEntries], |
| 1060 | ); | 1066 | ); |
| 1061 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = | 1067 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| ... | @@ -1675,6 +1681,12 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1675,6 +1681,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1675 | pendingAutoScrollFrameRef.current = null; | 1681 | pendingAutoScrollFrameRef.current = null; |
| 1676 | window.cancelAnimationFrame(pendingFrame); | 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 | const scheduleStickToBottom = useCallback(() => { | 1690 | const scheduleStickToBottom = useCallback(() => { |
| 1679 | if (pendingAutoScrollFrameRef.current !== null) return; | 1691 | if (pendingAutoScrollFrameRef.current !== null) return; |
| 1680 | pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => { | 1692 | pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => { |
| ... | @@ -1682,6 +1694,40 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1682,6 +1694,40 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1682 | scrollMessagesToBottom(); | 1694 | scrollMessagesToBottom(); |
| 1683 | }); | 1695 | }); |
| 1684 | }, [scrollMessagesToBottom]); | 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 | const forceStickToBottom = useCallback(() => { | 1731 | const forceStickToBottom = useCallback(() => { |
| 1686 | cancelPendingStickToBottom(); | 1732 | cancelPendingStickToBottom(); |
| 1687 | scrollMessagesToBottom(); | 1733 | scrollMessagesToBottom(); |
| ... | @@ -1751,8 +1797,9 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1751,8 +1797,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1751 | useEffect(() => { | 1797 | useEffect(() => { |
| 1752 | return () => { | 1798 | return () => { |
| 1753 | cancelPendingStickToBottom(); | 1799 | cancelPendingStickToBottom(); |
| | 1800 | cancelPendingInteractionAnchorAdjustment(); |
| 1754 | }; | 1801 | }; |
| 1755 | }, [cancelPendingStickToBottom]); | 1802 | }, [cancelPendingInteractionAnchorAdjustment, cancelPendingStickToBottom]); |
| 1756 | useLayoutEffect(() => { | 1803 | useLayoutEffect(() => { |
| 1757 | if (!activeThread?.id) return; | 1804 | if (!activeThread?.id) return; |
| 1758 | shouldAutoScrollRef.current = true; | 1805 | shouldAutoScrollRef.current = true; |
| ... | @@ -2279,7 +2326,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2279,7 +2326,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2279 | if (showPlanFollowUpPrompt && activeProposedPlan) { | 2326 | if (showPlanFollowUpPrompt && activeProposedPlan) { |
| 2280 | const followUpText = | 2327 | const followUpText = |
| 2281 | trimmed.length > 0 | 2328 | trimmed.length > 0 |
| 2282 | ? trimmed | 2329 | ? trimmed |
| 2283 | : buildPlanImplementationPrompt(activeProposedPlan.planMarkdown); | 2330 | : buildPlanImplementationPrompt(activeProposedPlan.planMarkdown); |
| 2284 | const nextInteractionMode = trimmed.length > 0 ? "plan" : "default"; | 2331 | const nextInteractionMode = trimmed.length > 0 ? "plan" : "default"; |
| 2285 | promptRef.current = ""; | 2332 | promptRef.current = ""; |
| ... | @@ -3323,6 +3370,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3323,6 +3370,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3323 | ref={setMessagesScrollContainerRef} | 3370 | ref={setMessagesScrollContainerRef} |
| 3324 | 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" | 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 | onScroll={onMessagesScroll} | 3372 | onScroll={onMessagesScroll} |
| | 3373 | onClickCapture={onMessagesClickCapture} |
| 3326 | onWheel={onMessagesWheel} | 3374 | onWheel={onMessagesWheel} |
| 3327 | onPointerDown={onMessagesPointerDown} | 3375 | onPointerDown={onMessagesPointerDown} |
| 3328 | onPointerUp={onMessagesPointerUp} | 3376 | onPointerUp={onMessagesPointerUp} |
| ... | @@ -4270,15 +4318,14 @@ const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBanner({ | ... | @@ -4270,15 +4318,14 @@ const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBanner({ |
| 4270 | return ( | 4318 | return ( |
| 4271 | <div className="px-4 py-3.5 sm:px-5 sm:py-4"> | 4319 | <div className="px-4 py-3.5 sm:px-5 sm:py-4"> |
| 4272 | <div className="flex flex-wrap items-center gap-2"> | 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 | {planTitle ? ( | 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 | ) : null} | 4324 | ) : null} |
| 4277 | </div> | 4325 | </div> |
| 4278 | <div className="mt-2 text-xs text-muted-foreground"> | 4326 | {/* <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 | 4327 | Review the plan |
| 4280 | type feedback to refine it. | 4328 | </div> */} |
| 4281 | </div> | | |
| 4282 | </div> | 4329 | </div> |
| 4283 | ); | 4330 | ); |
| 4284 | }); | 4331 | }); |