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.
This commit is contained in:
winewill committed 2026-09-16 15:18:03 +08:00
1 parent 60c39996b9
commit daf185fd24
6 files changed
+188 -7

No files matched your search

@@ -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
@@ -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.
@@ -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 气泡。
+32 -6
View File
@@ -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<HTMLElement> | 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 (
<>
<TooltipSuppression.Provider value={setSuppressed}>
{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 && (
<span
ref={bubble}
className={css.bubble}
@@ -156,6 +182,6 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
{resolvedLabel}
</span>
)}
</>
</TooltipSuppression.Provider>
)
}
@@ -354,4 +354,55 @@ describe('Tooltip', () => {
)
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('withdraws the enclosing bubble while a nested tooltip shows its own', () => {
render(
<Tooltip label="Open sidebar">
<button type="button">
anchor
<Tooltip label="Update — V1.2.3">
<span data-testid="badge" />
</Tooltip>
</button>
</Tooltip>,
)
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(
<Tooltip label="Open sidebar">
<button type="button">
anchor
<Tooltip label="Update"><span data-testid="badge" /></Tooltip>
</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
fireEvent.mouseEnter(screen.getByTestId('badge'))
expect(screen.getAllByRole('tooltip').map(bubble => bubble.textContent)).toEqual(['Update'])
view.rerender(
<Tooltip label="Open sidebar">
<button type="button">anchor</button>
</Tooltip>,
)
expect(screen.getByRole('tooltip').textContent).toBe('Open sidebar')
})
})
@@ -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(<SidebarRoot
collapsed width={56}
useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction}
usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])}
useResource={useResource} useWorkspaces={neverHook}
startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
renderSlot={((key: string) => key === 'sidebar.toggle.badge'
? <Tooltip label="Update — V1.2.3"><span data-testid="badge" /></Tooltip>
: 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', () => {