From 0c8fd2e4c58a7a612c49bd941ef3b03a369853c2 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 4 Oct 2026 02:57:00 +0000 Subject: [PATCH 1/2] fix(web): dismiss hovered timeline tooltips on scroll --- .../components/chat/MessagesTimeline.test.tsx | 89 ++++++++++++++++++- .../src/components/chat/MessagesTimeline.tsx | 6 +- apps/web/src/components/ui/tooltip.tsx | 76 +++++++++++++++- 3 files changed, 164 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 5b805d7f5649d..3a7932d9ebaf8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,3 +1,5 @@ +// @vitest-environment jsdom + import { ApprovalRequestId, CheckpointRef, @@ -15,6 +17,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 +230,7 @@ function stubDomGlobals() { }; vi.stubGlobal("Element", ElementStub); + vi.stubGlobal("getComputedStyle", undefined); vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => {}, @@ -256,13 +260,96 @@ 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", + ])("handles %s through the real tooltip interactions", async (interaction) => { + 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 tooltip = ( + + + 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(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(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()); + 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 ba6e6800db590..b757d6f4f41c9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -214,7 +214,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, @@ -1307,7 +1307,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return ( -
-
+
); diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index 7ba9e4c238c49..1017a787931fd 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 }; From 0b050eda292071029510cfc110512c70794e9681 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 4 Oct 2026 04:19:29 +0000 Subject: [PATCH 2/2] fix(web): dismiss controlled pull request tooltips on scroll --- .../components/chat/MessagesTimeline.test.tsx | 44 +++++++++++++++++-- .../pullRequest/PullRequestLinkPreview.tsx | 13 +++++- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 3a7932d9ebaf8..f7aeb679a87bd 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -5,6 +5,7 @@ import { CheckpointRef, EnvironmentId, MessageId, + ProjectId, RunId, ThreadId, } from "@t3tools/contracts"; @@ -275,7 +276,13 @@ describe("timeline tooltip scroll dismissal", () => { "hover then focus", "outside timeline", "wheel without scroll", - ])("handles %s through the real tooltip interactions", async (interaction) => { + "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(); @@ -285,7 +292,33 @@ describe("timeline tooltip scroll dismissal", () => { document.body.append(container); const root = createRoot(container); const onMouseEnter = vi.fn(); - const tooltip = ( + 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 @@ -318,7 +351,9 @@ describe("timeline tooltip scroll dismissal", () => { document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" })); trigger.focus(); } - if (interaction !== "delayed hover") await vi.advanceTimersByTimeAsync(60); + if (interaction !== "delayed hover") { + await vi.advanceTimersByTimeAsync(isPullRequest ? 400 : 60); + } }); expect(onMouseEnter).toHaveBeenCalledTimes(interaction === "focus" ? 0 : 1); expect( @@ -331,7 +366,7 @@ describe("timeline tooltip scroll dismissal", () => { ? new WheelEvent("wheel", { bubbles: true, deltaY: 100 }) : new Event("scroll"), ); - await vi.advanceTimersByTimeAsync(100); + await vi.advanceTimersByTimeAsync(isPullRequest ? 1000 : 100); }); expect( document.querySelector('[data-slot="tooltip-popup"][data-open]')?.textContent ?? null, @@ -343,6 +378,7 @@ describe("timeline tooltip scroll dismissal", () => { } } finally { await act(async () => root.unmount()); + querySpy?.mockRestore(); container.remove(); vi.useRealTimers(); vi.unstubAllGlobals(); diff --git a/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx b/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx index 3f183b264b2be..de87d9a5f1f59 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}