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
157 changes: 100 additions & 57 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,13 @@ import {
type TimelineLatestTurn,
} from "./MessagesTimeline.logic";
import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import {
Tooltip,
TooltipPopup,
TooltipScrollDismissScope,
TooltipTrigger,
useTooltipScrollDismiss,
} from "../ui/tooltip";
import {
deriveDisplayedUserMessageState,
type ParsedTerminalContextEntry,
Expand Down Expand Up @@ -196,6 +202,8 @@ const TIMELINE_MAINTAIN_SCROLL_AT_END = {
layout: true,
},
} as const;
// Keys that scroll the focused list; anything else leaves tooltips alone.
const SCROLL_DISMISS_KEYS = new Set(["PageUp", "PageDown", "Home", "End", "ArrowUp", "ArrowDown"]);

// ---------------------------------------------------------------------------
// Props (public API)
Expand Down Expand Up @@ -242,6 +250,38 @@ interface MessagesTimelineProps {
loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null;
}

// Rendered inside TooltipScrollDismissScope: context is only visible to
// descendants of its provider, so MessagesTimeline itself cannot consume it.
// Tooltips here close on real input gestures only (wheel, touch, scroll keys,
// presses inside the timeline). LegendList's maintainScrollAtEnd and minimap
// scrollToIndex fire plain scroll events with no gesture behind them, so
// streaming auto-follow never dismisses an open tooltip.
function TooltipScrollDismissListener({ node }: { node: HTMLDivElement | null }) {
const dismissTooltips = useTooltipScrollDismiss();
useEffect(() => {
if (!node || !dismissTooltips) {
return;
}
const dismiss = () => dismissTooltips();
const handleKeyDown = (event: { key: string }) => {
if (SCROLL_DISMISS_KEYS.has(event.key)) {
dismissTooltips();
}
};
node.addEventListener("wheel", dismiss, { passive: true });
node.addEventListener("touchmove", dismiss, { passive: true });
node.addEventListener("pointerdown", dismiss, { passive: true });
node.addEventListener("keydown", handleKeyDown);
return () => {
node.removeEventListener("wheel", dismiss);
node.removeEventListener("touchmove", dismiss);
node.removeEventListener("pointerdown", dismiss);
node.removeEventListener("keydown", handleKeyDown);
};
}, [dismissTooltips, node]);
return null;
}

// ---------------------------------------------------------------------------
// MessagesTimeline — list owner
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -566,62 +606,65 @@ export const MessagesTimeline = memo(function MessagesTimeline({
}

return (
<TimelineRowCtx value={sharedState}>
<TimelineRowActivityCtx value={activityState}>
<div ref={setTimelineViewportElement} className="relative h-full min-h-0">
<LegendList<MessagesTimelineRow>
ref={listRef}
data={rows}
keyExtractor={keyExtractor}
getItemType={getItemType}
renderItem={renderItem}
estimatedItemSize={90}
initialScrollAtEnd
{...(anchoredEndSpace ? { anchoredEndSpace } : {})}
contentInsetEndAdjustment={contentInsetEndAdjustment}
maintainScrollAtEnd={
anchoredEndSpace || !liveFollowEnabled || disclosureToggleSettling
? false
: TIMELINE_MAINTAIN_SCROLL_AT_END
}
maintainVisibleContentPosition={maintainVisibleContentPosition}
onScroll={handleScroll}
className={cn(
"scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5",
topFadeEnabled && "topbar-scroll-fade",
)}
ListHeaderComponent={
loadEarlier !== null ? (
<TimelineLoadEarlierHeader
loading={loadEarlier.loading}
onLoadEarlier={loadEarlier.onLoadEarlier}
fade={topFadeEnabled}
/>
) : topFadeEnabled ? (
TIMELINE_LIST_FADE_HEADER
) : (
TIMELINE_LIST_HEADER
)
}
ListFooterComponent={TIMELINE_LIST_FOOTER}
/>
<TimelineMinimap
items={minimapItems}
hasPersistentGutter={minimapHasPersistentGutter}
hitStripWidth={minimapHitStripWidth}
stripMap={minimapStripMap}
onSelect={(item) => {
onManualNavigation();
void listRef.current?.scrollToIndex({
index: item.rowIndex,
animated: true,
viewOffset: 24,
});
}}
/>
</div>
</TimelineRowActivityCtx>
</TimelineRowCtx>
<TooltipScrollDismissScope>
<TimelineRowCtx value={sharedState}>
<TimelineRowActivityCtx value={activityState}>
<div ref={setTimelineViewportElement} className="relative h-full min-h-0">
<TooltipScrollDismissListener node={timelineViewportElement} />
<LegendList<MessagesTimelineRow>
ref={listRef}
data={rows}
keyExtractor={keyExtractor}
getItemType={getItemType}
renderItem={renderItem}
estimatedItemSize={90}
initialScrollAtEnd
{...(anchoredEndSpace ? { anchoredEndSpace } : {})}
contentInsetEndAdjustment={contentInsetEndAdjustment}
maintainScrollAtEnd={
anchoredEndSpace || !liveFollowEnabled || disclosureToggleSettling
? false
: TIMELINE_MAINTAIN_SCROLL_AT_END
}
maintainVisibleContentPosition={maintainVisibleContentPosition}
onScroll={handleScroll}
className={cn(
"scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5",
topFadeEnabled && "topbar-scroll-fade",
)}
ListHeaderComponent={
loadEarlier !== null ? (
<TimelineLoadEarlierHeader
loading={loadEarlier.loading}
onLoadEarlier={loadEarlier.onLoadEarlier}
fade={topFadeEnabled}
/>
) : topFadeEnabled ? (
TIMELINE_LIST_FADE_HEADER
) : (
TIMELINE_LIST_HEADER
)
}
ListFooterComponent={TIMELINE_LIST_FOOTER}
/>
<TimelineMinimap
items={minimapItems}
hasPersistentGutter={minimapHasPersistentGutter}
hitStripWidth={minimapHitStripWidth}
stripMap={minimapStripMap}
onSelect={(item) => {
onManualNavigation();
void listRef.current?.scrollToIndex({
index: item.rowIndex,
animated: true,
viewOffset: 24,
});
}}
/>
</div>
</TimelineRowActivityCtx>
</TimelineRowCtx>
</TooltipScrollDismissScope>
);
});

Expand Down
116 changes: 114 additions & 2 deletions apps/web/src/components/ui/tooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,117 @@
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
import {
type ReactNode,
createContext,
use,
useEffect,
useMemo,
useRef,
type RefObject,
} from "react";

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

const TooltipCreateHandle = TooltipPrimitive.createHandle;

const TooltipProvider = TooltipPrimitive.Provider;

const Tooltip = TooltipPrimitive.Root;
/**
* Tooltips rendered inside this scope close when the scope owner reports a
* user scroll gesture (via useTooltipScrollDismiss). Used around the chat
* timeline: its tooltips portal above other surfaces (like the composer), so
* one left open after its trigger scrolls out from under a stationary pointer
* would paint over them. Programmatic scrolls — streaming auto-follow,
* minimap jumps — never dismiss anything because only real input gestures
* trigger the dismissal. Keyboard-opened tooltips are exempt so keyboard
* users keep them until focus moves.
*/
interface TooltipScrollDismiss {
register: (close: () => void) => () => void;
dismissAll: () => void;
}

const TooltipScrollDismissContext = createContext<TooltipScrollDismiss | null>(null);

function TooltipScrollDismissScope({ children }: { children: ReactNode }) {
const closeCallbacks = useRef(new Set<() => void>());
const dismiss = useMemo<TooltipScrollDismiss>(
() => ({
register: (close) => {
closeCallbacks.current.add(close);
return () => {
closeCallbacks.current.delete(close);
};
},
dismissAll: () => {
// Set iteration tolerates deletion of the in-flight entry, which is
// all a close callback ever does to the set.
for (const close of closeCallbacks.current) {
close();
}
},
}),
[],
);
return <TooltipScrollDismissContext value={dismiss}>{children}</TooltipScrollDismissContext>;
}

/** Dismisses every hover-opened tooltip under the nearest scope. Null outside one. */
export function useTooltipScrollDismiss(): (() => void) | null {
const dismiss = use(TooltipScrollDismissContext);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium ui/tooltip.tsx:60

dismissTooltips is always null, so MessagesTimeline never installs its wheel, touch, pointer, or keyboard dismissal listeners and scrolling leaves hover tooltips open. useTooltipScrollDismiss() runs before the returned TooltipScrollDismissScope, which is a descendant and cannot provide context to this hook call. Move the hook/listener logic into a child rendered beneath the scope, or place the scope above MessagesTimeline.

Also found in 1 other location(s)

apps/web/src/components/chat/MessagesTimeline.tsx:436

useTooltipScrollDismiss() is called by MessagesTimeline before (and therefore outside) the TooltipScrollDismissScope that this same component returns. React context is read only from providers above the calling component, not providers in its returned subtree, so dismissTooltips is always null here and the effect installs no gesture listeners. Timeline scrolling therefore still leaves hover tooltips open.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/ui/tooltip.tsx around line 60:

`dismissTooltips` is always `null`, so `MessagesTimeline` never installs its wheel, touch, pointer, or keyboard dismissal listeners and scrolling leaves hover tooltips open. `useTooltipScrollDismiss()` runs before the returned `TooltipScrollDismissScope`, which is a descendant and cannot provide context to this hook call. Move the hook/listener logic into a child rendered beneath the scope, or place the scope above `MessagesTimeline`.

Also found in 1 other location(s):
- apps/web/src/components/chat/MessagesTimeline.tsx:436 -- `useTooltipScrollDismiss()` is called by `MessagesTimeline` before (and therefore outside) the `TooltipScrollDismissScope` that this same component returns. React context is read only from providers above the calling component, not providers in its returned subtree, so `dismissTooltips` is always `null` here and the effect installs no gesture listeners. Timeline scrolling therefore still leaves hover tooltips open.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — the listener was consuming context from outside the scope it rendered, so dismissal could never activate. The hook now lives in a small TooltipScrollDismissListener child rendered inside TooltipScrollDismissScope (MessagesTimeline), wired to the viewport element it already tracks. Typecheck clean, MessagesTimeline tests 26/26.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

return dismiss?.dismissAll ?? null;
}

function Tooltip(props: TooltipPrimitive.Root.Props) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrapping TooltipPrimitive.Root in a non-generic component narrows the primitive's public prop surface: before this change Tooltip was TooltipPrimitive.Root, whose props are Root.Props<Payload>, so handle was TooltipHandle<Payload> and a render-function child received a typed payload. Now Payload collapses to unknown, which makes the TooltipCreateHandle export (createTooltipHandle<Payload>()) unusable through this wrapper — <Tooltip handle={TooltipCreateHandle<MyPayload>()}> no longer type-checks. Keeping the type parameter preserves the contract at no runtime cost:

Suggested change
function Tooltip(props: TooltipPrimitive.Root.Props) {
function Tooltip<Payload>(props: TooltipPrimitive.Root.Props<Payload>) {

(Root.Actions isn't payload-generic, so mergedActionsRef and the rest of the body are unaffected.)

Posted via Macroscope — UI Consistency

const { actionsRef: consumerActionsRef, onOpenChange, ...rootProps } = props;
const scrollDismiss = use(TooltipScrollDismissContext);
const actionsRef = useRef<TooltipPrimitive.Root.Actions>(null);
const registeredCloseRef = useRef<(() => void) | null>(null);
const consumerActionsRefMirror = useRef(consumerActionsRef);
consumerActionsRefMirror.current = consumerActionsRef;

useEffect(
() => () => {
registeredCloseRef.current?.();
},
[],
);

// Write through to a consumer-supplied actionsRef instead of dropping it.
const mergedActionsRef = useMemo<RefObject<TooltipPrimitive.Root.Actions | null>>(
() => ({
get current() {
return actionsRef.current;
},
set current(actions) {
actionsRef.current = actions;
const ref = consumerActionsRefMirror.current;
if (ref) {
ref.current = actions;
}
},
}),
[],
);

const handleOpenChange = (open: boolean, details: TooltipPrimitive.Root.ChangeEventDetails) => {
onOpenChange?.(open, details);
registeredCloseRef.current?.();
registeredCloseRef.current = null;
if (!scrollDismiss || !open || details.reason !== "trigger-hover") {
return;
}
const close = () => actionsRef.current?.close();
registeredCloseRef.current = scrollDismiss.register(close);
Comment on lines +96 to +104

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes behavior of a primitive every tooltip in the app renders through — prop forwarding (actionsRef write-through, onOpenChange interception) and open/close state transitions — but no test accompanies it. The repo already has focused primitive tests (apps/web/src/components/ui/menu.test.tsx, button.test.tsx) and timeline tests (apps/web/src/components/chat/MessagesTimeline.test.tsx); a small test would pin the contract: a hover-opened tooltip inside TooltipScrollDismissScope closes on wheel over the scope node, a plain programmatic scroll does not close it, a focus-opened tooltip is unaffected, and a consumer-supplied actionsRef still receives the actions object.

Posted via Macroscope — UI Consistency

};

return (
<TooltipPrimitive.Root
{...rootProps}
actionsRef={mergedActionsRef}
onOpenChange={handleOpenChange}
/>
);
}

function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
Expand Down Expand Up @@ -61,4 +166,11 @@ function TooltipPopup({
);
}

export { TooltipCreateHandle, TooltipProvider, Tooltip, TooltipTrigger, TooltipPopup };
export {
TooltipCreateHandle,
TooltipProvider,
TooltipScrollDismissScope,
Tooltip,
TooltipTrigger,
TooltipPopup,
};
Loading