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(
+
+
+
+ {label}
+
+ {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 tab
+
+ first tip
+
+
+
+ second tab
+
+ 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() }
}