-
Notifications
You must be signed in to change notification settings - Fork 4.7k
fix(web): close hover tooltips when the chat timeline scrolls #7860
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,12 +1,117 @@ | ||||||
| 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"; | ||||||
|
|
||||||
| 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<TooltipScrollDismiss | null>(null); | ||||||
|
|
||||||
| function TooltipScrollDismissScope({ children }: { children: ReactNode }) { | ||||||
| const closeCallbacks = useRef(new Set<() => void>()); | ||||||
| const dismiss = useMemo<TooltipScrollDismiss>( | ||||||
| () => ({ | ||||||
| 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 <TooltipScrollDismissContext value={dismiss}>{children}</TooltipScrollDismissContext>; | ||||||
| } | ||||||
|
|
||||||
| /** 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) { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wrapping
Suggested change
( Posted via Macroscope — UI Consistency |
||||||
| const { actionsRef: consumerActionsRef, onOpenChange, ...rootProps } = props; | ||||||
| const scrollDismiss = use(TooltipScrollDismissContext); | ||||||
| const actionsRef = useRef<TooltipPrimitive.Root.Actions>(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<RefObject<TooltipPrimitive.Root.Actions | null>>( | ||||||
| () => ({ | ||||||
| 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); | ||||||
|
Comment on lines
+96
to
+104
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This changes behavior of a primitive every tooltip in the app renders through — prop forwarding ( Posted via Macroscope — UI Consistency |
||||||
| }; | ||||||
|
|
||||||
| return ( | ||||||
| <TooltipPrimitive.Root | ||||||
| {...rootProps} | ||||||
| actionsRef={mergedActionsRef} | ||||||
| onOpenChange={handleOpenChange} | ||||||
| /> | ||||||
| ); | ||||||
| } | ||||||
|
|
||||||
| function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) { | ||||||
| return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />; | ||||||
|
|
@@ -61,4 +166,11 @@ function TooltipPopup({ | |||||
| ); | ||||||
| } | ||||||
|
|
||||||
| export { TooltipCreateHandle, TooltipProvider, Tooltip, TooltipTrigger, TooltipPopup }; | ||||||
| export { | ||||||
| TooltipCreateHandle, | ||||||
| TooltipProvider, | ||||||
| TooltipScrollDismissScope, | ||||||
| Tooltip, | ||||||
| TooltipTrigger, | ||||||
| TooltipPopup, | ||||||
| }; | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Medium
ui/tooltip.tsx:60dismissTooltipsis alwaysnull, soMessagesTimelinenever installs its wheel, touch, pointer, or keyboard dismissal listeners and scrolling leaves hover tooltips open.useTooltipScrollDismiss()runs before the returnedTooltipScrollDismissScope, which is a descendant and cannot provide context to this hook call. Move the hook/listener logic into a child rendered beneath the scope, or place the scope aboveMessagesTimeline.Also found in 1 other location(s)
apps/web/src/components/chat/MessagesTimeline.tsx:436🤖 Copy this AI Prompt to have your agent fix this:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed — the listener was consuming context from outside the scope it rendered, so dismissal could never activate. The hook now lives in a small TooltipScrollDismissListener child rendered inside TooltipScrollDismissScope (MessagesTimeline), wired to the viewport element it already tracks. Typecheck clean, MessagesTimeline tests 26/26.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry, I'm unable to act on this request because you do not have permissions within this repository.