Fix tab switching after terminal tab reorder (#6395)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-06-25 18:36:11 -07:00
committed by GitHub
co-authored by Orca
parent ab61c1fff3
commit d32d62a395
13 changed files with 66 additions and 315 deletions
@@ -36,13 +36,6 @@ vi.mock('@dnd-kit/sortable', () => ({
})
}))
vi.mock('./tab-strip-pointer-activation', () => ({
useTabStripPointerActivation: () => ({
isPressed: false,
onPointerDown: vi.fn()
})
}))
vi.mock('lucide-react', () => ({
Columns2: function Columns2(props: Record<string, unknown>) {
return { type: 'Columns2', props }
@@ -20,13 +20,11 @@ import {
getDropIndicatorClasses,
getTabRootStateClasses,
getTabStripBorderClasses,
showsTabSelectionChrome,
type DropIndicator
} from './drop-indicator'
import { preventMiddleButtonDefault } from './middle-button-default-guard'
import { translate } from '@/i18n/i18n'
import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules'
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection'
function formatBrowserTabUrlLabel(url: string): string {
@@ -171,11 +169,6 @@ export default function BrowserTab({
return () => window.removeEventListener('blur', dismiss)
}, [menuOpen])
const { isPressed, onPointerDown: onTabPointerDown } = useTabStripPointerActivation({
onActivate
})
const showsSelectionChrome = showsTabSelectionChrome(isActive, isPressed)
const tabRoot = (
<div
ref={setNodeRef}
@@ -183,12 +176,13 @@ export default function BrowserTab({
data-pinned={isPinned ? 'true' : 'false'}
{...attributes}
{...listeners}
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive, isPressed)}`}
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`}
onPointerDown={(e) => {
onTabPointerDown(
e,
listeners?.onPointerDown as ((event: React.PointerEvent<Element>) => void) | undefined
)
if (e.button !== 0) {
return
}
onActivate()
listeners?.onPointerDown?.(e)
}}
onMouseDown={(e) => {
if (e.button === 1) {
@@ -207,7 +201,7 @@ export default function BrowserTab({
}
}}
>
{showsSelectionChrome && <span className={ACTIVE_TAB_INDICATOR_CLASSES} aria-hidden />}
{isActive && <span className={ACTIVE_TAB_INDICATOR_CLASSES} aria-hidden />}
{/* Why: the browser tab icon is the only non-terminal, non-editor
surface in the tab strip. Coloring the Globe blue (matching the
in-app browser's identity and the default tab insertion bar)
@@ -224,7 +218,7 @@ export default function BrowserTab({
{!isPinned && (
<button
className={`flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
showsSelectionChrome
isActive
? 'text-muted-foreground hover:text-foreground hover:bg-muted'
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted'
}`}
@@ -55,13 +55,6 @@ vi.mock('@dnd-kit/sortable', () => ({
})
}))
vi.mock('./tab-strip-pointer-activation', () => ({
useTabStripPointerActivation: () => ({
isPressed: false,
onPointerDown: vi.fn()
})
}))
vi.mock('lucide-react', () => ({
Columns2: function Columns2(props: Record<string, unknown>) {
return { type: 'Columns2', props }
@@ -189,8 +182,7 @@ vi.mock('./drop-indicator', () => ({
ACTIVE_TAB_INDICATOR_CLASSES: 'active-tab-indicator',
getDropIndicatorClasses: () => '',
getTabStripBorderClasses: () => '',
getTabRootStateClasses: () => '',
showsTabSelectionChrome: () => true
getTabRootStateClasses: () => ''
}))
vi.mock('@/components/editor/markdown-preview-controls', () => ({
@@ -22,14 +22,12 @@ import {
getDropIndicatorClasses,
getTabRootStateClasses,
getTabStripBorderClasses,
showsTabSelectionChrome,
type DropIndicator
} from './drop-indicator'
import { canOpenMarkdownPreview } from '@/components/editor/markdown-preview-controls'
import { EditorFileTabContextMenu } from './EditorFileTabContextMenu'
import { translate } from '@/i18n/i18n'
import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules'
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
import { EditorFileTabCloseButton } from './EditorFileTabCloseButton'
export default function EditorFileTab({
@@ -201,11 +199,6 @@ export default function EditorFileTab({
return () => window.removeEventListener('blur', dismiss)
}, [menuOpen])
const { isPressed, onPointerDown: onTabPointerDown } = useTabStripPointerActivation({
onActivate,
disabled: isRenaming
})
const showsSelectionChrome = showsTabSelectionChrome(isActive, isPressed)
const dragListeners = isRenaming ? undefined : listeners
const tabRoot = (
@@ -215,12 +208,13 @@ export default function EditorFileTab({
data-pinned={isPinned ? 'true' : 'false'}
{...attributes}
{...dragListeners}
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive, isPressed)}`}
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`}
onPointerDown={(e) => {
onTabPointerDown(
e,
dragListeners?.onPointerDown as ((event: React.PointerEvent<Element>) => void) | undefined
)
if (isRenaming || e.button !== 0) {
return
}
onActivate()
dragListeners?.onPointerDown?.(e)
}}
onDoubleClick={() => {
if (file.isPreview && onMakePermanent) {
@@ -244,26 +238,26 @@ export default function EditorFileTab({
}
}}
>
{showsSelectionChrome && <span className={ACTIVE_TAB_INDICATOR_CLASSES} aria-hidden />}
{isActive && <span className={ACTIVE_TAB_INDICATOR_CLASSES} aria-hidden />}
{isConflictReview ? (
<ShieldAlert
className={`w-3 h-3 mr-1 shrink-0 ${showsSelectionChrome ? 'text-orange-400' : 'text-orange-400/70'}`}
className={`w-3 h-3 mr-1 shrink-0 ${isActive ? 'text-orange-400' : 'text-orange-400/70'}`}
/>
) : isCheckDetails ? (
<ListChecks
className={`w-3 h-3 mr-1 shrink-0 ${showsSelectionChrome ? 'text-foreground' : 'text-muted-foreground'}`}
className={`w-3 h-3 mr-1 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
/>
) : isDiff ? (
<GitCompareArrows
className={`w-3 h-3 mr-1 shrink-0 ${showsSelectionChrome ? 'text-foreground' : 'text-muted-foreground'}`}
className={`w-3 h-3 mr-1 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
/>
) : isMarkdownPreviewTab ? (
<Eye
className={`w-3.5 h-3.5 mr-1.5 shrink-0 ${showsSelectionChrome ? 'text-foreground' : 'text-muted-foreground'}`}
className={`w-3.5 h-3.5 mr-1.5 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
/>
) : (
<FileIcon
className={`w-3 h-3 mr-1 shrink-0 ${showsSelectionChrome ? 'text-foreground' : 'text-muted-foreground'}`}
className={`w-3 h-3 mr-1 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
/>
)}
{isPinned && <Pin className="mr-1 size-3 shrink-0 text-muted-foreground" aria-hidden />}
@@ -347,7 +341,7 @@ export default function EditorFileTab({
{!isPinned && (
<EditorFileTabCloseButton
fileIsDirty={file.isDirty}
showsSelectionChrome={showsSelectionChrome}
showsSelectionChrome={isActive}
onClose={onClose}
/>
)}
@@ -71,13 +71,6 @@ vi.mock('@dnd-kit/sortable', () => ({
})
}))
vi.mock('./tab-strip-pointer-activation', () => ({
useTabStripPointerActivation: () => ({
isPressed: false,
onPointerDown: vi.fn()
})
}))
vi.mock('lucide-react', () => ({
Columns2: function Columns2(props: Record<string, unknown>) {
return { type: 'Columns2', props }
@@ -178,8 +171,7 @@ vi.mock('./drop-indicator', () => ({
ACTIVE_TAB_INDICATOR_CLASSES: 'active-tab-indicator',
getDropIndicatorClasses: () => '',
getTabStripBorderClasses: () => '',
getTabRootStateClasses: () => '',
showsTabSelectionChrome: () => true
getTabRootStateClasses: () => ''
}))
vi.mock('./middle-button-default-guard', () => ({
@@ -17,14 +17,12 @@ import {
getDropIndicatorClasses,
getTabRootStateClasses,
getTabStripBorderClasses,
showsTabSelectionChrome,
type DropIndicator
} from './drop-indicator'
import { preventMiddleButtonDefault } from './middle-button-default-guard'
import { SortableTabContextMenu } from './SortableTabContextMenu'
import { translate } from '@/i18n/i18n'
import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules'
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel'
type SortableTabProps = {
@@ -201,14 +199,6 @@ export default function SortableTab({
// so dnd-kit's a11y attributes (aria-roledescription, etc.) remain on the element — only
// the pointer listeners are gated so a drag can't start while typing.
const dragListeners = isEditing ? undefined : listeners
const handleActivate = useCallback(() => {
onActivate(tab.id)
}, [onActivate, tab.id])
const { isPressed, onPointerDown: onTabPointerDown } = useTabStripPointerActivation({
onActivate: handleActivate,
disabled: isEditing
})
const showsSelectionChrome = showsTabSelectionChrome(isActive, isPressed)
const closeShortcut = useShortcutKeyDetails('tab.close')
const tabTitle = tab.customTitle ?? tab.title
const tabRoot = (
@@ -224,7 +214,6 @@ export default function SortableTab({
// pass even if the tab-bar render path had silently broken (the same
// tautology that let PR #1186's render crash ship past E2E in #1193).
data-active={isActive ? 'true' : 'false'}
data-pressed={isPressed ? 'true' : 'false'}
{...attributes}
{...dragListeners}
// Why: on unread activity, tint the whole tab with a subtle amber
@@ -234,7 +223,7 @@ export default function SortableTab({
// tab still reads as "selected + has activity". The wash is
// rendered as an absolutely-positioned child below so the ::after
// pseudo-element stays free for the drop indicator.
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive, isPressed)}`}
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`}
onDoubleClick={(e) => {
if (isEditing) {
return
@@ -243,10 +232,11 @@ export default function SortableTab({
handleRenameOpen()
}}
onPointerDown={(e) => {
onTabPointerDown(
e,
dragListeners?.onPointerDown as ((event: React.PointerEvent<Element>) => void) | undefined
)
if (isEditing || e.button !== 0) {
return
}
onActivate(tab.id)
dragListeners?.onPointerDown?.(e)
}}
onMouseDown={(e) => {
// Why: prevent default browser middle-click behavior (auto-scroll)
@@ -272,7 +262,7 @@ export default function SortableTab({
}
}}
>
{showsSelectionChrome && <span className={ACTIVE_TAB_INDICATOR_CLASSES} aria-hidden />}
{isActive && <span className={ACTIVE_TAB_INDICATOR_CLASSES} aria-hidden />}
{showActivityAffordance && (
// Why: amber wash for unread tabs. Rendered as a real DOM child so
// both drop indicators (::before left / ::after right in
@@ -294,7 +284,7 @@ export default function SortableTab({
// Why: coding-agent tabs should read as Claude/Codex/etc. while the
// harness is running; plain shells keep the generic terminal tile.
<span
className={`mr-1 inline-flex shrink-0 ${showsSelectionChrome ? '' : 'opacity-70'}`}
className={`mr-1 inline-flex shrink-0 ${isActive ? '' : 'opacity-70'}`}
data-agent-icon={tabAgent}
aria-hidden
>
@@ -310,7 +300,7 @@ export default function SortableTab({
// on inactive tabs to match the existing text treatment without
// desaturating the brand colors beyond recognition.
<span
className={`mr-1 inline-flex shrink-0 ${showsSelectionChrome ? '' : 'opacity-70'}`}
className={`mr-1 inline-flex shrink-0 ${isActive ? '' : 'opacity-70'}`}
data-shell-icon={shellForIcon ?? 'generic'}
aria-hidden
>
@@ -390,7 +380,7 @@ export default function SortableTab({
{isExpanded && !isEditing && (
<button
className={`mr-1 flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
showsSelectionChrome
isActive
? 'text-muted-foreground hover:text-foreground hover:bg-muted'
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted'
}`}
@@ -410,7 +400,7 @@ export default function SortableTab({
<TooltipTrigger asChild>
<button
className={`relative z-10 flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
showsSelectionChrome
isActive
? 'text-muted-foreground hover:text-foreground hover:bg-muted focus-visible:text-foreground focus-visible:bg-muted'
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted focus-visible:!text-foreground focus-visible:!bg-muted'
}`}
@@ -84,11 +84,4 @@ describe('getTabRootStateClasses', () => {
expect(classes).toContain('text-muted-foreground')
expect(classes).toContain('hover:text-foreground')
})
it('returns the selected-tab surface treatment while pressed before activation', () => {
const classes = getTabRootStateClasses(false, true)
expect(classes).toContain('bg-[color-mix(in_srgb,var(--foreground)_6%,var(--card))]')
expect(classes).toContain('text-foreground')
expect(classes).not.toContain('hover:text-foreground')
})
})
@@ -28,12 +28,8 @@ export function getDropIndicatorClasses(dropIndicator: DropIndicator): string {
export const ACTIVE_TAB_INDICATOR_CLASSES =
'pointer-events-none absolute inset-x-0 bottom-0 h-[2px] bg-[color-mix(in_srgb,var(--foreground)_60%,var(--card))] z-10'
export function showsTabSelectionChrome(isActive: boolean, isPressed = false): boolean {
return isActive || isPressed
}
export function getTabRootStateClasses(isActive: boolean, isPressed = false): string {
return showsTabSelectionChrome(isActive, isPressed)
export function getTabRootStateClasses(isActive: boolean): string {
return isActive
? 'bg-[color-mix(in_srgb,var(--foreground)_6%,var(--card))] text-foreground'
: 'bg-card text-muted-foreground hover:text-foreground'
}
@@ -1,122 +0,0 @@
/**
* @vitest-environment happy-dom
*/
import { act, useRef, useState } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { TabDragProvider } from '../tab-group/tab-drag-context'
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
function Probe({ onActivate }: { onActivate: () => void }): React.JSX.Element {
const [dragActive, setDragActive] = useState(false)
const dragActiveRef = useRef(false)
dragActiveRef.current = dragActive
return (
<TabDragProvider isTabDragActive={dragActive} isTabDragActiveRef={dragActiveRef}>
<ProbeButton onActivate={onActivate} onDragActiveChange={setDragActive} />
</TabDragProvider>
)
}
function ProbeButton({
onActivate,
onDragActiveChange
}: {
onActivate: () => void
onDragActiveChange: (active: boolean) => void
}): React.JSX.Element {
const { isPressed, onPointerDown } = useTabStripPointerActivation({ onActivate })
return (
<>
<button
type="button"
data-pressed={isPressed ? 'true' : 'false'}
onPointerDown={(event) => onPointerDown(event)}
>
Tab
</button>
<button type="button" onClick={() => onDragActiveChange(true)}>
Start drag
</button>
</>
)
}
let root: Root | null = null
let container: HTMLDivElement | null = null
function renderProbe(onActivate = vi.fn()): {
onActivate: ReturnType<typeof vi.fn>
tabButton: HTMLButtonElement
dragButton: HTMLButtonElement
} {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root?.render(<Probe onActivate={onActivate} />)
})
const buttons = container.querySelectorAll('button')
return {
onActivate,
tabButton: buttons[0] as HTMLButtonElement,
dragButton: buttons[1] as HTMLButtonElement
}
}
function dispatchPointer(target: EventTarget, type: string, button = 0): void {
target.dispatchEvent(new MouseEvent(type, { bubbles: true, button }))
}
afterEach(() => {
act(() => root?.unmount())
container?.remove()
root = null
container = null
})
describe('useTabStripPointerActivation', () => {
it('defers activation until pointerup', () => {
const { onActivate, tabButton } = renderProbe()
act(() => dispatchPointer(tabButton, 'pointerdown'))
expect(tabButton.dataset.pressed).toBe('true')
expect(onActivate).not.toHaveBeenCalled()
act(() => dispatchPointer(window, 'pointerup'))
expect(tabButton.dataset.pressed).toBe('false')
expect(onActivate).toHaveBeenCalledTimes(1)
})
it('activates when the release event reports no changed button', () => {
const { onActivate, tabButton } = renderProbe()
act(() => dispatchPointer(tabButton, 'pointerdown'))
act(() => dispatchPointer(window, 'pointerup', -1))
expect(tabButton.dataset.pressed).toBe('false')
expect(onActivate).toHaveBeenCalledTimes(1)
})
it('cancels pending activation on pointercancel', () => {
const { onActivate, tabButton } = renderProbe()
act(() => dispatchPointer(tabButton, 'pointerdown'))
act(() => dispatchPointer(window, 'pointercancel'))
expect(tabButton.dataset.pressed).toBe('false')
expect(onActivate).not.toHaveBeenCalled()
})
it('clears pending activation when a drag starts', () => {
const { onActivate, tabButton, dragButton } = renderProbe()
act(() => dispatchPointer(tabButton, 'pointerdown'))
act(() => dragButton.click())
act(() => dispatchPointer(window, 'pointerup'))
expect(tabButton.dataset.pressed).toBe('false')
expect(onActivate).not.toHaveBeenCalled()
})
})
@@ -1,71 +0,0 @@
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
import { useTabDragActive, useTabDragActiveRef } from '../tab-group/tab-drag-context'
export function useTabStripPointerActivation({
onActivate,
disabled = false
}: {
onActivate: () => void
disabled?: boolean
}): {
isPressed: boolean
onPointerDown: (
event: React.PointerEvent,
dragListener?: (event: React.PointerEvent<Element>) => void
) => void
} {
const isTabDragActive = useTabDragActive()
const isTabDragActiveRef = useTabDragActiveRef()
const [isPressed, setIsPressed] = useState(false)
const pendingActivationRef = useRef(false)
const onActivateRef = useRef(onActivate)
onActivateRef.current = onActivate
useLayoutEffect(() => {
if (!isTabDragActive) {
return
}
pendingActivationRef.current = false
setIsPressed(false)
}, [isTabDragActive])
useLayoutEffect(() => {
if (!isPressed) {
return
}
const finishPointerPress = (): void => {
// Why: pointerup often reports button -1/no changed button; the left-button
// gate is on pointerdown, so release must always clear the pending click.
const shouldActivate = pendingActivationRef.current && !isTabDragActiveRef.current
pendingActivationRef.current = false
setIsPressed(false)
if (shouldActivate) {
onActivateRef.current()
}
}
const cancelPointerPress = (): void => {
pendingActivationRef.current = false
setIsPressed(false)
}
window.addEventListener('pointerup', finishPointerPress)
window.addEventListener('pointercancel', cancelPointerPress)
return () => {
window.removeEventListener('pointerup', finishPointerPress)
window.removeEventListener('pointercancel', cancelPointerPress)
}
}, [isPressed, isTabDragActiveRef])
const onPointerDown = useCallback(
(event: React.PointerEvent, dragListener?: (event: React.PointerEvent<Element>) => void) => {
if (disabled || event.button !== 0) {
return
}
pendingActivationRef.current = true
setIsPressed(true)
dragListener?.(event)
},
[disabled]
)
return { isPressed, onPointerDown }
}
@@ -172,7 +172,7 @@ describe('resolveTabInsertion', () => {
// ---------------------------------------------------------------------------
describe('resolveTabIndicatorEdges', () => {
it('marks both tabs around a left-edge insertion slot', () => {
it('marks one edge for a left-edge insertion slot', () => {
const hovered: HoveredTabInsertion = {
groupId: 'group-1',
visibleTabId: 'tab-2',
@@ -180,12 +180,11 @@ describe('resolveTabIndicatorEdges', () => {
}
expect(resolveTabIndicatorEdges(['tab-1', 'tab-2', 'tab-3'], hovered)).toEqual([
{ visibleTabId: 'tab-1', side: 'right' },
{ visibleTabId: 'tab-2', side: 'left' }
])
})
it('marks both tabs around a right-edge insertion slot', () => {
it('marks one edge for a right-edge insertion slot', () => {
const hovered: HoveredTabInsertion = {
groupId: 'group-1',
visibleTabId: 'tab-2',
@@ -193,7 +192,6 @@ describe('resolveTabIndicatorEdges', () => {
}
expect(resolveTabIndicatorEdges(['tab-1', 'tab-2', 'tab-3'], hovered)).toEqual([
{ visibleTabId: 'tab-2', side: 'right' },
{ visibleTabId: 'tab-3', side: 'left' }
])
})
@@ -62,18 +62,10 @@ export function resolveTabIndicatorEdges(
}
const insertionIndex = hoveredIndex + (hoveredTabInsertion.side === 'right' ? 1 : 0)
const edges: TabIndicatorEdge[] = []
// Why: VS Code draws the insertion cue by marking both tabs adjacent to the
// slot so the two 1px edges read as one continuous bar between them.
if (insertionIndex > 0) {
edges.push({ visibleTabId: orderedVisibleTabIds[insertionIndex - 1]!, side: 'right' })
}
if (insertionIndex < orderedVisibleTabIds.length) {
edges.push({ visibleTabId: orderedVisibleTabIds[insertionIndex]!, side: 'left' })
return [{ visibleTabId: orderedVisibleTabIds[insertionIndex]!, side: 'left' }]
}
return edges
return [{ visibleTabId: orderedVisibleTabIds[insertionIndex - 1]!, side: 'right' }]
}
function equal(a: HoveredTabInsertion | null, b: HoveredTabInsertion | null): boolean {
+26 -16
View File
@@ -279,7 +279,9 @@ test.describe('Tabs', () => {
.toEqual([domOrderBefore[1], domOrderBefore[0], ...domOrderBefore.slice(2)])
})
test('clicking tabs still switches after a tab drag gesture releases', async ({ orcaPage }) => {
test('clicking tabs still switches after dragging a terminal tab to reorder', async ({
orcaPage
}) => {
const worktreeId = (await getActiveWorktreeId(orcaPage))!
await orcaPage.evaluate((targetWorktreeId) => {
@@ -297,41 +299,49 @@ test.describe('Tabs', () => {
.poll(() => countRenderedTabs(orcaPage), { timeout: 5_000 })
.toBeGreaterThanOrEqual(2)
const domOrder = await orcaPage.$$eval(SORTABLE_TAB, (nodes) =>
const domOrderBefore = await orcaPage.$$eval(SORTABLE_TAB, (nodes) =>
nodes.map((n) => (n as HTMLElement).dataset.tabId ?? '')
)
const [firstTabId, secondTabId] = domOrder
const [firstTabId, secondTabId] = domOrderBefore
expect(firstTabId).toBeTruthy()
expect(secondTabId).toBeTruthy()
await orcaPage.evaluate((tabId) => {
window.__store?.getState().setActiveTab(tabId)
}, firstTabId)
await tabLocator(orcaPage, firstTabId).click({ force: true })
await expect.poll(() => getDomActiveTabId(orcaPage), { timeout: 3_000 }).toBe(firstTabId)
const firstTabBox = await tabLocator(orcaPage, firstTabId).boundingBox()
const secondTabBox = await tabLocator(orcaPage, secondTabId).boundingBox()
expect(firstTabBox).not.toBeNull()
expect(secondTabBox).not.toBeNull()
const startX = firstTabBox!.x + firstTabBox!.width / 2
const startY = firstTabBox!.y + firstTabBox!.height / 2
const endX = secondTabBox!.x + secondTabBox!.width * 0.75
const endY = secondTabBox!.y + secondTabBox!.height / 2
await orcaPage.mouse.move(startX, startY)
await orcaPage.mouse.down()
// Why: exceed dnd-kit's 12px tab-drag threshold so this exercises the
// drag/click handshake, not just an ordinary tab press.
await orcaPage.mouse.move(startX + 24, startY, { steps: 4 })
// Why: this mirrors the release repro: drag a terminal tab across another
// tab far enough for dnd-kit to commit a reorder, then release on the tab
// strip before clicking tabs again.
await orcaPage.mouse.move(endX, endY, { steps: 8 })
await orcaPage.mouse.up()
// Reset selection through setup state, then prove the user-visible click
// path still activates another tab after the drag release.
await orcaPage.evaluate((tabId) => {
window.__store?.getState().setActiveTab(tabId)
}, firstTabId)
await expect.poll(() => getDomActiveTabId(orcaPage), { timeout: 3_000 }).toBe(firstTabId)
await expect
.poll(
async () =>
orcaPage.$$eval(SORTABLE_TAB, (nodes) =>
nodes.map((n) => (n as HTMLElement).dataset.tabId ?? '')
),
{ timeout: 5_000, message: 'Terminal tab drag did not reorder the tab strip' }
)
.toEqual([secondTabId, firstTabId, ...domOrderBefore.slice(2)])
await tabLocator(orcaPage, firstTabId).click({ force: true })
await expect.poll(() => getDomActiveTabId(orcaPage), { timeout: 3_000 }).toBe(firstTabId)
await tabLocator(orcaPage, secondTabId).click({ force: true })
await expect
.poll(() => getDomActiveTabId(orcaPage), {
timeout: 5_000,
message: 'Tab click did not activate after a completed tab drag gesture'
message: 'Tab click did not activate after a terminal tab reorder drag'
})
.toBe(secondTabId)
})