mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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')
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user