Unified · Split
| @@ -637,6 +637,11 @@ export default function ChatView({ threadId }: ChatViewProps) { · 636 unmodified lines | ||
| 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) { · 412 unmodified lines | ||
| 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) { · 613 unmodified lines | ||
| 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) { · 1 unmodified line | ||
| 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) { · 63 unmodified lines | ||
| 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) { · 520 unmodified lines | ||
| 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) { · 1037 unmodified lines | ||
| 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({ · 941 unmodified lines | ||
| 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 | }); |