Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -125,6 +125,19 @@ vi.mock("@pierre/diffs/react", () => {
return { FileDiff: MockFileDiff };
});

vi.mock("../ui/tooltip", async (importOriginal) => {
const actual = await importOriginal<typeof import("../ui/tooltip")>();

return {
...actual,
TooltipScrollDismissProvider: ({
children,
}: ComponentProps<typeof actual.TooltipScrollDismissProvider>) => (
<div data-tooltip-scroll-dismiss="enabled">{children}</div>
),
};
});

function matchMedia() {
return {
matches: false,
Expand Down Expand Up @@ -236,6 +249,17 @@ function buildAssistantTimelineEntry(text: string) {
}

describe("MessagesTimeline", () => {
it("enables tooltip scroll dismissal in the timeline", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[buildAssistantTimelineEntry("[Link](https://example.com)")]}
/>,
);

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.");
Expand Down
25 changes: 20 additions & 5 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -283,6 +284,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());
const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>());
const tooltipScrollDismissController = useMemo(createTooltipScrollDismissController, []);
const disclosureAnchorKeyRef = useRef<string | null>(null);
const disclosureSettleFrameRef = useRef<number | null>(null);
const disclosureSettleSecondFrameRef = useRef<number | null>(null);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -565,7 +572,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
);
}

return (
const timeline = (
<TimelineRowCtx value={sharedState}>
<TimelineRowActivityCtx value={activityState}>
<div ref={setTimelineViewportElement} className="relative h-full min-h-0">
Expand Down Expand Up @@ -623,6 +630,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({
</TimelineRowActivityCtx>
</TimelineRowCtx>
);

return (
<TooltipScrollDismissProvider
onTriggerHoverChange={tooltipScrollDismissController.setHoveredTooltip}
>
{timeline}
</TooltipScrollDismissProvider>
);
});

function keyExtractor(item: MessagesTimelineRow) {
Expand Down
125 changes: 125 additions & 0 deletions apps/web/src/components/ui/tooltip.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={className} {...props}>
{children}
</div>
);
}

function Element({ children, ...props }: ComponentProps<"div"> & { children?: ReactNode }) {
return <div {...props}>{children}</div>;
}

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<Node>();
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(<TooltipPopup>Global tooltip</TooltipPopup>);

expect(html).toContain("z-[140]");
});

it("keeps timeline tooltips above chat chrome", () => {
const html = renderToStaticMarkup(
<TooltipScrollDismissProvider onTriggerHoverChange={() => {}}>
<TooltipPopup>Timeline tooltip</TooltipPopup>
</TooltipScrollDismissProvider>,
);

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();
});
});
83 changes: 79 additions & 4 deletions apps/web/src/components/ui/tooltip.tsx
Original file line number Diff line number Diff line change
@@ -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<TooltipScrollDismissContextValue>(null);
const TooltipDismissContext = createContext<(() => void) | null>(null);

function TooltipScrollDismissProvider({
children,
onTriggerHoverChange,
}: {
children: ReactNode;
onTriggerHoverChange: (tooltip: HoveredTooltip | null) => void;
}) {
return (
<TooltipScrollDismissContext value={onTriggerHoverChange}>
{children}
</TooltipScrollDismissContext>
);
}

const TooltipProvider = TooltipPrimitive.Provider;

const Tooltip = TooltipPrimitive.Root;
function Tooltip<Payload>(props: TooltipPrimitive.Root.Props<Payload>) {
const onTriggerHoverChange = useContext(TooltipScrollDismissContext);

if (!onTriggerHoverChange) {
return <TooltipPrimitive.Root {...props} />;
}

return <TrackedTooltip {...props} />;
}

function TrackedTooltip<Payload>({ actionsRef, ...props }: TooltipPrimitive.Root.Props<Payload>) {
const localActionsRef = useRef<TooltipPrimitive.Root.Actions | null>(null);
const resolvedActionsRef = actionsRef ?? localActionsRef;
const dismiss = useCallback(() => resolvedActionsRef.current?.close(), [resolvedActionsRef]);

return (
<TooltipDismissContext value={dismiss}>
<TooltipPrimitive.Root actionsRef={resolvedActionsRef} {...props} />
</TooltipDismissContext>
);
}

function TooltipTrigger({ onMouseEnter, onMouseLeave, ...props }: TooltipPrimitive.Trigger.Props) {
const onTriggerHoverChange = useContext(TooltipScrollDismissContext);
const dismiss = useContext(TooltipDismissContext);

if (!onTriggerHoverChange || !dismiss) {
return (
<TooltipPrimitive.Trigger
data-slot="tooltip-trigger"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
{...props}
/>
);
}

function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
return (
<TooltipPrimitive.Trigger
data-slot="tooltip-trigger"
onMouseEnter={(event) => {
onTriggerHoverChange({ trigger: event.currentTarget, dismiss });
Comment thread
Ic3b3rg marked this conversation as resolved.
onMouseEnter?.(event);
}}
onMouseLeave={(event) => {
onTriggerHoverChange(null);
onMouseLeave?.(event);
}}
{...props}
/>
);
}

function TooltipPopup({
Expand Down Expand Up @@ -61,4 +129,11 @@ function TooltipPopup({
);
}

export { TooltipCreateHandle, TooltipProvider, Tooltip, TooltipTrigger, TooltipPopup };
export {
TooltipCreateHandle,
TooltipScrollDismissProvider,
TooltipProvider,
Tooltip,
TooltipTrigger,
TooltipPopup,
};
34 changes: 34 additions & 0 deletions apps/web/src/components/ui/tooltipScrollDismiss.ts
Original file line number Diff line number Diff line change
@@ -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();
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
},
};
}

export { createTooltipScrollDismissController, type HoveredTooltip };
Loading