Skip to content
Merged
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
125 changes: 124 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
// @vitest-environment jsdom

import {
ApprovalRequestId,
CheckpointRef,
EnvironmentId,
MessageId,
ProjectId,
RunId,
ThreadId,
} from "@t3tools/contracts";
Expand All @@ -15,6 +18,7 @@ import {
type ReactElement,
} from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { createRoot } from "react-dom/client";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { shouldUseRestingComposerLayout } from "../composerFooterLayout";
import { useComposerFocusState } from "./useComposerFocusState";
Expand Down Expand Up @@ -227,6 +231,7 @@ function stubDomGlobals() {
};

vi.stubGlobal("Element", ElementStub);
vi.stubGlobal("getComputedStyle", undefined);
vi.stubGlobal("localStorage", {
getItem: () => null,
setItem: () => {},
Expand Down Expand Up @@ -256,13 +261,131 @@ function stubDomGlobals() {

beforeEach(stubDomGlobals);
beforeAll(async () => {
stubDomGlobals();
Object.defineProperty(window, "matchMedia", { value: matchMedia, configurable: true });
({ MessagesTimeline, resolvePreviewAnnotationImage } = await import("./MessagesTimeline"));
}, 30_000);

const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z";

describe("timeline tooltip scroll dismissal", () => {
it.each([
"hover",
"delayed hover",
"focus",
"hover then focus",
"outside timeline",
"wheel without scroll",
"pr hover",
"pr delayed hover",
"pr focus",
"pr hover then focus",
])("handles %s through the real tooltip interactions", async (scenario) => {
const isPullRequest = scenario.startsWith("pr ");
const interaction = isPullRequest ? scenario.slice(3) : scenario;
vi.unstubAllGlobals();
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.useFakeTimers();
const { Tooltip, TooltipTrigger, TooltipPopup, TooltipScrollDismissArea } =
await vi.importActual<typeof import("../ui/tooltip")>("../ui/tooltip");
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
const onMouseEnter = vi.fn();
const query = await import("~/state/query");
const querySpy = isPullRequest
? vi.spyOn(query, "useEnvironmentQuery").mockReturnValue({
data: null,
dataUpdatedAt: 0,
error: "Pull request not found",
failure: null,
isPending: false,
isSuccess: false,
refresh: vi.fn(),
})
: null;
const { PullRequestLinkPreview } = await import("../pullRequest/PullRequestLinkPreview");
const tooltip = isPullRequest ? (
<PullRequestLinkPreview
link={<button onMouseEnter={onMouseEnter}>message link</button>}
originalUrl="https://example.com"
target={{
environmentId: ACTIVE_THREAD_ENVIRONMENT_ID,
input: {
projectId: ProjectId.make("project-1"),
repository: "pingdotgg/t3code",
number: 1,
},
}}
/>
) : (
<Tooltip>
<TooltipTrigger delay={50} onMouseEnter={onMouseEnter}>
message link
</TooltipTrigger>
<TooltipPopup>https://example.com</TooltipPopup>
</Tooltip>
);
try {
await act(async () => {
root.render(
<>
<TooltipScrollDismissArea>
<div data-testid="scrollable">
{interaction === "outside timeline" ? null : tooltip}
</div>
</TooltipScrollDismissArea>
{interaction === "outside timeline" ? tooltip : null}
</>,
);
});
const trigger = container.querySelector<HTMLButtonElement>("button")!;
const scrollable = container.querySelector<HTMLElement>('[data-testid="scrollable"]')!;
await act(async () => {
if (interaction !== "focus") {
trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
trigger.dispatchEvent(new MouseEvent("mouseenter"));
trigger.dispatchEvent(new MouseEvent("mousemove", { bubbles: true }));
}
if (interaction === "focus" || interaction === "hover then focus") {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" }));
trigger.focus();
}
if (interaction !== "delayed hover") {
await vi.advanceTimersByTimeAsync(isPullRequest ? 400 : 60);
}
});
expect(onMouseEnter).toHaveBeenCalledTimes(interaction === "focus" ? 0 : 1);
expect(
document.querySelector('[data-slot="tooltip-popup"][data-open]')?.textContent ?? null,
).toBe(interaction === "delayed hover" ? null : "https://example.com");

await act(async () => {
scrollable.dispatchEvent(
interaction === "wheel without scroll"
? new WheelEvent("wheel", { bubbles: true, deltaY: 100 })
: new Event("scroll"),
);
await vi.advanceTimersByTimeAsync(isPullRequest ? 1000 : 100);
});
expect(
document.querySelector('[data-slot="tooltip-popup"][data-open]')?.textContent ?? null,
).toBe(
interaction === "hover" || interaction === "delayed hover" ? null : "https://example.com",
);
if (interaction === "focus" || interaction === "hover then focus") {
expect(document.activeElement).toBe(trigger);
}
} finally {
await act(async () => root.unmount());
querySpy?.mockRestore();
container.remove();
vi.useRealTimers();
vi.unstubAllGlobals();
}
});
});

function buildProps() {
return {
isWorking: false,
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ import {
import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
import { Spinner } from "../ui/spinner";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { Tooltip, TooltipPopup, TooltipTrigger, TooltipScrollDismissArea } from "../ui/tooltip";
import { WorktreeSetupCard } from "./WorktreeSetupCard";
import {
ContextChipPopover as UserMessageContextPopover,
Expand Down Expand Up @@ -1307,7 +1307,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
return (
<TimelineRowCtx value={sharedState}>
<TimelineRowActivityCtx value={activityState}>
<div
<TooltipScrollDismissArea
ref={setTimelineViewportElement}
className="relative h-full min-h-0"
data-assistant-citation-viewport="true"
Expand Down Expand Up @@ -1376,7 +1376,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
});
}}
/>
</div>
</TooltipScrollDismissArea>
</TimelineRowActivityCtx>
</TimelineRowCtx>
);
Expand Down
13 changes: 11 additions & 2 deletions apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card";
import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts";
import {
cloneElement,
useRef,
useState,
type ComponentPropsWithoutRef,
type MouseEvent,
Expand Down Expand Up @@ -45,6 +47,7 @@ export function PullRequestLinkPreview({
fallback?: ReactNode;
}) {
const [open, setOpen] = useState(false);
const previewActionsRef = useRef<PreviewCardPrimitive.Root.Actions | null>(null);
const [resolvingClick, setResolvingClick] = useState(false);
const detailQuery = useEnvironmentQuery(
open
Expand Down Expand Up @@ -96,8 +99,14 @@ export function PullRequestLinkPreview({
: (detail?.author.login ?? null);

return (
<PreviewCard open={open} onOpenChange={setOpen}>
<Tooltip open={showUrlTooltip}>
<PreviewCard open={open} onOpenChange={setOpen} actionsRef={previewActionsRef}>
<Tooltip
open={showUrlTooltip}
onOpenChange={(nextOpen) => {
// Cancel the card's delayed hover too, without changing its content preview.
if (!nextOpen && !showCard) previewActionsRef.current?.close();
}}
>
<PreviewCardTrigger
render={<TooltipTrigger render={trigger} />}
delay={350}
Expand Down
76 changes: 73 additions & 3 deletions apps/web/src/components/ui/tooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,83 @@
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
import { createContext, use, useEffect, useRef, type ComponentProps, type RefObject } from "react";

import { cn } from "~/lib/utils";

const TooltipProvider = TooltipPrimitive.Provider;

const Tooltip = TooltipPrimitive.Root;
type TooltipActionsRef = RefObject<TooltipPrimitive.Root.Actions | null>;
const TooltipHoverContext = createContext<TooltipActionsRef | null>(null);
const TooltipScrollContext = createContext<RefObject<{
trigger: HTMLElement;
actionsRef: TooltipActionsRef;
} | null> | null>(null);

/** Dismisses hovered descendants on real scroll events without rerendering the timeline. */
function TooltipScrollDismissArea({ onScrollCapture, ...props }: ComponentProps<"div">) {
const hovered = useRef<{ trigger: HTMLElement; actionsRef: TooltipActionsRef } | null>(null);
return (
<TooltipScrollContext value={hovered}>
<div
{...props}
onScrollCapture={(event) => {
onScrollCapture?.(event);
const tooltip = hovered.current;
if (!tooltip || tooltip.trigger.contains(tooltip.trigger.ownerDocument.activeElement)) {
return;
}
hovered.current = null;
// Base UI also cancels delayed hover opens through this action.
tooltip.actionsRef.current?.close();
}}
/>
</TooltipScrollContext>
);
}

function Tooltip<Payload>(props: TooltipPrimitive.Root.Props<Payload>) {
const hovered = use(TooltipScrollContext);
const localActionsRef = useRef<TooltipPrimitive.Root.Actions | null>(null);
const actionsRef = props.actionsRef ?? localActionsRef;
useEffect(
() => () => {
if (hovered?.current?.actionsRef === actionsRef) hovered.current = null;
},
[actionsRef, hovered],
);

if (!hovered) return <TooltipPrimitive.Root {...props} />;
return (
<TooltipHoverContext value={actionsRef}>
<TooltipPrimitive.Root
{...props}
actionsRef={actionsRef}
onOpenChange={(open, details) => {
props.onOpenChange?.(open, details);
if (!open && !details.isCanceled && hovered.current?.actionsRef === actionsRef) {
hovered.current = null;
}
}}
/>
</TooltipHoverContext>
);
}

function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
const hovered = use(TooltipScrollContext);
const actionsRef = use(TooltipHoverContext);
if (!hovered || !actionsRef) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
return (
<TooltipPrimitive.Trigger
data-slot="tooltip-trigger"
{...props}
onMouseEnter={(event) => {
props.onMouseEnter?.(event);
hovered.current = { trigger: event.currentTarget, actionsRef };
}}
/>
);
}

function TooltipPopup({
Expand Down Expand Up @@ -64,4 +134,4 @@ function TooltipPopup({
);
}

export { TooltipProvider, Tooltip, TooltipTrigger, TooltipPopup };
export { TooltipProvider, Tooltip, TooltipTrigger, TooltipPopup, TooltipScrollDismissArea };
Loading