feat(tabs): redesign tab splits and terminal pane discoverability (#5927)

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: brennanb2025 <brennankbenson@gmail.com>
This commit is contained in:
Trevin Chow
2026-06-21 23:09:01 -07:00
committed by GitHub
co-authored by Cursor Orca brennanb2025
parent c0f1d386c7
commit bfb778570a
73 changed files with 4801 additions and 933 deletions
+39 -9
View File
@@ -217,11 +217,12 @@
border: 2px solid rgba(59, 130, 246, 0.5);
border-radius: 4px;
pointer-events: none;
transition:
left 80ms ease,
top 80ms ease,
width 80ms ease,
height 80ms ease;
}
.tab-drop-overlay__label {
font-size: 11px;
color: var(--primary-foreground);
background: color-mix(in srgb, var(--chart-2) 20%, transparent);
}
/* ── Pane title bar ──────────────────────────────────── */
@@ -278,9 +279,9 @@
.pane-title-drag-handle {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 12px;
left: 50%;
width: 32px;
height: var(--orca-pane-title-height);
z-index: 2;
display: flex;
align-items: center;
@@ -288,6 +289,7 @@
cursor: grab;
opacity: 0;
background: color-mix(in srgb, var(--orca-pane-title-fg) 36%, transparent);
transform: translateX(-50%);
transition: opacity 150ms ease;
}
@@ -347,6 +349,12 @@
outline-offset: 1px;
}
.pane-title-bar[data-chromeless] {
background: transparent;
border-bottom: none;
padding: 0 4px;
}
.pane-title-close {
flex-shrink: 0;
margin-left: auto;
@@ -358,6 +366,24 @@
color 120ms ease;
}
.pane-title-actions {
position: relative;
z-index: 3;
}
.pane-title-split-trigger {
color: var(--orca-pane-title-button-fg, rgb(255 255 255 / 0.3));
opacity: 0;
transition: opacity 120ms ease;
}
.pane-title-bar[data-active-pane] .pane-title-split-trigger,
.pane-title-bar[data-active-pane] .pane-title-close,
.pane-title-bar:hover .pane-title-split-trigger,
.pane-title-bar:focus-within .pane-title-split-trigger {
opacity: 1;
}
.pane-title-bar:hover .pane-title-close,
.pane-title-bar:focus-within .pane-title-close,
.pane-title-close:focus-visible {
@@ -367,7 +393,11 @@
.pane-title-close:hover,
.pane-title-close:active,
.pane-title-close[data-state='delayed-open'],
.pane-title-close[data-state='instant-open'] {
.pane-title-close[data-state='instant-open'],
.pane-title-split-trigger:hover,
.pane-title-split-trigger:active,
.pane-title-split-trigger[data-state='delayed-open'],
.pane-title-split-trigger[data-state='instant-open'] {
background: transparent;
color: var(--orca-pane-title-button-hover-fg, rgb(255 255 255 / 0.8));
}
@@ -21,10 +21,7 @@ import {
handleContextualTourOverlayKeyDown,
type ActiveTourRenderState
} from './ContextualTourOverlaySurface'
import {
REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT,
type RequestActiveTerminalPaneSplitDetail
} from '@/constants/terminal'
import { requestActiveTerminalPaneSplit } from '@/components/tab-bar/request-active-terminal-pane-split'
import { performContextualTourStepAction } from './contextual-tour-step-actions'
import { openWorkspaceCreationComposerWithTourHandoff } from './workspace-creation-tour-handoff'
@@ -321,14 +318,7 @@ export function ContextualTourOverlay(): JSX.Element | null {
openModal,
canCreateWorkspace,
openWorkspaceComposer: openWorkspaceCreationComposerWithTourHandoff,
dispatchTerminalPaneSplit: (detail) => {
window.dispatchEvent(
new CustomEvent<RequestActiveTerminalPaneSplitDetail>(
REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT,
{ detail }
)
)
},
dispatchTerminalPaneSplit: requestActiveTerminalPaneSplit,
schedule: (callback) => {
window.setTimeout(callback, 0)
}
@@ -36,6 +36,13 @@ 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 }
@@ -76,6 +83,21 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenuSeparator: function DropdownMenuSeparator() {
return { type: 'DropdownMenuSeparator', props: {} }
},
DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: unknown }) {
return { type: 'DropdownMenuShortcut', props }
},
DropdownMenuLabel: function DropdownMenuLabel(props: { children?: unknown }) {
return { type: 'DropdownMenuLabel', props }
},
DropdownMenuSub: function DropdownMenuSub(props: { children?: unknown }) {
return { type: 'DropdownMenuSub', props }
},
DropdownMenuSubContent: function DropdownMenuSubContent(props: { children?: unknown }) {
return { type: 'DropdownMenuSubContent', props }
},
DropdownMenuSubTrigger: function DropdownMenuSubTrigger(props: { children?: unknown }) {
return { type: 'DropdownMenuSubTrigger', props }
},
DropdownMenuTrigger: function DropdownMenuTrigger(props: { children?: unknown }) {
return { type: 'DropdownMenuTrigger', props }
}
@@ -133,7 +155,6 @@ async function renderBrowserTab(tab: BrowserTabState): Promise<unknown> {
onActivate: () => {},
onClose: () => {},
onCloseToRight: () => {},
onSplitGroup: () => {},
onDuplicate: () => {},
onTogglePin: () => {},
dragData: {
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { useSortable } from '@dnd-kit/sortable'
import { Globe, X, ExternalLink, Columns2, Rows2, Copy, Pin, PinOff } from 'lucide-react'
import { Globe, X, ExternalLink, Copy, Pin, PinOff } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -20,11 +20,14 @@ 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 {
if (url === ORCA_BROWSER_BLANK_URL || url === 'about:blank') {
@@ -107,7 +110,6 @@ export default function BrowserTab({
onActivate,
onClose,
onCloseToRight,
onSplitGroup,
onDuplicate,
onTogglePin,
dragData,
@@ -121,7 +123,6 @@ export default function BrowserTab({
onActivate: () => void
onClose: () => void
onCloseToRight: () => void
onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void
onDuplicate: () => void
onTogglePin: () => void
dragData: TabDragItemData
@@ -170,6 +171,11 @@ 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}
@@ -177,13 +183,12 @@ 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)}`}
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)}`}
onPointerDown={(e) => {
if (e.button !== 0) {
return
}
onActivate()
listeners?.onPointerDown?.(e)
onTabPointerDown(
e,
listeners?.onPointerDown as ((event: React.PointerEvent<Element>) => void) | undefined
)
}}
onMouseDown={(e) => {
if (e.button === 1) {
@@ -202,7 +207,7 @@ export default function BrowserTab({
}
}}
>
{isActive && <span className={ACTIVE_TAB_INDICATOR_CLASSES} aria-hidden />}
{showsSelectionChrome && <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)
@@ -219,7 +224,7 @@ export default function BrowserTab({
{!isPinned && (
<button
className={`flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
isActive
showsSelectionChrome
? 'text-muted-foreground hover:text-foreground hover:bg-muted'
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted'
}`}
@@ -276,23 +281,6 @@ export default function BrowserTab({
sideOffset={0}
align="start"
>
<DropdownMenuItem onSelect={() => onSplitGroup('up', tab.id)}>
<Rows2 className="mr-1.5 size-3.5" />
{translate('auto.components.tab.bar.BrowserTab.96354ed249', 'Split Up')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitGroup('down', tab.id)}>
<Rows2 className="mr-1.5 size-3.5" />
{translate('auto.components.tab.bar.BrowserTab.2186a8407c', 'Split Down')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitGroup('left', tab.id)}>
<Columns2 className="mr-1.5 size-3.5" />
{translate('auto.components.tab.bar.BrowserTab.7e8106899f', 'Split Left')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitGroup('right', tab.id)}>
<Columns2 className="mr-1.5 size-3.5" />
{translate('auto.components.tab.bar.BrowserTab.966feb9ad5', 'Split Right')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onDuplicate}>
<Copy className="mr-1.5 size-3.5" />
{translate('auto.components.tab.bar.BrowserTab.5d6e89891f', 'Duplicate Tab')}
@@ -308,6 +296,10 @@ export default function BrowserTab({
? translate('auto.components.tab.bar.BrowserTab.c5aaee8c39', 'Unpin Tab')
: translate('auto.components.tab.bar.BrowserTab.911542656f', 'Pin Tab')}
</DropdownMenuItem>
<TabWorkspaceLayoutMenuSection
unifiedTabId={dragData.unifiedTabId}
groupId={dragData.groupId}
/>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => !isPinned && onClose()} disabled={isPinned}>
{translate('auto.components.tab.bar.BrowserTab.1611a1324b', 'Close')}
@@ -9,7 +9,13 @@ const reactHookRuntime = vi.hoisted(() => ({
const appStoreMocks = vi.hoisted(() => ({
openMarkdownPreview: vi.fn(),
getState: vi.fn(() => ({
settings: {}
settings: {},
unifiedTabsByWorktree: {
'wt-1': [{ id: '/repo/untitled-5.md', groupId: 'group-1' }]
},
groupsByWorktree: {
'wt-1': [{ id: 'group-1', tabOrder: ['/repo/untitled-5.md', 'tab-2'] }]
}
}))
}))
@@ -49,6 +55,13 @@ 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 }
@@ -101,6 +114,18 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: unknown }) {
return { type: 'DropdownMenuShortcut', props }
},
DropdownMenuLabel: function DropdownMenuLabel(props: { children?: unknown }) {
return { type: 'DropdownMenuLabel', props }
},
DropdownMenuSub: function DropdownMenuSub(props: { children?: unknown }) {
return { type: 'DropdownMenuSub', props }
},
DropdownMenuSubContent: function DropdownMenuSubContent(props: { children?: unknown }) {
return { type: 'DropdownMenuSubContent', props }
},
DropdownMenuSubTrigger: function DropdownMenuSubTrigger(props: { children?: unknown }) {
return { type: 'DropdownMenuSubTrigger', props }
},
DropdownMenuTrigger: function DropdownMenuTrigger(props: { children?: unknown }) {
return { type: 'DropdownMenuTrigger', props }
}
@@ -164,7 +189,8 @@ vi.mock('./drop-indicator', () => ({
ACTIVE_TAB_INDICATOR_CLASSES: 'active-tab-indicator',
getDropIndicatorClasses: () => '',
getTabStripBorderClasses: () => '',
getTabRootStateClasses: () => ''
getTabRootStateClasses: () => '',
showsTabSelectionChrome: () => true
}))
vi.mock('@/components/editor/markdown-preview-controls', () => ({
@@ -217,7 +243,6 @@ async function renderEditorFileTab(
onCloseAll: () => {},
onMakePermanent,
onTogglePin: () => {},
onSplitGroup: () => {},
dragData: {
kind: 'tab',
worktreeId: file.worktreeId,
@@ -22,12 +22,14 @@ 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'
export default function EditorFileTab({
file,
@@ -41,7 +43,6 @@ export default function EditorFileTab({
onCloseAll,
onMakePermanent,
onTogglePin,
onSplitGroup,
dragData,
dropIndicator,
includeTopTabBorder = true
@@ -57,7 +58,6 @@ export default function EditorFileTab({
onCloseAll: () => void
onMakePermanent?: () => void
onTogglePin: () => void
onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void
dragData: TabDragItemData
dropIndicator?: DropIndicator
includeTopTabBorder?: boolean
@@ -200,20 +200,26 @@ 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 = (
<div
ref={setNodeRef}
data-tab-id={file.tabId ?? file.id}
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)}`}
{...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)}`}
onPointerDown={(e) => {
if (e.button !== 0) {
return
}
onActivate()
listeners?.onPointerDown?.(e)
onTabPointerDown(
e,
dragListeners?.onPointerDown as ((event: React.PointerEvent<Element>) => void) | undefined
)
}}
onDoubleClick={() => {
if (file.isPreview && onMakePermanent) {
@@ -237,26 +243,26 @@ export default function EditorFileTab({
}
}}
>
{isActive && <span className={ACTIVE_TAB_INDICATOR_CLASSES} aria-hidden />}
{showsSelectionChrome && <span className={ACTIVE_TAB_INDICATOR_CLASSES} aria-hidden />}
{isConflictReview ? (
<ShieldAlert
className={`w-3 h-3 mr-1 shrink-0 ${isActive ? 'text-orange-400' : 'text-orange-400/70'}`}
className={`w-3 h-3 mr-1 shrink-0 ${showsSelectionChrome ? 'text-orange-400' : 'text-orange-400/70'}`}
/>
) : isCheckDetails ? (
<ListChecks
className={`w-3 h-3 mr-1 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
className={`w-3 h-3 mr-1 shrink-0 ${showsSelectionChrome ? 'text-foreground' : 'text-muted-foreground'}`}
/>
) : isDiff ? (
<GitCompareArrows
className={`w-3 h-3 mr-1 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
className={`w-3 h-3 mr-1 shrink-0 ${showsSelectionChrome ? 'text-foreground' : 'text-muted-foreground'}`}
/>
) : isMarkdownPreviewTab ? (
<Eye
className={`w-3.5 h-3.5 mr-1.5 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
className={`w-3.5 h-3.5 mr-1.5 shrink-0 ${showsSelectionChrome ? 'text-foreground' : 'text-muted-foreground'}`}
/>
) : (
<FileIcon
className={`w-3 h-3 mr-1 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
className={`w-3 h-3 mr-1 shrink-0 ${showsSelectionChrome ? 'text-foreground' : 'text-muted-foreground'}`}
/>
)}
{isPinned && <Pin className="mr-1 size-3 shrink-0 text-muted-foreground" aria-hidden />}
@@ -342,7 +348,7 @@ export default function EditorFileTab({
className={`flex items-center justify-center w-4 h-4 rounded-sm ${
file.isDirty
? 'hidden group-hover:flex text-muted-foreground hover:text-foreground hover:bg-muted'
: isActive
: showsSelectionChrome
? 'text-muted-foreground hover:text-foreground hover:bg-muted'
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted'
}`}
@@ -390,6 +396,8 @@ export default function EditorFileTab({
open={menuOpen}
menuPoint={menuPoint}
file={file}
unifiedTabId={dragData.unifiedTabId}
groupId={dragData.groupId}
isPinned={isPinned}
isRenaming={isRenaming}
hasTabsToRight={hasTabsToRight}
@@ -405,7 +413,6 @@ export default function EditorFileTab({
onClose={onClose}
onCloseAll={onCloseAll}
onCloseToRight={onCloseToRight}
onSplitGroup={onSplitGroup}
onOpenMarkdownPreview={openMarkdownPreview}
/>
</>
@@ -18,6 +18,18 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: unknown }) {
return { type: 'DropdownMenuShortcut', props }
},
DropdownMenuLabel: function DropdownMenuLabel(props: { children?: unknown }) {
return { type: 'DropdownMenuLabel', props }
},
DropdownMenuSub: function DropdownMenuSub(props: { children?: unknown }) {
return { type: 'DropdownMenuSub', props }
},
DropdownMenuSubContent: function DropdownMenuSubContent(props: { children?: unknown }) {
return { type: 'DropdownMenuSubContent', props }
},
DropdownMenuSubTrigger: function DropdownMenuSubTrigger(props: { children?: unknown }) {
return { type: 'DropdownMenuSubTrigger', props }
},
DropdownMenuTrigger: function DropdownMenuTrigger(props: { children?: unknown }) {
return { type: 'DropdownMenuTrigger', props }
}
@@ -58,9 +70,33 @@ vi.mock('@/hooks/useShortcutLabel', () => ({
}))
const useAppStoreMock = Object.assign(
(selector: (state: { settings: Record<string, unknown> }) => unknown) =>
selector({ settings: {} }),
{ getState: () => ({ settings: {} }) }
(
selector: (state: {
settings: Record<string, unknown>
unifiedTabsByWorktree: Record<string, unknown[]>
groupsByWorktree: Record<string, unknown[]>
}) => unknown
) =>
selector({
settings: {},
unifiedTabsByWorktree: {
'wt-1': [{ id: 'tab-1', groupId: 'group-1' }]
},
groupsByWorktree: {
'wt-1': [{ id: 'group-1', tabOrder: ['tab-1', 'tab-2'] }]
}
}),
{
getState: () => ({
settings: {},
unifiedTabsByWorktree: {
'wt-1': [{ id: 'tab-1', groupId: 'group-1' }]
},
groupsByWorktree: {
'wt-1': [{ id: 'group-1', tabOrder: ['tab-1', 'tab-2'] }]
}
})
}
)
vi.mock('@/store', () => ({
@@ -151,6 +187,8 @@ async function renderMenu(): Promise<unknown> {
isDirty: false,
mode: 'edit'
},
unifiedTabId: 'tab-1',
groupId: 'group-1',
isPinned: false,
isRenaming: false,
hasTabsToRight: false,
@@ -166,7 +204,6 @@ async function renderMenu(): Promise<unknown> {
onClose: vi.fn(),
onCloseAll: vi.fn(),
onCloseToRight: vi.fn(),
onSplitGroup: vi.fn(),
onOpenMarkdownPreview: vi.fn()
})
}
@@ -1,4 +1,4 @@
import { Copy, ExternalLink, Columns2, Rows2, Pencil, Pin, PinOff } from 'lucide-react'
import { Copy, ExternalLink, Pencil, Pin, PinOff } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -13,6 +13,7 @@ import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import type { OpenFile } from '../../store/slices/editor'
import { shouldBlockEditorTabLocalOpen } from './editor-tab-local-open-guard'
import { translate } from '@/i18n/i18n'
import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection'
const isMac = navigator.userAgent.includes('Mac')
const isLinux = navigator.userAgent.includes('Linux')
@@ -28,6 +29,8 @@ type EditorFileTabContextMenuProps = {
open: boolean
menuPoint: { x: number; y: number }
file: OpenFile & { tabId?: string }
unifiedTabId: string
groupId: string
isPinned: boolean
isRenaming: boolean
hasTabsToRight: boolean
@@ -43,7 +46,6 @@ type EditorFileTabContextMenuProps = {
onClose: () => void
onCloseAll: () => void
onCloseToRight: () => void
onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void
onOpenMarkdownPreview: (
file: {
filePath: string
@@ -60,6 +62,8 @@ export function EditorFileTabContextMenu({
open,
menuPoint,
file,
unifiedTabId,
groupId,
isPinned,
isRenaming,
hasTabsToRight,
@@ -75,10 +79,8 @@ export function EditorFileTabContextMenu({
onClose,
onCloseAll,
onCloseToRight,
onSplitGroup,
onOpenMarkdownPreview
}: EditorFileTabContextMenuProps): React.JSX.Element {
const sourceVisibleTabId = file.tabId ?? file.id
const closeAllShortcut = useShortcutLabel('tab.closeAll')
const showCloseAllShortcut = closeAllShortcut !== 'Unassigned'
@@ -104,23 +106,6 @@ export function EditorFileTabContextMenu({
event.preventDefault()
}}
>
<DropdownMenuItem onSelect={() => onSplitGroup('up', sourceVisibleTabId)}>
<Rows2 className="mr-1.5 size-3.5" />
{translate('auto.components.tab.bar.EditorFileTabContextMenu.6b3efb106e', 'Split Up')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitGroup('down', sourceVisibleTabId)}>
<Rows2 className="mr-1.5 size-3.5" />
{translate('auto.components.tab.bar.EditorFileTabContextMenu.1d04b1630b', 'Split Down')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitGroup('left', sourceVisibleTabId)}>
<Columns2 className="mr-1.5 size-3.5" />
{translate('auto.components.tab.bar.EditorFileTabContextMenu.e3ff145b98', 'Split Left')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitGroup('right', sourceVisibleTabId)}>
<Columns2 className="mr-1.5 size-3.5" />
{translate('auto.components.tab.bar.EditorFileTabContextMenu.f7c3d7d5af', 'Split Right')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={!canRename || isRenaming}
onSelect={() => {
@@ -139,6 +124,7 @@ export function EditorFileTabContextMenu({
? translate('auto.components.tab.bar.EditorFileTabContextMenu.8e9d603a09', 'Unpin Tab')
: translate('auto.components.tab.bar.EditorFileTabContextMenu.fdd29eb669', 'Pin Tab')}
</DropdownMenuItem>
<TabWorkspaceLayoutMenuSection unifiedTabId={unifiedTabId} groupId={groupId} />
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => !isPinned && onClose()} disabled={isPinned}>
{translate('auto.components.tab.bar.EditorFileTabContextMenu.1ba8492c5b', 'Close')}
@@ -5,19 +5,33 @@ const reactHookRuntime = vi.hoisted(() => ({
index: 0
}))
const storeState = vi.hoisted(() => ({
agentStatusByPaneKey: {},
clearTabLaunchAgent: vi.fn(),
ptyIdsByTabId: {} as Record<string, string[]>,
renamingTabId: null as string | null,
repos: [],
setRenamingTabId: vi.fn((tabId: string | null) => {
storeState.renamingTabId = tabId
}),
terminalLayoutsByTabId: {},
worktreesByRepo: {},
unreadTerminalTabs: {} as Record<string, boolean>
}))
const storeState = vi.hoisted(
(): {
agentStatusByPaneKey: Record<string, unknown>
clearTabLaunchAgent: ReturnType<typeof vi.fn>
ptyIdsByTabId: Record<string, string[]>
renamingTabId: string | null
keybindings: Record<string, unknown>
repos: unknown[]
setRenamingTabId: ReturnType<typeof vi.fn>
terminalLayoutsByTabId: Record<string, unknown>
worktreesByRepo: Record<string, unknown>
unreadTerminalTabs: Record<string, boolean>
} => ({
agentStatusByPaneKey: {},
clearTabLaunchAgent: vi.fn(),
ptyIdsByTabId: {} as Record<string, string[]>,
renamingTabId: null as string | null,
keybindings: {},
repos: [],
setRenamingTabId: vi.fn((tabId: string | null) => {
storeState.renamingTabId = tabId
}),
terminalLayoutsByTabId: {},
worktreesByRepo: {},
unreadTerminalTabs: {} as Record<string, boolean>
})
)
vi.mock('react', async () => {
const actual = await vi.importActual<typeof import('react')>('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
@@ -57,6 +71,13 @@ 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 }
@@ -64,6 +85,12 @@ vi.mock('lucide-react', () => ({
Minimize2: function Minimize2(props: Record<string, unknown>) {
return { type: 'Minimize2', props }
},
PanelBottomClose: function PanelBottomClose(props: Record<string, unknown>) {
return { type: 'PanelBottomClose', props }
},
PanelRightClose: function PanelRightClose(props: Record<string, unknown>) {
return { type: 'PanelRightClose', props }
},
Pin: function Pin(props: Record<string, unknown>) {
return { type: 'Pin', props }
},
@@ -78,6 +105,10 @@ vi.mock('lucide-react', () => ({
}
}))
vi.mock('@/hooks/useShortcutLabel', () => ({
formatShortcutLabel: () => '⌘⇧\\'
}))
vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenu: function DropdownMenu(props: { children?: unknown }) {
return { type: 'DropdownMenu', props }
@@ -91,6 +122,21 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenuSeparator: function DropdownMenuSeparator() {
return { type: 'DropdownMenuSeparator', props: {} }
},
DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: unknown }) {
return { type: 'DropdownMenuShortcut', props }
},
DropdownMenuLabel: function DropdownMenuLabel(props: { children?: unknown }) {
return { type: 'DropdownMenuLabel', props }
},
DropdownMenuSub: function DropdownMenuSub(props: { children?: unknown }) {
return { type: 'DropdownMenuSub', props }
},
DropdownMenuSubContent: function DropdownMenuSubContent(props: { children?: unknown }) {
return { type: 'DropdownMenuSubContent', props }
},
DropdownMenuSubTrigger: function DropdownMenuSubTrigger(props: { children?: unknown }) {
return { type: 'DropdownMenuSubTrigger', props }
},
DropdownMenuTrigger: function DropdownMenuTrigger(props: { children?: unknown }) {
return props.children
}
@@ -130,15 +176,26 @@ vi.mock('./drop-indicator', () => ({
ACTIVE_TAB_INDICATOR_CLASSES: 'active-tab-indicator',
getDropIndicatorClasses: () => '',
getTabStripBorderClasses: () => '',
getTabRootStateClasses: () => ''
getTabRootStateClasses: () => '',
showsTabSelectionChrome: () => true
}))
vi.mock('./middle-button-default-guard', () => ({
preventMiddleButtonDefault: vi.fn()
}))
const useAppStoreExport = (selector: (state: typeof storeState) => unknown) => selector(storeState)
useAppStoreExport.getState = () => ({
unifiedTabsByWorktree: {
'wt-1': [{ id: 'terminal-tab-1', groupId: 'group-1' }]
},
groupsByWorktree: {
'wt-1': [{ id: 'group-1', tabOrder: ['terminal-tab-1', 'tab-2'] }]
}
})
vi.mock('@/store', () => ({
useAppStore: (selector: (state: typeof storeState) => unknown) => selector(storeState)
useAppStore: useAppStoreExport
}))
type ReactElementLike = {
@@ -162,6 +219,8 @@ async function renderSortableTab(): Promise<unknown> {
const module = await import('./SortableTab')
return module.default({
tab: makeTerminalTab() as never,
unifiedTabId: 'terminal-tab-1',
groupId: 'group-1',
tabCount: 1,
hasTabsToRight: false,
isActive: true,
@@ -175,7 +234,6 @@ async function renderSortableTab(): Promise<unknown> {
onSetTabColor: vi.fn(),
onTogglePin: vi.fn(),
onToggleExpand: vi.fn(),
onSplitGroup: vi.fn(),
dragData: {
kind: 'tab',
worktreeId: 'wt-1',
@@ -16,15 +16,19 @@ 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'
type SortableTabProps = {
tab: TerminalTab
unifiedTabId: string
groupId: string
tabCount: number
hasTabsToRight: boolean
isActive: boolean
@@ -38,7 +42,6 @@ type SortableTabProps = {
onSetTabColor: (tabId: string, color: string | null) => void
onTogglePin: () => void
onToggleExpand: (tabId: string) => void
onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void
dragData: TabDragItemData
dropIndicator?: DropIndicator
includeTopTabBorder?: boolean
@@ -48,6 +51,8 @@ export const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
export default function SortableTab({
tab,
unifiedTabId,
groupId,
tabCount,
hasTabsToRight,
isActive,
@@ -61,7 +66,6 @@ export default function SortableTab({
onSetTabColor,
onTogglePin,
onToggleExpand,
onSplitGroup,
dragData,
dropIndicator,
includeTopTabBorder = true
@@ -195,6 +199,14 @@ 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 tabTitle = tab.customTitle ?? tab.title
const tabRoot = (
<div
@@ -209,6 +221,7 @@ 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
@@ -218,7 +231,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)}`}
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)}`}
onDoubleClick={(e) => {
if (isEditing) {
return
@@ -227,11 +240,10 @@ export default function SortableTab({
handleRenameOpen()
}}
onPointerDown={(e) => {
if (isEditing || e.button !== 0) {
return
}
onActivate(tab.id)
dragListeners?.onPointerDown?.(e)
onTabPointerDown(
e,
dragListeners?.onPointerDown as ((event: React.PointerEvent<Element>) => void) | undefined
)
}}
onMouseDown={(e) => {
// Why: prevent default browser middle-click behavior (auto-scroll)
@@ -257,7 +269,7 @@ export default function SortableTab({
}
}}
>
{isActive && <span className={ACTIVE_TAB_INDICATOR_CLASSES} aria-hidden />}
{showsSelectionChrome && <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
@@ -279,7 +291,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 ${isActive ? '' : 'opacity-70'}`}
className={`mr-1 inline-flex shrink-0 ${showsSelectionChrome ? '' : 'opacity-70'}`}
data-agent-icon={tabAgent}
aria-hidden
>
@@ -295,7 +307,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 ${isActive ? '' : 'opacity-70'}`}
className={`mr-1 inline-flex shrink-0 ${showsSelectionChrome ? '' : 'opacity-70'}`}
data-shell-icon={shellForIcon ?? 'generic'}
aria-hidden
>
@@ -375,7 +387,7 @@ export default function SortableTab({
{isExpanded && !isEditing && (
<button
className={`mr-1 flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
isActive
showsSelectionChrome
? 'text-muted-foreground hover:text-foreground hover:bg-muted'
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted'
}`}
@@ -393,7 +405,7 @@ export default function SortableTab({
{!isEditing && !isPinned && (
<button
className={`relative z-10 flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
isActive
showsSelectionChrome
? 'text-muted-foreground hover:text-foreground hover:bg-muted'
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted'
}`}
@@ -447,19 +459,22 @@ export default function SortableTab({
<SortableTabContextMenu
tab={tab}
unifiedTabId={unifiedTabId}
groupId={groupId}
isActive={isActive}
open={menuOpen}
point={menuPoint}
tabCount={tabCount}
hasTabsToRight={hasTabsToRight}
isPinned={isPinned}
onOpenChange={setMenuOpen}
onActivate={onActivate}
onClose={onClose}
onCloseOthers={onCloseOthers}
onCloseToRight={onCloseToRight}
onRenameOpen={handleRenameOpen}
onSetTabColor={onSetTabColor}
onTogglePin={onTogglePin}
onSplitGroup={onSplitGroup}
/>
</>
)
@@ -0,0 +1,246 @@
/**
* @vitest-environment happy-dom
*/
import { act, type ComponentProps, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT } from '@/constants/terminal'
import { requestActiveTerminalPaneSplit } from './request-active-terminal-pane-split'
import { SortableTabContextMenu } from './SortableTabContextMenu'
const storeMock = vi.hoisted(() => ({
dropUnifiedTab: vi.fn(),
state: {
keybindings: {},
unifiedTabsByWorktree: {},
groupsByWorktree: {}
} as Record<string, unknown>
}))
vi.mock('@/hooks/useShortcutLabel', () => ({
formatShortcutLabel: () => '⌘D'
}))
vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenu: ({ children }: { children?: ReactNode }) => children,
DropdownMenuContent: ({ children }: { children?: ReactNode }) => children,
DropdownMenuItem: ({
children,
disabled,
onSelect
}: {
children?: ReactNode
disabled?: boolean
onSelect?: () => void
}) => (
<button type="button" disabled={disabled} onClick={() => onSelect?.()}>
{children}
</button>
),
DropdownMenuLabel: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DropdownMenuSeparator: () => null,
DropdownMenuSub: ({ children }: { children?: ReactNode }) => children,
DropdownMenuSubContent: ({ children }: { children?: ReactNode }) => children,
DropdownMenuSubTrigger: ({ children }: { children?: ReactNode }) => (
<button type="button">{children}</button>
),
DropdownMenuShortcut: ({ children }: { children?: ReactNode }) => children,
DropdownMenuTrigger: ({ children }: { children?: ReactNode }) => children
}))
vi.mock('lucide-react', () => ({
PanelBottomClose: () => null,
PanelRightClose: () => null,
Pin: () => null,
PinOff: () => null
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
vi.mock('../../store', () => ({
useAppStore: Object.assign(
(selector: (state: Record<string, unknown>) => unknown) => selector(storeMock.state),
{
getState: () => storeMock.state
}
)
}))
const mounted: { container: HTMLDivElement; root: Root }[] = []
function renderMenu(overrides: Partial<ComponentProps<typeof SortableTabContextMenu>> = {}): {
container: HTMLDivElement
root: Root
onActivate: ReturnType<typeof vi.fn>
} {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const onActivate = vi.fn()
act(() => {
root.render(
<SortableTabContextMenu
tab={{
id: 'term-1',
ptyId: null,
worktreeId: 'wt-1',
title: 'bash',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 0
}}
unifiedTabId="tab-1"
groupId="group-1"
isActive
open
point={{ x: 0, y: 0 }}
tabCount={2}
hasTabsToRight
isPinned={false}
onOpenChange={vi.fn()}
onActivate={onActivate}
onClose={vi.fn()}
onCloseOthers={vi.fn()}
onCloseToRight={vi.fn()}
onRenameOpen={vi.fn()}
onSetTabColor={vi.fn()}
onTogglePin={vi.fn()}
{...overrides}
/>
)
})
mounted.push({ container, root })
return { container, root, onActivate }
}
function getButton(container: HTMLElement, label: string): HTMLButtonElement {
const button = Array.from(container.querySelectorAll('button')).find((candidate) =>
candidate.textContent?.includes(label)
)
if (!(button instanceof HTMLButtonElement)) {
throw new Error(`Missing button: ${label}`)
}
return button
}
function getLastSplitEvent(spy: ReturnType<typeof vi.spyOn>): CustomEvent {
const event = spy.mock.calls.at(-1)?.[0]
if (!(event instanceof CustomEvent)) {
throw new Error('Expected a split request event')
}
return event
}
beforeEach(() => {
storeMock.dropUnifiedTab.mockReset()
storeMock.state = {
keybindings: {},
dropUnifiedTab: storeMock.dropUnifiedTab,
groupsByWorktree: {
'wt-1': [
{
id: 'group-1',
worktreeId: 'wt-1',
activeTabId: 'tab-1',
tabOrder: ['tab-1', 'tab-2']
}
]
},
unifiedTabsByWorktree: {
'wt-1': [
{
id: 'tab-1',
groupId: 'group-1',
worktreeId: 'wt-1',
contentType: 'terminal',
entityId: 'term-1',
label: 'bash',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 0
}
]
}
}
})
afterEach(() => {
for (const { container, root } of mounted.splice(0)) {
act(() => root.unmount())
container.remove()
}
vi.restoreAllMocks()
})
describe('requestActiveTerminalPaneSplit', () => {
it('dispatches the active terminal pane split event', () => {
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
requestActiveTerminalPaneSplit({ tabId: 'term-1', direction: 'vertical' })
expect(dispatchSpy).toHaveBeenCalledTimes(1)
const event = dispatchSpy.mock.calls[0]?.[0] as CustomEvent
expect(event.type).toBe(REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT)
expect(event.detail).toEqual({
tabId: 'term-1',
direction: 'vertical'
})
})
})
describe('SortableTabContextMenu', () => {
it('dispatches split requests and activates inactive terminal tabs first', () => {
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
const { container, onActivate } = renderMenu({ isActive: false })
act(() => getButton(container, 'Split terminal right').click())
expect(onActivate).toHaveBeenCalledWith('term-1')
expect(getLastSplitEvent(dispatchSpy).detail).toEqual({
tabId: 'term-1',
direction: 'vertical'
})
dispatchSpy.mockClear()
act(() => getButton(container, 'Split terminal down').click())
expect(getLastSplitEvent(dispatchSpy).detail).toEqual({
tabId: 'term-1',
direction: 'horizontal'
})
})
it('renders split actions and routes directions to the move path', () => {
storeMock.dropUnifiedTab.mockReturnValue(true)
const { container } = renderMenu()
expect(container.textContent).toContain('Move Tab to Split')
act(() => getButton(container, 'Right').click())
expect(storeMock.dropUnifiedTab).toHaveBeenCalledWith('tab-1', {
groupId: 'group-1',
splitDirection: 'right'
})
})
it('hides split actions for a single-tab group', () => {
storeMock.state = {
...storeMock.state,
groupsByWorktree: {
'wt-1': [
{
id: 'group-1',
worktreeId: 'wt-1',
activeTabId: 'tab-1',
tabOrder: ['tab-1']
}
]
}
}
const { container } = renderMenu()
expect(container.textContent).not.toContain('Move Tab to Split')
})
})
@@ -1,13 +1,18 @@
import { Columns2, Pin, PinOff, Rows2 } from 'lucide-react'
import { PanelBottomClose, PanelRightClose, Pin, PinOff } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import type { TerminalTab } from '../../../../shared/types'
import { useAppStore } from '../../store'
import { formatShortcutLabel } from '@/hooks/useShortcutLabel'
import { translate } from '@/i18n/i18n'
import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection'
import { requestActiveTerminalPaneSplit } from './request-active-terminal-pane-split'
const TAB_COLORS = [
{
@@ -74,37 +79,54 @@ const TAB_COLORS = [
type SortableTabContextMenuProps = {
tab: TerminalTab
unifiedTabId: string
groupId: string
isActive: boolean
open: boolean
point: { x: number; y: number }
tabCount: number
hasTabsToRight: boolean
isPinned: boolean
onOpenChange: (open: boolean) => void
onActivate: (tabId: string) => void
onClose: (tabId: string) => void
onCloseOthers: (tabId: string) => void
onCloseToRight: (tabId: string) => void
onRenameOpen: () => void
onSetTabColor: (tabId: string, color: string | null) => void
onTogglePin: () => void
onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void
}
export function SortableTabContextMenu({
tab,
unifiedTabId,
groupId,
isActive,
open,
point,
tabCount,
hasTabsToRight,
isPinned,
onOpenChange,
onActivate,
onClose,
onCloseOthers,
onCloseToRight,
onRenameOpen,
onSetTabColor,
onTogglePin,
onSplitGroup
onTogglePin
}: SortableTabContextMenuProps): React.JSX.Element {
const keybindings = useAppStore((state) => state.keybindings)
const splitRightShortcut = formatShortcutLabel('terminal.splitRight', keybindings)
const splitDownShortcut = formatShortcutLabel('terminal.splitDown', keybindings)
const splitActiveTerminalPane = (direction: 'vertical' | 'horizontal'): void => {
if (!isActive) {
onActivate(tab.id)
}
requestActiveTerminalPaneSplit({ tabId: tab.id, direction })
}
return (
<DropdownMenu open={open} onOpenChange={onOpenChange} modal={false}>
<DropdownMenuTrigger asChild>
@@ -115,23 +137,24 @@ export function SortableTabContextMenu({
style={{ left: point.x, top: point.y }}
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48" sideOffset={0} align="start">
<DropdownMenuItem onSelect={() => onSplitGroup('up', tab.id)}>
<Rows2 className="mr-1.5 size-3.5" />
{translate('auto.components.tab.bar.SortableTabContextMenu.591f9b12c1', 'Split Up')}
<DropdownMenuContent className="w-56" sideOffset={0} align="start">
<DropdownMenuItem onSelect={() => splitActiveTerminalPane('vertical')}>
<PanelRightClose />
{translate(
'auto.components.tab.bar.SortableTabContextMenu.splitTerminalRight',
'Split terminal right'
)}
<DropdownMenuShortcut>{splitRightShortcut}</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitGroup('down', tab.id)}>
<Rows2 className="mr-1.5 size-3.5" />
{translate('auto.components.tab.bar.SortableTabContextMenu.af80ed83c1', 'Split Down')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitGroup('left', tab.id)}>
<Columns2 className="mr-1.5 size-3.5" />
{translate('auto.components.tab.bar.SortableTabContextMenu.0ce4bae39d', 'Split Left')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitGroup('right', tab.id)}>
<Columns2 className="mr-1.5 size-3.5" />
{translate('auto.components.tab.bar.SortableTabContextMenu.21132389e9', 'Split Right')}
<DropdownMenuItem onSelect={() => splitActiveTerminalPane('horizontal')}>
<PanelBottomClose />
{translate(
'auto.components.tab.bar.SortableTabContextMenu.splitTerminalDown',
'Split terminal down'
)}
<DropdownMenuShortcut>{splitDownShortcut}</DropdownMenuShortcut>
</DropdownMenuItem>
<TabWorkspaceLayoutMenuSection unifiedTabId={unifiedTabId} groupId={groupId} />
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onTogglePin}>
{isPinned ? <PinOff className="mr-1.5 size-3.5" /> : <Pin className="mr-1.5 size-3.5" />}
@@ -88,6 +88,15 @@ vi.mock('@dnd-kit/sortable', () => ({
}
}))
vi.mock('./tab-strip-drag-scroll', () => ({
useTabStripDragScrollHandlers: () => ({
isTabDragActive: false,
onDragScrollStartEnter: vi.fn(),
onDragScrollEndEnter: vi.fn(),
onDragScrollLeave: vi.fn()
})
}))
const useAppStoreExport = (selector: Parameters<typeof useAppStoreMock>[0]): unknown =>
useAppStoreMock(selector)
useAppStoreExport.getState = vi.fn(() => ({
@@ -174,6 +183,18 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: unknown }) {
return { type: 'DropdownMenuShortcut', props }
},
DropdownMenuLabel: function DropdownMenuLabel(props: { children?: unknown }) {
return { type: 'DropdownMenuLabel', props }
},
DropdownMenuSub: function DropdownMenuSub(props: { children?: unknown }) {
return { type: 'DropdownMenuSub', props }
},
DropdownMenuSubContent: function DropdownMenuSubContent(props: { children?: unknown }) {
return { type: 'DropdownMenuSubContent', props }
},
DropdownMenuSubTrigger: function DropdownMenuSubTrigger(props: { children?: unknown }) {
return { type: 'DropdownMenuSubTrigger', props }
},
DropdownMenuTrigger: function DropdownMenuTrigger(props: { children?: unknown }) {
return { type: 'DropdownMenuTrigger', props }
}
+17 -19
View File
@@ -75,6 +75,7 @@ import { translate } from '@/i18n/i18n'
import { TabStripScrollIndicator } from './TabStripScrollIndicator'
import { getTabStripScrollMaskClassName } from './tab-strip-scroll-metrics'
import { useTabStripOverflowNavigation } from './tab-strip-overflow-navigation'
import { useTabStripDragScrollHandlers } from './tab-strip-drag-scroll'
const isWindows = navigator.userAgent.includes('Windows')
const isMacOs = navigator.userAgent.includes('Mac')
@@ -137,10 +138,6 @@ type TabBarProps = {
onMakePreviewFilePermanent?: (fileId: string, tabId?: string) => void
onPinFile?: (fileId: string, tabId?: string) => void
tabBarOrder?: string[]
onCreateSplitGroup?: (
direction: 'left' | 'right' | 'up' | 'down',
sourceVisibleTabId?: string
) => void
hoveredTabInsertion?: HoveredTabInsertion | null
/** Floating workspace panels are rounded; skip tab top borders that clash with the curve. */
tabStripChrome?: 'default' | 'floating-panel'
@@ -266,7 +263,6 @@ function TabBarInner({
onMakePreviewFilePermanent,
onPinFile,
tabBarOrder,
onCreateSplitGroup,
hoveredTabInsertion,
tabStripChrome = 'default'
}: TabBarProps): React.JSX.Element {
@@ -981,6 +977,10 @@ function TabBarInner({
tabCount: orderedItems.length,
worktreeId
})
const tabStripDragScroll = useTabStripDragScrollHandlers(scrollTabStrip, {
start: tabStripOverflowState.canScrollStart,
end: tabStripOverflowState.canScrollEnd
})
return (
<div
@@ -1005,8 +1005,13 @@ function TabBarInner({
'auto.components.tab.bar.TabBar.7a9b4af2af',
'Scroll tabs left'
)}
disabled={!tabStripOverflowState.canScrollStart}
aria-disabled={!tabStripOverflowState.canScrollStart}
disabled={
!tabStripDragScroll.isTabDragActive && !tabStripOverflowState.canScrollStart
}
onClick={() => scrollTabStrip('start')}
onPointerEnter={tabStripDragScroll.onDragScrollStartEnter}
onPointerLeave={tabStripDragScroll.onDragScrollLeave}
>
<ChevronLeft className="size-3.5" />
</Button>
@@ -1069,6 +1074,8 @@ function TabBarInner({
<SortableTab
key={item.id}
tab={terminalTab}
unifiedTabId={item.unifiedTabId}
groupId={resolvedGroupId}
tabCount={orderedItems.length}
hasTabsToRight={index < orderedItems.length - 1}
isActive={
@@ -1085,9 +1092,6 @@ function TabBarInner({
onSetTabColor={onSetTabColor}
onTogglePin={() => togglePinned(item)}
onToggleExpand={onTogglePaneExpand}
onSplitGroup={(direction, sourceVisibleTabId) =>
onCreateSplitGroup?.(direction, sourceVisibleTabId)
}
dragData={dragData}
dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null}
includeTopTabBorder={includeTopTabBorder}
@@ -1105,9 +1109,6 @@ function TabBarInner({
onActivate={() => onActivateBrowserTab?.(item.id)}
onClose={() => onCloseBrowserTab?.(item.id)}
onCloseToRight={() => onCloseToRight(item.id)}
onSplitGroup={(direction, sourceVisibleTabId) =>
onCreateSplitGroup?.(direction, sourceVisibleTabId)
}
onDuplicate={() => onDuplicateBrowserTab?.(item.id)}
onTogglePin={() => togglePinned(item)}
dragData={dragData}
@@ -1143,9 +1144,6 @@ function TabBarInner({
onCloseAll={() => onCloseAllFiles?.()}
onMakePermanent={() => {}}
onTogglePin={() => togglePinned(item)}
onSplitGroup={(direction, sourceVisibleTabId) =>
onCreateSplitGroup?.(direction, sourceVisibleTabId)
}
dragData={dragData}
dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null}
includeTopTabBorder={includeTopTabBorder}
@@ -1171,9 +1169,6 @@ function TabBarInner({
onMakePreviewFilePermanent?.(item.data.id, item.data.tabId)
}
onTogglePin={() => togglePinned(item)}
onSplitGroup={(direction, sourceVisibleTabId) =>
onCreateSplitGroup?.(direction, sourceVisibleTabId)
}
dragData={dragData}
dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null}
includeTopTabBorder={includeTopTabBorder}
@@ -1196,8 +1191,11 @@ function TabBarInner({
'auto.components.tab.bar.TabBar.232e075b07',
'Scroll tabs right'
)}
disabled={!tabStripOverflowState.canScrollEnd}
aria-disabled={!tabStripOverflowState.canScrollEnd}
disabled={!tabStripDragScroll.isTabDragActive && !tabStripOverflowState.canScrollEnd}
onClick={() => scrollTabStrip('end')}
onPointerEnter={tabStripDragScroll.onDragScrollEndEnter}
onPointerLeave={tabStripDragScroll.onDragScrollLeave}
>
<ChevronRight className="size-3.5" />
</Button>
@@ -140,6 +140,15 @@ vi.mock('@dnd-kit/sortable', () => ({
}
}))
vi.mock('./tab-strip-drag-scroll', () => ({
useTabStripDragScrollHandlers: () => ({
isTabDragActive: false,
onDragScrollStartEnter: vi.fn(),
onDragScrollEndEnter: vi.fn(),
onDragScrollLeave: vi.fn()
})
}))
const useAppStoreExport = (selector: Parameters<typeof useAppStoreMock>[0]): unknown =>
useAppStoreMock(selector)
useAppStoreExport.getState = vi.fn(() => ({
@@ -237,6 +246,18 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: unknown }) {
return { type: 'DropdownMenuShortcut', props }
},
DropdownMenuLabel: function DropdownMenuLabel(props: { children?: unknown }) {
return { type: 'DropdownMenuLabel', props }
},
DropdownMenuSub: function DropdownMenuSub(props: { children?: unknown }) {
return { type: 'DropdownMenuSub', props }
},
DropdownMenuSubContent: function DropdownMenuSubContent(props: { children?: unknown }) {
return { type: 'DropdownMenuSubContent', props }
},
DropdownMenuSubTrigger: function DropdownMenuSubTrigger(props: { children?: unknown }) {
return { type: 'DropdownMenuSubTrigger', props }
},
DropdownMenuTrigger: function DropdownMenuTrigger(props: { children?: unknown }) {
return { type: 'DropdownMenuTrigger', props }
}
@@ -0,0 +1,63 @@
import {
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuItem
} from '@/components/ui/dropdown-menu'
import type { TabSplitDirection } from '../../store/slices/tabs'
import { translate } from '@/i18n/i18n'
import { canMoveTabToNewPaneColumn, moveTabToNewPaneColumn } from './tab-move-to-pane-column'
const PANE_COLUMN_DIRECTIONS: TabSplitDirection[] = ['right', 'left', 'down', 'up']
function paneColumnDirectionLabel(direction: TabSplitDirection): string {
switch (direction) {
case 'right':
return translate('auto.components.tab.bar.TabWorkspaceLayoutMenuSection.right', 'Right')
case 'left':
return translate('auto.components.tab.bar.TabWorkspaceLayoutMenuSection.left', 'Left')
case 'down':
return translate('auto.components.tab.bar.TabWorkspaceLayoutMenuSection.down', 'Down')
case 'up':
return translate('auto.components.tab.bar.TabWorkspaceLayoutMenuSection.up', 'Up')
}
}
export function TabWorkspaceLayoutMenuSection({
unifiedTabId,
groupId
}: {
unifiedTabId: string
groupId: string
}): React.JSX.Element | null {
if (!canMoveTabToNewPaneColumn(unifiedTabId, groupId)) {
return null
}
return (
<>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{translate(
'auto.components.tab.bar.TabWorkspaceLayoutMenuSection.moveToPaneColumn',
'Move Tab to Split'
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{PANE_COLUMN_DIRECTIONS.map((direction) => (
<DropdownMenuItem
key={direction}
onSelect={() => {
moveTabToNewPaneColumn({ unifiedTabId, groupId, direction })
}}
>
{paneColumnDirectionLabel(direction)}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
)
}
@@ -84,4 +84,11 @@ 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,8 +28,12 @@ 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 getTabRootStateClasses(isActive: boolean): string {
return isActive
export function showsTabSelectionChrome(isActive: boolean, isPressed = false): boolean {
return isActive || isPressed
}
export function getTabRootStateClasses(isActive: boolean, isPressed = false): string {
return showsTabSelectionChrome(isActive, isPressed)
? 'bg-[color-mix(in_srgb,var(--foreground)_6%,var(--card))] text-foreground'
: 'bg-card text-muted-foreground hover:text-foreground'
}
@@ -0,0 +1,13 @@
import {
REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT,
type RequestActiveTerminalPaneSplitDetail
} from '@/constants/terminal'
export function requestActiveTerminalPaneSplit(detail: RequestActiveTerminalPaneSplitDetail): void {
window.dispatchEvent(
new CustomEvent<RequestActiveTerminalPaneSplitDetail>(
REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT,
{ detail }
)
)
}
@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '../../store'
import type { Tab } from '../../../../shared/types'
import { canMoveTabToNewPaneColumn, moveTabToNewPaneColumn } from './tab-move-to-pane-column'
const WT = 'wt-1'
const mocks = vi.hoisted(() => ({
mirrorWebRuntimeTabMove: vi.fn()
}))
vi.mock('./web-runtime-tab-move-mirror', () => ({
mirrorWebRuntimeTabMove: mocks.mirrorWebRuntimeTabMove
}))
describe('tab-move-to-pane-column', () => {
beforeEach(() => {
mocks.mirrorWebRuntimeTabMove.mockReset()
useAppStore.setState({
activeWorktreeId: WT,
groupsByWorktree: {
[WT]: [
{
id: 'group-1',
worktreeId: WT,
activeTabId: 'tab-a',
tabOrder: ['tab-a', 'tab-b']
}
]
},
unifiedTabsByWorktree: {
[WT]: [
{
id: 'tab-a',
groupId: 'group-1',
worktreeId: WT,
contentType: 'terminal',
entityId: 'term-a',
label: 'A',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 0
} satisfies Tab,
{
id: 'tab-b',
groupId: 'group-1',
worktreeId: WT,
contentType: 'terminal',
entityId: 'term-b',
label: 'B',
customLabel: null,
color: null,
sortOrder: 1,
createdAt: 1
} satisfies Tab
]
},
layoutByWorktree: {
[WT]: { type: 'leaf', groupId: 'group-1' }
}
})
})
it('allows moving when the source group has more than one tab', () => {
expect(canMoveTabToNewPaneColumn('tab-b', 'group-1')).toBe(true)
})
it('blocks moving the only tab in a group', () => {
useAppStore.setState({
groupsByWorktree: {
[WT]: [
{
id: 'group-1',
worktreeId: WT,
activeTabId: 'tab-a',
tabOrder: ['tab-a']
}
]
}
})
expect(canMoveTabToNewPaneColumn('tab-a', 'group-1')).toBe(false)
expect(
moveTabToNewPaneColumn({ unifiedTabId: 'tab-a', groupId: 'group-1', direction: 'right' })
).toBe(false)
})
it('creates a sibling split pane via dropUnifiedTab', () => {
const dropUnifiedTab = vi.fn(() => true)
useAppStore.setState({ dropUnifiedTab } as Partial<ReturnType<typeof useAppStore.getState>>)
expect(
moveTabToNewPaneColumn({ unifiedTabId: 'tab-b', groupId: 'group-1', direction: 'right' })
).toBe(true)
expect(dropUnifiedTab).toHaveBeenCalledWith('tab-b', {
groupId: 'group-1',
splitDirection: 'right'
})
expect(mocks.mirrorWebRuntimeTabMove).toHaveBeenCalledWith({
kind: 'split',
worktreeId: WT,
tabId: 'tab-b',
targetGroupId: 'group-1',
splitDirection: 'right'
})
})
it('does not mirror when the local store rejects the move', () => {
const dropUnifiedTab = vi.fn(() => false)
useAppStore.setState({ dropUnifiedTab } as Partial<ReturnType<typeof useAppStore.getState>>)
expect(
moveTabToNewPaneColumn({ unifiedTabId: 'tab-b', groupId: 'group-1', direction: 'right' })
).toBe(false)
expect(mocks.mirrorWebRuntimeTabMove).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,65 @@
import { useAppStore } from '../../store'
import type { TabSplitDirection } from '../../store/slices/tabs'
import { mirrorWebRuntimeTabMove } from './web-runtime-tab-move-mirror'
type TabMovePaneColumnState = Pick<
ReturnType<typeof useAppStore.getState>,
'unifiedTabsByWorktree' | 'groupsByWorktree'
>
export function canMoveTabToNewPaneColumnFromState(
state: TabMovePaneColumnState,
unifiedTabId: string,
groupId: string
): boolean {
for (const [worktreeId, tabs] of Object.entries(state.unifiedTabsByWorktree)) {
const tab = tabs.find((candidate) => candidate.id === unifiedTabId)
if (!tab || tab.groupId !== groupId) {
continue
}
const group = (state.groupsByWorktree[worktreeId] ?? []).find(
(candidate) => candidate.id === groupId
)
if (!group) {
return false
}
// Why: mirror dropUnifiedTab — splitting the only tab in a group onto an
// adjacent split pane is a layout no-op the store rejects.
return group.tabOrder.length > 1
}
return false
}
export function canMoveTabToNewPaneColumn(unifiedTabId: string, groupId: string): boolean {
return canMoveTabToNewPaneColumnFromState(useAppStore.getState(), unifiedTabId, groupId)
}
export function moveTabToNewPaneColumn(args: {
unifiedTabId: string
groupId: string
direction: TabSplitDirection
}): boolean {
const state = useAppStore.getState()
const worktreeId = Object.entries(state.unifiedTabsByWorktree).find(([, tabs]) =>
tabs.some(
(candidate) => candidate.id === args.unifiedTabId && candidate.groupId === args.groupId
)
)?.[0]
if (!worktreeId || !canMoveTabToNewPaneColumnFromState(state, args.unifiedTabId, args.groupId)) {
return false
}
const moved = state.dropUnifiedTab(args.unifiedTabId, {
groupId: args.groupId,
splitDirection: args.direction
})
if (moved) {
mirrorWebRuntimeTabMove({
kind: 'split',
worktreeId,
tabId: args.unifiedTabId,
targetGroupId: args.groupId,
splitDirection: args.direction
})
}
return moved
}
@@ -0,0 +1,68 @@
import { useCallback, useEffect, useRef } from 'react'
import { useTabDragActive } from '../tab-group/tab-drag-context'
const TAB_STRIP_DRAG_SCROLL_INTERVAL_MS = 180
export function useTabStripDragScrollHandlers(
scrollTabStrip: (direction: 'start' | 'end', behavior?: ScrollBehavior) => void,
canScroll: { start: boolean; end: boolean }
): {
isTabDragActive: boolean
onDragScrollStartEnter: () => void
onDragScrollEndEnter: () => void
onDragScrollLeave: () => void
} {
const isTabDragActive = useTabDragActive()
const intervalRef = useRef<number | null>(null)
const canScrollRef = useRef(canScroll)
canScrollRef.current = canScroll
const stopDragScroll = useCallback((): void => {
if (intervalRef.current !== null) {
window.clearInterval(intervalRef.current)
intervalRef.current = null
}
}, [])
const startDragScroll = useCallback(
(direction: 'start' | 'end'): void => {
stopDragScroll()
if (!isTabDragActive) {
return
}
const canScrollInDirection = (): boolean =>
direction === 'start' ? canScrollRef.current.start : canScrollRef.current.end
if (!canScrollInDirection()) {
return
}
const tick = (): void => {
if (!canScrollInDirection()) {
stopDragScroll()
return
}
scrollTabStrip(direction, 'auto')
}
tick()
intervalRef.current = window.setInterval(tick, TAB_STRIP_DRAG_SCROLL_INTERVAL_MS)
},
[isTabDragActive, scrollTabStrip, stopDragScroll]
)
useEffect(() => {
if (!isTabDragActive) {
stopDragScroll()
}
}, [isTabDragActive, stopDragScroll])
useEffect(() => () => stopDragScroll(), [stopDragScroll])
return {
isTabDragActive,
onDragScrollStartEnter: useCallback(() => startDragScroll('start'), [startDragScroll]),
onDragScrollEndEnter: useCallback(() => startDragScroll('end'), [startDragScroll]),
onDragScrollLeave: stopDragScroll
}
}
@@ -0,0 +1,19 @@
import { describe, expect, it, vi } from 'vitest'
import { scrollTabStripByStep } from './tab-strip-overflow-navigation'
describe('scrollTabStripByStep', () => {
it('scrolls instantly when requested for drag-hover navigation', () => {
const scrollBy = vi.fn()
const el = {
clientWidth: 200,
scrollBy
} as unknown as HTMLElement
scrollTabStripByStep(el, 'end', 'auto')
expect(scrollBy).toHaveBeenCalledWith({
left: 150,
behavior: 'auto'
})
})
})
@@ -9,6 +9,21 @@ import {
const TAB_STRIP_SCROLL_FRACTION = 0.75
const TAB_STRIP_MIN_SCROLL_STEP_PX = 120
export function scrollTabStripByStep(
el: HTMLElement,
direction: 'start' | 'end',
behavior: ScrollBehavior = 'smooth'
): void {
const scrollStep = Math.max(
TAB_STRIP_MIN_SCROLL_STEP_PX,
el.clientWidth * TAB_STRIP_SCROLL_FRACTION
)
el.scrollBy({
left: direction === 'start' ? -scrollStep : scrollStep,
behavior
})
}
const EMPTY_TAB_STRIP_OVERFLOW_STATE: TabStripScrollMetrics = {
hasOverflow: false,
canScrollStart: false,
@@ -30,7 +45,7 @@ export function useTabStripOverflowNavigation({
}): {
tabStripRef: RefObject<HTMLDivElement | null>
tabStripOverflowState: TabStripScrollMetrics
scrollTabStrip: (direction: 'start' | 'end') => void
scrollTabStrip: (direction: 'start' | 'end', behavior?: ScrollBehavior) => void
} {
const tabStripRef = useRef<HTMLDivElement>(null)
const prevStripLenRef = useRef<{ worktreeId: string; len: number } | null>(null)
@@ -48,20 +63,16 @@ export function useTabStripOverflowNavigation({
sameTabStripScrollMetrics(previous, next) ? previous : next
)
}, [])
const scrollTabStrip = useCallback((direction: 'start' | 'end'): void => {
const el = tabStripRef.current
if (!el) {
return
}
const scrollStep = Math.max(
TAB_STRIP_MIN_SCROLL_STEP_PX,
el.clientWidth * TAB_STRIP_SCROLL_FRACTION
)
el.scrollBy({
left: direction === 'start' ? -scrollStep : scrollStep,
behavior: 'smooth'
})
}, [])
const scrollTabStrip = useCallback(
(direction: 'start' | 'end', behavior: ScrollBehavior = 'smooth'): void => {
const el = tabStripRef.current
if (!el) {
return
}
scrollTabStripByStep(el, direction, behavior)
},
[]
)
useEffect(() => {
const el = tabStripRef.current
@@ -0,0 +1,112 @@
/**
* @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): void {
target.dispatchEvent(new MouseEvent(type, { bubbles: true, button: 0 }))
}
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('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()
})
})
@@ -0,0 +1,72 @@
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 = (event: PointerEvent): void => {
if (event.button !== 0) {
return
}
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 }
}
@@ -62,6 +62,10 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenuItem: ({ children }: { children?: ReactNode }) => <>{children}</>,
DropdownMenuSeparator: () => null,
DropdownMenuShortcut: ({ children }: { children?: ReactNode }) => <>{children}</>,
DropdownMenuLabel: ({ children }: { children?: ReactNode }) => <>{children}</>,
DropdownMenuSub: ({ children }: { children?: ReactNode }) => <>{children}</>,
DropdownMenuSubContent: ({ children }: { children?: ReactNode }) => <>{children}</>,
DropdownMenuSubTrigger: ({ children }: { children?: ReactNode }) => <>{children}</>,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
@@ -241,6 +245,8 @@ describe('tab title tooltips', () => {
const markup = renderToStaticMarkup(
<SortableTab
tab={makeTerminalTab({ customTitle: 'Custom terminal title' })}
unifiedTabId="terminal-1"
groupId="group-1"
tabCount={1}
hasTabsToRight={false}
isActive={true}
@@ -254,7 +260,6 @@ describe('tab title tooltips', () => {
onSetTabColor={vi.fn()}
onTogglePin={vi.fn()}
onToggleExpand={vi.fn()}
onSplitGroup={vi.fn()}
dragData={makeDragData('terminal', 'terminal-1')}
/>
)
@@ -273,6 +278,8 @@ describe('tab title tooltips', () => {
const markup = renderToStaticMarkup(
<SortableTab
tab={makeTerminalTab({ title: '✳ Claude Code' })}
unifiedTabId="terminal-1"
groupId="group-1"
tabCount={1}
hasTabsToRight={false}
isActive={true}
@@ -286,7 +293,6 @@ describe('tab title tooltips', () => {
onSetTabColor={vi.fn()}
onTogglePin={vi.fn()}
onToggleExpand={vi.fn()}
onSplitGroup={vi.fn()}
dragData={makeDragData('terminal', 'terminal-1')}
/>
)
@@ -309,7 +315,6 @@ describe('tab title tooltips', () => {
onActivate={vi.fn()}
onClose={vi.fn()}
onCloseToRight={vi.fn()}
onSplitGroup={vi.fn()}
onDuplicate={vi.fn()}
onTogglePin={vi.fn()}
dragData={makeDragData('browser', 'browser-1')}
@@ -340,7 +345,6 @@ describe('tab title tooltips', () => {
onCloseAll={vi.fn()}
onMakePermanent={vi.fn()}
onTogglePin={vi.fn()}
onSplitGroup={vi.fn()}
dragData={makeDragData('editor', 'editor-tab-1')}
/>
)
@@ -0,0 +1,22 @@
import type { RuntimeMobileSessionTabMove } from '../../../../shared/runtime-types'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { useAppStore } from '../../store'
import {
isWebRuntimeSessionActive,
moveWebRuntimeSessionTab
} from '../../runtime/web-runtime-session'
export function mirrorWebRuntimeTabMove(
args: RuntimeMobileSessionTabMove & {
worktreeId: string
}
): void {
const environmentId = getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), args.worktreeId)
if (!isWebRuntimeSessionActive(environmentId)) {
return
}
void moveWebRuntimeSessionTab({
...args,
environmentId
})
}
@@ -9,7 +9,8 @@ import {
readAiVaultSessionDragData
} from '@/lib/ai-vault-session-drag'
import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session'
import { resolveDropZone, type TabDropZone } from './useTabDragSplit'
import { resolveDropZone } from './tab-drop-zone'
import type { TabDropZone } from './useTabDragSplit'
import { translate } from '@/i18n/i18n'
type PaneDropTarget = {
@@ -1,5 +1,6 @@
import type { CSSProperties } from 'react'
import type { TabDropZone } from './useTabDragSplit'
import { translate } from '@/i18n/i18n'
function getOverlayStyle(zone: TabDropZone): CSSProperties {
switch (zone) {
@@ -16,8 +17,27 @@ function getOverlayStyle(zone: TabDropZone): CSSProperties {
}
}
export default function TabGroupDropOverlay({ zone }: { zone: TabDropZone }): React.JSX.Element {
export default function TabGroupDropOverlay({
zone,
showPaneColumnLabel = false,
fillContainer = false
}: {
zone: TabDropZone
showPaneColumnLabel?: boolean
/** When the parent already sizes the overlay to the target region. */
fillContainer?: boolean
}): React.JSX.Element {
return (
<div aria-hidden="true" className="tab-drop-overlay absolute" style={getOverlayStyle(zone)} />
<div
aria-hidden="true"
className="tab-drop-overlay absolute"
style={fillContainer ? { inset: 0 } : getOverlayStyle(zone)}
>
{showPaneColumnLabel && zone !== 'center' ? (
<span className="tab-drop-overlay__label pointer-events-none absolute bottom-2 left-2 rounded-sm px-1.5 py-0.5 font-medium">
{translate('auto.components.tab.group.TabGroupDropOverlay.paneColumnLabel', 'New split')}
</span>
) : null}
</div>
)
}
@@ -1,27 +1,22 @@
import { Suspense, useMemo } from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import { useDroppable } from '@dnd-kit/core'
import { Columns2, Ellipsis, Rows2, X } from 'lucide-react'
import { Columns2, Ellipsis, X } from 'lucide-react'
import { useAppStore } from '../../store'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import TabBar from '../tab-bar/TabBar'
import { TabBarQuickCommandsButton } from '../tab-bar/TabBarQuickCommandsButton'
import { useTabGroupWorkspaceModel } from './useTabGroupWorkspaceModel'
import TabGroupDropOverlay from './TabGroupDropOverlay'
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
import { resolveGroupTabFromVisibleId } from './tab-group-visible-id'
import {
getTabPaneBodyDroppableId,
type HoveredTabInsertion,
type TabDropZone
} from './useTabDragSplit'
import { getTabPaneBodyDroppableId, type HoveredTabInsertion } from './useTabDragSplit'
import { tabGroupBodyAnchorName } from './tab-group-body-anchor'
import { translate } from '@/i18n/i18n'
@@ -37,7 +32,6 @@ export default function TabGroupPanel({
reserveClosedExplorerToggleSpace,
reserveCollapsedSidebarHeaderSpace,
isTabDragActive = false,
activeDropZone = null,
hoveredTabInsertion = null
}: {
groupId: string
@@ -49,7 +43,6 @@ export default function TabGroupPanel({
reserveClosedExplorerToggleSpace: boolean
reserveCollapsedSidebarHeaderSpace: boolean
isTabDragActive?: boolean
activeDropZone?: TabDropZone | null
hoveredTabInsertion?: HoveredTabInsertion | null
}): React.JSX.Element {
const rightSidebarOpen = useAppStore((state) => state.rightSidebarOpen)
@@ -180,19 +173,20 @@ export default function TabGroupPanel({
commands.pinFile(item.entityId, item.id)
}}
tabBarOrder={tabBarOrder}
onCreateSplitGroup={commands.createSplitGroup}
hoveredTabInsertion={hoveredTabInsertion}
/>
)
const menuButtonClassName =
'my-auto flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent'
// Why: focused-only — the QC split-button and Pane Actions ellipsis both
// appear together so the action cluster never reflows when focus shifts
// between groups. Unfocused groups collapse the cluster fully (no
// reserved width) since the surrounding tab strip already absorbs the
// freed space.
const actionChromeClassName = `flex shrink-0 items-center gap-0.5 overflow-hidden transition-[opacity] duration-150 ${
// Why: every split pane owns its own split affordance (VS Code-style). The
// button always splits right; up/down and left stay on tab drag.
const splitPaneButtonClassName = `${menuButtonClassName} ${
isFocused ? 'opacity-100' : 'opacity-70 hover:opacity-100'
}`
// Why: focused-only — quick commands and Close split pane stay with the
// active pane so unfocused strips stay compact aside from the split control.
const focusedActionChromeClassName = `flex shrink-0 items-center gap-0.5 overflow-hidden transition-[opacity] duration-150 ${
isFocused ? 'ml-1.5 pointer-events-auto opacity-100' : 'pointer-events-none opacity-0 w-0'
}`
return (
@@ -207,7 +201,7 @@ export default function TabGroupPanel({
// reads as "selected" without making the unfocused content look
// washed out or hard to read. Only applied when `hasSplitGroups`
// because a lone group has nothing to contrast against.
className={`group/tab-group flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${
className={`group/tab-group relative flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${
hasSplitGroups
? // Why: drop the outer borders on the edge-touching groups. The
// TabGroupSplitLayout wrapper already paints a full-height
@@ -257,74 +251,63 @@ export default function TabGroupPanel({
/>
) : null}
<div className="min-w-0 flex-1 h-full">{tabBar}</div>
{/* Why: pane-scoped layout actions belong with the active pane instead
{/* Why: pane-scoped layout actions belong with each split pane instead
of the global tab-bar `+`, which should keep opening tabs exactly
as before. The local overflow menu holds split directions and
close-group without changing the existing tab-creation affordance. */}
as before. Split-right is one click (VS Code-style); close-group
stays on the focused pane only. */}
<div
className={actionChromeClassName}
className="ml-1.5 flex shrink-0 items-center gap-0.5"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
{isFocused ? (
<TabBarQuickCommandsButton worktreeId={worktreeId} groupId={groupId} />
) : null}
{isFocused ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={translate(
'auto.components.tab.group.TabGroupPanel.9acaf92093',
'Pane Actions'
)}
title={translate(
'auto.components.tab.group.TabGroupPanel.9acaf92093',
'Pane Actions'
)}
onClick={(event) => {
event.stopPropagation()
}}
className={menuButtonClassName}
>
<Ellipsis className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" sideOffset={4}>
<DropdownMenuItem
onSelect={() => {
commands.createSplitGroup('right')
}}
>
<Columns2 className="size-4" />
{translate('auto.components.tab.group.TabGroupPanel.ab1e2bff04', 'Split Right')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
commands.createSplitGroup('down')
}}
>
<Rows2 className="size-4" />
{translate('auto.components.tab.group.TabGroupPanel.4df2a06d36', 'Split Down')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
commands.createSplitGroup('left')
}}
>
<Columns2 className="size-4" />
{translate('auto.components.tab.group.TabGroupPanel.30137df7d0', 'Split Left')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
commands.createSplitGroup('up')
}}
>
<Rows2 className="size-4" />
{translate('auto.components.tab.group.TabGroupPanel.0db2081805', 'Split Up')}
</DropdownMenuItem>
{hasSplitGroups ? (
<>
<DropdownMenuSeparator />
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={translate(
'auto.components.tab.group.TabGroupPanel.addSplitPane',
'Add split pane'
)}
onClick={(event) => {
event.stopPropagation()
commands.createSplitGroup('right')
}}
className={splitPaneButtonClassName}
>
<Columns2 className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate(
'auto.components.tab.group.TabGroupPanel.addSplitPane',
'Add split pane'
)}
</TooltipContent>
</Tooltip>
<div className={focusedActionChromeClassName}>
{isFocused ? (
<TabBarQuickCommandsButton worktreeId={worktreeId} groupId={groupId} />
) : null}
{isFocused && hasSplitGroups ? (
<Tooltip>
<DropdownMenu modal={false}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={translate(
'auto.components.tab.group.TabGroupPanel.9acaf92093',
'Pane Actions'
)}
onClick={(event) => {
event.stopPropagation()
}}
className={menuButtonClassName}
>
<Ellipsis className="size-4" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end" side="bottom" sideOffset={4}>
<DropdownMenuItem
variant="destructive"
onSelect={() => {
@@ -333,15 +316,21 @@ export default function TabGroupPanel({
>
<X className="size-4" />
{translate(
'auto.components.tab.group.TabGroupPanel.f7d6ce445e',
'Close Group'
'auto.components.tab.group.TabGroupPanel.closePaneColumn',
'Close split pane'
)}
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent side="bottom" sideOffset={6}>
{translate(
'auto.components.tab.group.TabGroupPanel.9acaf92093',
'Pane Actions'
)}
</TooltipContent>
</Tooltip>
) : null}
</div>
</div>
{/* Why: Electron's native drag hit-test ignores z-index — a no-drag
element only overrides drag when it's a DOM descendant, not a
@@ -380,7 +369,6 @@ export default function TabGroupPanel({
data-contextual-tour-target="workspace-agent-terminal-tip"
/>
) : null}
{activeDropZone ? <TabGroupDropOverlay zone={activeDropZone} /> : null}
{activeTab &&
activeTab.contentType !== 'terminal' &&
activeTab.contentType !== 'browser' &&
@@ -1,3 +1,4 @@
import React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const setTabGroupSplitRatioMock = vi.fn()
@@ -33,6 +34,8 @@ vi.mock('./useTabDragSplit', () => ({
activeDrag: null,
collisionDetection: vi.fn(),
hoveredDropTarget: null,
hoveredTabInsertion: null,
isTabDragActiveRef: { current: false },
onDragCancel: vi.fn(),
onDragEnd: vi.fn(),
onDragMove: vi.fn(),
@@ -45,6 +48,22 @@ vi.mock('./useTabDragSplit', () => ({
import TabGroupSplitLayout from './TabGroupSplitLayout'
type ReactElementLike = {
type: string | ((props: Record<string, unknown>) => unknown)
props: Record<string, unknown>
}
function asElement(node: unknown): ReactElementLike {
return node as ReactElementLike
}
function invokeComponent(element: ReactElementLike): unknown {
if (typeof element.type === 'function') {
return element.type(element.props)
}
return element
}
describe('TabGroupSplitLayout', () => {
beforeEach(() => {
setTabGroupSplitRatioMock.mockClear()
@@ -53,6 +72,22 @@ describe('TabGroupSplitLayout', () => {
useAppStoreMock.mockClear()
})
function getLayoutWrapper(element: ReturnType<typeof TabGroupSplitLayout>) {
const dndContext = asElement(element.props.children)
return React.Children.toArray(dndContext.props.children as React.ReactNode)[0]
}
function getSplitNodeElement(element: ReturnType<typeof TabGroupSplitLayout>) {
const layoutWrapperChildren = React.Children.toArray(
asElement(getLayoutWrapper(element)).props.children as React.ReactNode
)
const splitBody = layoutWrapperChildren[1]
const splitNodeElement = React.Children.only(
asElement(splitBody).props.children as React.ReactNode
)
return invokeComponent(asElement(splitNodeElement))
}
function getLeafPanelProps(isWorktreeActive: boolean) {
const element = TabGroupSplitLayout({
layout: { type: 'leaf', groupId: 'group-1' },
@@ -61,13 +96,7 @@ describe('TabGroupSplitLayout', () => {
isWorktreeActive
})
// DndContext has multiple children (layout wrapper + DragOverlay). The
// layout wrapper holds [drag-strip, split-body]; the split-body holds the
// SplitNode element.
const layoutWrapper = element.props.children[0]
const splitBody = layoutWrapper.props.children[1]
const splitNodeElement = splitBody.props.children
const tabGroupPanelElement = splitNodeElement.type(splitNodeElement.props)
const tabGroupPanelElement = asElement(getSplitNodeElement(element))
return tabGroupPanelElement.props as {
groupId: string
worktreeId: string
@@ -112,9 +141,7 @@ describe('TabGroupSplitLayout', () => {
isWorktreeActive: true
})
const layoutWrapper = element.props.children[0]
expect(layoutWrapper.props.ref).toBe(setDragRootNodeMock)
expect(asElement(getLayoutWrapper(element)).props.ref).toBe(setDragRootNodeMock)
})
it('only reserves top-right header space for the floating explorer toggle', () => {
@@ -131,17 +158,15 @@ describe('TabGroupSplitLayout', () => {
isWorktreeActive: true
})
const layoutWrapper = element.props.children[0]
const splitBody = layoutWrapper.props.children[1]
const splitNodeElement = splitBody.props.children
const rootElement = splitNodeElement.type(splitNodeElement.props)
const leftChild = rootElement.props.children[0].props.children
const rightChild = rootElement.props.children[2].props.children
const leftPanelProps = leftChild.type(leftChild.props).props as {
const rootElement = asElement(getSplitNodeElement(element))
const rootChildren = rootElement.props.children as unknown[]
const leftChild = asElement(rootChildren[0]).props.children
const rightChild = asElement(rootChildren[2]).props.children
const leftPanelProps = asElement(invokeComponent(asElement(leftChild))).props as {
reserveClosedExplorerToggleSpace: boolean
reserveCollapsedSidebarHeaderSpace: boolean
}
const rightPanelProps = rightChild.type(rightChild.props).props as {
const rightPanelProps = asElement(invokeComponent(asElement(rightChild))).props as {
reserveClosedExplorerToggleSpace: boolean
reserveCollapsedSidebarHeaderSpace: boolean
}
@@ -174,13 +199,10 @@ describe('TabGroupSplitLayout', () => {
isWorktreeActive: true
})
const layoutWrapper = element.props.children[0]
const splitBody = layoutWrapper.props.children[1]
const splitNodeElement = splitBody.props.children
const rootElement = splitNodeElement.type(splitNodeElement.props)
const resizeHandle = rootElement.props.children[1]
const rootElement = asElement(getSplitNodeElement(element))
const resizeHandle = asElement((rootElement.props.children as unknown[])[1])
resizeHandle.props.onResizeStart()
;(resizeHandle.props.onResizeStart as () => void)()
expect(recordFeatureInteractionMock).toHaveBeenCalledWith('terminal-panes')
})
@@ -4,7 +4,9 @@ import type { TabGroupLayoutNode } from '../../../../shared/types'
import { useAppStore } from '../../store'
import TabGroupPanel from './TabGroupPanel'
import TabDragPreview from '../tab-bar/TabDragPreview'
import { type HoveredTabInsertion, type TabDropZone, useTabDragSplit } from './useTabDragSplit'
import { TabDragProvider } from './tab-drag-context'
import TabPaneColumnSplitDragOverlay from './TabPaneColumnSplitDragOverlay'
import { type HoveredTabInsertion, useTabDragSplit } from './useTabDragSplit'
const MIN_RATIO = 0.15
const MAX_RATIO = 0.85
@@ -120,8 +122,6 @@ function SplitNode({
touchesRightEdge,
touchesLeftEdge,
isTabDragActive,
activeDropGroupId,
activeDropZone,
hoveredTabInsertion
}: {
node: TabGroupLayoutNode
@@ -134,8 +134,6 @@ function SplitNode({
touchesRightEdge: boolean
touchesLeftEdge: boolean
isTabDragActive: boolean
activeDropGroupId: string | null
activeDropZone: TabDropZone | null
hoveredTabInsertion: HoveredTabInsertion | null
}): React.JSX.Element {
const setTabGroupSplitRatio = useAppStore((state) => state.setTabGroupSplitRatio)
@@ -157,7 +155,6 @@ function SplitNode({
reserveClosedExplorerToggleSpace={touchesTopEdge && touchesRightEdge}
reserveCollapsedSidebarHeaderSpace={touchesTopEdge && touchesLeftEdge}
isTabDragActive={isTabDragActive}
activeDropZone={activeDropGroupId === node.groupId ? activeDropZone : null}
hoveredTabInsertion={
hoveredTabInsertion?.groupId === node.groupId ? hoveredTabInsertion : null
}
@@ -185,8 +182,6 @@ function SplitNode({
touchesRightEdge={isHorizontal ? false : touchesRightEdge}
touchesLeftEdge={touchesLeftEdge}
isTabDragActive={isTabDragActive}
activeDropGroupId={activeDropGroupId}
activeDropZone={activeDropZone}
hoveredTabInsertion={hoveredTabInsertion}
/>
</div>
@@ -207,8 +202,6 @@ function SplitNode({
touchesRightEdge={touchesRightEdge}
touchesLeftEdge={isHorizontal ? false : touchesLeftEdge}
isTabDragActive={isTabDragActive}
activeDropGroupId={activeDropGroupId}
activeDropZone={activeDropZone}
hoveredTabInsertion={hoveredTabInsertion}
/>
</div>
@@ -231,22 +224,26 @@ export default function TabGroupSplitLayout({
const hasSplits = layout.type === 'split'
return (
<DndContext
sensors={dragSplit.sensors}
collisionDetection={dragSplit.collisionDetection}
onDragStart={dragSplit.onDragStart}
onDragMove={dragSplit.onDragMove}
onDragOver={dragSplit.onDragOver}
onDragEnd={dragSplit.onDragEnd}
onDragCancel={dragSplit.onDragCancel}
// Why: dnd-kit auto-scrolls the tab strip when the cursor approaches its
// edge, which in a multi-group layout creates a feedback loop — scroll
// shifts tabs under the cursor, `over` re-resolves, scroll runs again.
// We don't need autoscroll for tab-bar drags (strip fits the viewport),
// so disabling it is the simplest fix.
autoScroll={false}
<TabDragProvider
isTabDragActive={dragSplit.activeDrag !== null}
isTabDragActiveRef={dragSplit.isTabDragActiveRef}
>
{/* Why: the 10px drag strip sits ABOVE the split layout — lifted out of
<DndContext
sensors={dragSplit.sensors}
collisionDetection={dragSplit.collisionDetection}
onDragStart={dragSplit.onDragStart}
onDragMove={dragSplit.onDragMove}
onDragOver={dragSplit.onDragOver}
onDragEnd={dragSplit.onDragEnd}
onDragCancel={dragSplit.onDragCancel}
// Why: dnd-kit auto-scrolls the tab strip when the cursor approaches its
// edge, which in a multi-group layout creates a feedback loop — scroll
// shifts tabs under the cursor, `over` re-resolves, scroll runs again.
// We don't need autoscroll for tab-bar drags (strip fits the viewport),
// so disabling it is the simplest fix.
autoScroll={false}
>
{/* Why: the 10px drag strip sits ABOVE the split layout — lifted out of
each pane — so vertical split resize handles don't extend into the
window-drag region at the top. Only the split layout's own panes
own the resize handles, while this strip keeps the whole top of the
@@ -262,41 +259,48 @@ export default function TabGroupSplitLayout({
state. The leftmost pane suppresses its own `border-l` via
`touchesLeftEdge`, so the seam is always exactly 1px — previously
both painted and stacked into a 2px bar below the drag strip. */}
<div
ref={dragSplit.setDragRootNode}
className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden border-l border-border"
>
<div
className="h-[4px] shrink-0 bg-card"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
/>
<div className="flex flex-1 min-w-0 min-h-0 overflow-hidden">
<SplitNode
node={layout}
nodePath=""
worktreeId={worktreeId}
focusedGroupId={focusedGroupId}
isWorktreeActive={isWorktreeActive}
hasSplitGroups={hasSplits}
touchesTopEdge={true}
touchesRightEdge={true}
touchesLeftEdge={true}
isTabDragActive={dragSplit.activeDrag !== null}
activeDropGroupId={dragSplit.hoveredDropTarget?.groupId ?? null}
activeDropZone={dragSplit.hoveredDropTarget?.zone ?? null}
hoveredTabInsertion={dragSplit.hoveredTabInsertion}
ref={dragSplit.setDragRootNode}
className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden border-l border-border"
>
<div
className="h-[4px] shrink-0 bg-card"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
/>
<div className="flex flex-1 min-w-0 min-h-0 overflow-hidden">
<SplitNode
node={layout}
nodePath=""
worktreeId={worktreeId}
focusedGroupId={focusedGroupId}
isWorktreeActive={isWorktreeActive}
hasSplitGroups={hasSplits}
touchesTopEdge={true}
touchesRightEdge={true}
touchesLeftEdge={true}
isTabDragActive={dragSplit.activeDrag !== null}
hoveredTabInsertion={dragSplit.hoveredTabInsertion}
/>
</div>
</div>
</div>
{/* Why: the sortable tab is anchored inside its source tab strip (no
{/* Why: the sortable tab is anchored inside its source tab strip (no
transform while dragging), and that strip uses overflow-hidden so
the tab is invisible once the cursor leaves it. DragOverlay
renders a ghost in a document-level portal that tracks the cursor
across the whole window — the source tab keeps its spot, the
ghost follows the cursor. */}
<DragOverlay dropAnimation={null}>
{dragSplit.activeDrag ? <TabDragPreview drag={dragSplit.activeDrag} /> : null}
</DragOverlay>
</DndContext>
<DragOverlay dropAnimation={null}>
{dragSplit.activeDrag ? <TabDragPreview drag={dragSplit.activeDrag} /> : null}
</DragOverlay>
{dragSplit.hoveredDropTarget &&
dragSplit.hoveredDropTarget.zone !== 'center' &&
dragSplit.hoveredDropTarget.panelRect ? (
<TabPaneColumnSplitDragOverlay
panelRect={dragSplit.hoveredDropTarget.panelRect}
zone={dragSplit.hoveredDropTarget.zone}
/>
) : null}
</DndContext>
</TabDragProvider>
)
}
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest'
const { createPortalMock } = vi.hoisted(() => ({
createPortalMock: vi.fn((node: unknown) => node)
}))
vi.mock('react-dom', () => ({
createPortal: createPortalMock
}))
import TabPaneColumnSplitDragOverlay from './TabPaneColumnSplitDragOverlay'
function rect({
left,
top,
width,
height
}: {
left: number
top: number
width: number
height: number
}): DOMRect {
return {
left,
top,
width,
height,
right: left + width,
bottom: top + height
} as DOMRect
}
type ReactElementLike = {
props: Record<string, unknown>
}
describe('TabPaneColumnSplitDragOverlay', () => {
it('renders from cached panel bounds without reading layout during render', () => {
const querySelector = vi.fn()
vi.stubGlobal('document', {
body: {},
querySelector
})
const overlay = TabPaneColumnSplitDragOverlay({
panelRect: rect({ left: 100, top: 20, width: 400, height: 300 }),
zone: 'right'
})
expect(querySelector).not.toHaveBeenCalled()
expect((overlay as ReactElementLike).props.style).toEqual({
top: 20,
left: 300,
width: 200,
height: 300
})
vi.unstubAllGlobals()
})
})
@@ -0,0 +1,56 @@
import { createPortal } from 'react-dom'
import type { CSSProperties } from 'react'
import TabGroupDropOverlay from './TabGroupDropOverlay'
import type { TabDropZone } from './useTabDragSplit'
function getOverlayBounds(
rect: DOMRect,
zone: Exclude<TabDropZone, 'center'>
): Pick<CSSProperties, 'top' | 'left' | 'width' | 'height'> {
switch (zone) {
case 'up':
return {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height / 2
}
case 'down':
return {
top: rect.top + rect.height / 2,
left: rect.left,
width: rect.width,
height: rect.height / 2
}
case 'left':
return {
top: rect.top,
left: rect.left,
width: rect.width / 2,
height: rect.height
}
case 'right':
return {
top: rect.top,
left: rect.left + rect.width / 2,
width: rect.width / 2,
height: rect.height
}
}
}
export default function TabPaneColumnSplitDragOverlay({
panelRect,
zone
}: {
panelRect: DOMRect
zone: Exclude<TabDropZone, 'center'>
}): React.JSX.Element | null {
const bounds = getOverlayBounds(panelRect, zone)
return createPortal(
<div aria-hidden="true" className="pointer-events-none fixed z-[10001]" style={bounds}>
<TabGroupDropOverlay zone={zone} showPaneColumnLabel fillContainer />
</div>,
document.body
)
}
@@ -0,0 +1,37 @@
import { createContext, useContext, useMemo, type RefObject } from 'react'
type TabDragContextValue = {
isTabDragActive: boolean
isTabDragActiveRef: RefObject<boolean>
}
const defaultRef: RefObject<boolean> = { current: false }
const TabDragContext = createContext<TabDragContextValue>({
isTabDragActive: false,
isTabDragActiveRef: defaultRef
})
export function TabDragProvider({
isTabDragActive,
isTabDragActiveRef,
children
}: {
isTabDragActive: boolean
isTabDragActiveRef: RefObject<boolean>
children: React.ReactNode
}): React.JSX.Element {
const value = useMemo(
() => ({ isTabDragActive, isTabDragActiveRef }),
[isTabDragActive, isTabDragActiveRef]
)
return <TabDragContext.Provider value={value}>{children}</TabDragContext.Provider>
}
export function useTabDragActive(): boolean {
return useContext(TabDragContext).isTabDragActive
}
export function useTabDragActiveRef(): RefObject<boolean> {
return useContext(TabDragContext).isTabDragActiveRef
}
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { getDragPointer } from './tab-drag-pointer'
describe('getDragPointer', () => {
it('uses the activator client coordinates plus drag delta', () => {
expect(
getDragPointer({
activatorEvent: { clientX: 100, clientY: 40 },
delta: { x: 250, y: 10 },
active: { rect: { current: { initial: null, translated: null } } }
} as unknown as Parameters<typeof getDragPointer>[0])
).toEqual({ x: 350, y: 50 })
})
})
@@ -0,0 +1,36 @@
import type { DragEndEvent, DragMoveEvent, DragOverEvent } from '@dnd-kit/core'
type DragPointerEvent = Pick<
DragMoveEvent | DragOverEvent | DragEndEvent,
'activatorEvent' | 'delta' | 'active'
>
/** Pointer position during a tab drag. Why: sortable tabs stay visually anchored
* (no transform) while DragOverlay follows the cursor, so active.rect.translated
* never tracks the pointer and panel-edge split targets would stay wrong. */
export function getDragPointer(event: DragPointerEvent): { x: number; y: number } | null {
const activator = event.activatorEvent
if (
activator &&
typeof activator === 'object' &&
'clientX' in activator &&
'clientY' in activator &&
typeof activator.clientX === 'number' &&
typeof activator.clientY === 'number'
) {
return {
x: activator.clientX + event.delta.x,
y: activator.clientY + event.delta.y
}
}
const initial = event.active.rect.current.initial
if (!initial) {
return null
}
return {
x: initial.left + initial.width / 2 + event.delta.x,
y: initial.top + initial.height / 2 + event.delta.y
}
}
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it } from 'vitest'
import type { Tab } from '../../../../shared/types'
import { useAppStore } from '../../store'
import {
applyDragPreviewTab,
captureTabDragActivationSnapshot,
restoreTabDragActivationSnapshot
} from './tab-drag-preview-activation'
const WT = 'wt-preview-restore'
describe('restoreTabDragActivationSnapshot', () => {
beforeEach(() => {
useAppStore.setState({
activeWorktreeId: WT,
activeTabType: 'terminal',
activeTabId: 'terminal-1',
activeTabIdByWorktree: { [WT]: 'terminal-1' },
activeTabTypeByWorktree: { [WT]: 'terminal' },
groupsByWorktree: {
[WT]: [
{
id: 'group-1',
worktreeId: WT,
activeTabId: 'tab-1',
tabOrder: ['tab-1', 'tab-2']
}
]
},
unifiedTabsByWorktree: {
[WT]: [
{
id: 'tab-1',
groupId: 'group-1',
worktreeId: WT,
contentType: 'terminal',
entityId: 'terminal-1',
label: 'Terminal 1',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 0
} satisfies Tab,
{
id: 'tab-2',
groupId: 'group-1',
worktreeId: WT,
contentType: 'browser',
entityId: 'browser-1',
label: 'Browser',
customLabel: null,
color: null,
sortOrder: 1,
createdAt: 1
} satisfies Tab
]
},
activeGroupIdByWorktree: { [WT]: 'group-1' }
})
})
it('restores active-surface fields after a drag preview is cancelled', () => {
const snapshot = captureTabDragActivationSnapshot(WT)
applyDragPreviewTab({
worktreeId: WT,
groupId: 'group-1',
tabId: 'tab-2',
activeGroupId: 'group-1'
})
expect(useAppStore.getState().activeTabType).toBe('browser')
expect(useAppStore.getState().activeBrowserTabId).toBe('browser-1')
restoreTabDragActivationSnapshot(WT, snapshot)
const state = useAppStore.getState()
expect(state.groupsByWorktree[WT]?.[0]?.activeTabId).toBe('tab-1')
expect(state.activeTabType).toBe('terminal')
expect(state.activeTabId).toBe('terminal-1')
expect(state.activeTabIdByWorktree[WT]).toBe('terminal-1')
})
})
@@ -0,0 +1,205 @@
import { useAppStore } from '../../store'
import type { AppState } from '../../store/types'
export type TabDragActivationSnapshot = {
activeGroupId: string | null
activeTabIdByGroup: Record<string, string | null>
}
function previewActiveSurfacePatch(
state: AppState,
worktreeId: string,
groupId: string,
tabId: string | null
): Partial<AppState> {
if (state.activeWorktreeId !== worktreeId || !tabId) {
return {}
}
const unifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find(
(tab) => tab.id === tabId && tab.groupId === groupId
)
if (!unifiedTab) {
return {}
}
const nextActiveTabTypeByWorktree = (
tabType: AppState['activeTabType']
): AppState['activeTabTypeByWorktree'] => ({
...state.activeTabTypeByWorktree,
[worktreeId]: tabType
})
if (unifiedTab.contentType === 'terminal') {
return {
activeTabId: unifiedTab.entityId,
activeTabType: 'terminal',
activeTabIdByWorktree: {
...state.activeTabIdByWorktree,
[worktreeId]: unifiedTab.entityId
},
activeTabTypeByWorktree: nextActiveTabTypeByWorktree('terminal')
}
}
if (unifiedTab.contentType === 'browser') {
return {
activeBrowserTabId: unifiedTab.entityId,
activeTabType: 'browser',
activeBrowserTabIdByWorktree: {
...state.activeBrowserTabIdByWorktree,
[worktreeId]: unifiedTab.entityId
},
activeTabTypeByWorktree: nextActiveTabTypeByWorktree('browser')
}
}
if (unifiedTab.contentType === 'simulator') {
return {
activeTabType: 'simulator',
activeTabTypeByWorktree: nextActiveTabTypeByWorktree('simulator')
}
}
return {
activeFileId: unifiedTab.entityId,
activeTabType: 'editor',
activeFileIdByWorktree: {
...state.activeFileIdByWorktree,
[worktreeId]: unifiedTab.entityId
},
activeTabTypeByWorktree: nextActiveTabTypeByWorktree('editor')
}
}
export function captureTabDragActivationSnapshot(worktreeId: string): TabDragActivationSnapshot {
const state = useAppStore.getState()
const groups = state.groupsByWorktree[worktreeId] ?? []
return {
activeGroupId: state.activeGroupIdByWorktree[worktreeId] ?? null,
activeTabIdByGroup: Object.fromEntries(groups.map((group) => [group.id, group.activeTabId]))
}
}
export function applyDragPreviewTab({
worktreeId,
groupId,
tabId,
activeGroupId
}: {
worktreeId: string
groupId: string
tabId: string | null
activeGroupId: string
}): void {
useAppStore.setState((state): Partial<AppState> => {
const groups = state.groupsByWorktree[worktreeId] ?? []
const targetGroup = groups.find((group) => group.id === groupId)
const groupUnchanged = targetGroup?.activeTabId === tabId
const focusUnchanged = (state.activeGroupIdByWorktree[worktreeId] ?? null) === activeGroupId
const surfacePatch = previewActiveSurfacePatch(state, worktreeId, groupId, tabId)
if (groupUnchanged && focusUnchanged) {
return Object.keys(surfacePatch).length > 0 ? surfacePatch : {}
}
const next: Partial<AppState> = { ...surfacePatch }
if (!groupUnchanged) {
next.groupsByWorktree = {
...state.groupsByWorktree,
[worktreeId]: groups.map((group) =>
group.id === groupId ? { ...group, activeTabId: tabId } : group
)
}
}
if (!focusUnchanged) {
next.activeGroupIdByWorktree = {
...state.activeGroupIdByWorktree,
[worktreeId]: activeGroupId
}
}
return next
})
}
export function restoreTabDragActivationSnapshot(
worktreeId: string,
snapshot: TabDragActivationSnapshot
): void {
useAppStore.setState((state): Partial<AppState> => {
const groups = state.groupsByWorktree[worktreeId] ?? []
const groupsUnchanged = groups.every(
(group) => (snapshot.activeTabIdByGroup[group.id] ?? null) === group.activeTabId
)
const focusUnchanged =
(state.activeGroupIdByWorktree[worktreeId] ?? null) === snapshot.activeGroupId
const next: Partial<AppState> = {}
if (!groupsUnchanged) {
next.groupsByWorktree = {
...state.groupsByWorktree,
[worktreeId]: groups.map((group) => ({
...group,
activeTabId: snapshot.activeTabIdByGroup[group.id] ?? null
}))
}
}
if (!focusUnchanged) {
if (snapshot.activeGroupId === null) {
const nextActiveGroupIdByWorktree = { ...state.activeGroupIdByWorktree }
delete nextActiveGroupIdByWorktree[worktreeId]
next.activeGroupIdByWorktree = nextActiveGroupIdByWorktree
} else {
next.activeGroupIdByWorktree = {
...state.activeGroupIdByWorktree,
[worktreeId]: snapshot.activeGroupId
}
}
}
const restoredGroupId = snapshot.activeGroupId
if (restoredGroupId) {
const restoredTabId = snapshot.activeTabIdByGroup[restoredGroupId] ?? null
Object.assign(
next,
previewActiveSurfacePatch(state, worktreeId, restoredGroupId, restoredTabId)
)
}
if (Object.keys(next).length === 0) {
return {}
}
return next
})
}
export function restoreSourceGroupActiveTabAfterCrossGroupDrop({
worktreeId,
snapshot,
sourceGroupId,
movedTabId
}: {
worktreeId: string
snapshot: TabDragActivationSnapshot
sourceGroupId: string
movedTabId: string
}): void {
const preDragActiveTabId = snapshot.activeTabIdByGroup[sourceGroupId] ?? null
// Why: dropUnifiedTab already picks the next active tab when the moved tab
// was the source group's selection; only preview contamination needs undo.
if (preDragActiveTabId === movedTabId) {
return
}
useAppStore.setState((state): Partial<AppState> => {
const groups = state.groupsByWorktree[worktreeId] ?? []
const sourceGroup = groups.find((group) => group.id === sourceGroupId)
if (!sourceGroup || sourceGroup.activeTabId === preDragActiveTabId) {
return {}
}
return {
groupsByWorktree: {
...state.groupsByWorktree,
[worktreeId]: groups.map((group) =>
group.id === sourceGroupId ? { ...group, activeTabId: preDragActiveTabId } : group
)
}
}
})
}
@@ -0,0 +1,57 @@
import type { TabDragItemData } from './useTabDragSplit'
import { isPaneDropData, isTabDragData } from './useTabDragSplit'
export type DragPreviewTabTarget = {
groupId: string
tabId: string | null
}
export function resolveDragPreviewTabId({
activeDrag,
overData,
preDragActiveTabIdByGroup,
lastHoveredTabPreview = null
}: {
activeDrag: TabDragItemData
overData: unknown
preDragActiveTabIdByGroup: Record<string, string | null>
lastHoveredTabPreview?: DragPreviewTabTarget | null
}): DragPreviewTabTarget {
const sourceGroupId = activeDrag.groupId
const sourcePreDragTabId = preDragActiveTabIdByGroup[sourceGroupId] ?? null
if (isTabDragData(overData) && overData.unifiedTabId !== activeDrag.unifiedTabId) {
// Why: tab-strip hovers target split drops or reorder slots. Previewing
// the hovered tab's content reads like an in-tab split even though the drop
// opens a new split.
return { groupId: sourceGroupId, tabId: sourcePreDragTabId }
}
if (isPaneDropData(overData)) {
if (lastHoveredTabPreview?.groupId === overData.groupId && lastHoveredTabPreview.tabId) {
return lastHoveredTabPreview
}
if (overData.groupId === sourceGroupId) {
return { groupId: sourceGroupId, tabId: sourcePreDragTabId }
}
return {
groupId: overData.groupId,
tabId: preDragActiveTabIdByGroup[overData.groupId] ?? null
}
}
return { groupId: sourceGroupId, tabId: sourcePreDragTabId }
}
export function resolveSourceGroupRestoreOnDrop(
activeData: TabDragItemData,
targetGroupId: string,
restoreSnapshot: boolean
): TabDragItemData | undefined {
// Why: same-group splits keep the previewed active tab in the source pane via
// dropUnifiedTab; restoring the pre-drag snapshot would undo that preview.
if (restoreSnapshot || activeData.groupId === targetGroupId) {
return undefined
}
return activeData
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { resolvePaneColumnEdgeZone, TAB_GROUP_TAB_STRIP_HEIGHT_PX } from './tab-drop-zone'
describe('resolvePaneColumnEdgeZone', () => {
const panelRect = { left: 0, top: 0, width: 300, height: 200 }
it('returns right on the outer horizontal band in the body', () => {
expect(resolvePaneColumnEdgeZone(panelRect, { x: 260, y: 100 })).toBe('right')
})
it('returns null in the center band of the body', () => {
expect(resolvePaneColumnEdgeZone(panelRect, { x: 150, y: 100 })).toBeNull()
})
it('does not return up while the pointer is still in the tab strip', () => {
expect(
resolvePaneColumnEdgeZone(panelRect, {
x: 150,
y: TAB_GROUP_TAB_STRIP_HEIGHT_PX - 1
})
).toBeNull()
})
it('returns up on the top edge of the pane body', () => {
expect(
resolvePaneColumnEdgeZone(panelRect, {
x: 150,
y: TAB_GROUP_TAB_STRIP_HEIGHT_PX + 5
})
).toBe('up')
})
})
@@ -0,0 +1,94 @@
import type { TabDropZone } from './useTabDragSplit'
/** Matches TabGroupPanel tab row height (`h-[32px]`). */
export const TAB_GROUP_TAB_STRIP_HEIGHT_PX = 32
type PaneRect = { left: number; top: number; width: number; height: number }
export function resolveDropZone(
rect: { left: number; top: number; width: number; height: number },
point: { x: number; y: number }
): TabDropZone {
const localX = point.x - rect.left
const localY = point.y - rect.top
const edgeWidthThreshold = rect.width * 0.1
const edgeHeightThreshold = rect.height * 0.1
const splitWidthThreshold = rect.width / 3
// Why: VS Code keeps a center "merge" zone while biasing side-by-side drops
// toward left/right, which feels much more stable than a generic nearest-edge
// calculation once a workspace has nested splits.
if (
localX > edgeWidthThreshold &&
localX < rect.width - edgeWidthThreshold &&
localY > edgeHeightThreshold &&
localY < rect.height - edgeHeightThreshold
) {
return 'center'
}
if (localX < splitWidthThreshold) {
return 'left'
}
if (localX > splitWidthThreshold * 2) {
return 'right'
}
return localY < rect.height / 2 ? 'up' : 'down'
}
/** Outer band of a split pane panel where drags open another split. */
export function resolvePaneColumnEdgeZone(
panelRect: PaneRect,
point: { x: number; y: number },
options?: {
bodyRect?: PaneRect | null
tabStripHeightPx?: number
}
): Exclude<TabDropZone, 'center'> | null {
const localX = point.x - panelRect.left
const horizontalEdge = panelRect.width * 0.2
if (localX < horizontalEdge) {
return 'left'
}
if (localX > panelRect.width - horizontalEdge) {
return 'right'
}
const tabStripHeight = options?.tabStripHeightPx ?? TAB_GROUP_TAB_STRIP_HEIGHT_PX
const tabStripBottom = panelRect.top + tabStripHeight
// Why: the tab strip is for reorder/insertion targets. Vertical pane splits
// belong on the terminal/editor body edges only.
if (point.y < tabStripBottom) {
return null
}
const bodyRect =
options?.bodyRect ??
({
left: panelRect.left,
top: tabStripBottom,
width: panelRect.width,
height: Math.max(0, panelRect.height - tabStripHeight)
} satisfies PaneRect)
if (bodyRect.height <= 0) {
return null
}
const bodyLocalY = point.y - bodyRect.top
const verticalEdge = bodyRect.height * 0.2
if (bodyLocalY < verticalEdge) {
return 'up'
}
if (bodyLocalY > bodyRect.height - verticalEdge) {
return 'down'
}
return null
}
export type PaneColumnSplitTarget = {
groupId: string
zone: Exclude<TabDropZone, 'center'>
}
@@ -0,0 +1,638 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TabGroup, TabGroupLayoutNode } from '../../../../shared/types'
import type { TabDragItemData } from './useTabDragSplit'
import {
captureTabGroupPanelGeometrySnapshot,
findTabGroupPanelUnderPointer,
resolveActivePaneColumnSplitTarget,
resolvePanelEdgePaneColumnSplit
} from './tab-group-panel-split-target'
import { TAB_GROUP_TAB_STRIP_HEIGHT_PX } from './tab-drop-zone'
function makeDragData(overrides: Partial<TabDragItemData> = {}): TabDragItemData {
return {
kind: 'tab',
worktreeId: 'wt-1',
groupId: 'group-1',
unifiedTabId: 'tab-1',
visibleTabId: 'tab-1',
tabType: 'terminal',
label: 'tab-1',
...overrides
}
}
function makeEvent({
activeData,
overData = null,
pointer = { x: 0, y: 0 }
}: {
activeData: TabDragItemData
overData?: TabDragItemData | null
pointer?: { x: number; y: number }
}) {
return {
active: { data: { current: activeData } },
over: overData
? {
data: { current: overData },
rect: { left: 500, width: 120, top: 0, height: 32 }
}
: null,
delta: { x: 0, y: 0 },
activatorEvent: { clientX: pointer.x, clientY: pointer.y }
} as unknown as Parameters<typeof resolveActivePaneColumnSplitTarget>[0]['event']
}
function mockTabGroupRects(panelRect: DOMRect, bodyRect: DOMRect): void {
vi.stubGlobal('document', {
querySelector: vi.fn(() => ({
getBoundingClientRect: () => bodyRect,
parentElement: {
getBoundingClientRect: () => panelRect
}
})),
querySelectorAll: vi.fn(() => [
{
dataset: {
tabGroupBodyId: 'group-2',
worktreeId: 'wt-1'
}
}
])
})
}
function rect({
left,
top,
width,
height
}: {
left: number
top: number
width: number
height: number
}): DOMRect {
return {
left,
top,
width,
height,
right: left + width,
bottom: top + height
} as DOMRect
}
function mockTabGroupGeometry(
entries: {
groupId: string
panelRect: DOMRect
bodyRect: DOMRect
counts?: { panelReads: number; bodyReads: number }
}[]
): { queryAll: ReturnType<typeof vi.fn> } {
const bodies = entries.map((entry) => ({
dataset: {
tabGroupBodyId: entry.groupId,
worktreeId: 'wt-1'
},
getBoundingClientRect: () => {
if (entry.counts) {
entry.counts.bodyReads += 1
}
return entry.bodyRect
},
parentElement: {
getBoundingClientRect: () => {
if (entry.counts) {
entry.counts.panelReads += 1
}
return entry.panelRect
}
}
}))
const queryAll = vi.fn(() => bodies)
vi.stubGlobal('document', {
querySelector: vi.fn((selector: string) => {
const match = selector.match(/data-tab-group-body-id="([^"]+)"/)
return bodies.find((body) => body.dataset.tabGroupBodyId === match?.[1]) ?? null
}),
querySelectorAll: queryAll
})
return { queryAll }
}
function percentile(values: number[], p: number): number {
const sorted = [...values].sort((a, b) => a - b)
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))]
}
function fourGroupFixture(): {
counts: { panelReads: number; bodyReads: number }[]
groupsByWorktree: Record<string, TabGroup[]>
layoutByWorktree: Record<string, TabGroupLayoutNode>
queryAll: ReturnType<typeof vi.fn>
} {
const counts = [
{ panelReads: 0, bodyReads: 0 },
{ panelReads: 0, bodyReads: 0 },
{ panelReads: 0, bodyReads: 0 },
{ panelReads: 0, bodyReads: 0 }
]
const { queryAll } = mockTabGroupGeometry([
{
groupId: 'group-1',
panelRect: rect({ left: 0, top: 0, width: 300, height: 600 }),
bodyRect: rect({
left: 0,
top: TAB_GROUP_TAB_STRIP_HEIGHT_PX,
width: 300,
height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX
}),
counts: counts[0]
},
{
groupId: 'group-2',
panelRect: rect({ left: 304, top: 0, width: 300, height: 600 }),
bodyRect: rect({
left: 304,
top: TAB_GROUP_TAB_STRIP_HEIGHT_PX,
width: 300,
height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX
}),
counts: counts[1]
},
{
groupId: 'group-3',
panelRect: rect({ left: 608, top: 0, width: 300, height: 600 }),
bodyRect: rect({
left: 608,
top: TAB_GROUP_TAB_STRIP_HEIGHT_PX,
width: 300,
height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX
}),
counts: counts[2]
},
{
groupId: 'group-4',
panelRect: rect({ left: 912, top: 0, width: 300, height: 600 }),
bodyRect: rect({
left: 912,
top: TAB_GROUP_TAB_STRIP_HEIGHT_PX,
width: 300,
height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX
}),
counts: counts[3]
}
])
return {
counts,
queryAll,
groupsByWorktree: {
'wt-1': [
{ id: 'group-1', worktreeId: 'wt-1', activeTabId: 'tab-1', tabOrder: ['tab-1', 'tab-5'] },
{ id: 'group-2', worktreeId: 'wt-1', activeTabId: 'tab-2', tabOrder: ['tab-2'] },
{ id: 'group-3', worktreeId: 'wt-1', activeTabId: 'tab-3', tabOrder: ['tab-3'] },
{ id: 'group-4', worktreeId: 'wt-1', activeTabId: 'tab-4', tabOrder: ['tab-4'] }
]
},
layoutByWorktree: {
'wt-1': {
type: 'split',
direction: 'horizontal',
ratio: 0.5,
first: {
type: 'split',
direction: 'horizontal',
ratio: 0.5,
first: { type: 'leaf', groupId: 'group-1' },
second: { type: 'leaf', groupId: 'group-2' }
},
second: {
type: 'split',
direction: 'horizontal',
ratio: 0.5,
first: { type: 'leaf', groupId: 'group-3' },
second: { type: 'leaf', groupId: 'group-4' }
}
}
}
}
}
const horizontalLayout: TabGroupLayoutNode = {
type: 'split',
direction: 'horizontal',
ratio: 0.5,
first: { type: 'leaf', groupId: 'group-1' },
second: { type: 'leaf', groupId: 'group-2' }
}
const twoGroupLayout = {
'wt-1': [
{ id: 'group-1', worktreeId: 'wt-1', activeTabId: 'tab-1', tabOrder: ['tab-1'] },
{ id: 'group-2', worktreeId: 'wt-1', activeTabId: 'tab-2', tabOrder: ['tab-2'] }
]
}
const layoutByWorktree = { 'wt-1': horizontalLayout }
describe('resolvePanelEdgePaneColumnSplit', () => {
const panelRect = {
left: 500,
top: 0,
width: 400,
height: 600,
right: 900,
bottom: 600
} as DOMRect
const bodyRect = {
left: 500,
top: TAB_GROUP_TAB_STRIP_HEIGHT_PX,
width: 400,
height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX,
right: 900,
bottom: 600
} as DOMRect
beforeEach(() => {
mockTabGroupRects(panelRect, bodyRect)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns a right-edge split when the pointer is on the outer band', () => {
expect(
resolvePanelEdgePaneColumnSplit({
activeDrag: makeDragData({ groupId: 'group-1' }),
targetGroupId: 'group-2',
worktreeId: 'wt-1',
pointer: { x: 880, y: 300 },
groupsByWorktree: twoGroupLayout,
layoutByWorktree
})
).toEqual({ groupId: 'group-2', zone: 'right' })
})
it('returns null in the center band so cross-group tab insertion can win', () => {
expect(
resolvePanelEdgePaneColumnSplit({
activeDrag: makeDragData({ groupId: 'group-1' }),
targetGroupId: 'group-2',
worktreeId: 'wt-1',
pointer: { x: 700, y: 300 },
groupsByWorktree: twoGroupLayout,
layoutByWorktree
})
).toBeNull()
})
it('does not treat the tab strip as a top split edge', () => {
expect(
resolvePanelEdgePaneColumnSplit({
activeDrag: makeDragData({ groupId: 'group-2' }),
targetGroupId: 'group-1',
worktreeId: 'wt-1',
pointer: { x: 650, y: 16 },
groupsByWorktree: twoGroupLayout,
layoutByWorktree
})
).toBeNull()
})
it('suppresses adjacent sibling split drops that would collapse back to the current layout', () => {
const group1Panel = {
left: 0,
top: 0,
width: 400,
height: 600,
right: 400,
bottom: 600
} as DOMRect
expect(
resolvePanelEdgePaneColumnSplit({
activeDrag: makeDragData({ groupId: 'group-2', unifiedTabId: 'tab-2' }),
targetGroupId: 'group-1',
worktreeId: 'wt-1',
pointer: { x: 360, y: 300 },
groupsByWorktree: twoGroupLayout,
layoutByWorktree,
panelRect: group1Panel
})
).toBeNull()
})
it('rejects stale panel targets when the pointer has left the panel bounds', () => {
expect(
resolvePanelEdgePaneColumnSplit({
activeDrag: makeDragData({ groupId: 'group-1' }),
targetGroupId: 'group-2',
worktreeId: 'wt-1',
pointer: { x: 499, y: 300 },
groupsByWorktree: twoGroupLayout,
layoutByWorktree,
panelRect
})
).toBeNull()
})
})
describe('resolveActivePaneColumnSplitTarget', () => {
const panelRect = {
left: 500,
top: 0,
width: 400,
height: 600,
right: 900,
bottom: 600
} as DOMRect
const bodyRect = {
left: 500,
top: TAB_GROUP_TAB_STRIP_HEIGHT_PX,
width: 400,
height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX,
right: 900,
bottom: 600
} as DOMRect
beforeEach(() => {
mockTabGroupRects(panelRect, bodyRect)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('resolves cross-group panel-edge splits without an dnd-kit over target', () => {
expect(findTabGroupPanelUnderPointer('wt-1', { x: 880, y: 300 })?.groupId).toBe('group-2')
expect(
resolveActivePaneColumnSplitTarget({
event: makeEvent({
activeData: makeDragData({ groupId: 'group-1' }),
overData: null,
pointer: { x: 880, y: 300 }
}),
groupsByWorktree: twoGroupLayout,
layoutByWorktree,
worktreeId: 'wt-1',
getDragPointer: () => ({ x: 880, y: 300 })
})
).toEqual(expect.objectContaining({ groupId: 'group-2', zone: 'right' }))
})
it('skips pane-edge splits for cross-group tab-strip hovers', () => {
expect(
resolveActivePaneColumnSplitTarget({
event: makeEvent({
activeData: makeDragData({ groupId: 'group-2', unifiedTabId: 'tab-2' }),
overData: makeDragData({
groupId: 'group-1',
unifiedTabId: 'tab-1',
visibleTabId: 'tab-1'
}),
pointer: { x: 650, y: 16 }
}),
groupsByWorktree: twoGroupLayout,
layoutByWorktree,
worktreeId: 'wt-1',
getDragPointer: () => ({ x: 650, y: 16 })
})
).toBeNull()
})
it('resolves body-edge splits when a stale cross-group tab hover remains active', () => {
expect(
resolveActivePaneColumnSplitTarget({
event: makeEvent({
activeData: makeDragData({ groupId: 'group-1' }),
overData: makeDragData({
groupId: 'group-2',
unifiedTabId: 'tab-2',
visibleTabId: 'tab-2'
}),
pointer: { x: 880, y: 300 }
}),
groupsByWorktree: twoGroupLayout,
layoutByWorktree,
worktreeId: 'wt-1',
getDragPointer: () => ({ x: 880, y: 300 })
})
).toEqual(expect.objectContaining({ groupId: 'group-2', zone: 'right' }))
})
it('ignores stale over targets after the pointer leaves the cached panel', () => {
const geometry = {
entries: [
{
groupId: 'group-2',
panelRect,
bodyRect
}
],
byGroupId: new Map([['group-2', { groupId: 'group-2', panelRect, bodyRect }]])
}
expect(
resolveActivePaneColumnSplitTarget({
event: makeEvent({
activeData: makeDragData({ groupId: 'group-1' }),
overData: makeDragData({
groupId: 'group-2',
unifiedTabId: 'tab-2',
visibleTabId: 'tab-2'
}),
pointer: { x: 499, y: 300 }
}),
groupsByWorktree: twoGroupLayout,
layoutByWorktree,
worktreeId: 'wt-1',
getDragPointer: () => ({ x: 499, y: 300 }),
geometry
})
).toBeNull()
})
it('does not create a split target while hovering over another tab in the same strip', () => {
const panelRect = rect({ left: 500, top: 0, width: 400, height: 600 })
mockTabGroupGeometry([
{
groupId: 'group-1',
panelRect,
bodyRect: rect({
left: 500,
top: TAB_GROUP_TAB_STRIP_HEIGHT_PX,
width: 400,
height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX
})
}
])
const geometry = captureTabGroupPanelGeometrySnapshot('wt-1')
expect(
resolveActivePaneColumnSplitTarget({
event: makeEvent({
activeData: makeDragData({ groupId: 'group-1', unifiedTabId: 'tab-1' }),
overData: makeDragData({
groupId: 'group-1',
unifiedTabId: 'tab-2',
visibleTabId: 'tab-2'
}),
pointer: { x: 560, y: 16 }
}),
groupsByWorktree: {
'wt-1': [
{
id: 'group-1',
worktreeId: 'wt-1',
activeTabId: 'tab-1',
tabOrder: ['tab-1', 'tab-2']
}
]
},
layoutByWorktree: {
'wt-1': { type: 'leaf', groupId: 'group-1' }
},
worktreeId: 'wt-1',
getDragPointer: () => ({ x: 560, y: 16 }),
geometry
})
).toBeNull()
})
it('ignores same-group tab hovers after the pointer leaves the panel', () => {
const panelRect = rect({ left: 500, top: 0, width: 400, height: 600 })
mockTabGroupGeometry([
{
groupId: 'group-1',
panelRect,
bodyRect: rect({
left: 500,
top: TAB_GROUP_TAB_STRIP_HEIGHT_PX,
width: 400,
height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX
})
}
])
const geometry = captureTabGroupPanelGeometrySnapshot('wt-1')
expect(
resolveActivePaneColumnSplitTarget({
event: makeEvent({
activeData: makeDragData({ groupId: 'group-1', unifiedTabId: 'tab-1' }),
overData: makeDragData({
groupId: 'group-1',
unifiedTabId: 'tab-2',
visibleTabId: 'tab-2'
}),
pointer: { x: 560, y: 601 }
}),
groupsByWorktree: {
'wt-1': [
{
id: 'group-1',
worktreeId: 'wt-1',
activeTabId: 'tab-1',
tabOrder: ['tab-1', 'tab-2']
}
]
},
layoutByWorktree: {
'wt-1': { type: 'leaf', groupId: 'group-1' }
},
worktreeId: 'wt-1',
getDragPointer: () => ({ x: 560, y: 601 }),
geometry
})
).toBeNull()
})
it('reuses one geometry snapshot across drag frames instead of measuring layout per move', () => {
const {
counts,
groupsByWorktree,
layoutByWorktree: fourGroupLayoutByWorktree,
queryAll
} = fourGroupFixture()
const geometry = captureTabGroupPanelGeometrySnapshot('wt-1')
const points = [
{ x: 295, y: 300 },
{ x: 360, y: 300 },
{ x: 595, y: 300 },
{ x: 664, y: 300 },
{ x: 899, y: 300 },
{ x: 968, y: 300 },
{ x: 1204, y: 300 }
]
for (const pointer of points) {
resolveActivePaneColumnSplitTarget({
event: makeEvent({
activeData: makeDragData({ groupId: 'group-1' }),
overData: null,
pointer
}),
groupsByWorktree,
layoutByWorktree: fourGroupLayoutByWorktree,
worktreeId: 'wt-1',
getDragPointer: () => pointer,
geometry
})
}
expect(queryAll).toHaveBeenCalledTimes(1)
expect(counts.reduce((sum, entry) => sum + entry.panelReads + entry.bodyReads, 0)).toBe(8)
})
it('reports cached split-target resolution timing without gating CI on wall-clock time', () => {
const {
counts,
groupsByWorktree,
layoutByWorktree: fourGroupLayoutByWorktree,
queryAll
} = fourGroupFixture()
const geometry = captureTabGroupPanelGeometrySnapshot('wt-1')
const points = [
{ x: 295, y: 300 },
{ x: 360, y: 300 },
{ x: 595, y: 300 },
{ x: 664, y: 300 },
{ x: 899, y: 300 },
{ x: 968, y: 300 },
{ x: 1204, y: 300 },
{ x: 650, y: 300 }
]
const durations: number[] = []
for (let index = 0; index < 1_000; index += 1) {
const pointer = points[index % points.length]!
const startedAt = performance.now()
resolveActivePaneColumnSplitTarget({
event: makeEvent({
activeData: makeDragData({ groupId: 'group-1' }),
overData: null,
pointer
}),
groupsByWorktree,
layoutByWorktree: fourGroupLayoutByWorktree,
worktreeId: 'wt-1',
getDragPointer: () => pointer,
geometry
})
durations.push(performance.now() - startedAt)
}
const p95Ms = percentile(durations, 0.95)
const maxMs = Math.max(...durations)
console.info(
`tab split preview resolver perf: p95=${p95Ms.toFixed(3)}ms max=${maxMs.toFixed(
3
)}ms samples=${durations.length}`
)
expect(queryAll).toHaveBeenCalledTimes(1)
expect(counts.reduce((sum, entry) => sum + entry.panelReads + entry.bodyReads, 0)).toBe(8)
})
})
@@ -0,0 +1,264 @@
import type { DragEndEvent, DragMoveEvent, DragOverEvent } from '@dnd-kit/core'
import type { TabGroup, TabGroupLayoutNode } from '../../../../shared/types'
import { isPaneColumnSplitDropNoOp } from '../../store/slices/pane-column-split-drop-no-op'
import {
resolvePaneColumnEdgeZone,
TAB_GROUP_TAB_STRIP_HEIGHT_PX,
type PaneColumnSplitTarget
} from './tab-drop-zone'
import {
canDropTabIntoPaneBody,
isPaneDropData,
isTabDragData,
type TabDragItemData
} from './useTabDragSplit'
export type TabGroupPanelGeometryEntry = {
groupId: string
panelRect: DOMRect
bodyRect: DOMRect
}
export type TabGroupPanelGeometrySnapshot = {
entries: TabGroupPanelGeometryEntry[]
byGroupId: Map<string, TabGroupPanelGeometryEntry>
}
export type ActivePaneColumnSplitTarget = PaneColumnSplitTarget & {
panelRect?: DOMRect
}
function escapeCssAttrValue(value: string): string {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(value)
}
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
}
function getTabGroupBodyElement(groupId: string, worktreeId: string): HTMLElement | null {
const escapedGroupId = escapeCssAttrValue(groupId)
const escapedWorktreeId = escapeCssAttrValue(worktreeId)
return document.querySelector<HTMLElement>(
`[data-tab-group-body-id="${escapedGroupId}"][data-worktree-id="${escapedWorktreeId}"]`
)
}
export function getTabGroupPanelRect(groupId: string, worktreeId: string): DOMRect | null {
return getTabGroupBodyElement(groupId, worktreeId)?.parentElement?.getBoundingClientRect() ?? null
}
export function getTabGroupBodyRect(groupId: string, worktreeId: string): DOMRect | null {
return getTabGroupBodyElement(groupId, worktreeId)?.getBoundingClientRect() ?? null
}
export function captureTabGroupPanelGeometrySnapshot(
worktreeId: string
): TabGroupPanelGeometrySnapshot {
const escapedWorktreeId = escapeCssAttrValue(worktreeId)
const bodies = document.querySelectorAll<HTMLElement>(
`[data-tab-group-body-id][data-worktree-id="${escapedWorktreeId}"]`
)
const entries: TabGroupPanelGeometryEntry[] = []
for (const body of bodies) {
const groupId = body.dataset.tabGroupBodyId
const panelElement = body.parentElement
if (!groupId || !panelElement) {
continue
}
entries.push({
groupId,
panelRect: panelElement.getBoundingClientRect(),
bodyRect: body.getBoundingClientRect()
})
}
return {
entries,
byGroupId: new Map(entries.map((entry) => [entry.groupId, entry]))
}
}
export function findTabGroupPanelUnderPointer(
worktreeId: string,
pointer: { x: number; y: number },
options: {
geometry?: TabGroupPanelGeometrySnapshot | null
getPanelRect?: (groupId: string, worktreeId: string) => DOMRect | null
} = {}
): { groupId: string; panelRect: DOMRect } | null {
if (options.geometry) {
for (const entry of options.geometry.entries) {
const { panelRect } = entry
if (
pointer.x >= panelRect.left &&
pointer.x <= panelRect.right &&
pointer.y >= panelRect.top &&
pointer.y <= panelRect.bottom
) {
return { groupId: entry.groupId, panelRect }
}
}
return null
}
const getPanelRect = options.getPanelRect ?? getTabGroupPanelRect
const escapedWorktreeId = escapeCssAttrValue(worktreeId)
const bodies = document.querySelectorAll<HTMLElement>(
`[data-tab-group-body-id][data-worktree-id="${escapedWorktreeId}"]`
)
for (const body of bodies) {
const groupId = body.dataset.tabGroupBodyId
if (!groupId) {
continue
}
const panelRect = getPanelRect(groupId, worktreeId)
if (!panelRect) {
continue
}
if (
pointer.x >= panelRect.left &&
pointer.x <= panelRect.right &&
pointer.y >= panelRect.top &&
pointer.y <= panelRect.bottom
) {
return { groupId, panelRect }
}
}
return null
}
export function resolvePanelEdgePaneColumnSplit({
activeDrag,
targetGroupId,
worktreeId,
pointer,
groupsByWorktree,
layoutByWorktree,
panelRect: providedPanelRect,
bodyRect: providedBodyRect
}: {
activeDrag: TabDragItemData
targetGroupId: string
worktreeId: string
pointer: { x: number; y: number }
groupsByWorktree: Record<string, TabGroup[]>
layoutByWorktree: Record<string, TabGroupLayoutNode>
panelRect?: DOMRect | null
bodyRect?: DOMRect | null
}): PaneColumnSplitTarget | null {
const panelRect = providedPanelRect ?? getTabGroupPanelRect(targetGroupId, worktreeId)
if (!panelRect) {
return null
}
// Why: dnd-kit can keep a closest-center `over` target after the pointer
// leaves the pane; edge splits must only resolve inside the actual panel.
if (
pointer.x < panelRect.left ||
pointer.x > panelRect.left + panelRect.width ||
pointer.y < panelRect.top ||
pointer.y > panelRect.top + panelRect.height
) {
return null
}
const bodyRect = providedBodyRect ?? getTabGroupBodyRect(targetGroupId, worktreeId)
const zone = resolvePaneColumnEdgeZone(panelRect, pointer, {
bodyRect: bodyRect ?? null,
tabStripHeightPx: TAB_GROUP_TAB_STRIP_HEIGHT_PX
})
if (!zone) {
return null
}
const sourceGroup = (groupsByWorktree[worktreeId] ?? []).find(
(group) => group.id === activeDrag.groupId
)
if (
isPaneColumnSplitDropNoOp({
sourceGroupId: activeDrag.groupId,
targetGroupId,
splitDirection: zone,
sourceTabCount: sourceGroup?.tabOrder.length ?? 0,
layout: layoutByWorktree[worktreeId]
})
) {
return null
}
if (activeDrag.groupId === targetGroupId) {
if (
!canDropTabIntoPaneBody({
activeDrag,
groupsByWorktree,
overGroupId: targetGroupId,
worktreeId
})
) {
return null
}
}
return { groupId: targetGroupId, zone }
}
export function resolveActivePaneColumnSplitTarget({
event,
groupsByWorktree,
layoutByWorktree,
worktreeId,
getDragPointer,
geometry
}: {
event: DragMoveEvent | DragOverEvent | DragEndEvent
groupsByWorktree: Record<string, TabGroup[]>
layoutByWorktree: Record<string, TabGroupLayoutNode>
worktreeId: string
getDragPointer: (event: DragMoveEvent | DragOverEvent | DragEndEvent) => {
x: number
y: number
} | null
geometry?: TabGroupPanelGeometrySnapshot | null
}): ActivePaneColumnSplitTarget | null {
const activeData = event.active.data.current
const pointer = getDragPointer(event)
if (!isTabDragData(activeData) || !pointer) {
return null
}
const overData = event.over?.data.current
const panelHit = findTabGroupPanelUnderPointer(worktreeId, pointer, { geometry })
if (isTabDragData(overData)) {
// Why: tab-strip drags target reorder/insertion slots. Split creation stays
// on pane/body edges so hovering over a tab never surprises the user with a
// new split.
if (!panelHit || pointer.y < panelHit.panelRect.top + TAB_GROUP_TAB_STRIP_HEIGHT_PX) {
return null
}
}
const targetGroupId =
panelHit?.groupId ??
(isTabDragData(overData) ? overData.groupId : null) ??
(isPaneDropData(overData) ? overData.groupId : null)
if (!targetGroupId) {
return null
}
const targetGeometry = geometry?.byGroupId.get(targetGroupId)
const panelRect =
panelHit?.groupId === targetGroupId ? panelHit.panelRect : targetGeometry?.panelRect
const splitTarget = resolvePanelEdgePaneColumnSplit({
activeDrag: activeData,
targetGroupId,
worktreeId,
pointer,
groupsByWorktree,
layoutByWorktree,
panelRect,
bodyRect: targetGeometry?.bodyRect
})
return splitTarget ? { ...splitTarget, panelRect } : null
}
@@ -90,19 +90,18 @@ describe('resolveTabInsertion', () => {
expect(resolveTabInsertion(event, isTabDragData, () => null)).toBeNull()
})
it('returns side "left" when cursor center is left of the over midpoint', () => {
it('returns side "left" when cursor is in the left reorder edge', () => {
const overData = makeDragData({
unifiedTabId: 'tab-over',
visibleTabId: 'tab-over',
groupId: 'group-2'
})
// Over rect: left=100, width=100 → midpoint=150
// Over rect: left=100, width=100 → left edge ends at 130
const event = makeDragEvent({
activeData: makeDragData({ unifiedTabId: 'tab-active' }),
overData,
overRect: { left: 100, width: 100 }
})
// Cursor at x=120, which is left of midpoint 150
const result = resolveTabInsertion(event, isTabDragData, () => ({ x: 120, y: 10 }))
expect(result).toEqual({
groupId: 'group-2',
@@ -111,19 +110,18 @@ describe('resolveTabInsertion', () => {
})
})
it('returns side "right" when cursor center is right of the over midpoint', () => {
it('returns side "right" when cursor is in the right reorder edge', () => {
const overData = makeDragData({
unifiedTabId: 'tab-over',
visibleTabId: 'tab-over',
groupId: 'group-2'
})
// Over rect: left=100, width=100 → midpoint=150
// Over rect: left=100, width=100 → right edge starts at 170
const event = makeDragEvent({
activeData: makeDragData({ unifiedTabId: 'tab-active' }),
overData,
overRect: { left: 100, width: 100 }
})
// Cursor at x=180, which is right of midpoint 150
const result = resolveTabInsertion(event, isTabDragData, () => ({ x: 180, y: 10 }))
expect(result).toEqual({
groupId: 'group-2',
@@ -132,24 +130,39 @@ describe('resolveTabInsertion', () => {
})
})
it('returns side "right" when cursor is exactly at the midpoint', () => {
it('uses midpoint insertion when reordering within the same pane', () => {
const overData = makeDragData({
unifiedTabId: 'tab-over',
visibleTabId: 'tab-over',
groupId: 'group-1'
})
const event = makeDragEvent({
activeData: makeDragData({ unifiedTabId: 'tab-active', groupId: 'group-1' }),
overData,
overRect: { left: 0, width: 200 }
})
expect(resolveTabInsertion(event, isTabDragData, () => ({ x: 100, y: 10 }))).toEqual({
groupId: 'group-1',
visibleTabId: 'tab-over',
side: 'right'
})
})
it('uses midpoint insertion when dragging across split panes', () => {
const overData = makeDragData({
unifiedTabId: 'tab-over',
visibleTabId: 'tab-over',
groupId: 'group-2'
})
// Over rect: left=0, width=200 → midpoint=100
const event = makeDragEvent({
activeData: makeDragData({ unifiedTabId: 'tab-active' }),
activeData: makeDragData({ unifiedTabId: 'tab-active', groupId: 'group-1' }),
overData,
overRect: { left: 0, width: 200 }
})
// Cursor at x=100 — exactly at midpoint, not < midpoint so → 'right'
const result = resolveTabInsertion(event, isTabDragData, () => ({ x: 100, y: 10 }))
expect(result).toEqual({
expect(resolveTabInsertion(event, isTabDragData, () => ({ x: 80, y: 10 }))).toEqual({
groupId: 'group-2',
visibleTabId: 'tab-over',
side: 'right'
side: 'left'
})
})
})
@@ -1,12 +1,34 @@
import { describe, expect, it } from 'vitest'
import type { TabGroup } from '../../../../shared/types'
/**
* @vitest-environment happy-dom
*/
import { act, createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../../shared/types'
import { useAppStore } from '../../store'
import type { TabDragItemData } from './useTabDragSplit'
import { canDropTabIntoPaneBody } from './useTabDragSplit'
import {
canDropTabForPaneColumnSplit,
canDropTabIntoPaneBody,
useTabDragSplit
} from './useTabDragSplit'
vi.mock('../browser-pane/webview-registry', () => ({
acquireWebviewsDragPassthrough: vi.fn(() => vi.fn())
}))
vi.mock('../../runtime/web-runtime-session', () => ({
isWebRuntimeSessionActive: vi.fn(() => false),
moveWebRuntimeSessionTab: vi.fn()
}))
const WT = 'wt-1'
const mounted: { container: HTMLDivElement; root: Root }[] = []
function makeGroup(id: string, tabOrder: string[]): TabGroup {
return {
id,
worktreeId: 'wt-1',
worktreeId: WT,
activeTabId: tabOrder[0] ?? null,
tabOrder
}
@@ -15,7 +37,7 @@ function makeGroup(id: string, tabOrder: string[]): TabGroup {
function makeDragData(groupId: string, unifiedTabId = 'tab-1'): TabDragItemData {
return {
kind: 'tab',
worktreeId: 'wt-1',
worktreeId: WT,
groupId,
unifiedTabId,
visibleTabId: unifiedTabId,
@@ -24,14 +46,145 @@ function makeDragData(groupId: string, unifiedTabId = 'tab-1'): TabDragItemData
}
}
function rect({
left,
top,
width,
height
}: {
left: number
top: number
width: number
height: number
}): DOMRect {
return {
left,
top,
width,
height,
right: left + width,
bottom: top + height
} as DOMRect
}
function addPanelGeometry(groupId: string, panelRect: DOMRect, bodyRect: DOMRect): HTMLElement {
const panel = document.createElement('div')
const body = document.createElement('div')
body.dataset.tabGroupBodyId = groupId
body.dataset.worktreeId = WT
panel.getBoundingClientRect = () => panelRect
body.getBoundingClientRect = () => bodyRect
panel.appendChild(body)
document.body.appendChild(panel)
return panel
}
function makeDragEvent(activeData: TabDragItemData, pointer: { x: number; y: number }) {
return {
active: {
data: { current: activeData },
rect: { current: { initial: null } }
},
over: null,
delta: { x: 0, y: 0 },
activatorEvent: { clientX: pointer.x, clientY: pointer.y }
}
}
function renderDragHook(): ReturnType<typeof useTabDragSplit> {
let result: ReturnType<typeof useTabDragSplit> | null = null
function Probe(): null {
result = useTabDragSplit({ worktreeId: WT })
return null
}
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
act(() => root.render(createElement(Probe)))
mounted.push({ container, root })
if (!result) {
throw new Error('useTabDragSplit did not render')
}
return result
}
beforeEach(() => {
useAppStore.setState({
activeWorktreeId: WT,
activeGroupIdByWorktree: { [WT]: 'group-1' },
groupsByWorktree: {
[WT]: [makeGroup('group-1', ['tab-1', 'tab-3']), makeGroup('group-2', ['tab-2'])]
},
unifiedTabsByWorktree: {
[WT]: [
{
id: 'tab-1',
groupId: 'group-1',
worktreeId: WT,
contentType: 'terminal',
entityId: 'term-1',
label: 'one',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 0
} satisfies Tab,
{
id: 'tab-2',
groupId: 'group-2',
worktreeId: WT,
contentType: 'terminal',
entityId: 'term-2',
label: 'two',
customLabel: null,
color: null,
sortOrder: 1,
createdAt: 1
} satisfies Tab,
{
id: 'tab-3',
groupId: 'group-1',
worktreeId: WT,
contentType: 'terminal',
entityId: 'term-3',
label: 'three',
customLabel: null,
color: null,
sortOrder: 2,
createdAt: 2
} satisfies Tab
]
},
layoutByWorktree: {
[WT]: {
type: 'split',
direction: 'horizontal',
ratio: 0.5,
first: { type: 'leaf', groupId: 'group-1' },
second: { type: 'leaf', groupId: 'group-2' }
} satisfies TabGroupLayoutNode
}
})
})
afterEach(() => {
for (const { container, root } of mounted.splice(0)) {
act(() => root.unmount())
container.remove()
}
document.body.replaceChildren()
vi.clearAllMocks()
})
describe('canDropTabIntoPaneBody', () => {
it('rejects pane-body drops that would split a single tab onto itself', () => {
expect(
canDropTabIntoPaneBody({
activeDrag: makeDragData('group-1'),
groupsByWorktree: { 'wt-1': [makeGroup('group-1', ['tab-1'])] },
groupsByWorktree: { [WT]: [makeGroup('group-1', ['tab-1'])] },
overGroupId: 'group-1',
worktreeId: 'wt-1'
worktreeId: WT
})
).toBe(false)
})
@@ -40,9 +193,9 @@ describe('canDropTabIntoPaneBody', () => {
expect(
canDropTabIntoPaneBody({
activeDrag: makeDragData('group-1'),
groupsByWorktree: { 'wt-1': [makeGroup('group-1', ['tab-1', 'tab-2'])] },
groupsByWorktree: { [WT]: [makeGroup('group-1', ['tab-1', 'tab-2'])] },
overGroupId: 'group-1',
worktreeId: 'wt-1'
worktreeId: WT
})
).toBe(true)
})
@@ -52,11 +205,59 @@ describe('canDropTabIntoPaneBody', () => {
canDropTabIntoPaneBody({
activeDrag: makeDragData('group-1'),
groupsByWorktree: {
'wt-1': [makeGroup('group-1', ['tab-1']), makeGroup('group-2', ['tab-2'])]
[WT]: [makeGroup('group-1', ['tab-1']), makeGroup('group-2', ['tab-2'])]
},
overGroupId: 'group-2',
worktreeId: 'wt-1'
worktreeId: WT
})
).toBe(true)
})
it('rejects tab-on-tab split drops across groups', () => {
expect(
canDropTabForPaneColumnSplit({
activeDrag: makeDragData('group-1'),
groupsByWorktree: {
[WT]: [makeGroup('group-1', ['tab-1']), makeGroup('group-2', ['tab-2'])]
},
targetGroupId: 'group-2',
worktreeId: WT
})
).toBe(false)
})
})
describe('useTabDragSplit', () => {
it('commits a geometry-only pane split when drag end has no over target', () => {
addPanelGeometry(
'group-2',
rect({ left: 500, top: 0, width: 400, height: 600 }),
rect({ left: 500, top: 32, width: 400, height: 568 })
)
const activeData = makeDragData('group-1')
const dropUnifiedTab = vi.fn(() => true)
useAppStore.setState({ dropUnifiedTab } as Partial<ReturnType<typeof useAppStore.getState>>)
const drag = renderDragHook()
act(() => {
drag.onDragStart(
makeDragEvent(activeData, { x: 880, y: 300 }) as unknown as Parameters<
typeof drag.onDragStart
>[0]
)
})
act(() => {
drag.onDragEnd(
makeDragEvent(activeData, { x: 880, y: 300 }) as unknown as Parameters<
typeof drag.onDragEnd
>[0]
)
})
expect(dropUnifiedTab).toHaveBeenCalledWith('tab-1', {
groupId: 'group-2',
splitDirection: 'right'
})
})
})
@@ -1,7 +1,7 @@
/* oxlint-disable max-lines -- Why: the drag-split hook co-locates drop-zone
* resolution, same-group reordering, and cross-group handoff so state
* transitions stay readable in one place. */
import { useCallback, useRef, useState } from 'react'
import { useCallback, useRef, useState, type RefObject } from 'react'
import {
closestCenter,
pointerWithin,
@@ -16,20 +16,30 @@ import {
useSensors
} from '@dnd-kit/core'
import type { TabGroup, TuiAgent } from '../../../../shared/types'
import type { RuntimeMobileSessionTabMove } from '../../../../shared/runtime-types'
import { useAppStore } from '../../store'
import {
isWebRuntimeSessionActive,
moveWebRuntimeSessionTab
} from '../../runtime/web-runtime-session'
import type { TabSplitDirection } from '../../store/slices/tabs'
import { mirrorWebRuntimeTabMove } from '../tab-bar/web-runtime-tab-move-mirror'
import {
resolveTabInsertion,
useHoveredTabInsertion,
type HoveredTabInsertion
} from './tab-insertion'
import { acquireWebviewsDragPassthrough } from '../browser-pane/webview-registry'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import {
applyDragPreviewTab,
captureTabDragActivationSnapshot,
restoreSourceGroupActiveTabAfterCrossGroupDrop,
restoreTabDragActivationSnapshot,
type TabDragActivationSnapshot
} from './tab-drag-preview-activation'
import { resolveDragPreviewTabId, resolveSourceGroupRestoreOnDrop } from './tab-drag-preview-target'
import { getDragPointer } from './tab-drag-pointer'
import {
captureTabGroupPanelGeometrySnapshot,
resolveActivePaneColumnSplitTarget,
type ActivePaneColumnSplitTarget,
type TabGroupPanelGeometrySnapshot
} from './tab-group-panel-split-target'
export type { HoveredTabInsertion }
@@ -65,21 +75,7 @@ export type TabPaneDropData = {
export type HoveredTabDropTarget = {
groupId: string
zone: TabDropZone
}
function mirrorWebRuntimeTabMove(
args: RuntimeMobileSessionTabMove & {
worktreeId: string
}
): void {
const environmentId = getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), args.worktreeId)
if (!isWebRuntimeSessionActive(environmentId)) {
return
}
void moveWebRuntimeSessionTab({
...args,
environmentId
})
panelRect?: DOMRect
}
export function canDropTabIntoPaneBody({
@@ -113,69 +109,33 @@ export function canDropTabIntoPaneBody({
return true
}
function isTabDragData(value: unknown): value is TabDragItemData {
export function canDropTabForPaneColumnSplit(args: {
activeDrag: TabDragItemData | null
groupsByWorktree: Record<string, TabGroup[]>
targetGroupId: string
worktreeId: string
}): boolean {
if (!args.activeDrag || args.activeDrag.groupId !== args.targetGroupId) {
return false
}
return canDropTabIntoPaneBody({
activeDrag: args.activeDrag,
groupsByWorktree: args.groupsByWorktree,
overGroupId: args.targetGroupId,
worktreeId: args.worktreeId
})
}
export function isTabDragData(value: unknown): value is TabDragItemData {
return Boolean(value) && typeof value === 'object' && (value as TabDragItemData).kind === 'tab'
}
function isPaneDropData(value: unknown): value is TabPaneDropData {
export function isPaneDropData(value: unknown): value is TabPaneDropData {
return (
Boolean(value) && typeof value === 'object' && (value as TabPaneDropData).kind === 'pane-body'
)
}
function getDragCenter(
event: Pick<DragMoveEvent, 'active' | 'delta'>
): { x: number; y: number } | null {
const translated = event.active.rect.current.translated
if (translated) {
return {
x: translated.left + translated.width / 2,
y: translated.top + translated.height / 2
}
}
const initial = event.active.rect.current.initial
if (!initial) {
return null
}
return {
x: initial.left + initial.width / 2 + event.delta.x,
y: initial.top + initial.height / 2 + event.delta.y
}
}
export function resolveDropZone(
rect: { left: number; top: number; width: number; height: number },
point: { x: number; y: number }
): TabDropZone {
const localX = point.x - rect.left
const localY = point.y - rect.top
const edgeWidthThreshold = rect.width * 0.1
const edgeHeightThreshold = rect.height * 0.1
const splitWidthThreshold = rect.width / 3
// Why: VS Code keeps a center "merge" zone while biasing side-by-side drops
// toward left/right, which feels much more stable than a generic nearest-edge
// calculation once a workspace has nested splits.
if (
localX > edgeWidthThreshold &&
localX < rect.width - edgeWidthThreshold &&
localY > edgeHeightThreshold &&
localY < rect.height - edgeHeightThreshold
) {
return 'center'
}
if (localX < splitWidthThreshold) {
return 'left'
}
if (localX > splitWidthThreshold * 2) {
return 'right'
}
return localY < rect.height / 2 ? 'up' : 'down'
}
const collisionDetection: CollisionDetection = (args) => {
const pointerCollisions = pointerWithin(args)
return pointerCollisions.length > 0 ? pointerCollisions : closestCenter(args)
@@ -199,6 +159,7 @@ export function useTabDragSplit({
collisionDetection: CollisionDetection
hoveredDropTarget: HoveredTabDropTarget | null
hoveredTabInsertion: HoveredTabInsertion | null
isTabDragActiveRef: RefObject<boolean>
onDragCancel: () => void
onDragEnd: (event: DragEndEvent) => void
onDragMove: (event: DragMoveEvent) => void
@@ -212,7 +173,12 @@ export function useTabDragSplit({
const [activeDrag, setActiveDrag] = useState<TabDragItemData | null>(null)
const [hoveredDropTarget, setHoveredDropTarget] = useState<HoveredTabDropTarget | null>(null)
const releaseWebviewDragPassthroughRef = useRef<(() => void) | null>(null)
const tabInsertion = useHoveredTabInsertion(isTabDragData, getDragCenter)
const preDragActivationSnapshotRef = useRef<TabDragActivationSnapshot | null>(null)
const lastPreviewRef = useRef<{ groupId: string; tabId: string | null } | null>(null)
const lastHoveredTabPreviewRef = useRef<{ groupId: string; tabId: string } | null>(null)
const tabDragActiveRef = useRef(false)
const dragGeometryRef = useRef<TabGroupPanelGeometrySnapshot | null>(null)
const tabInsertion = useHoveredTabInsertion(isTabDragData, getDragPointer)
// Why: hidden worktrees stay mounted so their PTYs survive worktree
// switches, but their DndContext should not activate drags. We use an
@@ -250,60 +216,135 @@ export function useTabDragSplit({
)
const clearDragState = useCallback(() => {
tabDragActiveRef.current = false
releaseWebviewDragPassthrough()
setActiveDrag(null)
setHoveredDropTarget(null)
tabInsertion.clear()
preDragActivationSnapshotRef.current = null
lastPreviewRef.current = null
lastHoveredTabPreviewRef.current = null
dragGeometryRef.current = null
}, [releaseWebviewDragPassthrough, tabInsertion])
const updateHoveredPane = useCallback(
(event: DragMoveEvent | DragOverEvent) => {
const overData = event.over?.data.current
if (!event.over || !isPaneDropData(overData)) {
// Why: using functional updater to avoid a new null reference when
// the state is already null — prevents unnecessary re-renders during
// high-frequency onDragMove events.
setHoveredDropTarget((prev) => (prev === null ? prev : null))
const restorePreDragActivation = useCallback(() => {
const snapshot = preDragActivationSnapshotRef.current
if (!snapshot) {
return
}
restoreTabDragActivationSnapshot(worktreeId, snapshot)
}, [worktreeId])
const restoreSourceGroupAfterCrossGroupDrop = useCallback(
(activeData: TabDragItemData) => {
const snapshot = preDragActivationSnapshotRef.current
if (!snapshot) {
return
}
const activeData = event.active.data.current
if (
!isTabDragData(activeData) ||
!canDropTabIntoPaneBody({
activeDrag: activeData,
groupsByWorktree: useAppStore.getState().groupsByWorktree,
overGroupId: overData.groupId,
worktreeId
})
) {
setHoveredDropTarget((prev) => (prev === null ? prev : null))
return
}
const center = getDragCenter(event)
if (!center) {
setHoveredDropTarget((prev) => (prev === null ? prev : null))
return
}
// Why: onDragMove fires at pointer-move frequency (~60 fps). Creating
// a new { groupId, zone } object every time would trigger a state
// update and full re-render of the SplitNode tree on every frame even
// when nothing meaningful changed. The functional updater lets us
// compare against the previous value and return the same reference
// when groupId and zone are unchanged.
setHoveredDropTarget((prev) => {
const zone = resolveDropZone(event.over!.rect, center)
if (prev?.groupId === overData.groupId && prev?.zone === zone) {
return prev
}
return { groupId: overData.groupId, zone }
restoreSourceGroupActiveTabAfterCrossGroupDrop({
worktreeId,
snapshot,
sourceGroupId: activeData.groupId,
movedTabId: activeData.unifiedTabId
})
},
[worktreeId]
)
const finishDrag = useCallback(
(restoreSnapshot: boolean, activeData?: TabDragItemData) => {
if (restoreSnapshot) {
restorePreDragActivation()
} else if (activeData) {
restoreSourceGroupAfterCrossGroupDrop(activeData)
}
clearDragState()
},
[clearDragState, restorePreDragActivation, restoreSourceGroupAfterCrossGroupDrop]
)
const updateDragPreviewActivation = useCallback(
(event: DragMoveEvent | DragOverEvent, activeData: TabDragItemData) => {
const snapshot = preDragActivationSnapshotRef.current
if (!snapshot) {
return
}
const overData = event.over?.data.current
if (isTabDragData(overData) && overData.unifiedTabId !== activeData.unifiedTabId) {
lastHoveredTabPreviewRef.current = {
groupId: overData.groupId,
tabId: overData.unifiedTabId
}
}
const preview = resolveDragPreviewTabId({
activeDrag: activeData,
overData,
preDragActiveTabIdByGroup: snapshot.activeTabIdByGroup,
lastHoveredTabPreview: lastHoveredTabPreviewRef.current
})
const lastPreview = lastPreviewRef.current
if (lastPreview?.groupId === preview.groupId && lastPreview.tabId === preview.tabId) {
return
}
lastPreviewRef.current = preview
applyDragPreviewTab({
worktreeId,
groupId: preview.groupId,
tabId: preview.tabId,
activeGroupId: preview.groupId
})
},
[worktreeId]
)
const updateHoveredDropTargetFromSplit = useCallback(
(splitTarget: ActivePaneColumnSplitTarget | null) => {
if (!splitTarget) {
setHoveredDropTarget((prev) => (prev === null ? prev : null))
return
}
setHoveredDropTarget((prev) => {
if (prev?.groupId === splitTarget.groupId && prev?.zone === splitTarget.zone) {
return prev
}
return {
groupId: splitTarget.groupId,
zone: splitTarget.zone,
panelRect: splitTarget.panelRect
}
})
},
[]
)
const handleDragUpdate = useCallback(
(event: DragMoveEvent | DragOverEvent) => {
const activeData = event.active.data.current
if (isTabDragData(activeData) && activeData.worktreeId === worktreeId) {
updateDragPreviewActivation(event, activeData)
}
const state = useAppStore.getState()
const splitTarget = resolveActivePaneColumnSplitTarget({
event,
groupsByWorktree: state.groupsByWorktree,
layoutByWorktree: state.layoutByWorktree,
worktreeId,
getDragPointer,
geometry: dragGeometryRef.current
})
updateHoveredDropTargetFromSplit(splitTarget)
if (splitTarget) {
tabInsertion.clear()
} else {
tabInsertion.update(event)
}
},
[tabInsertion, updateDragPreviewActivation, updateHoveredDropTargetFromSplit, worktreeId]
)
const onDragStart = useCallback(
(event: DragStartEvent) => {
const dragData = event.active.data.current
@@ -313,6 +354,9 @@ export function useTabDragSplit({
}
setActiveDrag(dragData)
tabDragActiveRef.current = true
dragGeometryRef.current = captureTabGroupPanelGeometrySnapshot(worktreeId)
preDragActivationSnapshotRef.current = captureTabDragActivationSnapshot(worktreeId)
acquireWebviewDragPassthrough()
},
[acquireWebviewDragPassthrough, clearDragState, worktreeId]
@@ -320,41 +364,77 @@ export function useTabDragSplit({
const onDragMove = useCallback(
(event: DragMoveEvent) => {
updateHoveredPane(event)
tabInsertion.update(event)
handleDragUpdate(event)
},
[updateHoveredPane, tabInsertion]
[handleDragUpdate]
)
const onDragOver = useCallback(
(event: DragOverEvent) => {
updateHoveredPane(event)
tabInsertion.update(event)
},
[updateHoveredPane, tabInsertion]
)
const onDragOver = useCallback((_event: DragOverEvent) => {
// Why: onDragMove already carries over + delta; skipping duplicate work here
// avoids running split/insertion resolution twice in the same frame.
}, [])
const onDragEnd = useCallback(
(event: DragEndEvent) => {
const activeData = event.active.data.current
const overData = event.over?.data.current
let shouldRestorePreDragActivation = true
if (!event.over || !isTabDragData(activeData) || activeData.worktreeId !== worktreeId) {
clearDragState()
if (!isTabDragData(activeData) || activeData.worktreeId !== worktreeId) {
finishDrag(true)
return
}
const state = useAppStore.getState()
const paneColumnSplit = resolveActivePaneColumnSplitTarget({
event,
groupsByWorktree: state.groupsByWorktree,
layoutByWorktree: state.layoutByWorktree,
worktreeId,
getDragPointer,
geometry: dragGeometryRef.current
})
if (paneColumnSplit) {
const moved = dropUnifiedTab(activeData.unifiedTabId, {
groupId: paneColumnSplit.groupId,
splitDirection: paneColumnSplit.zone
})
if (moved) {
shouldRestorePreDragActivation = false
mirrorWebRuntimeTabMove({
kind: 'split',
worktreeId,
tabId: activeData.unifiedTabId,
targetGroupId: paneColumnSplit.groupId,
splitDirection: paneColumnSplit.zone
})
}
finishDrag(
shouldRestorePreDragActivation,
resolveSourceGroupRestoreOnDrop(
activeData,
paneColumnSplit.groupId,
shouldRestorePreDragActivation
)
)
return
}
if (!event.over) {
finishDrag(true)
return
}
if (isTabDragData(overData)) {
if (activeData.unifiedTabId === overData.unifiedTabId) {
clearDragState()
finishDrag(true)
return
}
const state = useAppStore.getState()
const groups = state.groupsByWorktree[worktreeId] ?? []
const targetGroup = groups.find((group) => group.id === overData.groupId)
if (!targetGroup) {
clearDragState()
finishDrag(true)
return
}
@@ -362,9 +442,14 @@ export function useTabDragSplit({
// insertion point depends on which side of that tab the cursor sits.
// Using the bar's computed side (re-derived here to avoid stale
// closures) means the drop always lands where the blue bar was drawn.
const insertion = resolveTabInsertion(event, isTabDragData, getDragCenter)
const insertion = resolveTabInsertion(event, isTabDragData, getDragPointer)
if (!insertion) {
finishDrag(true)
return
}
const overIndex = targetGroup.tabOrder.indexOf(overData.unifiedTabId)
const rawInsertIndex = overIndex + (insertion?.side === 'right' ? 1 : 0)
const rawInsertIndex = overIndex + (insertion.side === 'right' ? 1 : 0)
if (activeData.groupId === overData.groupId) {
const oldIndex = targetGroup.tabOrder.indexOf(activeData.unifiedTabId)
@@ -391,6 +476,7 @@ export function useTabDragSplit({
index
})
if (moved) {
shouldRestorePreDragActivation = false
mirrorWebRuntimeTabMove({
kind: 'move-to-group',
worktreeId,
@@ -401,62 +487,46 @@ export function useTabDragSplit({
}
}
clearDragState()
finishDrag(
shouldRestorePreDragActivation,
resolveSourceGroupRestoreOnDrop(
activeData,
overData.groupId,
shouldRestorePreDragActivation
)
)
return
}
if (isPaneDropData(overData)) {
if (
!canDropTabIntoPaneBody({
activeDrag: activeData,
groupsByWorktree: useAppStore.getState().groupsByWorktree,
overGroupId: overData.groupId,
worktreeId
if (activeData.groupId !== overData.groupId) {
const moved = dropUnifiedTab(activeData.unifiedTabId, {
groupId: overData.groupId
})
) {
clearDragState()
return
}
const center = getDragCenter(event)
if (center) {
const zone = resolveDropZone(event.over.rect, center)
// Why: a center drop onto the tab's own pane body is a no-op in the
// store (non-split same-group drops are ignored), but
// canDropTabIntoPaneBody still allows it when the source group has
// >1 tab — so the overlay advertises "center" as a valid target.
// Skip the call in that case to avoid misleading the user via a
// drop that silently does nothing.
if (zone !== 'center' || activeData.groupId !== overData.groupId) {
const moved = dropUnifiedTab(activeData.unifiedTabId, {
groupId: overData.groupId,
splitDirection: zone === 'center' ? undefined : zone
if (moved) {
shouldRestorePreDragActivation = false
mirrorWebRuntimeTabMove({
kind: 'move-to-group',
worktreeId,
tabId: activeData.unifiedTabId,
targetGroupId: overData.groupId
})
if (moved) {
if (zone === 'center') {
mirrorWebRuntimeTabMove({
kind: 'move-to-group',
worktreeId,
tabId: activeData.unifiedTabId,
targetGroupId: overData.groupId
})
} else {
mirrorWebRuntimeTabMove({
kind: 'split',
worktreeId,
tabId: activeData.unifiedTabId,
targetGroupId: overData.groupId,
splitDirection: zone
})
}
}
}
}
}
clearDragState()
finishDrag(
shouldRestorePreDragActivation,
isPaneDropData(overData)
? resolveSourceGroupRestoreOnDrop(
activeData,
overData.groupId,
shouldRestorePreDragActivation
)
: undefined
)
},
[clearDragState, dropUnifiedTab, reorderUnifiedTabs, worktreeId]
[dropUnifiedTab, finishDrag, reorderUnifiedTabs, worktreeId]
)
// Why: dnd-kit fires onDragCancel (not onDragEnd) when the user presses
@@ -464,14 +534,15 @@ export function useTabDragSplit({
// activeDrag and hoveredDropTarget state would remain stale, leaving the
// drop overlay visible indefinitely.
const onDragCancel = useCallback(() => {
clearDragState()
}, [clearDragState])
finishDrag(true)
}, [finishDrag])
return {
activeDrag,
collisionDetection,
hoveredDropTarget,
hoveredTabInsertion: tabInsertion.hoveredTabInsertion,
isTabDragActiveRef: tabDragActiveRef,
onDragCancel,
onDragEnd,
onDragMove,
@@ -214,11 +214,54 @@ describe('useTabGroupWorkspaceModel terminal activation focus', () => {
expect(mocks.createEmptySplitGroup).toHaveBeenCalledWith('wt-1', 'group-1', 'right')
expect(mocks.createTab).toHaveBeenCalledWith('wt-1', 'group-2')
expect(mocks.dropUnifiedTab).not.toHaveBeenCalled()
expect(mocks.recordFeatureInteraction).toHaveBeenCalledWith('terminal-pane-split')
expect(mocks.setActiveTab).toHaveBeenCalledWith('terminal-2')
expect(mocks.setActiveTabType).toHaveBeenCalledWith('terminal')
})
it('seeds a new terminal instead of moving the active tab when the group has multiple tabs', async () => {
const secondUnifiedTab = {
id: 'unified-terminal-2',
entityId: 'terminal-2',
groupId: 'group-1',
worktreeId: 'wt-1',
contentType: 'terminal',
label: 'Terminal 2',
customLabel: null,
color: null,
sortOrder: 1,
createdAt: 1
}
storeBox.state = {
...storeBox.state,
groupsByWorktree: {
'wt-1': [
{
id: 'group-1',
worktreeId: 'wt-1',
activeTabId: secondUnifiedTab.id,
tabOrder: ['unified-terminal-1', secondUnifiedTab.id]
}
]
},
unifiedTabsByWorktree: {
'wt-1': [...(storeBox.state?.unifiedTabsByWorktree?.['wt-1'] ?? []), secondUnifiedTab]
}
}
mocks.createEmptySplitGroup.mockReturnValue('group-2')
mocks.createTab.mockReturnValue({ id: 'terminal-3' })
const { useTabGroupWorkspaceModel } = await import('./useTabGroupWorkspaceModel')
const model = useTabGroupWorkspaceModel({ groupId: 'group-1', worktreeId: 'wt-1' })
model.commands.createSplitGroup('right')
expect(mocks.createEmptySplitGroup).toHaveBeenCalledWith('wt-1', 'group-1', 'right')
expect(mocks.createTab).toHaveBeenCalledWith('wt-1', 'group-2')
expect(mocks.dropUnifiedTab).not.toHaveBeenCalled()
expect(mocks.setActiveTab).toHaveBeenCalledWith('terminal-3')
})
it('closes client-local browser fallback tabs locally in remote workspaces', async () => {
mocks.isWebRuntimeSessionActive.mockReturnValue(true)
const browserTab = {
@@ -97,7 +97,6 @@ export function useTabGroupWorkspaceModel({
const closeBrowserTab = useAppStore((state) => state.closeBrowserTab)
const setActiveBrowserTab = useAppStore((state) => state.setActiveBrowserTab)
const setActiveWorktree = useAppStore((state) => state.setActiveWorktree)
const dropUnifiedTab = useAppStore((state) => state.dropUnifiedTab)
const createEmptySplitGroup = useAppStore((state) => state.createEmptySplitGroup)
const setTabCustomTitle = useAppStore((state) => state.setTabCustomTitle)
const setTabColor = useAppStore((state) => state.setTabColor)
@@ -456,52 +455,25 @@ export function useTabGroupWorkspaceModel({
)
const createSplitGroup = useCallback(
(direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId?: string) => {
const sourceTab =
groupTabs.find((candidate) =>
candidate.contentType === 'terminal' || candidate.contentType === 'browser'
? candidate.entityId === sourceVisibleTabId
: candidate.id === sourceVisibleTabId
) ?? activeTab
(direction: 'left' | 'right' | 'up' | 'down') => {
focusGroup(worktreeId, groupId)
if (!sourceTab) {
const newGroupId = createEmptySplitGroup(worktreeId, groupId, direction)
if (!newGroupId) {
return
}
// Why: for terminals specifically, splitting a single-tab group should
// still produce a useful split — spawn a fresh terminal in the new pane
// and leave the existing one behind. Moving the only tab would collapse
// the split immediately (see the same-group guard in dropUnifiedTab),
// giving the user nothing; a new terminal preserves the old shortcut
// flow without duplicating a persistent tab like editors/browsers would.
if (sourceTab.contentType === 'terminal' && groupTabs.length <= 1) {
const newGroupId = createEmptySplitGroup(worktreeId, groupId, direction)
if (!newGroupId) {
return
}
const terminal = createTab(worktreeId, newGroupId)
recordTerminalTabGroupSplit(terminal)
setActiveTab(terminal.id)
setActiveTabType('terminal')
return
}
// Why: split actions MOVE the source tab into the new pane rather than
// leaving a duplicate in the origin. Delegating to dropUnifiedTab reuses
// the same split+move path as drag-to-split so keyboard/menu splits and
// drag splits stay behaviorally identical, including collapsing the
// origin group if its last tab is the one we just moved.
dropUnifiedTab(sourceTab.id, { groupId, splitDirection: direction })
// Why: the tab-strip Split pane control adds a split pane to the right of
// the group that owns the button. Dragging tabs can still open other
// directions; this entry point always seeds a fresh terminal.
const terminal = createTab(worktreeId, newGroupId)
recordTerminalTabGroupSplit(terminal)
setActiveTab(terminal.id)
setActiveTabType('terminal')
},
[
activeTab,
createEmptySplitGroup,
createTab,
dropUnifiedTab,
focusGroup,
groupId,
groupTabs,
setActiveTab,
setActiveTabType,
worktreeId
@@ -3,11 +3,8 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import { createPortal } from 'react-dom'
import type { CSSProperties } from 'react'
import type { IDisposable } from '@xterm/xterm'
import { X } from 'lucide-react'
import { useAppStore } from '../../store'
import { isUnifiedTabPinned } from '@/store/pinned-tab-close-guard'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useLinkRoutingPreferenceDialog } from '@/components/link-routing-preference-dialog'
import { DaemonActionDialog, useDaemonActions } from '@/components/shared/useDaemonActions'
import {
@@ -41,6 +38,8 @@ import { MobileDriverOverlay } from './MobileDriverOverlay'
import { TerminalErrorToast } from './TerminalErrorToast'
import { TerminalSessionStateSaveFailureDialog } from './TerminalSessionStateSaveFailureDialog'
import TerminalContextMenu from './TerminalContextMenu'
import TerminalPaneHeaderOverlay from './TerminalPaneHeaderOverlay'
import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd'
import { TerminalAgentSessionForkDialog } from './TerminalAgentSessionForkDialog'
import { SessionRestoredBannerPortals } from './SessionRestoredBannerPortals'
import { useSessionRestoredBannerDismiss } from './useSessionRestoredBannerDismiss'
@@ -140,7 +139,6 @@ import {
import { getCachedTerminalTabForWorktree } from './terminal-tab-lookup'
import { getCachedTerminalGroupIdForWorktree } from './terminal-unified-tab-lookup'
import { useRepoById } from '@/store/selectors'
import { translate } from '@/i18n/i18n'
type TerminalPaneProps = {
tabId: string
@@ -1764,12 +1762,22 @@ export default function TerminalPane({
panes: manager.getPanes(),
paneTitles,
renamingPaneId,
sessionRestoredBannerPaneIds
sessionRestoredBannerPaneIds,
reservePaneHeaderSpace: isActive && (isVisible || shouldMeasureHiddenStartup)
})
if (needsFit) {
fitPanes(manager)
}
}, [paneCount, paneLayoutRevision, paneTitles, renamingPaneId, sessionRestoredBannerPaneIds])
}, [
paneCount,
paneLayoutRevision,
paneTitles,
renamingPaneId,
sessionRestoredBannerPaneIds,
isActive,
isVisible,
shouldMeasureHiddenStartup
])
const syncPaneTitleOverlayRects = useCallback((): void => {
const manager = managerRef.current
@@ -2300,6 +2308,33 @@ export default function TerminalPane({
managerRef.current?.setActivePane(paneId, { focus: false })
}, [])
const splitTerminalPaneFromHeader = useCallback(
(pane: ManagedPane, direction: 'vertical' | 'horizontal') => {
const manager = managerRef.current
if (!manager) {
return
}
splitTerminalPaneWithInheritedCwd({
manager,
getManager: () => managerRef.current,
paneTransports: paneTransportsRef.current,
paneCwdMap: paneCwdRef.current,
fallbackCwd: cwd ?? '',
pane,
direction,
source: 'context_menu'
})
},
[cwd]
)
const beginPaneDragFromHeader = useCallback(
(paneId: number, handle: HTMLElement, event: PointerEvent) => {
managerRef.current?.beginPaneDragFromPointerDown(paneId, handle, event)
},
[]
)
const effectiveAppearance = settings
? resolveEffectiveTerminalAppearance(settings, systemPrefersDark)
: null
@@ -2339,13 +2374,13 @@ export default function TerminalPane({
}
const activePane = managerRef.current?.getActivePane()
const managedPanes = managerRef.current?.getPanes() ?? []
return (
<>
<div
ref={setContainerRef}
className="absolute inset-0 min-h-0 min-w-0"
data-native-file-drop-target="terminal"
data-contextual-tour-target="terminal-pane-split-target"
data-terminal-tab-id={tabId}
data-pane-title-surface={titleUsesLightSurface ? 'light' : 'dark'}
style={terminalContainerStyle}
@@ -2465,159 +2500,38 @@ export default function TerminalPane({
}
}}
/>
{/* Title bars live in Orca-owned React DOM rather than xterm's pane
subtree. The pane still reserves title height for terminal layout,
while editor controls stay outside xterm's helper textarea focus zone. */}
<div
className="pane-title-overlay-layer"
data-pane-title-surface={titleUsesLightSurface ? 'light' : 'dark'}
style={{
display: terminalContentVisible ? undefined : 'none',
['--orca-pane-title-bg' as string]: paneTitleBackground,
...hiddenStartupStyle
}}
>
{(managerRef.current?.getPanes() ?? []).map((pane) => {
const title = paneTitles[pane.id]
const isEditing = renamingPaneId === pane.id
const overlayRect = paneTitleOverlayRects[pane.id]
if ((!title && !isEditing) || !overlayRect) {
return null
}
return (
<div
key={`pane-title-${pane.leafId}`}
className="pane-title-bar"
data-native-file-drop-target="terminal"
data-terminal-tab-id={tabId}
data-pane-prevent-terminal-focus=""
{...(isEditing ? { 'data-editing': '' } : {})}
onPointerDownCapture={() => activatePaneTitleInteraction(pane.id)}
onDragOver={(event) => {
activatePaneTitleInteraction(pane.id)
if (
event.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME) ||
event.dataTransfer.types.includes(WORKSPACE_FILE_PATHS_MIME)
) {
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
}
}}
onDrop={(event) => {
if (
!event.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME) &&
!event.dataTransfer.types.includes(WORKSPACE_FILE_PATHS_MIME)
) {
return
}
event.preventDefault()
event.stopPropagation()
activatePaneTitleInteraction(pane.id)
const manager = managerRef.current
if (!manager) {
return
}
void handleInternalTerminalFileDrop({
manager,
paneTransports: paneTransportsRef.current,
worktreeId,
tabId,
cwd,
dataTransfer: event.dataTransfer,
dropTarget: event.target
})
}}
onContextMenuCapture={(event) => contextMenu.onPaneTitleContextMenu(event, pane.id)}
style={{
left: overlayRect.left,
top: overlayRect.top,
width: overlayRect.width
}}
>
{isEditing ? (
<input
ref={renameInputRef}
className="pane-title-input"
aria-label={translate(
'auto.components.terminal.pane.TerminalPane.7dbbfcbecc',
'Pane title'
)}
placeholder={translate(
'auto.components.terminal.pane.TerminalPane.7dbbfcbecc',
'Pane title'
)}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleRenameSubmit()
} else if (e.key === 'Escape') {
handleRenameCancel()
}
}}
onBlur={handleRenameBlur}
/>
) : (
<>
{paneCount > 1 && (
<div
className="pane-title-drag-handle"
aria-hidden="true"
onPointerDown={(event) => {
managerRef.current?.beginPaneDragFromPointerDown(
pane.id,
event.currentTarget,
event.nativeEvent
)
}}
/>
)}
<button
type="button"
className="pane-title-text"
onClick={() => handleStartRename(pane.id)}
aria-label={translate(
'auto.components.terminal.pane.TerminalPane.cc5a2dc706',
'Edit pane title: {{value0}}',
{ value0: title }
)}
>
{title}
</button>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="pane-title-close"
onClick={(e) => {
e.stopPropagation()
handleRemoveTitle(pane.id)
}}
aria-label={translate(
'auto.components.terminal.pane.TerminalPane.f984ab2a30',
'Remove pane title: {{value0}}',
{ value0: title }
)}
>
<X className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{translate(
'auto.components.terminal.pane.TerminalPane.ac112e9036',
'Remove title'
)}
</TooltipContent>
</Tooltip>
</>
)}
</div>
)
})}
</div>
{(managerRef.current?.getPanes() ?? []).map((pane) => {
<TerminalPaneHeaderOverlay
tabId={tabId}
worktreeId={worktreeId}
cwd={cwd ?? ''}
showAlwaysOnHeaders={isActive && terminalContentVisible}
paneCount={paneCount}
activePaneId={activePane?.id}
panes={managedPanes}
paneTitles={paneTitles}
paneTitleOverlayRects={paneTitleOverlayRects}
renamingPaneId={renamingPaneId}
renameValue={renameValue}
renameInputRef={renameInputRef}
titleUsesLightSurface={titleUsesLightSurface}
paneTitleBackground={paneTitleBackground}
terminalContentVisible={terminalContentVisible}
hiddenStartupStyle={hiddenStartupStyle}
managerRef={managerRef}
paneTransportsRef={paneTransportsRef}
onSplitPane={splitTerminalPaneFromHeader}
onBeginPaneDrag={beginPaneDragFromHeader}
onActivatePaneTitleInteraction={activatePaneTitleInteraction}
onPaneTitleContextMenu={contextMenu.onPaneTitleContextMenu}
onStartRename={handleStartRename}
onRemoveTitle={handleRemoveTitle}
onClosePane={handleRequestClosePane}
onRenameValueChange={setRenameValue}
onRenameSubmit={handleRenameSubmit}
onRenameCancel={handleRenameCancel}
onRenameBlur={handleRenameBlur}
/>
{managedPanes.map((pane) => {
// Why: pane IDs can collide across tabs (e.g. tab 0 pane 1 and tab 1
// pane 1). Using the transport's actual ptyId avoids showing banners
// on the wrong pane when IDs overlap.
@@ -0,0 +1,143 @@
/**
* @vitest-environment happy-dom
*/
import { act, createRef, type ReactNode, type RefObject } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
import type { PtyTransport } from './pty-transport'
import TerminalPaneHeaderOverlay from './TerminalPaneHeaderOverlay'
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children?: ReactNode }) => children,
TooltipTrigger: ({ children }: { children?: ReactNode }) => children,
TooltipContent: ({ children }: { children?: ReactNode }) => <span>{children}</span>
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record<string, string>) =>
Object.entries(values ?? {}).reduce(
(text, [key, value]) => text.replace(`{{${key}}}`, value),
fallback
)
}))
const mounted: { container: HTMLDivElement; root: Root }[] = []
function makePane(id: number): ManagedPane {
const leafId = `leaf-${id}` as ManagedPane['leafId']
return {
id,
leafId,
stablePaneId: leafId,
container: document.createElement('div'),
linkTooltip: document.createElement('div'),
terminal: {} as ManagedPane['terminal'],
fitAddon: {} as ManagedPane['fitAddon'],
searchAddon: {} as ManagedPane['searchAddon'],
serializeAddon: {} as ManagedPane['serializeAddon']
}
}
function renderOverlay({
paneTitles,
paneCount = 2,
showAlwaysOnHeaders = true,
onClosePane = vi.fn(),
onRemoveTitle = vi.fn()
}: {
paneTitles: Record<number, string>
paneCount?: number
showAlwaysOnHeaders?: boolean
onClosePane?: ReturnType<typeof vi.fn>
onRemoveTitle?: ReturnType<typeof vi.fn>
}): {
container: HTMLDivElement
onClosePane: ReturnType<typeof vi.fn>
onRemoveTitle: ReturnType<typeof vi.fn>
} {
const panes = [makePane(1), makePane(2)]
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
act(() => {
root.render(
<TerminalPaneHeaderOverlay
tabId="tab-1"
worktreeId="wt-1"
cwd={path.join(path.sep, 'tmp')}
showAlwaysOnHeaders={showAlwaysOnHeaders}
paneCount={paneCount}
activePaneId={1}
panes={panes}
paneTitles={paneTitles}
paneTitleOverlayRects={{
1: { left: 0, top: 0, width: 200 },
2: { left: 220, top: 0, width: 200 }
}}
renamingPaneId={null}
renameValue=""
renameInputRef={createRef<HTMLInputElement>()}
titleUsesLightSurface={false}
paneTitleBackground="transparent"
terminalContentVisible
hiddenStartupStyle={{}}
managerRef={{ current: null } as RefObject<PaneManager | null>}
paneTransportsRef={{ current: new Map() } as RefObject<Map<number, PtyTransport>>}
onSplitPane={vi.fn()}
onBeginPaneDrag={vi.fn()}
onActivatePaneTitleInteraction={vi.fn()}
onPaneTitleContextMenu={vi.fn()}
onStartRename={vi.fn()}
onRemoveTitle={onRemoveTitle as (paneId: number) => void}
onClosePane={onClosePane as (paneId: number) => void}
onRenameValueChange={vi.fn()}
onRenameSubmit={vi.fn()}
onRenameCancel={vi.fn()}
onRenameBlur={vi.fn()}
/>
)
})
mounted.push({ container, root })
return { container, onClosePane, onRemoveTitle }
}
afterEach(() => {
for (const { container, root } of mounted.splice(0)) {
act(() => root.unmount())
container.remove()
}
})
describe('TerminalPaneHeaderOverlay', () => {
it('keeps the titled-pane close affordance as remove-title while headers are always on', () => {
const { container, onClosePane, onRemoveTitle } = renderOverlay({
paneTitles: { 1: 'server', 2: '' }
})
const removeTitle = container.querySelector<HTMLButtonElement>(
'button[aria-label="Remove pane title: server"]'
)
expect(removeTitle).not.toBeNull()
act(() => removeTitle?.click())
expect(onRemoveTitle).toHaveBeenCalledWith(1)
expect(onClosePane).not.toHaveBeenCalledWith(1)
})
it('keeps close-pane available for untitled split pane headers', () => {
const { container, onClosePane, onRemoveTitle } = renderOverlay({
paneTitles: { 1: '', 2: '' }
})
const closePane = container.querySelector<HTMLButtonElement>('button[aria-label="Close Pane"]')
expect(closePane).not.toBeNull()
act(() => closePane?.click())
expect(onClosePane).toHaveBeenCalledWith(1)
expect(onRemoveTitle).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,298 @@
import type { CSSProperties, RefObject } from 'react'
import { SquareSplitVertical, X } from 'lucide-react'
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import { WORKSPACE_FILE_PATH_MIME, WORKSPACE_FILE_PATHS_MIME } from '@/lib/workspace-file-drag'
import type { PtyTransport } from './pty-transport'
import { handleInternalTerminalFileDrop } from './terminal-drop-handler'
export type PaneTitleOverlayRect = {
left: number
top: number
width: number
}
type TerminalPaneHeaderOverlayProps = {
tabId: string
worktreeId: string
cwd: string
showAlwaysOnHeaders: boolean
paneCount: number
activePaneId: number | null | undefined
panes: readonly ManagedPane[]
paneTitles: Readonly<Record<number, string>>
paneTitleOverlayRects: Readonly<Record<number, PaneTitleOverlayRect>>
renamingPaneId: number | null
renameValue: string
renameInputRef: RefObject<HTMLInputElement | null>
titleUsesLightSurface: boolean
paneTitleBackground: string
terminalContentVisible: boolean
hiddenStartupStyle: CSSProperties
managerRef: RefObject<PaneManager | null>
paneTransportsRef: RefObject<Map<number, PtyTransport>>
onSplitPane: (pane: ManagedPane, direction: 'vertical' | 'horizontal') => void
onBeginPaneDrag: (paneId: number, handle: HTMLElement, event: PointerEvent) => void
onActivatePaneTitleInteraction: (paneId: number) => void
onPaneTitleContextMenu: (event: React.MouseEvent<HTMLElement>, paneId: number) => void
onStartRename: (paneId: number) => void
onRemoveTitle: (paneId: number) => void
onClosePane: (paneId: number) => void
onRenameValueChange: (value: string) => void
onRenameSubmit: () => void
onRenameCancel: () => void
onRenameBlur: () => void
}
export default function TerminalPaneHeaderOverlay({
tabId,
worktreeId,
cwd,
showAlwaysOnHeaders,
paneCount,
activePaneId,
panes,
paneTitles,
paneTitleOverlayRects,
renamingPaneId,
renameValue,
renameInputRef,
titleUsesLightSurface,
paneTitleBackground,
terminalContentVisible,
hiddenStartupStyle,
managerRef,
paneTransportsRef,
onSplitPane,
onBeginPaneDrag,
onActivatePaneTitleInteraction,
onPaneTitleContextMenu,
onStartRename,
onRemoveTitle,
onClosePane,
onRenameValueChange,
onRenameSubmit,
onRenameCancel,
onRenameBlur
}: TerminalPaneHeaderOverlayProps): React.JSX.Element {
const splitRightLabel = translate(
'auto.components.terminal.pane.TerminalContextMenu.20e565d865',
'Split Terminal Right'
)
return (
<div
className="pane-title-overlay-layer"
data-pane-title-surface={titleUsesLightSurface ? 'light' : 'dark'}
style={{
display: terminalContentVisible ? undefined : 'none',
['--orca-pane-title-bg' as string]: paneTitleBackground,
...hiddenStartupStyle
}}
>
{panes.map((pane) => {
const title = paneTitles[pane.id]
const isEditing = renamingPaneId === pane.id
const overlayRect = paneTitleOverlayRects[pane.id]
const isActivePane = activePaneId === pane.id
const isChromeless = showAlwaysOnHeaders && !title && !isEditing
const showHeader = overlayRect && (showAlwaysOnHeaders || Boolean(title) || isEditing)
if (!showHeader || !overlayRect) {
return null
}
return (
<div
key={`pane-title-${pane.leafId}`}
className="pane-title-bar"
data-native-file-drop-target="terminal"
data-terminal-tab-id={tabId}
data-pane-prevent-terminal-focus=""
{...(isActivePane ? { 'data-active-pane': '' } : {})}
{...(isChromeless ? { 'data-chromeless': '' } : {})}
{...(isEditing ? { 'data-editing': '' } : {})}
onPointerDownCapture={
title || isEditing ? () => onActivatePaneTitleInteraction(pane.id) : undefined
}
onDragOver={(event) => {
onActivatePaneTitleInteraction(pane.id)
if (
event.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME) ||
event.dataTransfer.types.includes(WORKSPACE_FILE_PATHS_MIME)
) {
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
}
}}
onDrop={(event) => {
if (
!event.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME) &&
!event.dataTransfer.types.includes(WORKSPACE_FILE_PATHS_MIME)
) {
return
}
event.preventDefault()
event.stopPropagation()
onActivatePaneTitleInteraction(pane.id)
const manager = managerRef.current
if (!manager) {
return
}
void handleInternalTerminalFileDrop({
manager,
paneTransports: paneTransportsRef.current,
worktreeId,
tabId,
cwd,
dataTransfer: event.dataTransfer,
dropTarget: event.target
})
}}
onContextMenuCapture={(event) => onPaneTitleContextMenu(event, pane.id)}
style={{
left: overlayRect.left,
top: overlayRect.top,
width: overlayRect.width
}}
>
{isEditing ? (
<input
ref={renameInputRef}
className="pane-title-input"
aria-label={translate(
'auto.components.terminal.pane.TerminalPane.7dbbfcbecc',
'Pane title'
)}
placeholder={translate(
'auto.components.terminal.pane.TerminalPane.7dbbfcbecc',
'Pane title'
)}
value={renameValue}
onChange={(event) => onRenameValueChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onRenameSubmit()
} else if (event.key === 'Escape') {
onRenameCancel()
}
}}
onBlur={onRenameBlur}
/>
) : (
<>
{paneCount > 1 && (
<div
className="pane-title-drag-handle"
aria-hidden="true"
onPointerDown={(event) => {
onBeginPaneDrag(pane.id, event.currentTarget, event.nativeEvent)
}}
/>
)}
{title ? (
<button
type="button"
className="pane-title-text"
onClick={() => onStartRename(pane.id)}
aria-label={translate(
'auto.components.terminal.pane.TerminalPane.cc5a2dc706',
'Edit pane title: {{value0}}',
{ value0: title }
)}
>
{title}
</button>
) : null}
<div className="pane-title-actions ml-auto flex shrink-0 items-center gap-0.5">
{showAlwaysOnHeaders ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="pane-title-split-trigger"
data-contextual-tour-target={
isActivePane ? 'terminal-pane-split-target' : undefined
}
aria-label={splitRightLabel}
onClick={(event) => {
event.stopPropagation()
onSplitPane(pane, 'vertical')
}}
>
<SquareSplitVertical className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{splitRightLabel}
</TooltipContent>
</Tooltip>
) : null}
{title ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="pane-title-close"
onClick={(event) => {
event.stopPropagation()
onRemoveTitle(pane.id)
}}
aria-label={translate(
'auto.components.terminal.pane.TerminalPane.f984ab2a30',
'Remove pane title: {{value0}}',
{ value0: title }
)}
>
<X className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{translate(
'auto.components.terminal.pane.TerminalPane.ac112e9036',
'Remove title'
)}
</TooltipContent>
</Tooltip>
) : paneCount > 1 && showAlwaysOnHeaders ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="pane-title-close"
onClick={(event) => {
event.stopPropagation()
onClosePane(pane.id)
}}
aria-label={translate(
'auto.components.terminal.pane.TerminalContextMenu.8c17d6786d',
'Close Pane'
)}
>
<X className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{translate(
'auto.components.terminal.pane.TerminalContextMenu.8c17d6786d',
'Close Pane'
)}
</TooltipContent>
</Tooltip>
) : null}
</div>
</>
)}
</div>
)
})}
</div>
)
}
@@ -12,13 +12,13 @@ import {
type KeybindingPlatform,
type TerminalShortcutPolicy
} from '../../../../shared/keybindings'
import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd'
import { type PaneCwdMap } from './resolve-split-cwd'
import { keyboardEventBelongsToScope } from './terminal-keyboard-scope'
import { normalizeSelectedTextForFileSearch } from '@/lib/file-search-selection'
import { isFindQueryTooLarge } from '@/lib/find-query-bounds'
import { splitWebRuntimeTerminal } from '@/runtime/web-runtime-session'
import { handleEmptyFloatingWorkspacePanelCloseShortcut } from '@/lib/floating-workspace-terminal-actions'
import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion'
import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd'
import { useAppStore } from '@/store'
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
@@ -411,41 +411,16 @@ export function useTerminalKeyboardShortcuts({
if (!pane) {
return
}
const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null
const telemetrySource = getKeyboardSplitTelemetrySource()
if (splitWebRuntimeTerminal(ptyId, action.direction, telemetrySource)) {
return
}
// Split-pane CWD inheritance (docs/ssh-split-pane-inherit-cwd.md):
// if we have a confirmed live OSC 7 for the source pane, split
// synchronously to preserve chaining on rapid Cmd+D. Otherwise fall
// back to an async resolve that queries pty.getCwd.
const cached = paneCwdRef.current.get(pane.id)
if (cached?.confirmed && cached.cwd) {
const createdPane = manager.splitPane(pane.id, action.direction, { cwd: cached.cwd })
recordKeyboardCreatedTerminalPaneSplit(createdPane, {
source: telemetrySource,
direction: action.direction
})
return
}
const paneIdAtDispatch = pane.id
const directionAtDispatch = action.direction
void (async () => {
const cwd = await resolveSplitCwd({
paneCwdMap: paneCwdRef.current,
sourcePaneId: paneIdAtDispatch,
sourcePtyId: ptyId,
fallbackCwd
})
const createdPane = managerRef.current?.splitPane(paneIdAtDispatch, directionAtDispatch, {
cwd
})
recordKeyboardCreatedTerminalPaneSplit(createdPane, {
source: telemetrySource,
direction: directionAtDispatch
})
})()
splitTerminalPaneWithInheritedCwd({
manager,
getManager: () => managerRef.current,
paneTransports: paneTransportsRef.current,
paneCwdMap: paneCwdRef.current,
fallbackCwd,
pane,
direction: action.direction,
source: getKeyboardSplitTelemetrySource()
})
}
}
@@ -76,6 +76,23 @@ describe('session restored banner pane state', () => {
expect(paneText(createdPane)).toBe(SESSION_RESTORED_BANNER_TEXT)
})
it('reserves title space for always-on pane headers on the active tab', () => {
const activePane = createPane(1)
const secondPane = createPane(2)
const needsFit = syncSessionRestoredBannerTitleSpace({
panes: [activePane, secondPane],
paneTitles: {},
renamingPaneId: null,
sessionRestoredBannerPaneIds: new Set(),
reservePaneHeaderSpace: true
})
expect(needsFit).toBe(true)
expect(activePane.container.hasAttribute('data-has-title')).toBe(true)
expect(secondPane.container.hasAttribute('data-has-title')).toBe(true)
})
it('renders and reserves title space only on the restored inactive split pane', async () => {
const activePane = createPane(1)
const inactiveRestoredPane = createPane(2)
@@ -88,10 +88,12 @@ export function syncSessionRestoredBannerTitleSpace(args: {
paneTitles: Readonly<Record<number, string>>
renamingPaneId: number | null
sessionRestoredBannerPaneIds: ReadonlySet<number>
reservePaneHeaderSpace?: boolean
}): boolean {
let needsFit = false
for (const pane of args.panes) {
const shouldShow =
args.reservePaneHeaderSpace === true ||
!!args.paneTitles[pane.id] ||
args.renamingPaneId === pane.id ||
args.sessionRestoredBannerPaneIds.has(pane.id)
@@ -0,0 +1,90 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
import type { PtyTransport } from './pty-transport'
import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd'
const mocks = vi.hoisted(() => ({
recordCreatedTerminalPaneSplit: vi.fn(),
resolveSplitCwd: vi.fn(),
splitWebRuntimeTerminal: vi.fn()
}))
vi.mock('@/runtime/web-runtime-session', () => ({
splitWebRuntimeTerminal: mocks.splitWebRuntimeTerminal
}))
vi.mock('./resolve-split-cwd', () => ({
resolveSplitCwd: mocks.resolveSplitCwd
}))
vi.mock('./terminal-pane-split-completion', () => ({
recordCreatedTerminalPaneSplit: mocks.recordCreatedTerminalPaneSplit
}))
function makeManager(splitPane: ReturnType<typeof vi.fn>): PaneManager {
return { splitPane } as unknown as PaneManager
}
async function flushAsyncSplit(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
}
describe('splitTerminalPaneWithInheritedCwd', () => {
beforeEach(() => {
mocks.recordCreatedTerminalPaneSplit.mockReset()
mocks.resolveSplitCwd.mockReset()
mocks.splitWebRuntimeTerminal.mockReset()
mocks.splitWebRuntimeTerminal.mockReturnValue(false)
})
it('uses the live manager after async cwd resolution', async () => {
const staleSplitPane = vi.fn()
const liveSplitPane = vi.fn(() => ({ id: 2 }))
mocks.resolveSplitCwd.mockResolvedValue('/resolved')
splitTerminalPaneWithInheritedCwd({
manager: makeManager(staleSplitPane),
getManager: () => makeManager(liveSplitPane),
paneTransports: new Map<number, PtyTransport>(),
paneCwdMap: new Map(),
fallbackCwd: '/fallback',
pane: { id: 1 } as ManagedPane,
direction: 'vertical',
source: 'context_menu'
})
await flushAsyncSplit()
expect(staleSplitPane).not.toHaveBeenCalled()
expect(liveSplitPane).toHaveBeenCalledWith(1, 'vertical', { cwd: '/resolved' })
expect(mocks.recordCreatedTerminalPaneSplit).toHaveBeenCalledWith(
{ id: 2 },
{ source: 'context_menu', direction: 'vertical' }
)
})
it('does not split a stale manager when the live manager is gone', async () => {
const staleSplitPane = vi.fn()
mocks.resolveSplitCwd.mockResolvedValue('/resolved')
splitTerminalPaneWithInheritedCwd({
manager: makeManager(staleSplitPane),
getManager: () => null,
paneTransports: new Map<number, PtyTransport>(),
paneCwdMap: new Map(),
fallbackCwd: '/fallback',
pane: { id: 1 } as ManagedPane,
direction: 'horizontal',
source: 'context_menu'
})
await flushAsyncSplit()
expect(staleSplitPane).not.toHaveBeenCalled()
expect(mocks.recordCreatedTerminalPaneSplit).toHaveBeenCalledWith(undefined, {
source: 'context_menu',
direction: 'horizontal'
})
})
})
@@ -0,0 +1,47 @@
import type { TerminalPaneSplitSource } from '../../../../shared/feature-education-telemetry'
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
import { splitWebRuntimeTerminal } from '@/runtime/web-runtime-session'
import type { PtyTransport } from './pty-transport'
import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd'
import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion'
export function splitTerminalPaneWithInheritedCwd(args: {
manager: PaneManager
getManager?: () => PaneManager | null
paneTransports: Map<number, PtyTransport>
paneCwdMap: PaneCwdMap
fallbackCwd: string
pane: ManagedPane
direction: 'vertical' | 'horizontal'
source: TerminalPaneSplitSource
}): void {
const ptyId = args.paneTransports.get(args.pane.id)?.getPtyId() ?? null
if (splitWebRuntimeTerminal(ptyId, args.direction, args.source)) {
return
}
const cached = args.paneCwdMap.get(args.pane.id)
if (cached?.confirmed && cached.cwd) {
const createdPane = args.manager.splitPane(args.pane.id, args.direction, { cwd: cached.cwd })
recordCreatedTerminalPaneSplit(createdPane, {
source: args.source,
direction: args.direction
})
return
}
const paneId = args.pane.id
const resolveManager = (): PaneManager | null =>
args.getManager ? args.getManager() : args.manager
void (async () => {
const cwd = await resolveSplitCwd({
paneCwdMap: args.paneCwdMap,
sourcePaneId: paneId,
sourcePtyId: ptyId,
fallbackCwd: args.fallbackCwd
})
const createdPane = resolveManager()?.splitPane(paneId, args.direction, { cwd })
recordCreatedTerminalPaneSplit(createdPane, {
source: args.source,
direction: args.direction
})
})()
}
@@ -5,11 +5,10 @@ import { toast } from 'sonner'
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
import type { PtyTransport } from './pty-transport'
import { getConnectionId } from '@/lib/connection-context'
import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd'
import type { PaneCwdMap } from './resolve-split-cwd'
import type { TerminalQuickCommand } from '../../../../shared/types'
import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands'
import { sendTerminalQuickCommandToPane } from './terminal-quick-command-dispatch'
import { splitWebRuntimeTerminal } from '@/runtime/web-runtime-session'
import { pasteTerminalText } from './terminal-bracketed-paste'
import { pasteTerminalClipboard } from './terminal-clipboard-paste'
import {
@@ -35,6 +34,7 @@ import {
type PreparedAgentSessionFork
} from './terminal-agent-session-fork'
import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion'
import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
@@ -310,39 +310,26 @@ export function useTerminalPaneContextMenu({
const onPaste = async (): Promise<void> => pasteResolvedPane('context-menu')
// Split-pane CWD inheritance (docs/ssh-split-pane-inherit-cwd.md):
// mirror the Cmd+D path — sync split on confirmed OSC 7 cache hit,
// otherwise fall back to async resolveSplitCwd.
const splitWithInheritedCwd = useCallback(
(
direction: 'vertical' | 'horizontal',
source: 'contextual_tour' | 'context_menu' = 'context_menu'
): void => {
const pane = resolveMenuPane()
if (!pane) {
const manager = managerRef.current
if (!pane || !manager) {
return
}
const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null
if (splitWebRuntimeTerminal(ptyId, direction, source)) {
return
}
const cached = paneCwdRef.current.get(pane.id)
if (cached?.confirmed && cached.cwd) {
const createdPane = managerRef.current?.splitPane(pane.id, direction, { cwd: cached.cwd })
recordContextMenuCreatedTerminalPaneSplit(createdPane, { source, direction })
return
}
const paneId = pane.id
void (async () => {
const cwd = await resolveSplitCwd({
paneCwdMap: paneCwdRef.current,
sourcePaneId: paneId,
sourcePtyId: ptyId,
fallbackCwd
})
const createdPane = managerRef.current?.splitPane(paneId, direction, { cwd })
recordContextMenuCreatedTerminalPaneSplit(createdPane, { source, direction })
})()
splitTerminalPaneWithInheritedCwd({
manager,
getManager: () => managerRef.current,
paneTransports: paneTransportsRef.current,
paneCwdMap: paneCwdRef.current,
fallbackCwd,
pane,
direction,
source
})
},
[fallbackCwd, managerRef, paneCwdRef, paneTransportsRef, resolveMenuPane]
)
+16 -2
View File
@@ -2436,7 +2436,9 @@
"9acaf92093": "Pane Actions",
"1bce81dba6": "simulator",
"1ff1c77616": "browser",
"586d2ac445": "terminal"
"586d2ac445": "terminal",
"addSplitPane": "Add split pane",
"closePaneColumn": "Close split pane"
},
"AiVaultSessionDropLayer": {
"dropOntoTerminalPane": "Drop onto a terminal pane to resume this session.",
@@ -2444,6 +2446,9 @@
"localWorkspacesOnly": "Resume from history is only available in local workspaces.",
"openLocalWorkspace": "Open a local workspace before resuming a session.",
"sessionQueued": "Session queued"
},
"TabGroupDropOverlay": {
"paneColumnLabel": "New split"
}
},
"bar": {
@@ -2514,7 +2519,9 @@
"cb3eadefd2": "Blue",
"20baa43c05": "None",
"60f958ec75": "Pin Tab",
"417722e9c2": "Unpin Tab"
"417722e9c2": "Unpin Tab",
"splitTerminalRight": "Split terminal right",
"splitTerminalDown": "Split terminal down"
},
"TabBar": {
"b1a132357f": "New tab",
@@ -2606,6 +2613,13 @@
}
}
}
},
"TabWorkspaceLayoutMenuSection": {
"right": "Right",
"left": "Left",
"down": "Down",
"up": "Up",
"moveToPaneColumn": "Move Tab to Split"
}
}
},
+32 -18
View File
@@ -2436,7 +2436,9 @@
"9acaf92093": "Acciones del panel",
"1bce81dba6": "simulador",
"1ff1c77616": "navegador",
"586d2ac445": "terminal"
"586d2ac445": "terminal",
"addSplitPane": "Agregar panel dividido",
"closePaneColumn": "Cerrar panel dividido"
},
"AiVaultSessionDropLayer": {
"dropOntoTerminalPane": "Drop onto a terminal pane to resume this session.",
@@ -2444,6 +2446,9 @@
"localWorkspacesOnly": "Resume from history is only available in local workspaces.",
"openLocalWorkspace": "Open a local workspace before resuming a session.",
"sessionQueued": "Session queued"
},
"TabGroupDropOverlay": {
"paneColumnLabel": "Nueva división"
}
},
"bar": {
@@ -2514,7 +2519,9 @@
"cb3eadefd2": "Azul",
"20baa43c05": "Ninguno",
"60f958ec75": "Pestaña Fijar",
"417722e9c2": "Desanclar pestaña"
"417722e9c2": "Desanclar pestaña",
"splitTerminalRight": "Dividir terminal a la derecha",
"splitTerminalDown": "Dividir terminal hacia abajo"
},
"TabBar": {
"b1a132357f": "Nueva pestaña",
@@ -2606,6 +2613,13 @@
}
}
}
},
"TabWorkspaceLayoutMenuSection": {
"right": "Derecha",
"left": "Izquierda",
"down": "Abajo",
"up": "Arriba",
"moveToPaneColumn": "Mover pestaña a división"
}
}
},
@@ -9127,15 +9141,15 @@
"monthsAgo": "{{value0}}mo ago",
"yearsAgo": "{{value0}}y ago",
"sessionId": "ID de sesión",
"originalAsk": "Original ask",
"latestTurns": "Latest turns",
"noPreviewAvailable": "No conversation preview available",
"messageCount": "{{value0}} msgs",
"userRole": "You",
"agentRole": "Agent",
"toolRole": "Tool",
"systemRole": "System",
"sessionRole": "Session"
"originalAsk": "Solicitud original",
"latestTurns": "Últimos turnos",
"noPreviewAvailable": "No hay vista previa de conversación disponible",
"messageCount": "{{value0}} mensajes",
"userRole": "",
"agentRole": "Agente",
"toolRole": "Herramienta",
"systemRole": "Sistema",
"sessionRole": "Sesn"
},
"AiVaultSessionRow": {
"resumeAgentSession": "Resume {{value0}} session",
@@ -9153,13 +9167,13 @@
"showDetails": "Mostrar detalles",
"moreSessionActions": "Más acciones de sesión",
"moreActions": "Más acciones",
"noPreviewAvailable": "No conversation preview available",
"dragToResume": "Drag to resume in a new tab",
"userRole": "You",
"agentRole": "Agent",
"toolRole": "Tool",
"systemRole": "System",
"sessionRole": "Session"
"noPreviewAvailable": "No hay vista previa de conversación disponible",
"dragToResume": "Arrastra para reanudar en una pestaña nueva",
"userRole": "",
"agentRole": "Agente",
"toolRole": "Herramienta",
"systemRole": "Sistema",
"sessionRole": "Sesn"
},
"FileExplorerNameFilter": {
"26fb73c6e3": "Buscar archivos",
+32 -18
View File
@@ -2436,7 +2436,9 @@
"9acaf92093": "ペインの操作",
"1bce81dba6": "シミュレータ",
"1ff1c77616": "ブラウザ",
"586d2ac445": "terminal"
"586d2ac445": "terminal",
"addSplitPane": "分割ペインを追加",
"closePaneColumn": "分割ペインを閉じる"
},
"AiVaultSessionDropLayer": {
"dropOntoTerminalPane": "Drop onto a terminal pane to resume this session.",
@@ -2444,6 +2446,9 @@
"localWorkspacesOnly": "Resume from history is only available in local workspaces.",
"openLocalWorkspace": "Open a local workspace before resuming a session.",
"sessionQueued": "Session queued"
},
"TabGroupDropOverlay": {
"paneColumnLabel": "新しい分割"
}
},
"bar": {
@@ -2514,7 +2519,9 @@
"cb3eadefd2": "青",
"20baa43c05": "なし",
"60f958ec75": "ピンタブ",
"417722e9c2": "タブの固定を解除する"
"417722e9c2": "タブの固定を解除する",
"splitTerminalRight": "ターミナルを右に分割",
"splitTerminalDown": "ターミナルを下に分割"
},
"TabBar": {
"b1a132357f": "新規タブ",
@@ -2606,6 +2613,13 @@
}
}
}
},
"TabWorkspaceLayoutMenuSection": {
"right": "右",
"left": "左",
"down": "下",
"up": "上",
"moveToPaneColumn": "タブを分割へ移動"
}
}
},
@@ -9127,15 +9141,15 @@
"monthsAgo": "{{value0}}mo ago",
"yearsAgo": "{{value0}}y ago",
"sessionId": "セッション ID",
"originalAsk": "Original ask",
"latestTurns": "Latest turns",
"noPreviewAvailable": "No conversation preview available",
"messageCount": "{{value0}} msgs",
"userRole": "You",
"agentRole": "Agent",
"toolRole": "Tool",
"systemRole": "System",
"sessionRole": "Session"
"originalAsk": "最初の依頼",
"latestTurns": "最新のやり取り",
"noPreviewAvailable": "会話プレビューはありません",
"messageCount": "{{value0}} 件のメッセージ",
"userRole": "あなた",
"agentRole": "エージェント",
"toolRole": "ツール",
"systemRole": "システム",
"sessionRole": "セッション"
},
"AiVaultSessionRow": {
"resumeAgentSession": "Resume {{value0}} session",
@@ -9153,13 +9167,13 @@
"showDetails": "詳細を表示",
"moreSessionActions": "セッションのその他の操作",
"moreActions": "その他の操作",
"noPreviewAvailable": "No conversation preview available",
"dragToResume": "Drag to resume in a new tab",
"userRole": "You",
"agentRole": "Agent",
"toolRole": "Tool",
"systemRole": "System",
"sessionRole": "Session"
"noPreviewAvailable": "会話プレビューはありません",
"dragToResume": "新しいタブで再開するにはドラッグ",
"userRole": "あなた",
"agentRole": "エージェント",
"toolRole": "ツール",
"systemRole": "システム",
"sessionRole": "セッション"
},
"FileExplorerNameFilter": {
"26fb73c6e3": "ファイルを検索",
+32 -18
View File
@@ -2436,7 +2436,9 @@
"9acaf92093": "창 작업",
"1bce81dba6": "시뮬레이터",
"1ff1c77616": "브라우저",
"586d2ac445": "terminal"
"586d2ac445": "terminal",
"addSplitPane": "분할 창 추가",
"closePaneColumn": "분할 창 닫기"
},
"AiVaultSessionDropLayer": {
"dropOntoTerminalPane": "이 세션을 재개하려면 terminal 창에 놓으세요.",
@@ -2444,6 +2446,9 @@
"localWorkspacesOnly": "기록에서 재개하기는 로컬 워크스페이스에서만 사용할 수 있습니다.",
"openLocalWorkspace": "세션을 재개하기 전에 로컬 워크스페이스를 여세요.",
"sessionQueued": "세션이 대기열에 추가됨"
},
"TabGroupDropOverlay": {
"paneColumnLabel": "새 분할"
}
},
"bar": {
@@ -2514,7 +2519,9 @@
"cb3eadefd2": "파란색",
"20baa43c05": "없음",
"60f958ec75": "탭 고정",
"417722e9c2": "탭 고정 해제"
"417722e9c2": "탭 고정 해제",
"splitTerminalRight": "터미널을 오른쪽으로 분할",
"splitTerminalDown": "터미널을 아래로 분할"
},
"TabBar": {
"b1a132357f": "새 탭",
@@ -2606,6 +2613,13 @@
}
}
}
},
"TabWorkspaceLayoutMenuSection": {
"right": "오른쪽",
"left": "왼쪽",
"down": "아래",
"up": "위",
"moveToPaneColumn": "탭을 분할로 이동"
}
}
},
@@ -9127,15 +9141,15 @@
"monthsAgo": "{{value0}}개월 전",
"yearsAgo": "{{value0}}년 전",
"sessionId": "세션 ID",
"originalAsk": "Original ask",
"latestTurns": "Latest turns",
"noPreviewAvailable": "No conversation preview available",
"messageCount": "{{value0}} msgs",
"userRole": "You",
"agentRole": "Agent",
"toolRole": "Tool",
"systemRole": "System",
"sessionRole": "Session"
"originalAsk": "원래 요청",
"latestTurns": "최근 대화",
"noPreviewAvailable": "대화 미리보기를 사용할 수 없습니다",
"messageCount": "메시지 {{value0}}",
"userRole": "",
"agentRole": "에이전트",
"toolRole": "도구",
"systemRole": "시스템",
"sessionRole": "세션"
},
"AiVaultSessionRow": {
"resumeAgentSession": "{{value0}} 세션 재개",
@@ -9153,13 +9167,13 @@
"showDetails": "세부정보 표시",
"moreSessionActions": "세션 추가 작업",
"moreActions": "추가 작업",
"noPreviewAvailable": "No conversation preview available",
"dragToResume": "Drag to resume in a new tab",
"userRole": "You",
"agentRole": "Agent",
"toolRole": "Tool",
"systemRole": "System",
"sessionRole": "Session"
"noPreviewAvailable": "대화 미리보기를 사용할 수 없습니다",
"dragToResume": "새 탭에서 재개하려면 드래그",
"userRole": "",
"agentRole": "에이전트",
"toolRole": "도구",
"systemRole": "시스템",
"sessionRole": "세션"
},
"FileExplorerNameFilter": {
"26fb73c6e3": "파일 찾기",
+32 -18
View File
@@ -2436,7 +2436,9 @@
"9acaf92093": "窗格操作",
"1bce81dba6": "模拟器",
"1ff1c77616": "浏览器",
"586d2ac445": "terminal"
"586d2ac445": "terminal",
"addSplitPane": "添加拆分窗格",
"closePaneColumn": "关闭拆分窗格"
},
"AiVaultSessionDropLayer": {
"dropOntoTerminalPane": "拖放到 terminal 面板以恢复此会话。",
@@ -2444,6 +2446,9 @@
"localWorkspacesOnly": "从历史恢复仅支持本地工作区。",
"openLocalWorkspace": "恢复会话前请先打开一个本地工作区。",
"sessionQueued": "会话已排队"
},
"TabGroupDropOverlay": {
"paneColumnLabel": "新建拆分"
}
},
"bar": {
@@ -2514,7 +2519,9 @@
"cb3eadefd2": "Blue",
"20baa43c05": "没有任何",
"60f958ec75": "引脚标签",
"417722e9c2": "取消固定标签"
"417722e9c2": "取消固定标签",
"splitTerminalRight": "向右拆分终端",
"splitTerminalDown": "向下拆分终端"
},
"TabBar": {
"b1a132357f": "新标签页",
@@ -2606,6 +2613,13 @@
}
}
}
},
"TabWorkspaceLayoutMenuSection": {
"right": "右",
"left": "左",
"down": "下",
"up": "上",
"moveToPaneColumn": "将标签页移至拆分"
}
}
},
@@ -9127,15 +9141,15 @@
"monthsAgo": "{{value0}} 个月前",
"yearsAgo": "{{value0}} 年前",
"sessionId": "会话 ID",
"originalAsk": "Original ask",
"latestTurns": "Latest turns",
"noPreviewAvailable": "No conversation preview available",
"messageCount": "{{value0}} msgs",
"userRole": "You",
"agentRole": "Agent",
"toolRole": "Tool",
"systemRole": "System",
"sessionRole": "Session"
"originalAsk": "原始请求",
"latestTurns": "最近轮次",
"noPreviewAvailable": "没有可用的对话预览",
"messageCount": "{{value0}} 条消息",
"userRole": "",
"agentRole": "智能体",
"toolRole": "工具",
"systemRole": "系统",
"sessionRole": "会话"
},
"AiVaultSessionRow": {
"resumeAgentSession": "恢复 {{value0}} 会话",
@@ -9153,13 +9167,13 @@
"showDetails": "显示详情",
"moreSessionActions": "更多会话操作",
"moreActions": "更多操作",
"noPreviewAvailable": "No conversation preview available",
"dragToResume": "Drag to resume in a new tab",
"userRole": "You",
"agentRole": "Agent",
"toolRole": "Tool",
"systemRole": "System",
"sessionRole": "Session"
"noPreviewAvailable": "没有可用的对话预览",
"dragToResume": "拖动以在新标签页中恢复",
"userRole": "",
"agentRole": "智能体",
"toolRole": "工具",
"systemRole": "系统",
"sessionRole": "会话"
},
"FileExplorerNameFilter": {
"26fb73c6e3": "查找文件",
@@ -1,6 +1,11 @@
import type { DropZone, ManagedPaneInternal } from './pane-manager-types'
import type { DragReorderCallbacks, DragReorderState } from './pane-drag-reorder'
import { handlePaneDrop, hideDropOverlay, showDropOverlay } from './pane-drag-reorder'
import {
handlePaneDrop,
hideDropOverlay,
isPaneDropNoOp,
showDropOverlay
} from './pane-drag-reorder'
const DRAG_THRESHOLD = 5
@@ -161,6 +166,15 @@ function updateDropTarget(
const rect = targetPane.container.getBoundingClientRect()
const zone = resolveDropZone(clientX, clientY, rect)
const sourcePaneId = state.dragSourcePaneId
if (
sourcePaneId !== null &&
isPaneDropNoOp(sourcePaneId, targetPane.id, zone, callbacks.getPanes())
) {
overlay.style.display = 'none'
state.currentDropTarget = null
return
}
state.currentDropTarget = { paneId: targetPane.id, zone }
positionDropOverlay(overlay, rect, zone)
}
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ManagedPaneInternal } from './pane-manager-types'
import type * as PaneTreeOpsModule from './pane-tree-ops'
import { attachPaneDrag } from './pane-drag-pointer'
import { createDragReorderState } from './pane-drag-reorder'
import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
@@ -7,10 +8,14 @@ import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
const detachPaneFromTree = vi.hoisted(() => vi.fn())
const insertPaneNextTo = vi.hoisted(() => vi.fn())
vi.mock('./pane-tree-ops', () => ({
detachPaneFromTree,
insertPaneNextTo
}))
vi.mock('./pane-tree-ops', async (importOriginal) => {
const actual = await importOriginal<typeof PaneTreeOpsModule>()
return {
...actual,
detachPaneFromTree,
insertPaneNextTo
}
})
type FakeListener = (event: PointerEvent) => void
@@ -1,6 +1,6 @@
import type { DropZone, ManagedPane, ManagedPaneInternal } from './pane-manager-types'
import type { PaneStyleOptions } from './pane-manager-types'
import { detachPaneFromTree, insertPaneNextTo } from './pane-tree-ops'
import { detachPaneFromTree, findPaneChildren, insertPaneNextTo } from './pane-tree-ops'
// ---------------------------------------------------------------------------
// Drag-to-reorder panes
@@ -46,6 +46,47 @@ export function cancelActivePaneDrag(state: DragReorderState): void {
state.currentDropTarget = null
}
/** True when dropping source onto target in zone would leave pane order unchanged. */
export function isPaneDropNoOp(
sourcePaneId: number,
targetPaneId: number,
zone: DropZone,
panes: Map<number, ManagedPaneInternal>
): boolean {
if (sourcePaneId === targetPaneId) {
return true
}
const source = panes.get(sourcePaneId)
const target = panes.get(targetPaneId)
if (!source || !target) {
return true
}
const parent = target.container.parentElement
if (!parent?.classList.contains('pane-split')) {
return false
}
if (source.container.parentElement !== parent) {
return false
}
const children = findPaneChildren(parent)
const targetIndex = children.indexOf(target.container)
const sourceIndex = children.indexOf(source.container)
if (targetIndex === -1 || sourceIndex === -1) {
return false
}
const isVerticalSplit = parent.classList.contains('is-vertical')
const zoneIsHorizontal = zone === 'top' || zone === 'bottom'
if (zoneIsHorizontal === isVerticalSplit) {
return false
}
return zone === 'right' || zone === 'bottom'
? sourceIndex === targetIndex + 1
: sourceIndex === targetIndex - 1
}
/** Move a pane from its current position to a new position relative to a target pane. */
export function handlePaneDrop(
sourcePaneId: number,
@@ -58,6 +99,9 @@ export function handlePaneDrop(
return
}
const panes = callbacks.getPanes()
if (isPaneDropNoOp(sourcePaneId, targetPaneId, zone, panes)) {
return
}
const source = panes.get(sourcePaneId)
const target = panes.get(targetPaneId)
if (!source || !target) {
@@ -0,0 +1,81 @@
/**
* @vitest-environment happy-dom
*/
import { describe, expect, it } from 'vitest'
import type { ManagedPaneInternal } from './pane-manager-types'
import { isPaneDropNoOp } from './pane-drag-reorder'
import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
function makeLeafId(id: number): TerminalLeafId {
return `${id}${id}${id}${id}${id}${id}${id}${id}-${id}${id}${id}${id}-4${id}${id}${id}-8${id}${id}${id}-${id}${id}${id}${id}${id}${id}${id}${id}${id}${id}${id}${id}` as TerminalLeafId
}
function createPane(id: number, container: HTMLElement): ManagedPaneInternal {
const leafId = makeLeafId(id)
container.classList.add('pane')
container.dataset.paneId = String(id)
container.dataset.leafId = leafId
return {
id,
leafId,
stablePaneId: leafId,
terminal: {} as never,
container,
xtermContainer: {} as never,
linkTooltip: {} as never,
terminalGpuAcceleration: 'auto',
gpuRenderingEnabled: true,
webglAttachmentDeferred: false,
webglDisabledAfterContextLoss: false,
hasComplexScriptOutput: false,
webglAddon: null,
ligaturesAddon: null,
fitResizeObserver: null,
pendingObservedFitRafId: null,
fitAddon: {} as never,
searchAddon: {} as never,
serializeAddon: {} as never,
unicode11Addon: {} as never,
webLinksAddon: {} as never,
compositionHandler: null,
pendingSplitScrollState: null,
debugLabel: null
}
}
function createVerticalSplit(paneIds: readonly number[]): Map<number, ManagedPaneInternal> {
const split = document.createElement('div')
split.className = 'pane-split is-vertical'
document.body.appendChild(split)
const panes = new Map<number, ManagedPaneInternal>()
for (const id of paneIds) {
const container = document.createElement('div')
split.appendChild(container)
panes.set(id, createPane(id, container))
}
return panes
}
describe('isPaneDropNoOp', () => {
it('treats dropping an already-right sibling onto the left pane as a no-op', () => {
const panes = createVerticalSplit([1, 2])
expect(isPaneDropNoOp(2, 1, 'right', panes)).toBe(true)
})
it('treats dropping an already-left sibling onto the right pane as a no-op', () => {
const panes = createVerticalSplit([1, 2])
expect(isPaneDropNoOp(1, 2, 'left', panes)).toBe(true)
})
it('allows reordering when a third pane sits between source and target', () => {
const panes = createVerticalSplit([1, 2, 3])
expect(isPaneDropNoOp(3, 1, 'right', panes)).toBe(false)
})
it('allows swapping adjacent vertical panes via the opposite edge', () => {
const panes = createVerticalSplit([1, 2])
expect(isPaneDropNoOp(2, 1, 'left', panes)).toBe(false)
expect(isPaneDropNoOp(1, 2, 'right', panes)).toBe(false)
})
})
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import type { TabGroupLayoutNode } from '../../../../shared/types'
import {
findLayoutSiblingOnSplitSide,
isPaneColumnSplitDropNoOp
} from './pane-column-split-drop-no-op'
function horizontalSplit(firstGroupId: string, secondGroupId: string): TabGroupLayoutNode {
return {
type: 'split',
direction: 'horizontal',
ratio: 0.5,
first: { type: 'leaf', groupId: firstGroupId },
second: { type: 'leaf', groupId: secondGroupId }
}
}
describe('findLayoutSiblingOnSplitSide', () => {
const layout = horizontalSplit('group-left', 'group-right')
it('finds the right sibling when splitting the left column to the right', () => {
expect(findLayoutSiblingOnSplitSide(layout, 'group-left', 'right')).toBe('group-right')
})
it('finds the left sibling when splitting the right column to the left', () => {
expect(findLayoutSiblingOnSplitSide(layout, 'group-right', 'left')).toBe('group-left')
})
it('returns null for split directions that would not target the adjacent sibling', () => {
expect(findLayoutSiblingOnSplitSide(layout, 'group-left', 'left')).toBeNull()
expect(findLayoutSiblingOnSplitSide(layout, 'group-right', 'right')).toBeNull()
})
})
describe('isPaneColumnSplitDropNoOp', () => {
const layout = horizontalSplit('group-left', 'group-right')
it('treats splitting the only tab in a group onto itself as a no-op', () => {
expect(
isPaneColumnSplitDropNoOp({
sourceGroupId: 'group-left',
targetGroupId: 'group-left',
splitDirection: 'right',
sourceTabCount: 1,
layout
})
).toBe(true)
})
it('treats dragging the only tab onto the adjacent sibling edge as a no-op', () => {
expect(
isPaneColumnSplitDropNoOp({
sourceGroupId: 'group-right',
targetGroupId: 'group-left',
splitDirection: 'right',
sourceTabCount: 1,
layout
})
).toBe(true)
expect(
isPaneColumnSplitDropNoOp({
sourceGroupId: 'group-left',
targetGroupId: 'group-right',
splitDirection: 'left',
sourceTabCount: 1,
layout
})
).toBe(true)
})
it('allows splits when the source group would still have other tabs', () => {
expect(
isPaneColumnSplitDropNoOp({
sourceGroupId: 'group-right',
targetGroupId: 'group-left',
splitDirection: 'right',
sourceTabCount: 2,
layout
})
).toBe(false)
})
})
@@ -0,0 +1,71 @@
import type { TabGroupLayoutNode } from '../../../../shared/types'
import type { TabSplitDirection } from './tabs'
function getDirectLayoutSiblingOnSplitSide(
split: Extract<TabGroupLayoutNode, { type: 'split' }>,
targetGroupId: string,
splitDirection: TabSplitDirection
): string | null {
const { first, second, direction } = split
if (first.type === 'leaf' && first.groupId === targetGroupId) {
if (direction === 'horizontal' && splitDirection === 'right' && second.type === 'leaf') {
return second.groupId
}
if (direction === 'vertical' && splitDirection === 'down' && second.type === 'leaf') {
return second.groupId
}
}
if (second.type === 'leaf' && second.groupId === targetGroupId) {
if (direction === 'horizontal' && splitDirection === 'left' && first.type === 'leaf') {
return first.groupId
}
if (direction === 'vertical' && splitDirection === 'up' && first.type === 'leaf') {
return first.groupId
}
}
return null
}
export function findLayoutSiblingOnSplitSide(
root: TabGroupLayoutNode,
targetGroupId: string,
splitDirection: TabSplitDirection
): string | null {
if (root.type === 'leaf') {
return null
}
const directSibling = getDirectLayoutSiblingOnSplitSide(root, targetGroupId, splitDirection)
if (directSibling) {
return directSibling
}
return (
findLayoutSiblingOnSplitSide(root.first, targetGroupId, splitDirection) ??
findLayoutSiblingOnSplitSide(root.second, targetGroupId, splitDirection)
)
}
/** True when a pane-column split drop would collapse back to the current layout. */
export function isPaneColumnSplitDropNoOp(args: {
sourceGroupId: string
targetGroupId: string
splitDirection: TabSplitDirection
sourceTabCount: number
layout: TabGroupLayoutNode | undefined
}): boolean {
if (args.sourceGroupId === args.targetGroupId && args.sourceTabCount <= 1) {
return true
}
if (args.sourceTabCount !== 1 || !args.layout) {
return false
}
return (
findLayoutSiblingOnSplitSide(args.layout, args.targetGroupId, args.splitDirection) ===
args.sourceGroupId
)
}
+35 -3
View File
@@ -975,9 +975,7 @@ describe('TabsSlice', () => {
const state = store.getState()
const moved = state.unifiedTabsByWorktree[WT].find((item) => item.id === tab.id)
expect(moved?.groupId).toBe(targetGroupId)
expect(
state.groupsByWorktree[WT].find((group) => group.id === sourceGroupId)?.tabOrder
).toEqual([])
expect(state.groupsByWorktree[WT].find((group) => group.id === sourceGroupId)).toBeUndefined()
expect(
state.groupsByWorktree[WT].find((group) => group.id === targetGroupId)?.tabOrder
).toEqual([tab.id])
@@ -1200,6 +1198,40 @@ describe('TabsSlice', () => {
expect(state.groupsByWorktree[WT][0].tabOrder).toEqual([onlyTab.id])
expect(state.layoutByWorktree[WT]).toEqual({ type: 'leaf', groupId: sourceGroupId })
})
it('treats splitting the only tab onto the adjacent sibling edge as a no-op', () => {
store.getState().createUnifiedTab(WT, 'editor', {
id: 'file-a.ts',
label: 'file-a.ts'
})
const right = store.getState().createUnifiedTab(WT, 'terminal', {
id: 'terminal-1',
label: 'Terminal 1'
})
const leftGroupId = store.getState().groupsByWorktree[WT][0].id
expect(
store.getState().dropUnifiedTab(right.id, {
groupId: leftGroupId,
splitDirection: 'right'
})
).toBe(true)
const rightGroupId = store
.getState()
.unifiedTabsByWorktree[WT].find((tab) => tab.id === right.id)?.groupId
expect(rightGroupId).toBeTruthy()
const moved = store.getState().dropUnifiedTab(right.id, {
groupId: leftGroupId,
splitDirection: 'right'
})
expect(moved).toBe(false)
expect(
store.getState().unifiedTabsByWorktree[WT].find((tab) => tab.id === right.id)?.groupId
).toBe(rightGroupId)
})
})
// ─── setTabLabel / setTabCustomLabel / setUnifiedTabColor ─────────
+57 -16
View File
@@ -26,6 +26,7 @@ import {
sanitizeRecentTabIds,
updateGroup
} from './tab-group-state'
import { isPaneColumnSplitDropNoOp } from './pane-column-split-drop-no-op'
import { buildHydratedTabState, pruneTabGroupLayoutForGroups } from './tabs-hydration'
import { buildOrphanTerminalCleanupPatch, getOrphanTerminalIds } from './terminal-orphan-helpers'
import { createBrowserUuid } from '@/lib/browser-uuid'
@@ -1378,18 +1379,49 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
}
return group
})
let nextLayoutByWorktree = state.layoutByWorktree
let nextActiveGroupIdByWorktreeResolved = nextActiveGroupIdByWorktree
let filteredGroups = nextGroups
if (sourceOrder.length === 0) {
filteredGroups = nextGroups.filter((group) => group.id !== sourceGroup.id)
const collapsedState = collapseGroupLayout(
nextLayoutByWorktree,
nextActiveGroupIdByWorktreeResolved,
worktreeId,
sourceGroup.id,
targetGroupId
)
nextLayoutByWorktree = collapsedState.layoutByWorktree
nextActiveGroupIdByWorktreeResolved = collapsedState.activeGroupIdByWorktree
}
const nextGroupsByWorktree = {
...state.groupsByWorktree,
[worktreeId]: filteredGroups
}
const nextUnifiedTabsByWorktree = {
...state.unifiedTabsByWorktree,
[worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) =>
candidate.id === tabId ? { ...candidate, groupId: targetGroupId } : candidate
)
}
return {
unifiedTabsByWorktree: {
...state.unifiedTabsByWorktree,
[worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) =>
candidate.id === tabId ? { ...candidate, groupId: targetGroupId } : candidate
)
},
groupsByWorktree: {
...state.groupsByWorktree,
[worktreeId]: nextGroups
},
activeGroupIdByWorktree: nextActiveGroupIdByWorktree
unifiedTabsByWorktree: nextUnifiedTabsByWorktree,
groupsByWorktree: nextGroupsByWorktree,
layoutByWorktree: nextLayoutByWorktree,
activeGroupIdByWorktree: nextActiveGroupIdByWorktreeResolved,
...(state.activeWorktreeId === worktreeId
? buildActiveSurfacePatch(
{
...state,
unifiedTabsByWorktree: nextUnifiedTabsByWorktree,
groupsByWorktree: nextGroupsByWorktree,
layoutByWorktree: nextLayoutByWorktree,
activeGroupIdByWorktree: nextActiveGroupIdByWorktreeResolved
},
worktreeId,
nextActiveGroupIdByWorktreeResolved[worktreeId] ?? null
)
: {})
}
})
if (moved && opts?.recordInteraction !== false) {
@@ -1418,11 +1450,20 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
if (!isSplitDrop && tab.groupId === target.groupId) {
return {}
}
if (isSplitDrop && tab.groupId === target.groupId && sourceGroup.tabOrder.length <= 1) {
// Why: dragging the final tab in a group onto that same group's edge
// would create a transient sibling only to collapse the source
// immediately, leaving the layout unchanged while still churning focus
// and group IDs. Treat that as a no-op instead of faking a split.
const layout = state.layoutByWorktree[worktreeId]
if (
isSplitDrop &&
isPaneColumnSplitDropNoOp({
sourceGroupId: sourceGroup.id,
targetGroupId: target.groupId,
splitDirection: target.splitDirection!,
sourceTabCount: sourceGroup.tabOrder.length,
layout
})
) {
// Why: dragging the final tab in a group onto that same group's edge,
// or onto the adjacent sibling's matching edge, creates a transient
// column only to collapse the emptied source immediately.
return {}
}
+6 -11
View File
@@ -844,16 +844,12 @@ test.describe('Terminal Panes', () => {
await expect(orcaPage.locator('.pane-title-text', { hasText: title })).toHaveCount(1)
})
test('Set Title remove button hover stays transparent', async ({ orcaPage }) => {
const title = `Remove hover title ${Date.now()}`
test('Always-on pane header split button hover stays transparent', async ({ orcaPage }) => {
const splitButton = orcaPage.getByRole('button', { name: 'Split Terminal Right' })
await expect(splitButton).toBeVisible()
await splitButton.hover()
await setPaneTitleFromTerminalMenu(orcaPage, title)
const removeButton = orcaPage.getByRole('button', { name: `Remove pane title: ${title}` })
await orcaPage.locator('.pane-title-bar', { hasText: title }).hover()
await removeButton.hover()
await expect(orcaPage.getByText('Remove title', { exact: true })).toBeVisible()
const hoverStyle = await removeButton.evaluate((element) => {
const hoverStyle = await splitButton.evaluate((element) => {
const style = getComputedStyle(element)
return {
backgroundColor: style.backgroundColor,
@@ -907,8 +903,7 @@ test.describe('Terminal Panes', () => {
await expectSavedLayoutNotToContainTitle(orcaPage, tabId, paneTitle)
await setPaneTitleFromTerminalMenu(orcaPage, removeButtonTitle)
await orcaPage.locator('.pane-title-bar', { hasText: removeButtonTitle }).hover()
await orcaPage.getByRole('button', { name: `Remove pane title: ${removeButtonTitle}` }).click()
await setPaneTitleFromTerminalMenu(orcaPage, '')
await expect(orcaPage.locator('.pane-title-text', { hasText: removeButtonTitle })).toBeHidden()
await expectSavedLayoutNotToContainTitle(orcaPage, tabId, removeButtonTitle)