From 32b77f4fa370a5195ad16ab709405b1038c49fcd Mon Sep 17 00:00:00 2001 From: maria Date: Sun, 4 Oct 2026 08:54:31 -0300 Subject: [PATCH] fix(web): dismiss hovered timeline tooltips on scroll (#15455) --- .../components/chat/MessagesTimeline.test.tsx | 125 +++++++++++++++++- .../src/components/chat/MessagesTimeline.tsx | 6 +- .../pullRequest/PullRequestLinkPreview.tsx | 13 +- apps/web/src/components/ui/tooltip.tsx | 76 ++++++++++- 4 files changed, 211 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 5b805d7f5649dc18f866c471f62a124af02fc634..f7aeb679a87bdaa4e3120daa329baa64556cc18d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,8 +1,11 @@ +// @vitest-environment jsdom + import { ApprovalRequestId, CheckpointRef, EnvironmentId, MessageId, + ProjectId, RunId, ThreadId, } from "@t3tools/contracts"; @@ -15,6 +18,7 @@ import { type ReactElement, } from "react"; import { renderToStaticMarkup } from "react-dom/server"; +import { createRoot } from "react-dom/client"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { shouldUseRestingComposerLayout } from "../composerFooterLayout"; import { useComposerFocusState } from "./useComposerFocusState"; @@ -227,6 +231,7 @@ function stubDomGlobals() { }; vi.stubGlobal("Element", ElementStub); + vi.stubGlobal("getComputedStyle", undefined); vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => {}, @@ -256,13 +261,131 @@ function stubDomGlobals() { beforeEach(stubDomGlobals); beforeAll(async () => { - stubDomGlobals(); + Object.defineProperty(window, "matchMedia", { value: matchMedia, configurable: true }); ({ MessagesTimeline, resolvePreviewAnnotationImage } = await import("./MessagesTimeline")); }, 30_000); const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z"; +describe("timeline tooltip scroll dismissal", () => { + it.each([ + "hover", + "delayed hover", + "focus", + "hover then focus", + "outside timeline", + "wheel without scroll", + "pr hover", + "pr delayed hover", + "pr focus", + "pr hover then focus", + ])("handles %s through the real tooltip interactions", async (scenario) => { + const isPullRequest = scenario.startsWith("pr "); + const interaction = isPullRequest ? scenario.slice(3) : scenario; + vi.unstubAllGlobals(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.useFakeTimers(); + const { Tooltip, TooltipTrigger, TooltipPopup, TooltipScrollDismissArea } = + await vi.importActual("../ui/tooltip"); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + const onMouseEnter = vi.fn(); + const query = await import("~/state/query"); + const querySpy = isPullRequest + ? vi.spyOn(query, "useEnvironmentQuery").mockReturnValue({ + data: null, + dataUpdatedAt: 0, + error: "Pull request not found", + failure: null, + isPending: false, + isSuccess: false, + refresh: vi.fn(), + }) + : null; + const { PullRequestLinkPreview } = await import("../pullRequest/PullRequestLinkPreview"); + const tooltip = isPullRequest ? ( + message link} + originalUrl="https://example.com" + target={{ + environmentId: ACTIVE_THREAD_ENVIRONMENT_ID, + input: { + projectId: ProjectId.make("project-1"), + repository: "pingdotgg/t3code", + number: 1, + }, + }} + /> + ) : ( + + + message link + + https://example.com + + ); + try { + await act(async () => { + root.render( + <> + +
+ {interaction === "outside timeline" ? null : tooltip} +
+
+ {interaction === "outside timeline" ? tooltip : null} + , + ); + }); + const trigger = container.querySelector("button")!; + const scrollable = container.querySelector('[data-testid="scrollable"]')!; + await act(async () => { + if (interaction !== "focus") { + trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + trigger.dispatchEvent(new MouseEvent("mouseenter")); + trigger.dispatchEvent(new MouseEvent("mousemove", { bubbles: true })); + } + if (interaction === "focus" || interaction === "hover then focus") { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" })); + trigger.focus(); + } + if (interaction !== "delayed hover") { + await vi.advanceTimersByTimeAsync(isPullRequest ? 400 : 60); + } + }); + expect(onMouseEnter).toHaveBeenCalledTimes(interaction === "focus" ? 0 : 1); + expect( + document.querySelector('[data-slot="tooltip-popup"][data-open]')?.textContent ?? null, + ).toBe(interaction === "delayed hover" ? null : "https://example.com"); + + await act(async () => { + scrollable.dispatchEvent( + interaction === "wheel without scroll" + ? new WheelEvent("wheel", { bubbles: true, deltaY: 100 }) + : new Event("scroll"), + ); + await vi.advanceTimersByTimeAsync(isPullRequest ? 1000 : 100); + }); + expect( + document.querySelector('[data-slot="tooltip-popup"][data-open]')?.textContent ?? null, + ).toBe( + interaction === "hover" || interaction === "delayed hover" ? null : "https://example.com", + ); + if (interaction === "focus" || interaction === "hover then focus") { + expect(document.activeElement).toBe(trigger); + } + } finally { + await act(async () => root.unmount()); + querySpy?.mockRestore(); + container.remove(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + } + }); +}); + function buildProps() { return { isWorking: false, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index bcaaa61b0b10eaa21230b13f058b7faefca450c6..45940bca888eb2485230b8193e6a98e2e8bca442 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -220,7 +220,7 @@ import { import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Spinner } from "../ui/spinner"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { Tooltip, TooltipPopup, TooltipTrigger, TooltipScrollDismissArea } from "../ui/tooltip"; import { WorktreeSetupCard } from "./WorktreeSetupCard"; import { ContextChipPopover as UserMessageContextPopover, @@ -1325,7 +1325,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return ( -
-
+
); diff --git a/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx b/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx index 3f183b264b2beac50b5b998a8a8d80a647d70f84..de87d9a5f1f5972043ec3f052f3963ab320d1a73 100644 --- a/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx +++ b/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx @@ -1,7 +1,9 @@ +import type { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"; import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts"; import { cloneElement, + useRef, useState, type ComponentPropsWithoutRef, type MouseEvent, @@ -45,6 +47,7 @@ export function PullRequestLinkPreview({ fallback?: ReactNode; }) { const [open, setOpen] = useState(false); + const previewActionsRef = useRef(null); const [resolvingClick, setResolvingClick] = useState(false); const detailQuery = useEnvironmentQuery( open @@ -96,8 +99,14 @@ export function PullRequestLinkPreview({ : (detail?.author.login ?? null); return ( - - + + { + // Cancel the card's delayed hover too, without changing its content preview. + if (!nextOpen && !showCard) previewActionsRef.current?.close(); + }} + > } delay={350} diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index 7ba9e4c238c49422a12d604691b59d58e28ddde8..1017a787931fd4233f5571c46b8842bd0fa9ee79 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -1,13 +1,83 @@ import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"; +import { createContext, use, useEffect, useRef, type ComponentProps, type RefObject } from "react"; import { cn } from "~/lib/utils"; const TooltipProvider = TooltipPrimitive.Provider; -const Tooltip = TooltipPrimitive.Root; +type TooltipActionsRef = RefObject; +const TooltipHoverContext = createContext(null); +const TooltipScrollContext = createContext | null>(null); + +/** Dismisses hovered descendants on real scroll events without rerendering the timeline. */ +function TooltipScrollDismissArea({ onScrollCapture, ...props }: ComponentProps<"div">) { + const hovered = useRef<{ trigger: HTMLElement; actionsRef: TooltipActionsRef } | null>(null); + return ( + +
{ + onScrollCapture?.(event); + const tooltip = hovered.current; + if (!tooltip || tooltip.trigger.contains(tooltip.trigger.ownerDocument.activeElement)) { + return; + } + hovered.current = null; + // Base UI also cancels delayed hover opens through this action. + tooltip.actionsRef.current?.close(); + }} + /> + + ); +} + +function Tooltip(props: TooltipPrimitive.Root.Props) { + const hovered = use(TooltipScrollContext); + const localActionsRef = useRef(null); + const actionsRef = props.actionsRef ?? localActionsRef; + useEffect( + () => () => { + if (hovered?.current?.actionsRef === actionsRef) hovered.current = null; + }, + [actionsRef, hovered], + ); + + if (!hovered) return ; + return ( + + { + props.onOpenChange?.(open, details); + if (!open && !details.isCanceled && hovered.current?.actionsRef === actionsRef) { + hovered.current = null; + } + }} + /> + + ); +} function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) { - return ; + const hovered = use(TooltipScrollContext); + const actionsRef = use(TooltipHoverContext); + if (!hovered || !actionsRef) { + return ; + } + return ( + { + props.onMouseEnter?.(event); + hovered.current = { trigger: event.currentTarget, actionsRef }; + }} + /> + ); } function TooltipPopup({ @@ -64,4 +134,4 @@ function TooltipPopup({ ); } -export { TooltipProvider, Tooltip, TooltipTrigger, TooltipPopup }; +export { TooltipProvider, Tooltip, TooltipTrigger, TooltipPopup, TooltipScrollDismissArea }; -- 2.54.0