diff --git a/src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx b/src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx index 98ef2712dbf..d8fc28a881c 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx @@ -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 ( - - - - - - {closeShortcut ? `${closeLabel} (${closeShortcut})` : closeLabel} - - + + + ) } diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 3f128f811fa..763f3b7f131 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -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 = (
)} {!isEditing && !isPinned && ( - - - - - - {closeShortcut ? `${closeLabel} (${closeShortcut})` : closeLabel} - - + } + }} + onMouseDown={(e) => { + if (e.button === 0) { + e.stopPropagation() + } + }} + onClick={(e) => { + e.preventDefault() + e.stopPropagation() + onClose(tab.id) + }} + > + + + )}
) diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index 312dccfafed..e83f32a4852 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -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 diff --git a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts index 1e258e0fa3f..68ffa7d5224 100644 --- a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts @@ -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(() => { diff --git a/src/renderer/src/components/tab-bar/TabBar.windows-shell-remote-runtime.test.ts b/src/renderer/src/components/tab-bar/TabBar.windows-shell-remote-runtime.test.ts index 3e4609a8bac..784d888f82d 100644 --- a/src/renderer/src/components/tab-bar/TabBar.windows-shell-remote-runtime.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.windows-shell-remote-runtime.test.ts @@ -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(() => { diff --git a/src/renderer/src/components/tab-bar/TabBar.windows-shell-ssh-host.test.ts b/src/renderer/src/components/tab-bar/TabBar.windows-shell-ssh-host.test.ts index 8254dec2e95..1e4788f2154 100644 --- a/src/renderer/src/components/tab-bar/TabBar.windows-shell-ssh-host.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.windows-shell-ssh-host.test.ts @@ -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(() => { diff --git a/src/renderer/src/components/tab-bar/TabCloseTooltip.tsx b/src/renderer/src/components/tab-bar/TabCloseTooltip.tsx new file mode 100644 index 00000000000..519e10d358d --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabCloseTooltip.tsx @@ -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 ( + + {children} + + {closeShortcut ? `${closeLabel} (${closeShortcut})` : closeLabel} + + + ) +} diff --git a/src/renderer/src/components/tab-bar/TabStripTooltipProvider.test.tsx b/src/renderer/src/components/tab-bar/TabStripTooltipProvider.test.tsx new file mode 100644 index 00000000000..6fb89da330f --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabStripTooltipProvider.test.tsx @@ -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( + + + + + + {tip} + + + ) +} + +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( + + + + + + first tip + + + + + + second tip + + + ) + + 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() + }) +}) diff --git a/src/renderer/src/components/tab-bar/TabStripTooltipProvider.tsx b/src/renderer/src/components/tab-bar/TabStripTooltipProvider.tsx new file mode 100644 index 00000000000..3cdbe55b721 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabStripTooltipProvider.tsx @@ -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 ( + + {children} + + ) +} diff --git a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.state-tooltip.test.tsx b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.state-tooltip.test.tsx new file mode 100644 index 00000000000..9b9138548c1 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.state-tooltip.test.tsx @@ -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) => null)) + +vi.mock('@/components/AgentStateDot', () => ({ + AgentStateDot: (props: Record) => { + agentDotMock(props) + return null + } +})) + +import { TerminalTabLeadingIcon } from './TerminalTabLeadingIcon' + +describe('TerminalTabLeadingIcon agent-state glyph', () => { + it('renders the state glyph without a hover tooltip', () => { + renderToStaticMarkup( + + ) + + expect(agentDotMock).toHaveBeenCalledWith(expect.objectContaining({ title: null })) + }) +}) diff --git a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx index 5251b3e75bb..bfa628b72ce 100644 --- a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx +++ b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx @@ -76,7 +76,8 @@ export function TerminalTabLeadingIcon({ data-agent-activity-status={activityStatus} className="mr-1 inline-flex shrink-0 items-center gap-1" > - + {/* Why: the state glyph is self-describing and the tab title already has a tooltip; hover here stays quiet. */} + {/* Why: status and identity answer different questions. Keep the agent logo beside the state glyph so parallel tabs remain scannable. */} {agent ? : null} diff --git a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx index 693691a5b80..9e2cc2c5c97 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx @@ -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 ? ( - - ) : null} + + {renderedItems} + {clientHostedBrowserRows.length > 0 ? ( + + ) : null} + { } } +// 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 { + 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 { return { buildStatusMap: () => new Map() } }