authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-04 08:54:31-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-04 08:54:31-03:00
log32b77f4fa370a5195ad16ab709405b1038c49fcd
treee95cbc7ffc5af10f33c3bf01d92a8828c8ae17fb
parentf9db42d14fd36e7d21f5dcbf0027f267d53c2989
signature Signed by PGP key B5690EEEBB952194

fix(web): dismiss hovered timeline tooltips on scroll (#15455)


4 files changed, 211 insertions(+), 9 deletions(-)

apps/web/src/components/chat/MessagesTimeline.test.tsx+124-1
......@@ -1,8 +1,11 @@
1// @vitest-environment jsdom
2
13import {
24 ApprovalRequestId,
35 CheckpointRef,
46 EnvironmentId,
57 MessageId,
8 ProjectId,
69 RunId,
710 ThreadId,
811} from "@t3tools/contracts";
......@@ -15,6 +18,7 @@ import {
1518 type ReactElement,
1619} from "react";
1720import { renderToStaticMarkup } from "react-dom/server";
21import { createRoot } from "react-dom/client";
1822import { create, type ReactTestRenderer } from "react-test-renderer";
1923import { shouldUseRestingComposerLayout } from "../composerFooterLayout";
2024import { useComposerFocusState } from "./useComposerFocusState";
......@@ -227,6 +231,7 @@ function stubDomGlobals() {
227231 };
228232
229233 vi.stubGlobal("Element", ElementStub);
234 vi.stubGlobal("getComputedStyle", undefined);
230235 vi.stubGlobal("localStorage", {
231236 getItem: () => null,
232237 setItem: () => {},
......@@ -256,13 +261,131 @@ function stubDomGlobals() {
256261
257262beforeEach(stubDomGlobals);
258263beforeAll(async () => {
259 stubDomGlobals();
264 Object.defineProperty(window, "matchMedia", { value: matchMedia, configurable: true });
260265 ({ MessagesTimeline, resolvePreviewAnnotationImage } = await import("./MessagesTimeline"));
261266}, 30_000);
262267
263268const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
264269const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z";
265270
271describe("timeline tooltip scroll dismissal", () => {
272 it.each([
273 "hover",
274 "delayed hover",
275 "focus",
276 "hover then focus",
277 "outside timeline",
278 "wheel without scroll",
279 "pr hover",
280 "pr delayed hover",
281 "pr focus",
282 "pr hover then focus",
283 ])("handles %s through the real tooltip interactions", async (scenario) => {
284 const isPullRequest = scenario.startsWith("pr ");
285 const interaction = isPullRequest ? scenario.slice(3) : scenario;
286 vi.unstubAllGlobals();
287 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
288 vi.useFakeTimers();
289 const { Tooltip, TooltipTrigger, TooltipPopup, TooltipScrollDismissArea } =
290 await vi.importActual<typeof import("../ui/tooltip")>("../ui/tooltip");
291 const container = document.createElement("div");
292 document.body.append(container);
293 const root = createRoot(container);
294 const onMouseEnter = vi.fn();
295 const query = await import("~/state/query");
296 const querySpy = isPullRequest
297 ? vi.spyOn(query, "useEnvironmentQuery").mockReturnValue({
298 data: null,
299 dataUpdatedAt: 0,
300 error: "Pull request not found",
301 failure: null,
302 isPending: false,
303 isSuccess: false,
304 refresh: vi.fn(),
305 })
306 : null;
307 const { PullRequestLinkPreview } = await import("../pullRequest/PullRequestLinkPreview");
308 const tooltip = isPullRequest ? (
309 <PullRequestLinkPreview
310 link={<button onMouseEnter={onMouseEnter}>message link</button>}
311 originalUrl="https://example.com"
312 target={{
313 environmentId: ACTIVE_THREAD_ENVIRONMENT_ID,
314 input: {
315 projectId: ProjectId.make("project-1"),
316 repository: "pingdotgg/t3code",
317 number: 1,
318 },
319 }}
320 />
321 ) : (
322 <Tooltip>
323 <TooltipTrigger delay={50} onMouseEnter={onMouseEnter}>
324 message link
325 </TooltipTrigger>
326 <TooltipPopup>https://example.com</TooltipPopup>
327 </Tooltip>
328 );
329 try {
330 await act(async () => {
331 root.render(
332 <>
333 <TooltipScrollDismissArea>
334 <div data-testid="scrollable">
335 {interaction === "outside timeline" ? null : tooltip}
336 </div>
337 </TooltipScrollDismissArea>
338 {interaction === "outside timeline" ? tooltip : null}
339 </>,
340 );
341 });
342 const trigger = container.querySelector<HTMLButtonElement>("button")!;
343 const scrollable = container.querySelector<HTMLElement>('[data-testid="scrollable"]')!;
344 await act(async () => {
345 if (interaction !== "focus") {
346 trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
347 trigger.dispatchEvent(new MouseEvent("mouseenter"));
348 trigger.dispatchEvent(new MouseEvent("mousemove", { bubbles: true }));
349 }
350 if (interaction === "focus" || interaction === "hover then focus") {
351 document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" }));
352 trigger.focus();
353 }
354 if (interaction !== "delayed hover") {
355 await vi.advanceTimersByTimeAsync(isPullRequest ? 400 : 60);
356 }
357 });
358 expect(onMouseEnter).toHaveBeenCalledTimes(interaction === "focus" ? 0 : 1);
359 expect(
360 document.querySelector('[data-slot="tooltip-popup"][data-open]')?.textContent ?? null,
361 ).toBe(interaction === "delayed hover" ? null : "https://example.com");
362
363 await act(async () => {
364 scrollable.dispatchEvent(
365 interaction === "wheel without scroll"
366 ? new WheelEvent("wheel", { bubbles: true, deltaY: 100 })
367 : new Event("scroll"),
368 );
369 await vi.advanceTimersByTimeAsync(isPullRequest ? 1000 : 100);
370 });
371 expect(
372 document.querySelector('[data-slot="tooltip-popup"][data-open]')?.textContent ?? null,
373 ).toBe(
374 interaction === "hover" || interaction === "delayed hover" ? null : "https://example.com",
375 );
376 if (interaction === "focus" || interaction === "hover then focus") {
377 expect(document.activeElement).toBe(trigger);
378 }
379 } finally {
380 await act(async () => root.unmount());
381 querySpy?.mockRestore();
382 container.remove();
383 vi.useRealTimers();
384 vi.unstubAllGlobals();
385 }
386 });
387});
388
266389function buildProps() {
267390 return {
268391 isWorking: false,
apps/web/src/components/chat/MessagesTimeline.tsx+3-3
......@@ -220,7 +220,7 @@ import {
220220import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
221221import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
222222import { Spinner } from "../ui/spinner";
223import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
223import { Tooltip, TooltipPopup, TooltipTrigger, TooltipScrollDismissArea } from "../ui/tooltip";
224224import { WorktreeSetupCard } from "./WorktreeSetupCard";
225225import {
226226 ContextChipPopover as UserMessageContextPopover,
......@@ -1325,7 +1325,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
13251325 return (
13261326 <TimelineRowCtx value={sharedState}>
13271327 <TimelineRowActivityCtx value={activityState}>
1328 <div
1328 <TooltipScrollDismissArea
13291329 ref={setTimelineViewportElement}
13301330 className="relative h-full min-h-0"
13311331 data-assistant-citation-viewport="true"
......@@ -1394,7 +1394,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
13941394 });
13951395 }}
13961396 />
1397 </div>
1397 </TooltipScrollDismissArea>
13981398 </TimelineRowActivityCtx>
13991399 </TimelineRowCtx>
14001400 );
apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx+11-2
......@@ -1,7 +1,9 @@
1import type { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card";
12import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
23import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts";
34import {
45 cloneElement,
6 useRef,
57 useState,
68 type ComponentPropsWithoutRef,
79 type MouseEvent,
......@@ -45,6 +47,7 @@ export function PullRequestLinkPreview({
4547 fallback?: ReactNode;
4648}) {
4749 const [open, setOpen] = useState(false);
50 const previewActionsRef = useRef<PreviewCardPrimitive.Root.Actions | null>(null);
4851 const [resolvingClick, setResolvingClick] = useState(false);
4952 const detailQuery = useEnvironmentQuery(
5053 open
......@@ -96,8 +99,14 @@ export function PullRequestLinkPreview({
9699 : (detail?.author.login ?? null);
97100
98101 return (
99 <PreviewCard open={open} onOpenChange={setOpen}>
100 <Tooltip open={showUrlTooltip}>
102 <PreviewCard open={open} onOpenChange={setOpen} actionsRef={previewActionsRef}>
103 <Tooltip
104 open={showUrlTooltip}
105 onOpenChange={(nextOpen) => {
106 // Cancel the card's delayed hover too, without changing its content preview.
107 if (!nextOpen && !showCard) previewActionsRef.current?.close();
108 }}
109 >
101110 <PreviewCardTrigger
102111 render={<TooltipTrigger render={trigger} />}
103112 delay={350}
apps/web/src/components/ui/tooltip.tsx+73-3
......@@ -1,13 +1,83 @@
11import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
2import { createContext, use, useEffect, useRef, type ComponentProps, type RefObject } from "react";
23
34import { cn } from "~/lib/utils";
45
56const TooltipProvider = TooltipPrimitive.Provider;
67
7const Tooltip = TooltipPrimitive.Root;
8type TooltipActionsRef = RefObject<TooltipPrimitive.Root.Actions | null>;
9const TooltipHoverContext = createContext<TooltipActionsRef | null>(null);
10const TooltipScrollContext = createContext<RefObject<{
11 trigger: HTMLElement;
12 actionsRef: TooltipActionsRef;
13} | null> | null>(null);
14
15/** Dismisses hovered descendants on real scroll events without rerendering the timeline. */
16function TooltipScrollDismissArea({ onScrollCapture, ...props }: ComponentProps<"div">) {
17 const hovered = useRef<{ trigger: HTMLElement; actionsRef: TooltipActionsRef } | null>(null);
18 return (
19 <TooltipScrollContext value={hovered}>
20 <div
21 {...props}
22 onScrollCapture={(event) => {
23 onScrollCapture?.(event);
24 const tooltip = hovered.current;
25 if (!tooltip || tooltip.trigger.contains(tooltip.trigger.ownerDocument.activeElement)) {
26 return;
27 }
28 hovered.current = null;
29 // Base UI also cancels delayed hover opens through this action.
30 tooltip.actionsRef.current?.close();
31 }}
32 />
33 </TooltipScrollContext>
34 );
35}
36
37function Tooltip<Payload>(props: TooltipPrimitive.Root.Props<Payload>) {
38 const hovered = use(TooltipScrollContext);
39 const localActionsRef = useRef<TooltipPrimitive.Root.Actions | null>(null);
40 const actionsRef = props.actionsRef ?? localActionsRef;
41 useEffect(
42 () => () => {
43 if (hovered?.current?.actionsRef === actionsRef) hovered.current = null;
44 },
45 [actionsRef, hovered],
46 );
47
48 if (!hovered) return <TooltipPrimitive.Root {...props} />;
49 return (
50 <TooltipHoverContext value={actionsRef}>
51 <TooltipPrimitive.Root
52 {...props}
53 actionsRef={actionsRef}
54 onOpenChange={(open, details) => {
55 props.onOpenChange?.(open, details);
56 if (!open && !details.isCanceled && hovered.current?.actionsRef === actionsRef) {
57 hovered.current = null;
58 }
59 }}
60 />
61 </TooltipHoverContext>
62 );
63}
864
965function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) {
10 return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
66 const hovered = use(TooltipScrollContext);
67 const actionsRef = use(TooltipHoverContext);
68 if (!hovered || !actionsRef) {
69 return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
70 }
71 return (
72 <TooltipPrimitive.Trigger
73 data-slot="tooltip-trigger"
74 {...props}
75 onMouseEnter={(event) => {
76 props.onMouseEnter?.(event);
77 hovered.current = { trigger: event.currentTarget, actionsRef };
78 }}
79 />
80 );
1181}
1282
1383function TooltipPopup({
......@@ -64,4 +134,4 @@ function TooltipPopup({
64134 );
65135}
66136
67export { TooltipProvider, Tooltip, TooltipTrigger, TooltipPopup };
137export { TooltipProvider, Tooltip, TooltipTrigger, TooltipPopup, TooltipScrollDismissArea };