authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-10 17:59:23-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 17:59:23-07:00
log1cfd1c1a4489b96defb1cea79fc8561cf5dc2a24
tree0667564b0aa322c739c8ac3020858a2a77bde933
parentc73b45480fb79575a90794bfd1d45d538aacb108
signature Signed by PGP key B5690EEEBB952194

fix(web): toggling tools and thinking at the bottom keeps you at the bottom (#17954)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

2 files changed, 124 insertions(+), 21 deletions(-)

apps/web/src/components/chat/MessagesTimeline.test.tsx+71
......@@ -670,6 +670,77 @@ describe("MessagesTimeline", () => {
670670 },
671671 );
672672
673 it.each([true, false])(
674 "keeps the end in view when tool output toggles only at the end: %s",
675 async (isAtEnd) => {
676 const frames = new Map<number, FrameRequestCallback>();
677 let nextFrame = 0;
678 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
679 frames.set(++nextFrame, callback);
680 return nextFrame;
681 });
682 vi.stubGlobal("cancelAnimationFrame", (frame: number) => frames.delete(frame));
683 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
684 const flushFrame = () =>
685 act(() => {
686 const callbacks = [...frames.values()];
687 frames.clear();
688 callbacks.forEach((callback) => callback(0));
689 });
690 const viewport = { scrollTop: 400, scrollHeight: 1000 };
691 const props = buildProps();
692 props.listRef.current = {
693 getState: () => ({ isAtEnd }),
694 getScrollableNode: () => viewport,
695 } as unknown as LegendListRef;
696 let renderer: ReactTestRenderer | undefined;
697 try {
698 await act(() => {
699 renderer = create(
700 <MessagesTimeline
701 {...props}
702 timelineEntries={[
703 {
704 id: "tool",
705 kind: "work",
706 createdAt: MESSAGE_CREATED_AT,
707 entry: {
708 id: "tool",
709 createdAt: MESSAGE_CREATED_AT,
710 label: "Run command",
711 tone: "tool",
712 toolLifecycleStatus: "completed",
713 detail: "Command output",
714 },
715 },
716 ]}
717 />,
718 );
719 });
720 const toggle = renderer!.root.findByProps({ "aria-expanded": false });
721 const list = renderer!.root.find(
722 (node) => node.props.ListFooterComponent !== undefined && node.props.onItemSizeChanged,
723 );
724 for (const scrollHeight of [1300, 900]) {
725 await act(() => toggle.props.onClick());
726 // The rows grow or shrink once the list measures them.
727 viewport.scrollHeight = scrollHeight;
728 await flushFrame();
729 expect(viewport.scrollTop).toBe(isAtEnd ? scrollHeight : 400);
730 // Tool output that renders late is pinned before the next frame.
731 viewport.scrollHeight = scrollHeight + 200;
732 await act(() => list.props.onItemSizeChanged());
733 expect(viewport.scrollTop).toBe(isAtEnd ? scrollHeight + 200 : 400);
734 await flushFrame();
735 await flushFrame();
736 viewport.scrollTop = 400;
737 }
738 } finally {
739 await act(() => renderer?.unmount());
740 }
741 },
742 );
743
673744 it("preserves arbitrary XML-like tags and comparisons in rendered user messages", async () => {
674745 const { MessagesTimeline } = await import("./MessagesTimeline");
675746 const markup = renderToStaticMarkup(
apps/web/src/components/chat/MessagesTimeline.tsx+53-21
......@@ -718,34 +718,59 @@ const ConversationTimeline = memo(function ConversationTimeline({
718718 };
719719 }, [settlingListIdentity]);
720720
721 // A toggle made at the end keeps the end in view while rows re-measure.
722 // Anywhere else, the toggled row holds its place.
723 const disclosurePinsEndRef = useRef(false);
724 const disclosureCollapsedRef = useRef(false);
725 const pinDisclosureToEnd = useCallback(() => {
726 if (!disclosurePinsEndRef.current) return;
727 const element = listRef.current?.getScrollableNode();
728 if (element) element.scrollTop = element.scrollHeight;
729 }, [listRef]);
730
731 // Settles two frames after the last toggle, or after the last row resize
732 // while pinned to the end, since tool output can render a few frames late.
733 const scheduleDisclosureSettle = useCallback(() => {
734 if (disclosureSettleFrameRef.current !== null) {
735 cancelAnimationFrame(disclosureSettleFrameRef.current);
736 }
737 if (disclosureSettleSecondFrameRef.current !== null) {
738 cancelAnimationFrame(disclosureSettleSecondFrameRef.current);
739 }
740 disclosureSettleFrameRef.current = requestAnimationFrame(() => {
741 pinDisclosureToEnd();
742 disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {
743 pinDisclosureToEnd();
744 disclosurePinsEndRef.current = false;
745 disclosureAnchorKeyRef.current = null;
746 setDisclosureToggleSettling(false);
747 disclosureSettleFrameRef.current = null;
748 disclosureSettleSecondFrameRef.current = null;
749 // Wait for row measurement and the disclosure click's blur check.
750 // Closing output can reveal the end without a scroll event.
751 if (
752 disclosureCollapsedRef.current &&
753 resolveTimelineIsAtEnd(listRef.current?.getState()) === true
754 ) {
755 onToolOutputCollapsedAtEnd?.();
756 }
757 });
758 });
759 }, [listRef, onToolOutputCollapsedAtEnd, pinDisclosureToEnd]);
760
721761 const suspendEndScrollMaintenanceForDisclosure = useCallback(
722762 (anchorKey: string, collapsed = false) => {
723763 disclosureAnchorKeyRef.current = anchorKey;
764 disclosureCollapsedRef.current = collapsed;
765 disclosurePinsEndRef.current = resolveTimelineIsAtEnd(listRef.current?.getState()) === true;
724766 setDisclosureToggleSettling(true);
725 if (disclosureSettleFrameRef.current !== null) {
726 cancelAnimationFrame(disclosureSettleFrameRef.current);
727 }
728 if (disclosureSettleSecondFrameRef.current !== null) {
729 cancelAnimationFrame(disclosureSettleSecondFrameRef.current);
730 }
731 disclosureSettleFrameRef.current = requestAnimationFrame(() => {
732 disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {
733 disclosureAnchorKeyRef.current = null;
734 setDisclosureToggleSettling(false);
735 disclosureSettleFrameRef.current = null;
736 disclosureSettleSecondFrameRef.current = null;
737 // Wait for row measurement and the disclosure click's blur check.
738 // Closing output can reveal the end without a scroll event.
739 if (collapsed && resolveTimelineIsAtEnd(listRef.current?.getState()) === true) {
740 onToolOutputCollapsedAtEnd?.();
741 }
742 });
743 });
767 scheduleDisclosureSettle();
744768 },
745 [listRef, onToolOutputCollapsedAtEnd],
769 [listRef, scheduleDisclosureSettle],
746770 );
747771
748772 const shouldRestoreVisibleContentPosition = useCallback((row: MessagesTimelineRow) => {
773 if (disclosurePinsEndRef.current) return false;
749774 const disclosureAnchorKey = disclosureAnchorKeyRef.current;
750775 return disclosureAnchorKey === null || row.id === disclosureAnchorKey;
751776 }, []);
......@@ -1165,6 +1190,13 @@ const ConversationTimeline = memo(function ConversationTimeline({
11651190 onContentOverflowChange(measureContentOverflow());
11661191 });
11671192 }, [measureContentOverflow, onContentOverflowChange]);
1193 const handleItemSizeChanged = useCallback(() => {
1194 if (disclosurePinsEndRef.current) {
1195 queueMicrotask(pinDisclosureToEnd);
1196 scheduleDisclosureSettle();
1197 }
1198 reportContentOverflow();
1199 }, [pinDisclosureToEnd, reportContentOverflow, scheduleDisclosureSettle]);
11681200 useEffect(() => cancelContentOverflowFrame, [cancelContentOverflowFrame]);
11691201 // The list's own layout effects have already run here, so estimated row
11701202 // positions are in place. Reporting before the first paint lets a thread
......@@ -1615,7 +1647,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
16151647 }
16161648 maintainScrollAtEndThreshold={1}
16171649 onScroll={handleScroll}
1618 onItemSizeChanged={reportContentOverflow}
1650 onItemSizeChanged={handleItemSizeChanged}
16191651 className={cn(
16201652 "messages-timeline-scroll scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain [overflow-anchor:none]",
16211653 topFadeEnabled && "topbar-scroll-fade",