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