From 64b0366d8cf06ad74b982fe3584b30fece91f115 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 22 Aug 2026 02:09:13 +0100 Subject: [PATCH] fix(web): scope tooltip scroll dismiss to user scrolls --- .../src/components/chat/MessagesTimeline.tsx | 157 +++++++++++------- apps/web/src/components/ui/tooltip.tsx | 116 ++++++++++++- 2 files changed, 214 insertions(+), 59 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 6d89140d1105..baff99d22c80 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -90,7 +90,13 @@ import { type TimelineLatestTurn, } from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + Tooltip, + TooltipPopup, + TooltipScrollDismissScope, + TooltipTrigger, + useTooltipScrollDismiss, +} from "../ui/tooltip"; import { deriveDisplayedUserMessageState, type ParsedTerminalContextEntry, @@ -196,6 +202,8 @@ const TIMELINE_MAINTAIN_SCROLL_AT_END = { layout: true, }, } as const; +// Keys that scroll the focused list; anything else leaves tooltips alone. +const SCROLL_DISMISS_KEYS = new Set(["PageUp", "PageDown", "Home", "End", "ArrowUp", "ArrowDown"]); // --------------------------------------------------------------------------- // Props (public API) @@ -242,6 +250,38 @@ interface MessagesTimelineProps { loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null; } +// Rendered inside TooltipScrollDismissScope: context is only visible to +// descendants of its provider, so MessagesTimeline itself cannot consume it. +// Tooltips here close on real input gestures only (wheel, touch, scroll keys, +// presses inside the timeline). LegendList's maintainScrollAtEnd and minimap +// scrollToIndex fire plain scroll events with no gesture behind them, so +// streaming auto-follow never dismisses an open tooltip. +function TooltipScrollDismissListener({ node }: { node: HTMLDivElement | null }) { + const dismissTooltips = useTooltipScrollDismiss(); + useEffect(() => { + if (!node || !dismissTooltips) { + return; + } + const dismiss = () => dismissTooltips(); + const handleKeyDown = (event: { key: string }) => { + if (SCROLL_DISMISS_KEYS.has(event.key)) { + dismissTooltips(); + } + }; + node.addEventListener("wheel", dismiss, { passive: true }); + node.addEventListener("touchmove", dismiss, { passive: true }); + node.addEventListener("pointerdown", dismiss, { passive: true }); + node.addEventListener("keydown", handleKeyDown); + return () => { + node.removeEventListener("wheel", dismiss); + node.removeEventListener("touchmove", dismiss); + node.removeEventListener("pointerdown", dismiss); + node.removeEventListener("keydown", handleKeyDown); + }; + }, [dismissTooltips, node]); + return null; +} + // --------------------------------------------------------------------------- // MessagesTimeline — list owner // --------------------------------------------------------------------------- @@ -566,62 +606,65 @@ export const MessagesTimeline = memo(function MessagesTimeline({ } return ( - - -
- - ref={listRef} - data={rows} - keyExtractor={keyExtractor} - getItemType={getItemType} - renderItem={renderItem} - estimatedItemSize={90} - initialScrollAtEnd - {...(anchoredEndSpace ? { anchoredEndSpace } : {})} - contentInsetEndAdjustment={contentInsetEndAdjustment} - maintainScrollAtEnd={ - anchoredEndSpace || !liveFollowEnabled || disclosureToggleSettling - ? false - : TIMELINE_MAINTAIN_SCROLL_AT_END - } - maintainVisibleContentPosition={maintainVisibleContentPosition} - onScroll={handleScroll} - className={cn( - "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5", - topFadeEnabled && "topbar-scroll-fade", - )} - ListHeaderComponent={ - loadEarlier !== null ? ( - - ) : topFadeEnabled ? ( - TIMELINE_LIST_FADE_HEADER - ) : ( - TIMELINE_LIST_HEADER - ) - } - ListFooterComponent={TIMELINE_LIST_FOOTER} - /> - { - onManualNavigation(); - void listRef.current?.scrollToIndex({ - index: item.rowIndex, - animated: true, - viewOffset: 24, - }); - }} - /> -
-
-
+ + + +
+ + + ref={listRef} + data={rows} + keyExtractor={keyExtractor} + getItemType={getItemType} + renderItem={renderItem} + estimatedItemSize={90} + initialScrollAtEnd + {...(anchoredEndSpace ? { anchoredEndSpace } : {})} + contentInsetEndAdjustment={contentInsetEndAdjustment} + maintainScrollAtEnd={ + anchoredEndSpace || !liveFollowEnabled || disclosureToggleSettling + ? false + : TIMELINE_MAINTAIN_SCROLL_AT_END + } + maintainVisibleContentPosition={maintainVisibleContentPosition} + onScroll={handleScroll} + className={cn( + "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5", + topFadeEnabled && "topbar-scroll-fade", + )} + ListHeaderComponent={ + loadEarlier !== null ? ( + + ) : topFadeEnabled ? ( + TIMELINE_LIST_FADE_HEADER + ) : ( + TIMELINE_LIST_HEADER + ) + } + ListFooterComponent={TIMELINE_LIST_FOOTER} + /> + { + onManualNavigation(); + void listRef.current?.scrollToIndex({ + index: item.rowIndex, + animated: true, + viewOffset: 24, + }); + }} + /> +
+
+
+
); }); diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index 77b15a01e16b..38536deb96b6 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -1,4 +1,13 @@ import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"; +import { + type ReactNode, + createContext, + use, + useEffect, + useMemo, + useRef, + type RefObject, +} from "react"; import { cn } from "~/lib/utils"; @@ -6,7 +15,103 @@ const TooltipCreateHandle = TooltipPrimitive.createHandle; const TooltipProvider = TooltipPrimitive.Provider; -const Tooltip = TooltipPrimitive.Root; +/** + * Tooltips rendered inside this scope close when the scope owner reports a + * user scroll gesture (via useTooltipScrollDismiss). Used around the chat + * timeline: its tooltips portal above other surfaces (like the composer), so + * one left open after its trigger scrolls out from under a stationary pointer + * would paint over them. Programmatic scrolls — streaming auto-follow, + * minimap jumps — never dismiss anything because only real input gestures + * trigger the dismissal. Keyboard-opened tooltips are exempt so keyboard + * users keep them until focus moves. + */ +interface TooltipScrollDismiss { + register: (close: () => void) => () => void; + dismissAll: () => void; +} + +const TooltipScrollDismissContext = createContext(null); + +function TooltipScrollDismissScope({ children }: { children: ReactNode }) { + const closeCallbacks = useRef(new Set<() => void>()); + const dismiss = useMemo( + () => ({ + register: (close) => { + closeCallbacks.current.add(close); + return () => { + closeCallbacks.current.delete(close); + }; + }, + dismissAll: () => { + // Set iteration tolerates deletion of the in-flight entry, which is + // all a close callback ever does to the set. + for (const close of closeCallbacks.current) { + close(); + } + }, + }), + [], + ); + return {children}; +} + +/** Dismisses every hover-opened tooltip under the nearest scope. Null outside one. */ +export function useTooltipScrollDismiss(): (() => void) | null { + const dismiss = use(TooltipScrollDismissContext); + return dismiss?.dismissAll ?? null; +} + +function Tooltip(props: TooltipPrimitive.Root.Props) { + const { actionsRef: consumerActionsRef, onOpenChange, ...rootProps } = props; + const scrollDismiss = use(TooltipScrollDismissContext); + const actionsRef = useRef(null); + const registeredCloseRef = useRef<(() => void) | null>(null); + const consumerActionsRefMirror = useRef(consumerActionsRef); + consumerActionsRefMirror.current = consumerActionsRef; + + useEffect( + () => () => { + registeredCloseRef.current?.(); + }, + [], + ); + + // Write through to a consumer-supplied actionsRef instead of dropping it. + const mergedActionsRef = useMemo>( + () => ({ + get current() { + return actionsRef.current; + }, + set current(actions) { + actionsRef.current = actions; + const ref = consumerActionsRefMirror.current; + if (ref) { + ref.current = actions; + } + }, + }), + [], + ); + + const handleOpenChange = (open: boolean, details: TooltipPrimitive.Root.ChangeEventDetails) => { + onOpenChange?.(open, details); + registeredCloseRef.current?.(); + registeredCloseRef.current = null; + if (!scrollDismiss || !open || details.reason !== "trigger-hover") { + return; + } + const close = () => actionsRef.current?.close(); + registeredCloseRef.current = scrollDismiss.register(close); + }; + + return ( + + ); +} function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) { return ; @@ -61,4 +166,11 @@ function TooltipPopup({ ); } -export { TooltipCreateHandle, TooltipProvider, Tooltip, TooltipTrigger, TooltipPopup }; +export { + TooltipCreateHandle, + TooltipProvider, + TooltipScrollDismissScope, + Tooltip, + TooltipTrigger, + TooltipPopup, +};