From daf185fd24dd3d4670556d062ebeff697a5da4ea Mon Sep 17 00:00:00 2001 From: winewill Date: Wed, 16 Sep 2026 15:18:03 +0800 Subject: [PATCH] fix(client): yield the enclosing tooltip to a nested one The collapsed rail's update badge renders inside the expand button, so hovering it showed both the version notice and "Open sidebar". Tooltip now publishes a suppression setter that a nested tooltip claims while its own bubble is visible; the enclosing tooltip keeps its hover state and measured position, so its bubble returns as soon as the nested one is gone. --- ...09-16-nested-tooltip-suppression.i18n.yaml | 6 +++ .../2026-09-16-nested-tooltip-suppression.md | 35 +++++++++++++ ...026-09-16-nested-tooltip-suppression.zh.md | 35 +++++++++++++ packages/client/ui-primitives/src/Tooltip.tsx | 38 +++++++++++--- .../tests/tooltip.client.spec.tsx | 51 +++++++++++++++++++ .../tests/sidebar-root.client.spec.tsx | 30 ++++++++++- 6 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.md create mode 100644 .agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.zh.md diff --git a/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.i18n.yaml new file mode 100644 index 0000000000..7079d4ec79 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.md +2026-09-16-nested-tooltip-suppression.md: 1b06a3a1a4f754aa78e821ee8a1479dab19873ff +2026-09-16-nested-tooltip-suppression.zh.md: 96063f78b281deb3561045bb73fcffa64a2c1ef8 diff --git a/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.md b/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.md new file mode 100644 index 0000000000..1b06a3a1a4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.md @@ -0,0 +1,35 @@ +# Agent Note: Suppress an enclosing tooltip while a nested one is shown + +Status: implemented + +English | [中文](2026-09-16-nested-tooltip-suppression.zh.md) + +## Problem + +The collapsed sidebar renders its update badge through the `sidebar.toggle.badge` slot inside the expand button, and both the button and the badge attach a `Tooltip`. Hovering the badge showed two bubbles at once — the version notice and "Open sidebar" — because the badge is a DOM descendant of the button: the button's hover state stays true for as long as the pointer rests on the badge, and each tooltip correctly follows its own anchor. + +## Decision + +[Tooltip](../../../../packages/client/ui-primitives/src/Tooltip.tsx) provides a suppression setter through an internal `TooltipSuppression` context wrapped around its cloned anchor and its bubble. A nested tooltip consumes the nearest setter and announces whenever its own bubble becomes visible. An enclosing tooltip that receives a suppression claim keeps its hover/focus triggers and its measured position but does not render its bubble, so the enclosing bubble returns in the same commit that the nested one disappears. + +The nested tooltip announces synchronously inside `show()` and through a `visible` effect. The synchronous call keeps a nested pair that becomes visible in one React commit from painting both bubbles for a frame; the effect releases the claim on every other path that hides a bubble, including unmount and a `disabled` flip. + +No component in `ui-sidebar` or `ui-settings-general` changed: the badge already renders inside the toggle's anchor, so the shared primitive resolves the overlap for any future nested pair as well. + +## Alternatives considered + +**Disable the toggle tooltip whenever an update exists.** The simplest guard, and wrong: it removes "Open sidebar" from ordinary rail hovers for the whole time an update is available, including hovers that never touch the badge. + +**Disable the toggle tooltip while the pointer is over the badge.** Hover-scoped, but `Tooltip` clears its hover and focus triggers when it is disabled, and no new `mouseenter` fires on the button while the pointer moves from the badge onto the button's own area. The enclosing bubble would stay gone until the pointer left and re-entered the button. + +**Stop the badge's `mouseenter` from reaching the button.** It only helps a pointer that enters the badge directly; a pointer that enters the button first and then moves onto the badge already has the enclosing bubble visible, because the enclosing hover never ended. + +**Render the badge outside the toggle's anchor.** The slot is declared inside the toggle button, and the badge must sit on the button's corner; moving the anchor out of the button would either detach the bubble from the control or change the slot's declared placement. + +## Consequences + +The rule is a property of tooltip nesting, not of the update badge: any tooltip whose anchor contains another tooltip now yields the bubble to the innermost visible one. A tooltip with no nested tooltip is unaffected — it consumes a null context and never announces. + +The enclosing tooltip's bubble is withdrawn only while a descendant is visible; its trigger state survives, so no re-entry is needed to restore it. Suppression does not change what the anchors do: the button still toggles the sidebar and the badge remains a non-interactive marker. + +[Tooltip tests](../../../../packages/client/ui-primitives/tests/tooltip.client.spec.tsx) cover withdrawal, restoration when the nested anchor is left for the enclosing one with a real `relatedTarget`, and release when a shown nested tooltip unmounts. The [sidebar shell test](../../../../packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx) covers the product wiring: the rail badge replaces the toggle bubble after the toggle's own hover delay has already elapsed. diff --git a/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.zh.md b/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.zh.md new file mode 100644 index 0000000000..96063f78b2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 嵌套提示显示时抑制外层提示 + +Status: implemented + +[English](2026-09-16-nested-tooltip-suppression.md) | 中文 + +## 问题 + +侧边栏收起时,更新蓝点通过 `sidebar.toggle.badge` 插槽渲染在展开按钮内部,而按钮和蓝点各自挂了一个 `Tooltip`。hover 蓝点会同时出现两个气泡——版本提示和“打开侧边栏”——因为蓝点是按钮的 DOM 后代:只要指针停在蓝点上,按钮的 hover 状态就保持为真,而两个 tooltip 都在正确地跟随各自的 anchor。 + +## 决策 + +[Tooltip](../../../../packages/client/ui-primitives/src/Tooltip.tsx)通过内部 `TooltipSuppression` context 提供一个抑制 setter,并把该 context 包在克隆后的 anchor 与气泡外层。嵌套的 tooltip 消费最近的 setter,并在自己的气泡可见时上报。收到抑制声明后,外层 tooltip 保留 hover/focus 触发状态与已测量的位置,但不渲染自己的气泡,因此嵌套气泡消失的同一提交里外层气泡就会回来。 + +嵌套 tooltip 既在 `show()` 中同步上报,也通过 `visible` effect 上报。同步调用避免在同一 React 提交中同时可见的一对工具提示多绘制一帧的两个气泡;effect 则在其他所有隐藏气泡的路径上释放抑制,包括卸载和 `disabled` 翻转。 + +`ui-sidebar` 与 `ui-settings-general` 中的组件无需改动:蓝点本就渲染在 toggle 的 anchor 内,因此共享 primitive 也顺带解决了未来任何嵌套组合。 + +## 考虑过的替代方案 + +**只要存在更新就禁用 toggle 的 tooltip。** 这是最简单的防护,但也是错的:它会在更新可用的整个期间移除普通 rail hover 的“打开侧边栏”,包括根本不会碰到蓝点的 hover。 + +**指针位于蓝点上时禁用 toggle 的 tooltip。** 作用域正确,但 `Tooltip` 被禁用时会清空自己的 hover 与 focus 触发状态,而指针从蓝点移到按钮自身区域时按钮不会再触发新的 `mouseenter`。外层气泡会一直消失,直到指针离开按钮并重新进入。 + +**阻止蓝点的 `mouseenter` 传播到按钮。** 这只能帮助直接从蓝点进入的指针;先进入按钮再移到蓝点上的指针,其外层气泡早已可见,因为外层的 hover 从未结束过。 + +**把蓝点渲染到 toggle 的 anchor 之外。** 该插槽声明在 toggle 按钮内部,蓝点必须落在按钮角上;把 anchor 移出按钮要么让气泡脱离控件,要么改变插槽声明的放置位置。 + +## 结果 + +这条规则是 tooltip 嵌套的性质,而不是更新蓝点的性质:任何 anchor 内含另一个 tooltip 的 tooltip 现在都会把气泡让给最内层可见的那个。没有嵌套 tooltip 的 tooltip 不受影响——它消费到的是 null context,永远不会上报。 + +外层 tooltip 仅在子级可见期间收回气泡;它的触发状态得以保留,因此不需要重新进入即可恢复。抑制不改变 anchor 的行为:按钮仍然切换侧边栏,蓝点仍然是非交互标记。 + +[Tooltip 测试](../../../../packages/client/ui-primitives/tests/tooltip.client.spec.tsx)覆盖收回、用真实 `relatedTarget` 从嵌套 anchor 移到外层 anchor 时的恢复,以及已显示的嵌套 tooltip 卸载时的释放。[侧边栏 shell 测试](../../../../packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx)覆盖产品接线:在 toggle 自身的 hover 延迟已经过去之后,rail 蓝点气泡仍替换掉 toggle 气泡。 diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index a1893a1d39..41baf5b785 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -1,13 +1,20 @@ // Cloning the anchor preserves its layout context. Fixed positioning lets the // bubble escape ancestor overflow clipping without a portal. -import { cloneElement, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { cloneElement, createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react' import type { FocusEventHandler, MouseEventHandler, MutableRefObject, ReactElement, Ref } from 'react' import css from './Tooltip.module.css' /** Bubble placement relative to the anchor. */ export type TooltipSide = 'right' | 'bottom' | 'top' +/** + * Suppression channel from a tooltip to the tooltips above it: a tooltip hands + * this setter to its own descendants, and a visible descendant bubble calls it + * so the ancestor withdraws its bubble for as long as the descendant shows one. + */ +const TooltipSuppression = createContext<((suppressed: boolean) => void) | null>(null) + /** Props Tooltip injects into its anchor child; the child's own handlers are chained ahead of the tooltip's. */ interface AnchorProps { ref?: Ref | undefined @@ -93,6 +100,20 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, // BOTH clear (hovering away from a focused anchor must not drop it). const triggers = useRef({ hover: false, focus: false }) + // A nested tooltip's bubble owns the pointer position, so this tooltip + // withdraws its own while a descendant shows one; the state below is set by + // the descendants this tooltip wraps. Announcing on every visibility change + // covers hide, disable, and unmount; show() also announces synchronously so + // a nested pair shown in one commit never paints both bubbles. + const suppressAncestors = useContext(TooltipSuppression) + const [suppressed, setSuppressed] = useState(false) + const announce = useCallback((active: boolean) => { suppressAncestors?.(active) }, [suppressAncestors]) + const visible = pos !== null && !disabled + useEffect(() => { + announce(visible) + return () => { announce(false) } + }, [announce, visible]) + // Disabling mid-hover (e.g. clicking a rail control expands the sidebar) // must drop an already-visible bubble: no mouseleave fires. const cancelShow = useCallback(() => { @@ -119,6 +140,7 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, // where this anchor's position demands it. setPlacement(side) setPos({ x: side === 'right' ? r.right + 10 : r.left + r.width / 2, top: r.top, bottom: r.bottom }) + announce(true) } const showAfterHoverDelay = () => { cancelShow() @@ -131,21 +153,25 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, show() }, delayMs) } + const withdraw = () => { + setPos(null) + announce(false) + } const hide = () => { cancelShow() - if (!triggers.current.hover && !triggers.current.focus) setPos(null) + if (!triggers.current.hover && !triggers.current.focus) withdraw() } return ( - <> + {cloneElement(children, { ref: mergedRef, onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; showAfterHoverDelay() }, - onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; cancelShow(); setPos(null) }, + onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; cancelShow(); withdraw() }, onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; cancelShow(); show() }, onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() }, })} - {pos !== null && ( + {visible && !suppressed && ( )} - + ) } diff --git a/packages/client/ui-primitives/tests/tooltip.client.spec.tsx b/packages/client/ui-primitives/tests/tooltip.client.spec.tsx index a884b58615..26f66958ab 100644 --- a/packages/client/ui-primitives/tests/tooltip.client.spec.tsx +++ b/packages/client/ui-primitives/tests/tooltip.client.spec.tsx @@ -354,4 +354,55 @@ describe('Tooltip', () => { ) expect(screen.queryByRole('tooltip')).toBeNull() }) + + it('withdraws the enclosing bubble while a nested tooltip shows its own', () => { + render( + + + , + ) + const anchor = screen.getByText('anchor') + const badge = screen.getByTestId('badge') + fireEvent.mouseEnter(anchor) + expect(screen.getByRole('tooltip').textContent).toBe('Open sidebar') + + // Entering the nested anchor withdraws the enclosing bubble instead of + // stacking both; the enclosing anchor stays hovered, so nothing is lost. + fireEvent.mouseEnter(badge) + expect(screen.getAllByRole('tooltip').map(bubble => bubble.textContent)).toEqual(['Update — V1.2.3']) + + // Leaving the nested anchor for the enclosing one restores its bubble; + // the pointer never left the enclosing anchor, so only the badge is left. + fireEvent.mouseLeave(badge, { relatedTarget: anchor }) + expect(screen.getByRole('tooltip').textContent).toBe('Open sidebar') + + fireEvent.mouseLeave(anchor) + expect(screen.queryByRole('tooltip')).toBeNull() + }) + + it('releases the enclosing bubble when a shown nested tooltip unmounts', () => { + const view = render( + + + , + ) + fireEvent.mouseEnter(screen.getByText('anchor')) + fireEvent.mouseEnter(screen.getByTestId('badge')) + expect(screen.getAllByRole('tooltip').map(bubble => bubble.textContent)).toEqual(['Update']) + + view.rerender( + + + , + ) + expect(screen.getByRole('tooltip').textContent).toBe('Open sidebar') + }) }) diff --git a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx index 0e54868133..d513ae5341 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx @@ -1,7 +1,8 @@ // @vitest-environment jsdom import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { ReactNode } from 'react' import type { SidebarFooterActionOwnerProps, SidebarRootComponentProps, SidebarSectionOwnerProps, @@ -191,6 +192,33 @@ describe('SidebarRoot shell', () => { expect(b.regionOwner().wide).toBe(false) expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy() }) + + it('shows only the badge bubble while the rail badge is hovered inside the toggle', () => { + vi.useFakeTimers() + render( {}} usePanels={selector => selector([])} + useResource={useResource} useWorkspaces={neverHook} + startSession={vi.fn()} toggleSidebar={vi.fn()} t={t} + renderSlot={((key: string) => key === 'sidebar.toggle.badge' + ? + : null) as SidebarRootComponentProps['renderSlot']} + />) + const toggle = screen.getByRole('button', { name: 'Open sidebar' }) + fireEvent.mouseEnter(toggle) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByRole('tooltip').textContent).toBe('Open sidebar') + // The badge's own bubble replaces the toggle's rather than stacking on it, + // even after the toggle's longer hover delay has elapsed. + fireEvent.mouseEnter(screen.getByTestId('badge')) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getAllByRole('tooltip').map(bubble => bubble.textContent)).toEqual(['Update — V1.2.3']) + fireEvent.mouseLeave(screen.getByTestId('badge'), { relatedTarget: toggle }) + expect(screen.getByRole('tooltip').textContent).toBe('Open sidebar') + fireEvent.mouseLeave(toggle) + expect(screen.queryByRole('tooltip')).toBeNull() + }) }) it('keeps the macOS sidebar toggle in its top strip', () => {