diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index ea6cda9b6208..80ebaa96a307 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,5 +1,5 @@ import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts"; -import { createRef, type ReactNode, type Ref } from "react"; +import { type ComponentProps, createRef, type ReactNode, type Ref } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; import type { LegendListRef } from "@legendapp/list/react"; @@ -125,6 +125,19 @@ vi.mock("@pierre/diffs/react", () => { return { FileDiff: MockFileDiff }; }); +vi.mock("../ui/tooltip", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + TooltipScrollDismissProvider: ({ + children, + }: ComponentProps) => ( +
{children}
+ ), + }; +}); + function matchMedia() { return { matches: false, @@ -236,6 +249,17 @@ function buildAssistantTimelineEntry(text: string) { } describe("MessagesTimeline", () => { + it("enables tooltip scroll dismissal in the timeline", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-tooltip-scroll-dismiss="enabled"'); + }); + it("renders the worked-for row at assistant response text size", () => { const turnId = TurnId.make("turn-with-fold"); const assistantEntry = buildAssistantTimelineEntry("Done."); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 6d89140d1105..f1aa5878fd84 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -90,7 +90,8 @@ import { type TimelineLatestTurn, } from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { Tooltip, TooltipPopup, TooltipScrollDismissProvider, TooltipTrigger } from "../ui/tooltip"; +import { createTooltipScrollDismissController } from "../ui/tooltipScrollDismiss"; import { deriveDisplayedUserMessageState, type ParsedTerminalContextEntry, @@ -283,6 +284,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); const [minimapStripMap] = useState(() => new Map()); + const tooltipScrollDismissController = useMemo(createTooltipScrollDismissController, []); const disclosureAnchorKeyRef = useRef(null); const disclosureSettleFrameRef = useRef(null); const disclosureSettleSecondFrameRef = useRef(null); @@ -438,7 +440,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return config ? { ...config, onReady: handleAnchorReady } : undefined; }, [anchorMessageId, handleAnchorReady, rows]); - const handleScroll = useCallback(() => { + const updateTimelineScrollState = useCallback(() => { const state = listRef.current?.getState?.(); const isAtEnd = resolveTimelineIsAtEnd(state, contentInsetEndAdjustment); if (isAtEnd !== undefined) { @@ -468,10 +470,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({ } }, [contentInsetEndAdjustment, listRef, minimapItems, minimapStripMap, onIsAtEndChange]); + const handleScroll = useCallback(() => { + tooltipScrollDismissController.dismissHoveredTooltip(); + updateTimelineScrollState(); + }, [tooltipScrollDismissController, updateTimelineScrollState]); + useEffect(() => { - const frame = requestAnimationFrame(handleScroll); + const frame = requestAnimationFrame(updateTimelineScrollState); return () => cancelAnimationFrame(frame); - }, [handleScroll, rows.length]); + }, [rows.length, updateTimelineScrollState]); useEffect(() => { if (!timelineViewportElement) { @@ -565,7 +572,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); } - return ( + const timeline = (
@@ -623,6 +630,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); + + return ( + + {timeline} + + ); }); function keyExtractor(item: MessagesTimelineRow) { diff --git a/apps/web/src/components/ui/tooltip.test.tsx b/apps/web/src/components/ui/tooltip.test.tsx new file mode 100644 index 000000000000..d5cc887fd24c --- /dev/null +++ b/apps/web/src/components/ui/tooltip.test.tsx @@ -0,0 +1,125 @@ +import { type ComponentProps, Fragment, type ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("@base-ui/react/tooltip", () => { + function Positioner({ + children, + className, + ...props + }: ComponentProps<"div"> & { children?: ReactNode }) { + return ( +
+ {children} +
+ ); + } + + function Element({ children, ...props }: ComponentProps<"div"> & { children?: ReactNode }) { + return
{children}
; + } + + return { + Tooltip: { + createHandle: () => ({}), + Provider: Fragment, + Root: Fragment, + Trigger: Element, + Portal: Fragment, + Positioner, + Popup: Element, + Viewport: Element, + }, + }; +}); + +import { TooltipPopup, TooltipScrollDismissProvider } from "./tooltip"; +import { createTooltipScrollDismissController } from "./tooltipScrollDismiss"; + +function createFakeTooltipTrigger() { + const ownerDocument = { + activeElement: null as Element | null, + }; + const descendants = new Set(); + const trigger = { + ownerDocument, + contains(node: Node | null) { + return node === trigger || (node !== null && descendants.has(node)); + }, + } as unknown as HTMLElement; + + return { descendants, ownerDocument, trigger }; +} + +describe("tooltip layering", () => { + it("keeps global tooltips above dropdowns", () => { + const html = renderToStaticMarkup(Global tooltip); + + expect(html).toContain("z-[140]"); + }); + + it("keeps timeline tooltips above chat chrome", () => { + const html = renderToStaticMarkup( + {}}> + Timeline tooltip + , + ); + + expect(html).toContain("z-[140]"); + expect(html).not.toContain("z-[15]"); + }); +}); + +describe("tooltip scroll dismissal", () => { + it("dismisses the hovered tooltip once when its content scrolls", () => { + const controller = createTooltipScrollDismissController(); + const { trigger } = createFakeTooltipTrigger(); + const dismiss = vi.fn(); + + controller.setHoveredTooltip({ trigger, dismiss }); + controller.dismissHoveredTooltip(); + controller.dismissHoveredTooltip(); + + expect(dismiss).toHaveBeenCalledOnce(); + }); + + it("keeps a focused tooltip open when its content scrolls", () => { + const controller = createTooltipScrollDismissController(); + const { ownerDocument, trigger } = createFakeTooltipTrigger(); + const dismiss = vi.fn(); + ownerDocument.activeElement = trigger; + + controller.setHoveredTooltip({ trigger, dismiss }); + controller.dismissHoveredTooltip(); + + expect(dismiss).not.toHaveBeenCalled(); + }); + + it("dismisses a hovered tooltip on a later scroll after focus leaves", () => { + const controller = createTooltipScrollDismissController(); + const { ownerDocument, trigger } = createFakeTooltipTrigger(); + const dismiss = vi.fn(); + ownerDocument.activeElement = trigger; + + controller.setHoveredTooltip({ trigger, dismiss }); + controller.dismissHoveredTooltip(); + ownerDocument.activeElement = null; + controller.dismissHoveredTooltip(); + + expect(dismiss).toHaveBeenCalledOnce(); + }); + + it("keeps a tooltip open when focus is within its trigger", () => { + const controller = createTooltipScrollDismissController(); + const { descendants, ownerDocument, trigger } = createFakeTooltipTrigger(); + const focusedChild = {} as Element; + const dismiss = vi.fn(); + descendants.add(focusedChild); + ownerDocument.activeElement = focusedChild; + + controller.setHoveredTooltip({ trigger, dismiss }); + controller.dismissHoveredTooltip(); + + expect(dismiss).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index 77b15a01e16b..fe49df261c07 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -1,15 +1,83 @@ import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"; +import { createContext, type ReactNode, useCallback, useContext, useRef } from "react"; import { cn } from "~/lib/utils"; +import type { HoveredTooltip } from "./tooltipScrollDismiss"; const TooltipCreateHandle = TooltipPrimitive.createHandle; +type TooltipScrollDismissContextValue = ((tooltip: HoveredTooltip | null) => void) | null; + +const TooltipScrollDismissContext = createContext(null); +const TooltipDismissContext = createContext<(() => void) | null>(null); + +function TooltipScrollDismissProvider({ + children, + onTriggerHoverChange, +}: { + children: ReactNode; + onTriggerHoverChange: (tooltip: HoveredTooltip | null) => void; +}) { + return ( + + {children} + + ); +} + const TooltipProvider = TooltipPrimitive.Provider; -const Tooltip = TooltipPrimitive.Root; +function Tooltip(props: TooltipPrimitive.Root.Props) { + const onTriggerHoverChange = useContext(TooltipScrollDismissContext); + + if (!onTriggerHoverChange) { + return ; + } + + return ; +} + +function TrackedTooltip({ actionsRef, ...props }: TooltipPrimitive.Root.Props) { + const localActionsRef = useRef(null); + const resolvedActionsRef = actionsRef ?? localActionsRef; + const dismiss = useCallback(() => resolvedActionsRef.current?.close(), [resolvedActionsRef]); + + return ( + + + + ); +} + +function TooltipTrigger({ onMouseEnter, onMouseLeave, ...props }: TooltipPrimitive.Trigger.Props) { + const onTriggerHoverChange = useContext(TooltipScrollDismissContext); + const dismiss = useContext(TooltipDismissContext); + + if (!onTriggerHoverChange || !dismiss) { + return ( + + ); + } -function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) { - return ; + return ( + { + onTriggerHoverChange({ trigger: event.currentTarget, dismiss }); + onMouseEnter?.(event); + }} + onMouseLeave={(event) => { + onTriggerHoverChange(null); + onMouseLeave?.(event); + }} + {...props} + /> + ); } function TooltipPopup({ @@ -61,4 +129,11 @@ function TooltipPopup({ ); } -export { TooltipCreateHandle, TooltipProvider, Tooltip, TooltipTrigger, TooltipPopup }; +export { + TooltipCreateHandle, + TooltipScrollDismissProvider, + TooltipProvider, + Tooltip, + TooltipTrigger, + TooltipPopup, +}; diff --git a/apps/web/src/components/ui/tooltipScrollDismiss.ts b/apps/web/src/components/ui/tooltipScrollDismiss.ts new file mode 100644 index 000000000000..eb96cae98ec7 --- /dev/null +++ b/apps/web/src/components/ui/tooltipScrollDismiss.ts @@ -0,0 +1,34 @@ +type HoveredTooltip = { + trigger: HTMLElement; + dismiss: () => void; +}; + +type TooltipScrollDismissController = { + setHoveredTooltip: (tooltip: HoveredTooltip | null) => void; + dismissHoveredTooltip: () => void; +}; + +function createTooltipScrollDismissController(): TooltipScrollDismissController { + let hoveredTooltip: HoveredTooltip | null = null; + + return { + setHoveredTooltip(tooltip) { + hoveredTooltip = tooltip; + }, + dismissHoveredTooltip() { + const tooltip = hoveredTooltip; + + if ( + tooltip === null || + tooltip.trigger.contains(tooltip.trigger.ownerDocument.activeElement) + ) { + return; + } + + hoveredTooltip = null; + tooltip.dismiss(); + }, + }; +} + +export { createTooltipScrollDismissController, type HoveredTooltip };