From 5071a8a9147cd7af2fcbf302be638bfea46eda62 Mon Sep 17 00:00:00 2001
From: kev1n77
Date: Sat, 10 Oct 2026 14:42:56 +0800
Subject: [PATCH] fix(ui): keep an interactive tooltip preview open while it
scrolls
An open tooltip subscribes to capture-phase scroll events so a moving anchor never leaves a stale preview behind. Scrolling the tooltip's own scroll container is not a moving anchor: with the wheel over the sub-agent preview, or a drag on the preview scrollbar, that listener closed the preview exactly when the reader was scrolling it to read the rest of the task description.
Ignore scroll events whose target sits inside the tooltip surface so the preview body can be scrolled, while a scroll owned by the surface behind the trigger still dismisses it. Cover both directions in the tooltip visibility suite.
---
.../ui/src/components/Tooltip/Tooltip.tsx | 8 ++++++-
src/web-ui/src/shared/ui/Tooltip.test.tsx | 21 +++++++++++++++++++
2 files changed, 28 insertions(+), 1 deletion(-)
diff --git a/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx b/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx
index 160ebc6417..326cba2194 100644
--- a/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx
+++ b/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx
@@ -263,7 +263,13 @@ export function Tooltip({
// Capture nested, non-bubbling scrolls during both delayed and visible states.
// Dormant tooltips do not need a document-level scroll subscription.
if (view && !scrollCleanupRef.current) {
- const dismissOnScroll = () => hideCurrentRef.current();
+ const dismissOnScroll = (event: Event) => {
+ // Scrolling the tooltip's own scroll container (a wheel over the
+ // preview or a scrollbar drag inside it) is not a moving anchor.
+ const surface = tooltipRef.current;
+ if (surface && event.target instanceof Node && surface.contains(event.target)) return;
+ hideCurrentRef.current();
+ };
view.addEventListener("scroll", dismissOnScroll, { capture: true, passive: true });
view.visualViewport?.addEventListener("scroll", dismissOnScroll, { passive: true });
scrollCleanupRef.current = () => {
diff --git a/src/web-ui/src/shared/ui/Tooltip.test.tsx b/src/web-ui/src/shared/ui/Tooltip.test.tsx
index c13447c768..4769c09ec9 100644
--- a/src/web-ui/src/shared/ui/Tooltip.test.tsx
+++ b/src/web-ui/src/shared/ui/Tooltip.test.tsx
@@ -114,6 +114,27 @@ describe('shared tooltip visibility', () => {
expect(popup()).toBeNull();
});
+ it('keeps an interactive preview open while its own scroll container scrolls', () => {
+ render(
+ Sub-agent details
}>
+
+ ,
+ );
+ const button = host.querySelector('button')!;
+ enter(button); advance(); advance(1);
+ const card = popup()!;
+ expect(card.textContent).toBe('Sub-agent details');
+ // The tooltip body is its own scroll container: wheel scrolling or a
+ // scrollbar drag inside the preview must not dismiss the preview.
+ act(() => card.dispatchEvent(new Event('scroll')));
+ expect(popup()).toBe(card);
+ act(() => card.querySelector('[data-openbitfun-part="content"]')!.dispatchEvent(new Event('scroll')));
+ expect(popup()).toBe(card);
+ // A scroll owned by the surface behind the trigger still dismisses it.
+ act(() => host.dispatchEvent(new Event('scroll')));
+ expect(popup()).toBeNull();
+ });
+
it('dismisses pinned cards on visual viewport scrolling', () => {
const viewport = new EventTarget();
vi.stubGlobal('visualViewport', viewport);