From 22534fe64441369ec019cc3565403a4a29bdb670 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 9 Oct 2026 05:11:11 -0700 Subject: [PATCH] Show whole-tab hover cards with icons and smooth tab-to-tab slides (#26811) * Show whole-tab hover cards with icons and sliding handoffs * Remove the gap below tab hover cards * Keep tab hover handoffs reliable during rapid reversals * Place hover-card icons and program names below the title * Fix nested keyboard hints and complete hover-card translations * Isolate keyboard hover checks from terminal activation focus * Sample rapid tab slides within the renderer * Control hover frame recording around actual pointer input * Keep tab hover cards warm through StrictMode ref reattachment * Keep hidden renderer animation frames live during hover checks * Sample hidden tab hover cards with an unthrottled timer * Narrow hover animation effects and avoid shadowing window * Keep hidden renderers painting while checking hover card frames --- .../components/tab-bar/BrowserTab.test.tsx | 6 + .../src/components/tab-bar/BrowserTab.tsx | 23 +- .../tab-bar/ClientHostedBrowserTab.tsx | 107 +++++---- .../components/tab-bar/EditorFileTab.test.tsx | 9 + .../src/components/tab-bar/EditorFileTab.tsx | 34 +-- .../SortableTab.rename-shortcut.test.tsx | 9 + .../src/components/tab-bar/SortableTab.tsx | 45 ++-- .../SortableTab.update-depth-probe.test.tsx | 6 +- .../components/tab-bar/TabCloseTooltip.tsx | 16 +- .../src/components/tab-bar/TabHoverCard.tsx | 136 ++++++++++++ .../tab-bar/TabStripTooltipProvider.test.tsx | 102 ++++++++- .../tab-bar/TabStripTooltipProvider.tsx | 77 ++++++- .../tab-bar/tab-title-tooltip.test.tsx | 27 ++- src/renderer/src/components/ui/tooltip.tsx | 22 +- src/renderer/src/i18n/locales/en.json | 5 + tests/e2e/tab-hover-card.spec.ts | 203 ++++++++++++++++++ 16 files changed, 696 insertions(+), 131 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/TabHoverCard.tsx create mode 100644 tests/e2e/tab-hover-card.spec.ts diff --git a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx index 369286413ed..88f1544543b 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx @@ -126,6 +126,12 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ } })) +vi.mock('./TabHoverCard', () => ({ + TabHoverCard: function TabHoverCard(props: { children?: unknown }) { + return props.children + } +})) + vi.mock('@/components/ui/tooltip', () => ({ Tooltip: function Tooltip(props: { children?: unknown }) { return { type: 'Tooltip', props } diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx index 31a8401d5bc..aad35e166b3 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx @@ -17,7 +17,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { TabHoverCard } from './TabHoverCard' import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants' import { redactKagiSessionToken } from '../../../../shared/browser-url' import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types' @@ -232,16 +232,17 @@ export default function BrowserTab({ {menuOpen ? ( tabRoot ) : ( - - {tabRoot} - - {tabLabel} - - + } + programName={ + isBlankBrowserTab(tab) + ? translate('tabHoverCard.browser', 'Browser') + : formatBrowserTabUrlLabel(redactKagiSessionToken(tab.url)) + } + > + {tabRoot} + )} diff --git a/src/renderer/src/components/tab-bar/ClientHostedBrowserTab.tsx b/src/renderer/src/components/tab-bar/ClientHostedBrowserTab.tsx index 5c9e2de6d1a..7ceb12db86a 100644 --- a/src/renderer/src/components/tab-bar/ClientHostedBrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/ClientHostedBrowserTab.tsx @@ -1,5 +1,5 @@ import { Laptop, Loader2, X } from 'lucide-react' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { TabHoverCard } from './TabHoverCard' import { translate } from '@/i18n/i18n' import type { ClientHostedBrowserRow } from '../../../../shared/client-hosted-browser-rows' import { @@ -45,63 +45,58 @@ export default function ClientHostedBrowserTab({ return (
- - -
{ - if (event.button === 1) { - event.preventDefault() - } - }} - onMouseUp={preventMiddleButtonDefault} - onAuxClick={(event) => { - if (event.button === 1) { - event.preventDefault() - event.stopPropagation() - onClose() - } + } + description={hostDescription} + > +
{ + if (event.button === 1) { + event.preventDefault() + } + }} + onMouseUp={preventMiddleButtonDefault} + onAuxClick={(event) => { + if (event.button === 1) { + event.preventDefault() + event.stopPropagation() + onClose() + } + }} + > + {isActive && } + + + {label} + + -
- - -
{label}
-
{hostDescription}
-
- + + +
+
) } diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx index 061a8319be4..14fd4d4ba6f 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx @@ -161,6 +161,9 @@ vi.mock('@/components/ui/input', () => ({ })) vi.mock('@/components/ui/tooltip', () => ({ + TooltipProvider: function TooltipProvider(props: { children?: unknown }) { + return props.children + }, Tooltip: function Tooltip(props: { children?: unknown }) { return { type: 'Tooltip', props } }, @@ -172,6 +175,12 @@ vi.mock('@/components/ui/tooltip', () => ({ } })) +vi.mock('./TabHoverCard', () => ({ + TabHoverCard: function TabHoverCard(props: { children?: unknown }) { + return props.children + } +})) + vi.mock('@/components/editor/editor-labels', () => ({ getEditorDisplayLabel: (file: OpenFile) => file.relativePath })) diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 5d35a4a915f..f30c6104005 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -2,7 +2,7 @@ import { createElement, useCallback, useEffect, useRef, useState } from 'react' import { useSortable } from '@dnd-kit/sortable' import { GitCompareArrows, Eye, ShieldAlert, Pin, ListChecks } from 'lucide-react' import { Input } from '@/components/ui/input' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { TabHoverCard } from './TabHoverCard' import { basename } from '@/lib/path' import { getEditorDisplayLabel } from '@/components/editor/editor-labels' import { renameFileOnDisk } from '@/lib/rename-file' @@ -90,6 +90,15 @@ export default function EditorFileTab({ const isConflictReview = file.mode === 'conflict-review' const isCheckDetails = file.mode === 'check-details' const isMarkdownPreviewTab = file.mode === 'markdown-preview' + const HoverIcon = isConflictReview + ? ShieldAlert + : isCheckDetails + ? ListChecks + : isDiff + ? GitCompareArrows + : isMarkdownPreviewTab + ? Eye + : FileIcon // Why: only deleted/renamed mean the file is gone from its path, which is // what strikethrough conveys. 'changed' keeps a normal label — its surface // is the changed-on-disk banner inside the editor. @@ -151,11 +160,8 @@ export default function EditorFileTab({ // so one user action cannot start a second rename against the old path. renameCancelledRef.current = true setIsRenaming(false) - if (!newName) { - return - } const oldName = basename(file.filePath) - if (newName === oldName) { + if (!newName || newName === oldName) { return } const worktreePath = getUntitledFileRoot(file, worktree?.path ?? null) @@ -398,16 +404,14 @@ export default function EditorFileTab({ {isRenaming || menuOpen ? ( tabRoot ) : ( - - {tabRoot} - - {tabLabel} - - + + {tabRoot} + )} diff --git a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx index b0c357461d9..3576c549680 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx @@ -178,7 +178,16 @@ vi.mock('@/components/ui/input', () => ({ } })) +vi.mock('./TabHoverCard', () => ({ + TabHoverCard: function TabHoverCard(props: { children?: unknown }) { + return props.children + } +})) + vi.mock('@/components/ui/tooltip', () => ({ + TooltipProvider: function TooltipProvider(props: { children?: unknown }) { + return props.children + }, Tooltip: function Tooltip(props: { children?: unknown }) { return { type: 'Tooltip', props } }, diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 70f26076e55..746fe031e4e 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -3,9 +3,11 @@ import { useSortable } from '@dnd-kit/sortable' import { X, Minimize2, Pin } from 'lucide-react' import { stripLeadingAgentTitleDecoration } from '../../../../shared/agent-title-decoration' import { useTabAgent } from '@/lib/use-tab-agent' +import { getAgentLabel } from '@/lib/agent-catalog' +import { basename } from '@/lib/path' import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { Input } from '@/components/ui/input' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { TabHoverCard } from './TabHoverCard' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { TabDragItemData } from '../tab-group/useTabDragSplit' import { useAppStore } from '../../store' @@ -282,21 +284,8 @@ export default function SortableTab({ className="mr-1 h-5 min-w-[72px] flex-1 px-1 py-0 text-xs" spellCheck={false} /> - ) : isEditing || menuOpen ? ( - {displayTitle} ) : ( - - - {displayTitle} - - - {displayTitle} - - + {displayTitle} )} {tab.color && !isEditing && ( - {tabRoot} + {isEditing || menuOpen ? ( + tabRoot + ) : ( + + } + > + {tabRoot} + + )} ({ vi.mock('@/lib/use-tab-agent', () => ({ useTabAgent: () => null })) vi.mock('@/components/ui/tooltip', () => ({ + TooltipProvider: ({ children }: { children: ReactNode }) => <>{children}, Tooltip: ({ children }: { children: ReactNode }) =>
{children}
, TooltipContent: ({ children }: { children: ReactNode }) =>
{children}
, TooltipTrigger: ({ children, asChild }: { children: ReactNode; asChild?: boolean }) => @@ -108,7 +109,10 @@ vi.mock('./TerminalTabLeadingIcon', () => ({ })) vi.mock('./shell-icons', () => ({ ShellIcon: () => })) -vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => })) +vi.mock('@/lib/agent-catalog', () => ({ + AgentIcon: () => , + getAgentLabel: (agent: string) => agent +})) vi.mock('../sidebar/WorktreeCardHelpers', () => ({ FilledBellIcon: () => })) function makeTab(overrides: Partial = {}): TerminalTab { diff --git a/src/renderer/src/components/tab-bar/TabCloseTooltip.tsx b/src/renderer/src/components/tab-bar/TabCloseTooltip.tsx index 519e10d358d..ef603601de0 100644 --- a/src/renderer/src/components/tab-bar/TabCloseTooltip.tsx +++ b/src/renderer/src/components/tab-bar/TabCloseTooltip.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { translate } from '@/i18n/i18n' import { TAB_TOOLTIP_DELAY_MS } from './TabStripTooltipProvider' @@ -15,11 +15,13 @@ export function TabCloseTooltip({ children }: { children: ReactNode }): React.JS ) return ( - - {children} - - {closeShortcut ? `${closeLabel} (${closeShortcut})` : closeLabel} - - + + + {children} + + {closeShortcut ? `${closeLabel} (${closeShortcut})` : closeLabel} + + + ) } diff --git a/src/renderer/src/components/tab-bar/TabHoverCard.tsx b/src/renderer/src/components/tab-bar/TabHoverCard.tsx new file mode 100644 index 00000000000..b8949d696c2 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabHoverCard.tsx @@ -0,0 +1,136 @@ +import { useCallback, useContext, useId, useRef, type ReactNode } from 'react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + TabCardOpenContext, + TabCardPlacementContext, + TAB_TOOLTIP_SKIP_DELAY_MS +} from './TabStripTooltipProvider' + +export function TabHoverCard({ + children, + title, + icon, + programName, + description +}: { + children: ReactNode + title: string + icon: ReactNode + programName: string + description?: string +}): React.JSX.Element { + const placement = useContext(TabCardPlacementContext) + const content = useRef(null) + const cardId = useId() + const openContext = useContext(TabCardOpenContext) + const notifyOpenChange = openContext?.onOpenChange + + const captureClosedPlacement = useCallback( + (element: HTMLDivElement | null) => { + const previous = placement?.current + if (!element || previous?.element !== element) { + return + } + const { left, top } = element.getBoundingClientRect() + previous.left = left + previous.top = top + previous.closedAt = performance.now() + }, + [placement] + ) + + const setContentElement = useCallback( + (element: HTMLDivElement | null) => { + const previous = content.current + if (element) { + // StrictMode reattaches refs without closing the tooltip. + notifyOpenChange?.(cardId, true) + } else if (previous) { + notifyOpenChange?.(cardId, false) + captureClosedPlacement(previous) + } + content.current = element + }, + [captureClosedPlacement, cardId, notifyOpenChange] + ) + + const handlePlaced = (): void => { + const element = content.current + if (!element || !placement) { + return + } + const rect = element.getBoundingClientRect() + const previous = placement.current + if ( + previous && + previous.closedAt !== null && + performance.now() - previous.closedAt < TAB_TOOLTIP_SKIP_DELAY_MS && + !window.matchMedia('(prefers-reduced-motion: reduce)').matches + ) { + // Continue from the painted position when reversing direction during a slide. + const origin = previous.element.isConnected + ? previous.element.getBoundingClientRect() + : previous + element.animate( + [ + { translate: `${origin.left - rect.left}px ${origin.top - rect.top}px`, opacity: 1 }, + { translate: '0 0', opacity: 1 } + ], + { duration: 150, easing: 'ease-out' } + ) + } + placement.current = { element, left: rect.left, top: rect.top, closedAt: null } + } + + return ( + { + openContext?.onOpenChange(cardId, open) + if (!open && content.current?.isConnected) { + captureClosedPlacement(content.current) + } + }} + > + { + // Nested controls own their focus hints. + if (event.target !== event.currentTarget) { + event.preventDefault() + } + }} + > + {children} + + +
+
+
+ {title} +
+ {description &&
{description}
} +
+
+ + {icon} + + {programName} +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/tab-bar/TabStripTooltipProvider.test.tsx b/src/renderer/src/components/tab-bar/TabStripTooltipProvider.test.tsx index 6fb89da330f..be9074076f1 100644 --- a/src/renderer/src/components/tab-bar/TabStripTooltipProvider.test.tsx +++ b/src/renderer/src/components/tab-bar/TabStripTooltipProvider.test.tsx @@ -1,8 +1,29 @@ // @vitest-environment happy-dom import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { StrictMode, useContext } from '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' +import { TabCloseTooltip } from './TabCloseTooltip' +import { TabHoverCard } from './TabHoverCard' +import { + TAB_TOOLTIP_DELAY_MS, + TAB_TOOLTIP_SKIP_DELAY_MS, + TabCardOpenContext, + TabStripTooltipProvider +} from './TabStripTooltipProvider' + +vi.mock('@/hooks/useShortcutLabel', () => ({ useOptionalShortcutLabel: () => null })) + +function CardEvents({ id }: { id: string }): React.JSX.Element { + const strip = useContext(TabCardOpenContext) + return ( + <> + + + {strip?.isWarm ? 'immediate' : 'delayed'} + + ) +} function renderTooltip(label: string, tip: string): void { render( @@ -44,7 +65,7 @@ describe('TabStripTooltipProvider', () => { expect(screen.getByText('tab tooltip')).toBeTruthy() }) - it('does not let a recently-closed tooltip skip the delay on the next tab', () => { + it('opens the next tab immediately after a card was shown', () => { render( @@ -74,15 +95,84 @@ describe('TabStripTooltipProvider', () => { fireEvent.pointerLeave(first) fireEvent.pointerMove(second, { pointerType: 'mouse' }) - // Radix's default would open this one instantly for 300ms after a close. + act(() => { + vi.advanceTimersByTime(0) + }) + expect(screen.getByText('second tip')).toBeTruthy() + expect(screen.queryByText('first tip')).toBeNull() + + fireEvent.pointerLeave(second) + act(() => { + vi.advanceTimersByTime(301) + }) + fireEvent.pointerMove(first, { pointerType: 'mouse' }) act(() => { vi.advanceTimersByTime(TAB_TOOLTIP_DELAY_MS - 1) }) - expect(screen.queryByText('second tip')).toBeNull() - + expect(screen.queryByText('first tip')).toBeNull() act(() => { vi.advanceTimersByTime(1) }) - expect(screen.getByText('second tip')).toBeTruthy() + expect(screen.getByText('first tip')).toBeTruthy() + }) + + it('ignores a late close from the previous card and resets the delay after the active card closes', () => { + render( + + + + + ) + const delay = screen.getByLabelText('first delay') + expect(delay.textContent).toBe('delayed') + fireEvent.click(screen.getByText('open first')) + fireEvent.click(screen.getByText('close first')) + fireEvent.click(screen.getByText('open second')) + fireEvent.click(screen.getByText('close first')) + act(() => vi.advanceTimersByTime(TAB_TOOLTIP_SKIP_DELAY_MS + 1)) + expect(delay.textContent).toBe('immediate') + + fireEvent.click(screen.getByText('close second')) + act(() => vi.advanceTimersByTime(TAB_TOOLTIP_SKIP_DELAY_MS - 1)) + expect(delay.textContent).toBe('immediate') + act(() => vi.advanceTimersByTime(1)) + expect(delay.textContent).toBe('delayed') + }) + + it('shows only the close hint when the close button receives keyboard focus', () => { + const { container } = render( + + } programName="Terminal"> +
+ + + +
+
+
+ ) + act(() => screen.getByRole('button', { name: 'close' }).focus()) + expect(container.ownerDocument.querySelector('[data-tab-close-tooltip]')).not.toBeNull() + expect(container.ownerDocument.querySelector('[data-tab-hover-card]')).toBeNull() + act(() => screen.getByRole('button', { name: 'close' }).parentElement?.focus()) + expect(container.ownerDocument.querySelector('[data-tab-hover-card]')).not.toBeNull() + expect(container.ownerDocument.querySelector('[data-tab-close-tooltip]')).toBeNull() + }) + + it('keeps the strip warm while a card is open in StrictMode', () => { + render( + + + + } programName="Terminal"> + + + + + ) + act(() => screen.getByRole('button', { name: 'tab' }).focus()) + expect(screen.getByLabelText('probe delay').textContent).toBe('immediate') + act(() => vi.advanceTimersByTime(TAB_TOOLTIP_SKIP_DELAY_MS + 1)) + expect(screen.getByLabelText('probe delay').textContent).toBe('immediate') }) }) diff --git a/src/renderer/src/components/tab-bar/TabStripTooltipProvider.tsx b/src/renderer/src/components/tab-bar/TabStripTooltipProvider.tsx index 3cdbe55b721..5bcbbc9335d 100644 --- a/src/renderer/src/components/tab-bar/TabStripTooltipProvider.tsx +++ b/src/renderer/src/components/tab-bar/TabStripTooltipProvider.tsx @@ -1,16 +1,77 @@ -import type { ReactNode } from 'react' +import { + createContext, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, + type RefObject +} 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 const TAB_TOOLTIP_SKIP_DELAY_MS = 300 + +type TabCardPlacement = { + element: HTMLElement + left: number + top: number + closedAt: number | null +} + +export const TabCardPlacementContext = createContext | null>( + null +) + +export const TabCardOpenContext = createContext<{ + isWarm: boolean + onOpenChange: (id: string, open: boolean) => void +} | null>(null) 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. + const placement = useRef(null) + const openCardId = useRef(null) + const cooldownTimer = useRef | null>(null) + const [isWarm, setIsWarm] = useState(false) + const onOpenChange = useCallback((id: string, open: boolean) => { + // A previous card's close may arrive after the next card opens. + if (!open && openCardId.current !== id) { + return + } + if (cooldownTimer.current !== null) { + clearTimeout(cooldownTimer.current) + cooldownTimer.current = null + } + openCardId.current = open ? id : null + if (open) { + setIsWarm(true) + } else { + cooldownTimer.current = setTimeout(() => { + cooldownTimer.current = null + setIsWarm(false) + }, TAB_TOOLTIP_SKIP_DELAY_MS) + } + }, []) + useEffect( + () => () => { + if (cooldownTimer.current !== null) { + clearTimeout(cooldownTimer.current) + } + }, + [] + ) + const openContext = useMemo(() => ({ isWarm, onOpenChange }), [isWarm, onOpenChange]) return ( - - {children} - + + + + {children} + + + ) } diff --git a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx index d2f7cc4bdcb..3522861dd0f 100644 --- a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx @@ -27,19 +27,28 @@ vi.mock('@dnd-kit/sortable', () => ({ })) vi.mock('@/components/ui/tooltip', () => ({ + TooltipProvider: ({ children }: { children: ReactNode }) => <>{children}, Tooltip: ({ children }: { children: ReactNode }) =>
{children}
, TooltipContent: ({ children, className, side, - sideOffset + sideOffset, + variant }: { children: ReactNode className?: string side?: string sideOffset?: number + variant?: string }) => ( -
+
{children}
), @@ -79,6 +88,7 @@ vi.mock('./shell-icons', () => ({ })) vi.mock('@/lib/agent-catalog', () => ({ + getAgentLabel: (agent: string) => (agent === 'claude' ? 'Claude' : agent), AgentIcon: ({ agent }: { agent: string }) => })) @@ -189,8 +199,8 @@ function expectTabContainerWidth(markup: string, root: string): void { function expectTooltipContent(markup: string, text: string): void { expect(markup).toContain('data-tooltip-content="true"') expect(markup).toContain('data-side="bottom"') - expect(markup).toContain('data-side-offset="6"') - expect(markup).toContain('max-w-80 whitespace-normal break-words text-left') + expect(markup).toContain('data-variant="tab-preview" data-side="bottom" data-side-offset="0"') + expect(markup).toContain('data-variant="tab-preview"') expect(markup).toContain(text) } @@ -271,9 +281,11 @@ describe('tab title tooltips', () => { ) expectTooltipContent(markup, 'Custom terminal title') + expect(markup).toContain('>Terminal') expect(markup).not.toContain('Runtime terminal title') expect(markup).toContain('data-tooltip-trigger="true"') const root = openingTag(markup, 'data-testid', 'sortable-tab') + expect(root).toContain('data-tooltip-trigger="true"') expect(root).toContain('role="tab"') expect(root).toContain('tabindex="0"') expectTabContainerWidth(markup, root) @@ -305,8 +317,9 @@ describe('tab title tooltips', () => { /> ) - expect(markup).toContain('data-agent-icon="claude"') + expect(markup.match(/data-agent-icon="claude"/g)).toHaveLength(2) expectTooltipContent(markup, 'Claude Code') + expect(markup).toContain('>Claude') expect(markup).toContain('data-tooltip-trigger="true"') expect(markup).toContain('>Claude Code') expect(markup).not.toContain('data-shell-icon="generic"') @@ -316,7 +329,7 @@ describe('tab title tooltips', () => { it('uses the browser tab fallback label from the tab prop, not the live URL', () => { const markup = renderToStaticMarkup( { ) expectTooltipContent(markup, 'example.com/docs/long-browser-tab-path') + expect(markup.match(/src="https:\/\/example.com\/favicon.ico"/g)).toHaveLength(2) expect(markup).not.toContain('live.example') const root = openingTag(markup, 'data-sortable-id', 'browser-1') expect(root).toContain('data-tooltip-trigger="true"') @@ -366,6 +380,7 @@ describe('tab title tooltips', () => { ) expectTooltipContent(markup, 'VeryLongEditorFileName.tsx') + expect(markup).toContain('>Editor') expect(markup).toContain('line-through') expect(markup).toContain('renamed') const root = openingTag(markup, 'data-sortable-id', 'editor-tab-1') diff --git a/src/renderer/src/components/ui/tooltip.tsx b/src/renderer/src/components/ui/tooltip.tsx index 222d8c44723..84b9446872a 100644 --- a/src/renderer/src/components/ui/tooltip.tsx +++ b/src/renderer/src/components/ui/tooltip.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import * as TooltipPrimitive from 'radix-ui/tooltip' +import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/utils' function TooltipProvider({ @@ -28,13 +29,27 @@ function TooltipTrigger({ ...props }: React.ComponentProps } +const tooltipContentVariants = cva('pointer-events-none z-[90] rounded-md text-xs', { + variants: { + variant: { + default: + 'w-fit origin-(--radix-tooltip-content-transform-origin) animate-in bg-foreground px-3 py-1.5 text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95', + 'tab-preview': + 'w-64 max-w-[calc(100vw-1rem)] border border-border bg-popover p-3 text-popover-foreground shadow-floating motion-safe:animate-in motion-safe:fade-in-0 data-[state=closed]:animate-none' + } + }, + defaultVariants: { variant: 'default' } +}) + function TooltipContent({ className, sideOffset = 0, showArrow = true, + variant, children, ...props -}: React.ComponentProps & { showArrow?: boolean }) { +}: React.ComponentProps & + VariantProps & { showArrow?: boolean; onPlaced?: () => void }) { return (