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,8 +1,11 @@
1// @vitest-environment jsdom
2
1import {3import {
2 ApprovalRequestId,4 ApprovalRequestId,
3 CheckpointRef,5 CheckpointRef,
4 EnvironmentId,6 EnvironmentId,
5 MessageId,7 MessageId,
8 ProjectId,
6 RunId,9 RunId,
7 ThreadId,10 ThreadId,
8} from "@t3tools/contracts";11} from "@t3tools/contracts";
...@@ -15,6 +18,7 @@ import {...@@ -15,6 +18,7 @@ import {
15 type ReactElement,18 type ReactElement,
16} from "react";19} from "react";
17import { renderToStaticMarkup } from "react-dom/server";20import { renderToStaticMarkup } from "react-dom/server";
21import { createRoot } from "react-dom/client";
18import { create, type ReactTestRenderer } from "react-test-renderer";22import { create, type ReactTestRenderer } from "react-test-renderer";
19import { shouldUseRestingComposerLayout } from "../composerFooterLayout";23import { shouldUseRestingComposerLayout } from "../composerFooterLayout";
20import { useComposerFocusState } from "./useComposerFocusState";24import { useComposerFocusState } from "./useComposerFocusState";
...@@ -227,6 +231,7 @@ function stubDomGlobals() {...@@ -227,6 +231,7 @@ function stubDomGlobals() {
227 };231 };
228232
229 vi.stubGlobal("Element", ElementStub);233 vi.stubGlobal("Element", ElementStub);
234 vi.stubGlobal("getComputedStyle", undefined);
230 vi.stubGlobal("localStorage", {235 vi.stubGlobal("localStorage", {
231 getItem: () => null,236 getItem: () => null,
232 setItem: () => {},237 setItem: () => {},
...@@ -256,13 +261,131 @@ function stubDomGlobals() {...@@ -256,13 +261,131 @@ function stubDomGlobals() {
256261
257beforeEach(stubDomGlobals);262beforeEach(stubDomGlobals);
258beforeAll(async () => {263beforeAll(async () => {
259 stubDomGlobals();264 Object.defineProperty(window, "matchMedia", { value: matchMedia, configurable: true });
260 ({ MessagesTimeline, resolvePreviewAnnotationImage } = await import("./MessagesTimeline"));265 ({ MessagesTimeline, resolvePreviewAnnotationImage } = await import("./MessagesTimeline"));
261}, 30_000);266}, 30_000);
262267
263const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local");268const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
264const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z";269const 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
266function buildProps() {389function buildProps() {
267 return {390 return {
268 isWorking: false,391 isWorking: false,
apps/web/src/components/chat/MessagesTimeline.tsx+3-3
...@@ -220,7 +220,7 @@ import {...@@ -220,7 +220,7 @@ import {
220import { TerminalContextInlineChip } from "./TerminalContextInlineChip";220import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
221import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";221import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
222import { Spinner } from "../ui/spinner";222import { Spinner } from "../ui/spinner";
223import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";223import { Tooltip, TooltipPopup, TooltipTrigger, TooltipScrollDismissArea } from "../ui/tooltip";
224import { WorktreeSetupCard } from "./WorktreeSetupCard";224import { WorktreeSetupCard } from "./WorktreeSetupCard";
225import {225import {
226 ContextChipPopover as UserMessageContextPopover,226 ContextChipPopover as UserMessageContextPopover,
...@@ -1325,7 +1325,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -1325,7 +1325,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
1325 return (1325 return (
1326 <TimelineRowCtx value={sharedState}>1326 <TimelineRowCtx value={sharedState}>
1327 <TimelineRowActivityCtx value={activityState}>1327 <TimelineRowActivityCtx value={activityState}>
1328 <div1328 <TooltipScrollDismissArea
1329 ref={setTimelineViewportElement}1329 ref={setTimelineViewportElement}
1330 className="relative h-full min-h-0"1330 className="relative h-full min-h-0"
1331 data-assistant-citation-viewport="true"1331 data-assistant-citation-viewport="true"
...@@ -1394,7 +1394,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -1394,7 +1394,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
1394 });1394 });
1395 }}1395 }}
1396 />1396 />
1397 </div>1397 </TooltipScrollDismissArea>
1398 </TimelineRowActivityCtx>1398 </TimelineRowActivityCtx>
1399 </TimelineRowCtx>1399 </TimelineRowCtx>
1400 );1400 );
apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx+11-2
...@@ -1,7 +1,9 @@...@@ -1,7 +1,9 @@
1import type { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card";
1import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";2import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
2import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts";3import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts";
3import {4import {
4 cloneElement,5 cloneElement,
6 useRef,
5 useState,7 useState,
6 type ComponentPropsWithoutRef,8 type ComponentPropsWithoutRef,
7 type MouseEvent,9 type MouseEvent,
...@@ -45,6 +47,7 @@ export function PullRequestLinkPreview({...@@ -45,6 +47,7 @@ export function PullRequestLinkPreview({
45 fallback?: ReactNode;47 fallback?: ReactNode;
46}) {48}) {
47 const [open, setOpen] = useState(false);49 const [open, setOpen] = useState(false);
50 const previewActionsRef = useRef<PreviewCardPrimitive.Root.Actions | null>(null);
48 const [resolvingClick, setResolvingClick] = useState(false);51 const [resolvingClick, setResolvingClick] = useState(false);
49 const detailQuery = useEnvironmentQuery(52 const detailQuery = useEnvironmentQuery(
50 open53 open
...@@ -96,8 +99,14 @@ export function PullRequestLinkPreview({...@@ -96,8 +99,14 @@ export function PullRequestLinkPreview({
96 : (detail?.author.login ?? null);99 : (detail?.author.login ?? null);
97100
98 return (101 return (
99 <PreviewCard open={open} onOpenChange={setOpen}>102 <PreviewCard open={open} onOpenChange={setOpen} actionsRef={previewActionsRef}>
100 <Tooltip open={showUrlTooltip}>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 >
101 <PreviewCardTrigger110 <PreviewCardTrigger
102 render={<TooltipTrigger render={trigger} />}111 render={<TooltipTrigger render={trigger} />}
103 delay={350}112 delay={350}
apps/web/src/components/ui/tooltip.tsx+73-3
...@@ -1,13 +1,83 @@...@@ -1,13 +1,83 @@
1import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";1import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
2import { createContext, use, useEffect, useRef, type ComponentProps, type RefObject } from "react";
23
3import { cn } from "~/lib/utils";4import { cn } from "~/lib/utils";
45
5const TooltipProvider = TooltipPrimitive.Provider;6const 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
9function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) {65function 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 );
11}81}
1282
13function TooltipPopup({83function TooltipPopup({
...@@ -64,4 +134,4 @@ function TooltipPopup({...@@ -64,4 +134,4 @@ function TooltipPopup({
64 );134 );
65}135}
66136
67export { TooltipProvider, Tooltip, TooltipTrigger, TooltipPopup };137export { TooltipProvider, Tooltip, TooltipTrigger, TooltipPopup, TooltipScrollDismissArea };