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
This commit is contained in:
Neil
2026-10-09 05:11:11 -07:00
committed by GitHub
parent 93a6b1abff
commit 22534fe644
16 changed files with 696 additions and 131 deletions
@@ -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 }
@@ -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
) : (
<Tooltip>
<TooltipTrigger asChild>{tabRoot}</TooltipTrigger>
<TooltipContent
side="bottom"
sideOffset={6}
className="max-w-80 whitespace-normal break-words text-left"
>
{tabLabel}
</TooltipContent>
</Tooltip>
<TabHoverCard
title={tabLabel}
icon={<BrowserFavicon faviconUrl={tab.faviconUrl} className="size-4" />}
programName={
isBlankBrowserTab(tab)
? translate('tabHoverCard.browser', 'Browser')
: formatBrowserTabUrlLabel(redactKagiSessionToken(tab.url))
}
>
{tabRoot}
</TabHoverCard>
)}
</div>
@@ -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 (
<div {...slotProps}>
<Tooltip>
<TooltipTrigger asChild>
<div
data-client-hosted-browser-row-id={row.browserPageId}
className={`group relative flex h-full cursor-pointer select-none items-center px-1.5 text-xs outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getTabRootStateClasses(isActive)}`}
onPointerDown={onActivate}
onMouseDown={(event) => {
if (event.button === 1) {
event.preventDefault()
}
}}
onMouseUp={preventMiddleButtonDefault}
onAuxClick={(event) => {
if (event.button === 1) {
event.preventDefault()
event.stopPropagation()
onClose()
}
<TabHoverCard
title={label}
programName={translate('tabHoverCard.browser', 'Browser')}
icon={<Laptop className="size-4" />}
description={hostDescription}
>
<div
data-client-hosted-browser-row-id={row.browserPageId}
className={`group relative flex h-full cursor-pointer select-none items-center px-1.5 text-xs outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getTabRootStateClasses(isActive)}`}
onPointerDown={onActivate}
onMouseDown={(event) => {
if (event.button === 1) {
event.preventDefault()
}
}}
onMouseUp={preventMiddleButtonDefault}
onAuxClick={(event) => {
if (event.button === 1) {
event.preventDefault()
event.stopPropagation()
onClose()
}
}}
>
{isActive && <span className={ACTIVE_TAB_INDICATOR_CLASSES} aria-hidden />}
<PageIcon
className={`mr-1 size-3 shrink-0 ${loading ? 'motion-safe:animate-spin' : ''} ${row.hostAbsent ? 'text-muted-foreground' : 'text-blue-500'}`}
aria-hidden
/>
<span
className={`${TAB_LABEL_WIDTH_CLASSES} mr-1 ${row.hostAbsent ? 'text-muted-foreground' : ''}`}
>
{label}
</span>
<button
type="button"
aria-label={translate('browser.clientHosted.hostRowClose', 'Close hosted page')}
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-sm ${
isActive
? 'text-muted-foreground hover:bg-muted hover:text-foreground'
: 'text-transparent group-hover:text-muted-foreground hover:!bg-muted hover:!text-foreground'
}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation()
onClose()
}}
>
{isActive && <span className={ACTIVE_TAB_INDICATOR_CLASSES} aria-hidden />}
<PageIcon
className={`mr-1 size-3 shrink-0 ${loading ? 'motion-safe:animate-spin' : ''} ${row.hostAbsent ? 'text-muted-foreground' : 'text-blue-500'}`}
aria-hidden
/>
<span
className={`${TAB_LABEL_WIDTH_CLASSES} mr-1 ${row.hostAbsent ? 'text-muted-foreground' : ''}`}
>
{label}
</span>
<button
type="button"
aria-label={translate('browser.clientHosted.hostRowClose', 'Close hosted page')}
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-sm ${
isActive
? 'text-muted-foreground hover:bg-muted hover:text-foreground'
: 'text-transparent group-hover:text-muted-foreground hover:!bg-muted hover:!text-foreground'
}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation()
onClose()
}}
>
<X className="h-3 w-3" />
</button>
</div>
</TooltipTrigger>
<TooltipContent
side="bottom"
sideOffset={6}
className="max-w-80 whitespace-normal break-words text-left"
>
<div>{label}</div>
<div className="text-muted-foreground">{hostDescription}</div>
</TooltipContent>
</Tooltip>
<X className="h-3 w-3" />
</button>
</div>
</TabHoverCard>
</div>
)
}
@@ -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
}))
@@ -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
) : (
<Tooltip>
<TooltipTrigger asChild>{tabRoot}</TooltipTrigger>
<TooltipContent
side="bottom"
sideOffset={6}
className="max-w-80 whitespace-normal break-words text-left"
>
{tabLabel}
</TooltipContent>
</Tooltip>
<TabHoverCard
title={tabLabel}
programName={translate('tabHoverCard.editor', 'Editor')}
icon={createElement(HoverIcon, { className: 'size-4' })}
description={file.relativePath}
>
{tabRoot}
</TabHoverCard>
)}
</div>
@@ -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 }
},
@@ -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 ? (
<span className={`${TAB_LABEL_WIDTH_CLASSES} mr-1`}>{displayTitle}</span>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span className={`${TAB_LABEL_WIDTH_CLASSES} mr-1`}>{displayTitle}</span>
</TooltipTrigger>
<TooltipContent
side="bottom"
sideOffset={6}
className="max-w-80 whitespace-normal break-words text-left"
>
{displayTitle}
</TooltipContent>
</Tooltip>
<span className={`${TAB_LABEL_WIDTH_CLASSES} mr-1`}>{displayTitle}</span>
)}
{tab.color && !isEditing && (
<span
@@ -372,7 +361,31 @@ export default function SortableTab({
setMenuOpen(true)
}}
>
{tabRoot}
{isEditing || menuOpen ? (
tabRoot
) : (
<TabHoverCard
title={displayTitle}
programName={
tabAgent
? getAgentLabel(tabAgent)
: shellForIcon
? basename(shellForIcon)
: translate('tabHoverCard.terminal', 'Terminal')
}
icon={
<TerminalTabLeadingIcon
agent={tabAgent}
activityStatus={activityStatus}
shell={shellForIcon}
showUnreadActivity={false}
isActive
/>
}
>
{tabRoot}
</TabHoverCard>
)}
</div>
<SortableTabContextMenu
@@ -71,6 +71,7 @@ vi.mock('@dnd-kit/sortable', () => ({
vi.mock('@/lib/use-tab-agent', () => ({ useTabAgent: () => null }))
vi.mock('@/components/ui/tooltip', () => ({
TooltipProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
Tooltip: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TooltipContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ children, asChild }: { children: ReactNode; asChild?: boolean }) =>
@@ -108,7 +109,10 @@ vi.mock('./TerminalTabLeadingIcon', () => ({
}))
vi.mock('./shell-icons', () => ({ ShellIcon: () => <span /> }))
vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => <span /> }))
vi.mock('@/lib/agent-catalog', () => ({
AgentIcon: () => <span />,
getAgentLabel: (agent: string) => agent
}))
vi.mock('../sidebar/WorktreeCardHelpers', () => ({ FilledBellIcon: () => <span /> }))
function makeTab(overrides: Partial<TerminalTab> = {}): TerminalTab {
@@ -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 (
<Tooltip delayDuration={TAB_CLOSE_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{closeShortcut ? `${closeLabel} (${closeShortcut})` : closeLabel}
</TooltipContent>
</Tooltip>
<TooltipProvider delayDuration={TAB_CLOSE_TOOLTIP_DELAY_MS} skipDelayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6} data-tab-close-tooltip="true">
{closeShortcut ? `${closeLabel} (${closeShortcut})` : closeLabel}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
@@ -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<HTMLDivElement>(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 (
<Tooltip
delayDuration={openContext?.isWarm ? 0 : undefined}
onOpenChange={(open) => {
openContext?.onOpenChange(cardId, open)
if (!open && content.current?.isConnected) {
captureClosedPlacement(content.current)
}
}}
>
<TooltipTrigger
asChild
onFocus={(event) => {
// Nested controls own their focus hints.
if (event.target !== event.currentTarget) {
event.preventDefault()
}
}}
>
{children}
</TooltipTrigger>
<TooltipContent
ref={setContentElement}
variant="tab-preview"
showArrow={false}
side="bottom"
align="start"
sideOffset={0}
onPlaced={handlePlaced}
data-tab-hover-card="true"
>
<div className="space-y-2 text-left">
<div className="space-y-1">
<div className="break-words font-medium" data-tab-hover-card-title>
{title}
</div>
{description && <div className="break-all text-muted-foreground">{description}</div>}
</div>
<div
className="flex items-center gap-2 text-muted-foreground"
data-tab-hover-card-program
>
<span className="flex size-4 shrink-0 items-center justify-center" aria-hidden>
{icon}
</span>
<span className="min-w-0 break-all">{programName}</span>
</div>
</div>
</TooltipContent>
</Tooltip>
)
}
@@ -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 (
<>
<button onClick={() => strip?.onOpenChange(id, true)}>open {id}</button>
<button onClick={() => strip?.onOpenChange(id, false)}>close {id}</button>
<output aria-label={`${id} delay`}>{strip?.isWarm ? 'immediate' : 'delayed'}</output>
</>
)
}
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(
<TabStripTooltipProvider>
<Tooltip>
@@ -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(
<TabStripTooltipProvider>
<CardEvents id="first" />
<CardEvents id="second" />
</TabStripTooltipProvider>
)
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(
<TabStripTooltipProvider>
<TabHoverCard title="Build" icon={<span />} programName="Terminal">
<div tabIndex={0}>
<TabCloseTooltip>
<button>close</button>
</TabCloseTooltip>
</div>
</TabHoverCard>
</TabStripTooltipProvider>
)
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(
<StrictMode>
<TabStripTooltipProvider>
<CardEvents id="probe" />
<TabHoverCard title="Build" icon={<span />} programName="Terminal">
<button>tab</button>
</TabHoverCard>
</TabStripTooltipProvider>
</StrictMode>
)
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')
})
})
@@ -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<RefObject<TabCardPlacement | null> | 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<TabCardPlacement | null>(null)
const openCardId = useRef<string | null>(null)
const cooldownTimer = useRef<ReturnType<typeof setTimeout> | 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 (
<TooltipProvider delayDuration={TAB_TOOLTIP_DELAY_MS} skipDelayDuration={0}>
{children}
</TooltipProvider>
<TabCardPlacementContext.Provider value={placement}>
<TabCardOpenContext.Provider value={openContext}>
<TooltipProvider
delayDuration={TAB_TOOLTIP_DELAY_MS}
skipDelayDuration={TAB_TOOLTIP_SKIP_DELAY_MS}
>
{children}
</TooltipProvider>
</TabCardOpenContext.Provider>
</TabCardPlacementContext.Provider>
)
}
@@ -27,19 +27,28 @@ vi.mock('@dnd-kit/sortable', () => ({
}))
vi.mock('@/components/ui/tooltip', () => ({
TooltipProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
Tooltip: ({ children }: { children: ReactNode }) => <div data-tooltip-root>{children}</div>,
TooltipContent: ({
children,
className,
side,
sideOffset
sideOffset,
variant
}: {
children: ReactNode
className?: string
side?: string
sideOffset?: number
variant?: string
}) => (
<div data-tooltip-content data-side={side} data-side-offset={sideOffset} className={className}>
<div
data-tooltip-content
data-variant={variant}
data-side={side}
data-side-offset={sideOffset}
className={className}
>
{children}
</div>
),
@@ -79,6 +88,7 @@ vi.mock('./shell-icons', () => ({
}))
vi.mock('@/lib/agent-catalog', () => ({
getAgentLabel: (agent: string) => (agent === 'claude' ? 'Claude' : agent),
AgentIcon: ({ agent }: { agent: string }) => <span data-agent-catalog-icon={agent} />
}))
@@ -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</span>')
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</span>')
expect(markup).toContain('data-tooltip-trigger="true"')
expect(markup).toContain('>Claude Code</span>')
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(
<BrowserTab
tab={makeBrowserTab({ title: '' })}
tab={makeBrowserTab({ title: '', faviconUrl: 'https://example.com/favicon.ico' })}
isActive={false}
isPinned={false}
hasTabsToRight={false}
@@ -334,6 +347,7 @@ describe('tab title tooltips', () => {
)
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</span>')
expect(markup).toContain('line-through')
expect(markup).toContain('renamed')
const root = openingTag(markup, 'data-sortable-id', 'editor-tab-1')
+17 -5
View File
@@ -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<typeof TooltipPrimiti
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
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<typeof TooltipPrimitive.Content> & { showArrow?: boolean }) {
}: React.ComponentProps<typeof TooltipPrimitive.Content> &
VariantProps<typeof tooltipContentVariants> & { showArrow?: boolean; onPlaced?: () => void }) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
@@ -42,10 +57,7 @@ function TooltipContent({
sideOffset={sideOffset}
// Why: tooltip portals can be triggered from inside menus/popovers.
// Keep labels above those floating surfaces instead of hidden behind them.
className={cn(
'pointer-events-none z-[90] w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs 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',
className
)}
className={cn(tooltipContentVariants({ variant }), className)}
{...props}
>
{children}
+5
View File
@@ -19653,5 +19653,10 @@
"updateRuntime": "Update the remote runtime to change workspace links",
"updateRuntimeSafely": "Update the remote runtime to safely change workspace links"
}
},
"tabHoverCard": {
"browser": "Browser",
"editor": "Editor",
"terminal": "Terminal"
}
}
+203
View File
@@ -0,0 +1,203 @@
import type { CDPSession, JSHandle, Page } from '@playwright/test'
import { expect, test } from './helpers/orca-app'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
type CardFrame = {
title: string
left: number
count: number
sliding: boolean
at: number
states: string[]
}
type CardRecording = { stop: () => CardFrame[] }
type CardCapture = { recording: JSHandle<CardRecording>; cdp: CDPSession }
async function startCardRecording(page: Page): Promise<CardCapture> {
const cdp = await page.context().newCDPSession(page)
let hasFrame = false
cdp.on('Page.screencastFrame', (frame) => {
hasFrame = true
void cdp.send('Page.screencastFrameAck', { sessionId: frame.sessionId }).catch(() => {})
})
// Keep the hidden renderer painting so samples represent rendered frames.
await cdp.send('Page.enable')
await cdp.send('Page.startScreencast', {
format: 'jpeg',
quality: 20,
maxWidth: 640,
maxHeight: 480
})
await expect.poll(() => hasFrame).toBe(true)
const recording = await page.evaluateHandle(() => {
const frames: CardFrame[] = []
const started = performance.now()
let nextFrame = 0
const record = (): void => {
const cards = document.querySelectorAll('[data-tab-hover-card]')
const element = cards[0]
frames.push({
at: performance.now() - started,
states: Array.from(cards, (card) => card.getAttribute('data-state') ?? ''),
title: element?.querySelector('[data-tab-hover-card-title]')?.textContent?.trim() ?? '',
left: element?.getBoundingClientRect().left ?? -1,
count: cards.length,
sliding:
element
?.getAnimations()
.some(
(animation) =>
animation.effect instanceof KeyframeEffect &&
animation.effect.getKeyframes().some((frame) => frame.translate !== undefined)
) ?? false
})
if (performance.now() - started < 10_000) {
nextFrame = requestAnimationFrame(record)
}
}
record()
return {
stop: () => {
cancelAnimationFrame(nextFrame)
return frames
}
}
})
return { recording, cdp }
}
async function stopCardRecording({ recording, cdp }: CardCapture): Promise<CardFrame[]> {
const frames = await recording.evaluate((recording) => recording.stop())
await recording.dispose()
await cdp.send('Page.stopScreencast')
await cdp.detach()
return frames
}
test('whole-tab hover cards slide immediately between neighboring tabs', async ({
electronApp,
orcaPage
}) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await orcaPage.evaluate(() => {
const s = window.__store?.getState()
const worktreeId = s?.activeWorktreeId
if (!s || !worktreeId) {
throw new Error('Missing workspace')
}
const first = s.tabsByWorktree[worktreeId]?.[0]
if (!first) {
throw new Error('Missing terminal tab')
}
s.setTabCustomTitle(first.id, 'Build and test the hover cards')
const second = s.createTab(worktreeId)
s.setTabCustomTitle(second.id, 'Review changes')
})
const first = orcaPage.locator('[data-testid="sortable-tab"]').first()
const second = orcaPage.locator('[data-testid="sortable-tab"]').nth(1)
const firstBox = await first.boundingBox()
const secondBox = await second.boundingBox()
if (!firstBox || !secondBox) {
throw new Error('Missing tab bounds')
}
const card = orcaPage.locator('[data-tab-hover-card]')
const cardTitle = card.locator('[data-tab-hover-card-title]')
// The left padding and icon used to sit outside the terminal title's trigger.
await orcaPage.mouse.move(firstBox.x + 3, firstBox.y + firstBox.height / 2)
await orcaPage.waitForTimeout(200)
await expect(card).toHaveCount(0)
await expect(cardTitle).toHaveText('Build and test the hover cards')
const program = card.locator('[data-tab-hover-card-program]')
await expect(program.locator('[data-shell-icon]')).toHaveCount(1)
await expect(program).toHaveText('Terminal')
const titleBox = await cardTitle.boundingBox()
const programBox = await program.boundingBox()
expect(programBox?.y).toBeGreaterThan((titleBox?.y ?? 0) + (titleBox?.height ?? 0))
await orcaPage.waitForTimeout(200)
const browserWindow = await electronApp.browserWindow(orcaPage)
await browserWindow.evaluate((window) => window.webContents.setBackgroundThrottling(false))
await browserWindow.dispose()
const recording = await startCardRecording(orcaPage)
await orcaPage.mouse.move(secondBox.x + 3, secondBox.y + secondBox.height / 2)
await expect(cardTitle).toHaveText('Review changes', { timeout: 150 })
await orcaPage.waitForTimeout(200)
const frames = await stopCardRecording(recording)
const firstTargetFrame = frames.findIndex((frame) => frame.title === 'Review changes')
expect(firstTargetFrame, JSON.stringify(frames)).toBeGreaterThan(0)
expect(
frames
.slice(0, firstTargetFrame)
.every((frame) => frame.title === 'Build and test the hover cards')
).toBe(true)
expect(frames.slice(firstTargetFrame).every((frame) => frame.title === 'Review changes')).toBe(
true
)
expect(frames.some((frame) => frame.sliding)).toBe(true)
expect(frames[firstTargetFrame]?.left).toBeLessThan(secondBox.x - 10)
await expect(cardTitle).toHaveText('Review changes')
await orcaPage.waitForTimeout(500)
await orcaPage.mouse.move(firstBox.x + 3, firstBox.y + firstBox.height / 2)
await expect(cardTitle).toHaveText('Build and test the hover cards', { timeout: 150 })
await orcaPage.waitForTimeout(600)
await orcaPage.mouse.move(secondBox.x + 3, secondBox.y + secondBox.height / 2)
await expect(cardTitle).toHaveText('Review changes', { timeout: 150 })
const reversalRecording = await startCardRecording(orcaPage)
for (let index = 0; index < 12; index += 1) {
const target = index % 2 === 0 ? firstBox : secondBox
const title = index % 2 === 0 ? 'Build and test the hover cards' : 'Review changes'
await orcaPage.mouse.move(target.x + 3, target.y + target.height / 2)
await expect(cardTitle).toHaveText(title, { timeout: 150 })
await orcaPage.waitForTimeout(30)
await expect(card).toHaveCount(1)
}
const reversalFrames = await stopCardRecording(reversalRecording)
expect(
reversalFrames.every((frame) => frame.count === 1),
JSON.stringify(reversalFrames)
).toBe(true)
for (const title of ['Build and test the hover cards', 'Review changes']) {
expect(reversalFrames.some((frame) => frame.title === title && frame.sliding)).toBe(true)
}
await first.locator('[data-tab-close-button]').hover()
await orcaPage.waitForTimeout(100)
await expect(cardTitle).toHaveText('Build and test the hover cards')
await expect(orcaPage.locator('[data-tab-hover-card], [data-tab-close-tooltip]')).toHaveCount(1)
await orcaPage.waitForTimeout(500)
await expect(cardTitle).toHaveText('Build and test the hover cards')
await expect(orcaPage.locator('[data-tab-hover-card], [data-tab-close-tooltip]')).toHaveCount(1)
await expect(orcaPage.locator('[data-tab-close-tooltip]')).toBeVisible()
await expect(card).toHaveCount(0)
await orcaPage.mouse.move(secondBox.x + 3, secondBox.y + secondBox.height / 2)
await expect(cardTitle).toHaveText('Review changes')
await orcaPage.emulateMedia({ reducedMotion: 'reduce' })
await orcaPage.mouse.move(firstBox.x + 3, firstBox.y + firstBox.height / 2)
await expect(cardTitle).toHaveText('Build and test the hover cards')
expect(await card.evaluate((element) => element.getAnimations().length)).toBe(0)
await first.locator('[data-tab-close-button]').focus()
await expect(orcaPage.locator('[data-tab-close-tooltip]')).toBeVisible()
await expect(card).toHaveCount(0)
await first.focus()
await expect(cardTitle).toHaveText('Build and test the hover cards')
await expect(orcaPage.locator('[data-tab-close-tooltip]')).toHaveCount(0)
await first.press('Escape')
await expect(card).toHaveCount(0)
await first.click({ position: { x: 3, y: firstBox.height / 2 } })
await expect(card).toHaveCount(0)
await orcaPage.mouse.move(firstBox.x + 3, firstBox.y + firstBox.height + 100)
await orcaPage.waitForTimeout(400)
await orcaPage.mouse.move(secondBox.x + 3, secondBox.y + secondBox.height / 2)
await orcaPage.waitForTimeout(200)
await expect(card).toHaveCount(0)
await expect(cardTitle).toHaveText('Review changes')
await orcaPage.waitForTimeout(500)
})