Skip to content

fix(web): close hover tooltips when the chat timeline scrolls - #7860

Open
Exotic209093 wants to merge 1 commit into
pingdotgg:mainfrom
Exotic209093:fix/tooltip-dismiss-on-timeline-scroll
Open

fix(web): close hover tooltips when the chat timeline scrolls#7860
Exotic209093 wants to merge 1 commit into
pingdotgg:mainfrom
Exotic209093:fix/tooltip-dismiss-on-timeline-scroll

Conversation

@Exotic209093

@Exotic209093 Exotic209093 commented Aug 22, 2026

Copy link
Copy Markdown

Hovering a Markdown link in the chat opens its tooltip, and scrolling the timeline without moving the pointer leaves the tooltip open after its trigger has scrolled away. Since tooltips portal above the chat surface, it then paints over the composer and its controls.

Tooltips now opt into scroll dismissal through a TooltipScrollDismissScope that wraps the messages timeline: when a tooltip opens via hover inside that scope, it attaches a capture-phase scroll listener on document (so inner scrollers are covered) and closes itself on the first scroll event, detaching cleanly right after and on unmount. Tooltips opened by keyboard focus are untouched, so keyboard users keep them until focus moves, and tooltips outside the timeline behave exactly as before.

Fixes #7767

ox-alpha via opencode

Note

Close hover tooltips on real scroll gestures in MessagesTimeline

  • Adds TooltipScrollDismissScope and useTooltipScrollDismiss to tooltip.tsx. A new context tracks hover-opened tooltips and exposes a dismissAll callable.
  • Wraps the Tooltip component so hover-opened tooltips register their close callback with the nearest scope and unregister on close or unmount. actionsRef is proxied to preserve consumer-supplied refs.
  • Adds TooltipScrollDismissListener and wraps timeline content in MessagesTimeline.tsx. The listener calls dismissAll on wheel, touchmove, pointerdown, and keydown for scroll keys (PageUp, PageDown, Home, End, ArrowUp, ArrowDown).
  • Programmatic scrolls (auto-follow, minimap) do not trigger dismissal because only real user input gestures are intercepted.
  • Behavioral Change: Tooltip is no longer a direct alias of TooltipPrimitive.Root; it is a wrapper that intercepts onOpenChange and manages actionsRef internally. Consumers outside a TooltipScrollDismissScope see unchanged behavior.

Macroscope summarized 64b0366.

@coderabbitai

coderabbitai Bot commented Aug 22, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2bdd3b6b-7a5e-4ccb-875c-a487b4d34e15

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Aug 22, 2026
threadId: event.payload.threadId,
requestId,
turnId,
createdAt: event.payload.createdAt,

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 Layers/ProviderCommandReactor.ts:1272

The synthetic user-input.resolved activity can sort before its corresponding user-input.requested, leaving the request pending after a successful interrupt. This happens because it reuses event.payload.createdAt, which may equal or precede the request timestamp; use a server-generated ordering/timestamp that is guaranteed to follow the request.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/Layers/ProviderCommandReactor.ts around line 1272:

The synthetic `user-input.resolved` activity can sort before its corresponding `user-input.requested`, leaving the request pending after a successful interrupt. This happens because it reuses `event.payload.createdAt`, which may equal or precede the request timestamp; use a server-generated ordering/timestamp that is guaranteed to follow the request.

yield* providerService.interruptTurn({ threadId: event.payload.threadId });
// Some providers discard their callbacks without emitting a matching
// resolution event. Close those requests after the interrupt succeeds.
yield* Effect.forEach(pendingUserInputRequests(thread.activities), ({ requestId, turnId }) =>

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 Layers/ProviderCommandReactor.ts:1267

interruptTurn can emit user-input.resolved itself, but this loop uses the pre-interrupt thread.activities snapshot and appends another resolution for the same requestId, causing duplicate entries such as both “submitted” and “cancelled”. Re-read the thread’s pending activities after the interrupt before appending synthetic cancellations.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/Layers/ProviderCommandReactor.ts around line 1267:

`interruptTurn` can emit `user-input.resolved` itself, but this loop uses the pre-interrupt `thread.activities` snapshot and appends another resolution for the same `requestId`, causing duplicate entries such as both “submitted” and “cancelled”. Re-read the thread’s pending activities after the interrupt before appending synthetic cancellations.

@macroscopeapp macroscopeapp Bot left a comment

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.

Reviewed the web UI changes (apps/web/src/components/ui/tooltip.tsx, apps/web/src/components/chat/MessagesTimeline.tsx). The scroll-dismiss behavior added to the shared Tooltip primitive has two contract/behavior concerns; details inline.

Posted via Macroscope — UI Consistency

Comment thread apps/web/src/components/ui/tooltip.tsx Outdated
Comment thread apps/web/src/components/ui/tooltip.tsx Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 947726b6e5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

threadId: event.payload.threadId,
requestId,
turnId,
createdAt: event.payload.createdAt,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Order cancellation after the pending request

When a remote client's clock is behind the server, event.payload.createdAt can precede the provider-generated user-input.requested timestamp because command metadata uses the client clock (packages/client-runtime/src/operations/commands.ts:72-81). Activity state is reconstructed chronologically (apps/server/src/orchestration/projector.ts:171-185), so this cancellation is processed before the request and the question remains pending after Stop. Generate a server-side timestamp or otherwise guarantee lifecycle ordering for the resolution.

AGENTS.md reference: AGENTS.md:L74-L74

Useful? React with 👍 / 👎.

yield* providerService.interruptTurn({ threadId: event.payload.threadId });
// Some providers discard their callbacks without emitting a matching
// resolution event. Close those requests after the interrupt succeeds.
yield* Effect.forEach(pendingUserInputRequests(thread.activities), ({ requestId, turnId }) =>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid appending a second user-input resolution

When stopping a Cursor or Grok turn with an open question, their adapters already settle pending input during interruption (CursorAdapter.ts:1067-1072 and GrokAdapter.ts:1328-1332), which resumes the request handler and emits user-input.resolved. This loop still uses the thread snapshot loaded before interruptTurn, so it unconditionally persists another, contradictory "User input cancelled" resolution; both durable activities then render in the timeline. Re-read pending state after the interrupt or synthesize cancellation only for adapters that do not resolve it themselves.

AGENTS.md reference: AGENTS.md:L71-L71

Useful? React with 👍 / 👎.

expect(harness.interruptTurn.mock.calls[0]?.[0]).toEqual({
threadId: "thread-1",
});
await waitFor(async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Wait on the reactor drain instead of polling

This new assertion polls the asynchronously projected read model with the test's ten-second timeout even though the harness exposes drain(). That makes the test timing-dependent under slow CI and violates the event-sourced test requirement to wait on receipts or worker drains; dispatch the interrupt and await the reactor drain before asserting the activity.

AGENTS.md reference: AGENTS.md:L109-L109

Useful? React with 👍 / 👎.

Comment on lines +279 to +281
function pendingUserInputRequests(
activities: OrchestrationThread["activities"],
): ReadonlyArray<{ readonly requestId: string; readonly turnId: TurnId | null }> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Split the server interrupt behavior from the tooltip fix

The proposed change is titled as a web tooltip fix, but this helper and its companion reactor changes independently alter durable server behavior whenever a turn is stopped. Keeping these concerns together makes the server behavior easy to miss during review and impossible to revert independently from the tooltip change; move it and its test into a separate change.

AGENTS.md reference: AGENTS.md:L119-L119

Useful? React with 👍 / 👎.

@cursor cursor Bot left a comment

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.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 947726b. Configure here.

turnId,
createdAt: event.payload.createdAt,
}),
);

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.

Duplicate user-input cancel on interrupt

Medium Severity

Pending user-input cancellation after interrupt always uses the pre-interrupt thread.activities snapshot, so every open request is closed with a cancelled user-input.resolved even when the provider already emits its own resolution during interruptTurn. For Claude, abort already publishes user-input.resolved, so interrupt now leaves both a cancelled activity and a submitted one for the same requestId.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 947726b. Configure here.

@macroscopeapp

macroscopeapp Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Would Approve

Macroscope's review found this PR approvable — This PR adds scroll-dismiss behavior for tooltips in the chat timeline - a self-contained UI fix. The implementation correctly uses React context with a child listener component, and the Macroscope feedback about context consumption was addressed. Only two frontend files are changed with no backend impact.

Not approved because:

  • 3 blocking correctness issues found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

@Exotic209093
Exotic209093 force-pushed the fix/tooltip-dismiss-on-timeline-scroll branch from 947726b to 459564f Compare August 22, 2026 01:38
@github-actions github-actions Bot added size:M 30-99 changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Aug 22, 2026
@Exotic209093
Exotic209093 force-pushed the fix/tooltip-dismiss-on-timeline-scroll branch from 459564f to 9bb16e9 Compare August 22, 2026 01:51
@github-actions github-actions Bot added size:L 100-499 changed lines (additions + deletions). and removed size:M 30-99 changed lines (additions + deletions). labels Aug 22, 2026

/** 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.

@macroscopeapp macroscopeapp Bot left a comment

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.

One blocking finding: the scroll-dismiss wiring is dead at runtime because the hook is called outside the scope it renders, so the timeline never dismisses hover tooltips.

Posted via Macroscope — UI Consistency

Comment thread apps/web/src/components/chat/MessagesTimeline.tsx Outdated
@Exotic209093
Exotic209093 force-pushed the fix/tooltip-dismiss-on-timeline-scroll branch from 9bb16e9 to 64b0366 Compare August 22, 2026 02:48

@macroscopeapp macroscopeapp Bot left a comment

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.

Reviewed the tooltip primitive change and its timeline call site. The scope/listener split now resolves correctly (the listener is a descendant of the provider, so useTooltipScrollDismiss() is non-null), and gating registration on details.reason === "trigger-hover" does exempt focus/keyboard opens ("trigger-focus") as documented. Two findings on the shared-primitive contract below.

Posted via Macroscope — UI Consistency

Comment on lines +96 to +104
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);

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 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Chat tooltips remain visible over the composer while scrolling

1 participant