fix(tabs): stop tab and close tooltips firing while grazing the strip (#25808)

* fix(tabs): delay tab and close tooltips past grazing

Tab title and close-icon tooltips fired near-instantly while dragging
across the strip: Radix's provider-wide skipDelayDuration reopens the
next tooltip with no delay for 300ms after any tooltip closes.

Scope a nested TooltipProvider to the tab strip with a 700ms delay and
skipDelayDuration=0, so each tab tooltip waits the full delay.

* fix(tabs): drop close-button tooltip and lengthen tab tooltip delay

Remove the hover tooltip from the tab close button entirely (aria-label
keeps the accessible name), and raise the tab-title tooltip delay from
700ms to 1500ms so grazing or scrolling the strip no longer pops a label.
Add an interaction test proving the delay is enforced and Radix's
skip-delay is disabled.

* fix(tabs): delay the agent-state glyph tooltip to match the strip

The tab's leading agent-state dot renders its own feature tooltip with a
hardcoded 200ms delay, so tabs with a live agent state (working/done/
needs-attention) popped almost instantly while plain tabs waited the full
strip delay — the 'every other tab' inconsistency. Thread an optional
delay through StateIndicatorTooltip/AgentStateDot and pass the tab-strip
1500ms from TerminalTabLeadingIcon; other surfaces keep 200ms.

* fix(tabs): remove the agent-state glyph tooltip on tabs

Hovering a tab's status glyph (spinner/check/question) popped its own
tooltip after 200ms, so tabs with a live agent state popped while plain
tabs stayed quiet. Remove that tooltip entirely via title={null}; the
glyph stays self-describing with its aria-label, and the tab title keeps
its tooltip. Reverts the now-unused delay plumbing.

* reduce the time down from 1500 ms to 500 ms

* fix(tabs): restore the close-tab shortcut hint with a slower delay

Close buttons share one TabCloseTooltip that waits 1s, longer than the 500ms tab-title tooltip. Also merges the tooltip provider tests.
This commit is contained in:
Jinjing
2026-10-06 10:03:04 -07:00
committed by GitHub
parent 7faa9f7cd3
commit cad8ea4fa7
13 changed files with 264 additions and 100 deletions
@@ -1,7 +1,6 @@
import { X } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel'
import { translate } from '@/i18n/i18n'
import { TabCloseTooltip } from './TabCloseTooltip'
export function EditorFileTabCloseButton({
fileIsDirty,
@@ -12,43 +11,32 @@ export function EditorFileTabCloseButton({
showsSelectionChrome: boolean
onClose: () => void
}): React.JSX.Element {
const closeShortcut = useOptionalShortcutLabel('tab.close')
const closeLabel = translate(
'auto.components.tab.bar.EditorFileTabCloseButton.a768f428f1',
'Close tab'
)
return (
<Tooltip>
<TooltipTrigger asChild>
<button
className={`flex items-center justify-center w-4 h-4 rounded-sm ${
fileIsDirty
? 'hidden group-hover:flex text-muted-foreground hover:text-foreground hover:bg-muted'
: showsSelectionChrome
? 'text-muted-foreground hover:text-foreground hover:bg-muted'
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted'
}`}
type="button"
// Why: simulator unified tabs reuse this tab chrome, so E2E needs
// the same stable close affordance on the real button users click.
data-tab-close-button="true"
aria-label={translate(
'auto.components.tab.bar.EditorFileTabCloseButton.4655cf570e',
'Close tab'
)}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
onClose()
}}
>
<X className="w-3 h-3" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{closeShortcut ? `${closeLabel} (${closeShortcut})` : closeLabel}
</TooltipContent>
</Tooltip>
<TabCloseTooltip>
<button
className={`flex items-center justify-center w-4 h-4 rounded-sm ${
fileIsDirty
? 'hidden group-hover:flex text-muted-foreground hover:text-foreground hover:bg-muted'
: showsSelectionChrome
? 'text-muted-foreground hover:text-foreground hover:bg-muted'
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted'
}`}
type="button"
// Why: simulator unified tabs reuse this tab chrome, so E2E needs
// the same stable close affordance on the real button users click.
data-tab-close-button="true"
aria-label={translate(
'auto.components.tab.bar.EditorFileTabCloseButton.4655cf570e',
'Close tab'
)}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
onClose()
}}
>
<X className="w-3 h-3" />
</button>
</TabCloseTooltip>
)
}
@@ -22,8 +22,8 @@ import { SortableTabContextMenu } from './SortableTabContextMenu'
import { translate } from '@/i18n/i18n'
import { TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules'
import { useTabStripSlotProps } from './use-tab-strip-slot-props'
import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel'
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
import { TabCloseTooltip } from './TabCloseTooltip'
import { TerminalTabLeadingIcon } from './TerminalTabLeadingIcon'
import {
isTerminalTabActivityLive,
@@ -174,8 +174,6 @@ export default function SortableTab({
disabled: isEditing
})
const slotProps = useTabStripSlotProps(tab.id, isActive)
const closeShortcut = useOptionalShortcutLabel('tab.close')
const closeLabel = translate('auto.components.tab.bar.SortableTab.95db5f2f7d', 'Close tab')
const tabTitle = tab.customTitle ?? tab.title
const tabRoot = (
<div
@@ -322,45 +320,40 @@ export default function SortableTab({
</button>
)}
{!isEditing && !isPinned && (
<Tooltip>
<TooltipTrigger asChild>
<button
className={`relative z-10 flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
isActive
? 'text-muted-foreground hover:text-foreground hover:bg-muted focus-visible:text-foreground focus-visible:bg-muted'
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted focus-visible:!text-foreground focus-visible:!bg-muted'
}`}
// Why: stable accessible name lets E2E drive the real close path (hover, then X) instead of calling the store.
aria-label={translate(
'auto.components.tab.bar.SortableTab.6df69d9388',
'Close tab {{value0}}',
{ value0: tabTitle }
)}
type="button"
data-tab-close-button="true"
onPointerDown={(e) => {
if (e.button === 0) {
e.stopPropagation()
}
}}
onMouseDown={(e) => {
if (e.button === 0) {
e.stopPropagation()
}
}}
onClick={(e) => {
e.preventDefault()
<TabCloseTooltip>
<button
className={`relative z-10 flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
isActive
? 'text-muted-foreground hover:text-foreground hover:bg-muted focus-visible:text-foreground focus-visible:bg-muted'
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted focus-visible:!text-foreground focus-visible:!bg-muted'
}`}
// Why: stable accessible name lets E2E drive the real close path instead of calling the store.
aria-label={translate(
'auto.components.tab.bar.SortableTab.6df69d9388',
'Close tab {{value0}}',
{ value0: tabTitle }
)}
type="button"
data-tab-close-button="true"
onPointerDown={(e) => {
if (e.button === 0) {
e.stopPropagation()
onClose(tab.id)
}}
>
<X className="w-3 h-3" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{closeShortcut ? `${closeLabel} (${closeShortcut})` : closeLabel}
</TooltipContent>
</Tooltip>
}
}}
onMouseDown={(e) => {
if (e.button === 0) {
e.stopPropagation()
}
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onClose(tab.id)
}}
>
<X className="w-3 h-3" />
</button>
</TabCloseTooltip>
)}
</div>
)
@@ -1,6 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { expandNode } from './tab-bar-dropdown-menu-item-probe'
import { stubHeadlessReact, stubShallowSelector } from './tab-bar-windows-shell-launch-render-stubs'
import {
stubHeadlessReact,
stubShallowSelector,
stubTooltip
} from './tab-bar-windows-shell-launch-render-stubs'
const appStoreSnapshot: {
activeTabId: string | null
@@ -181,11 +185,7 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
}
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: 'Tooltip',
TooltipContent: 'TooltipContent',
TooltipTrigger: 'TooltipTrigger'
}))
vi.mock('@/components/ui/tooltip', () => stubTooltip())
type ReactElementLike = {
type: unknown
@@ -14,7 +14,8 @@ import {
stubSortableTab,
stubStatusDisplay,
stubTabInsertion,
stubTabStripDragScroll
stubTabStripDragScroll,
stubTooltip
} from './tab-bar-windows-shell-launch-render-stubs'
import {
appStoreSnapshot,
@@ -38,6 +39,7 @@ vi.mock('./QuickLaunchButton', () => stubQuickLaunchButton())
vi.mock('./shell-icons', () => stubShellIcons())
vi.mock('@/lib/focus-terminal-tab-surface', () => stubFocusTerminalTabSurface())
vi.mock('@/components/ui/dropdown-menu', () => stubDropdownMenu())
vi.mock('@/components/ui/tooltip', () => stubTooltip())
describe('TabBar PowerShell launch wiring', () => {
beforeEach(() => {
@@ -14,7 +14,8 @@ import {
stubSortableTab,
stubStatusDisplay,
stubTabInsertion,
stubTabStripDragScroll
stubTabStripDragScroll,
stubTooltip
} from './tab-bar-windows-shell-launch-render-stubs'
import {
appStoreSnapshot,
@@ -38,6 +39,7 @@ vi.mock('./QuickLaunchButton', () => stubQuickLaunchButton())
vi.mock('./shell-icons', () => stubShellIcons())
vi.mock('@/lib/focus-terminal-tab-surface', () => stubFocusTerminalTabSurface())
vi.mock('@/components/ui/dropdown-menu', () => stubDropdownMenu())
vi.mock('@/components/ui/tooltip', () => stubTooltip())
describe('TabBar PowerShell launch wiring', () => {
beforeEach(() => {
@@ -14,7 +14,8 @@ import {
stubSortableTab,
stubStatusDisplay,
stubTabInsertion,
stubTabStripDragScroll
stubTabStripDragScroll,
stubTooltip
} from './tab-bar-windows-shell-launch-render-stubs'
import {
appStoreSnapshot,
@@ -38,6 +39,7 @@ vi.mock('./QuickLaunchButton', () => stubQuickLaunchButton())
vi.mock('./shell-icons', () => stubShellIcons())
vi.mock('@/lib/focus-terminal-tab-surface', () => stubFocusTerminalTabSurface())
vi.mock('@/components/ui/dropdown-menu', () => stubDropdownMenu())
vi.mock('@/components/ui/tooltip', () => stubTooltip())
describe('TabBar PowerShell launch wiring', () => {
beforeEach(() => {
@@ -0,0 +1,25 @@
import type { ReactNode } from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel'
import { translate } from '@/i18n/i18n'
import { TAB_TOOLTIP_DELAY_MS } from './TabStripTooltipProvider'
// Why: the close hint is secondary to the tab title, so it waits longer.
export const TAB_CLOSE_TOOLTIP_DELAY_MS = TAB_TOOLTIP_DELAY_MS + 500
export function TabCloseTooltip({ children }: { children: ReactNode }): React.JSX.Element {
const closeShortcut = useOptionalShortcutLabel('tab.close')
const closeLabel = translate(
'auto.components.tab.bar.EditorFileTabCloseButton.a768f428f1',
'Close tab'
)
return (
<Tooltip delayDuration={TAB_CLOSE_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{closeShortcut ? `${closeLabel} (${closeShortcut})` : closeLabel}
</TooltipContent>
</Tooltip>
)
}
@@ -0,0 +1,88 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { TAB_TOOLTIP_DELAY_MS, TabStripTooltipProvider } from './TabStripTooltipProvider'
function renderTooltip(label: string, tip: string): void {
render(
<TabStripTooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button type="button">{label}</button>
</TooltipTrigger>
<TooltipContent side="bottom">{tip}</TooltipContent>
</Tooltip>
</TabStripTooltipProvider>
)
}
describe('TabStripTooltipProvider', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
cleanup()
})
it('holds a tab tooltip until the full delay elapses', () => {
renderTooltip('tab label', 'tab tooltip')
fireEvent.pointerMove(screen.getByRole('button', { name: 'tab label' }), {
pointerType: 'mouse'
})
act(() => {
vi.advanceTimersByTime(TAB_TOOLTIP_DELAY_MS - 1)
})
expect(screen.queryByText('tab tooltip')).toBeNull()
act(() => {
vi.advanceTimersByTime(1)
})
expect(screen.getByText('tab tooltip')).toBeTruthy()
})
it('does not let a recently-closed tooltip skip the delay on the next tab', () => {
render(
<TabStripTooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button type="button">first tab</button>
</TooltipTrigger>
<TooltipContent>first tip</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button type="button">second tab</button>
</TooltipTrigger>
<TooltipContent>second tip</TooltipContent>
</Tooltip>
</TabStripTooltipProvider>
)
const first = screen.getByRole('button', { name: 'first tab' })
const second = screen.getByRole('button', { name: 'second tab' })
fireEvent.pointerMove(first, { pointerType: 'mouse' })
act(() => {
vi.advanceTimersByTime(TAB_TOOLTIP_DELAY_MS)
})
expect(screen.getByText('first tip')).toBeTruthy()
fireEvent.pointerLeave(first)
fireEvent.pointerMove(second, { pointerType: 'mouse' })
// Radix's default would open this one instantly for 300ms after a close.
act(() => {
vi.advanceTimersByTime(TAB_TOOLTIP_DELAY_MS - 1)
})
expect(screen.queryByText('second tip')).toBeNull()
act(() => {
vi.advanceTimersByTime(1)
})
expect(screen.getByText('second tip')).toBeTruthy()
})
})
@@ -0,0 +1,16 @@
import type { ReactNode } from 'react'
import { TooltipProvider } from '@/components/ui/tooltip'
// Why: longer than the app-wide 400ms so a pointer merely passing over the strip never pops a title.
export const TAB_TOOLTIP_DELAY_MS = 500
export function TabStripTooltipProvider({ children }: { children: ReactNode }): React.JSX.Element {
// Why: skipDelayDuration=0 keeps the full delay after a tooltip closes. Radix
// otherwise opens the next tab's label instantly for 300ms after any close,
// which is what makes dragging across the strip fire tooltips back to back.
return (
<TooltipProvider delayDuration={TAB_TOOLTIP_DELAY_MS} skipDelayDuration={0}>
{children}
</TooltipProvider>
)
}
@@ -0,0 +1,29 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
const agentDotMock = vi.hoisted(() => vi.fn((_props: Record<string, unknown>) => null))
vi.mock('@/components/AgentStateDot', () => ({
AgentStateDot: (props: Record<string, unknown>) => {
agentDotMock(props)
return null
}
}))
import { TerminalTabLeadingIcon } from './TerminalTabLeadingIcon'
describe('TerminalTabLeadingIcon agent-state glyph', () => {
it('renders the state glyph without a hover tooltip', () => {
renderToStaticMarkup(
<TerminalTabLeadingIcon
agent="claude"
activityStatus="working"
shell={undefined}
showUnreadActivity={false}
isActive
/>
)
expect(agentDotMock).toHaveBeenCalledWith(expect.objectContaining({ title: null }))
})
})
@@ -76,7 +76,8 @@ export function TerminalTabLeadingIcon({
data-agent-activity-status={activityStatus}
className="mr-1 inline-flex shrink-0 items-center gap-1"
>
<AgentStateDot state={dotState} size="md" />
{/* Why: the state glyph is self-describing and the tab title already has a tooltip; hover here stays quiet. */}
<AgentStateDot state={dotState} size="md" title={null} />
{/* Why: status and identity answer different questions. Keep the agent
logo beside the state glyph so parallel tabs remain scannable. */}
{agent ? <TerminalTabAgentIdentityIcon agent={agent} isActive={isActive} /> : null}
@@ -13,6 +13,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { QuickLaunchAgentMenuItems } from './QuickLaunchButton'
import TabBarCreateEntry from './TabBarCreateEntry'
import { TabStripScrollIndicator } from './TabStripScrollIndicator'
import { TabStripTooltipProvider } from './TabStripTooltipProvider'
import { getTabStripScrollMaskClassName } from './tab-strip-scroll-metrics'
import type { useTabStripOverflowNavigation } from './tab-strip-overflow-navigation'
import type { useTabStripDragScrollHandlers } from './tab-strip-drag-scroll'
@@ -159,16 +160,18 @@ export function renderTabBarSurface({
.filter(Boolean)
.join(' ')}
>
{renderedItems}
{clientHostedBrowserRows.length > 0 ? (
<ClientHostedBrowserTabRows
rows={clientHostedBrowserRows}
worktreeId={worktreeId}
groupId={resolvedGroupId}
groupActiveTabId={props.groupActiveTabId ?? null}
includeTopTabBorder={includeTopTabBorder}
/>
) : null}
<TabStripTooltipProvider>
{renderedItems}
{clientHostedBrowserRows.length > 0 ? (
<ClientHostedBrowserTabRows
rows={clientHostedBrowserRows}
worktreeId={worktreeId}
groupId={resolvedGroupId}
groupActiveTabId={props.groupActiveTabId ?? null}
includeTopTabBorder={includeTopTabBorder}
/>
) : null}
</TabStripTooltipProvider>
</div>
<TabStripScrollIndicator
hasOverflow={tabStripOverflowState.hasOverflow}
@@ -46,6 +46,21 @@ export function stubTabStripDragScroll(): Record<string, unknown> {
}
}
// Tooltip primitives render to inert descriptors so the probe never invokes a
// hook-using provider/trigger under the headless React stub.
export function stubTooltip(): Record<string, unknown> {
const passthrough = (type: string) =>
function TooltipPrimitiveStub(props: { children?: unknown }) {
return { type, props }
}
return {
TooltipProvider: passthrough('TooltipProvider'),
Tooltip: passthrough('Tooltip'),
TooltipTrigger: passthrough('TooltipTrigger'),
TooltipContent: passthrough('TooltipContent')
}
}
export function stubStatusDisplay(): Record<string, unknown> {
return { buildStatusMap: () => new Map() }
}