This commit is contained in:
Brennan Benson 2026-04-20 21:17:15 -07:00 committed by GitHub
commit afe4dad107
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1355 additions and 471 deletions

View file

@ -966,6 +966,7 @@ function Terminal(): React.JSX.Element | null {
onCloseAllFiles={closeAllFiles}
onPinFile={pinFile}
tabBarOrder={tabBarOrder}
onReorder={setTabBarOrder}
/>,
titlebarTabsTarget
)}

View file

@ -13,7 +13,7 @@ import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants'
import type { BrowserTab as BrowserTabState } from '../../../../shared/types'
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from './SortableTab'
import { getLiveBrowserUrl } from '../browser-pane/browser-runtime'
import type { TabDragItemData } from '../tab-group/useTabDragSplit'
import { getDropIndicatorClasses, type DropIndicator } from './drop-indicator'
function formatBrowserTabUrlLabel(url: string): string {
if (url === ORCA_BROWSER_BLANK_URL || url === 'about:blank') {
@ -27,7 +27,7 @@ function formatBrowserTabUrlLabel(url: string): string {
}
}
function getBrowserTabLabel(tab: BrowserTabState): string {
export function getBrowserTabLabel(tab: BrowserTabState): string {
if (
!tab.title ||
tab.title === tab.url ||
@ -51,7 +51,12 @@ export default function BrowserTab({
onClose,
onCloseToRight,
onSplitGroup,
dragData
groupId,
unifiedTabId,
sortableId,
dragData,
dropIndicator,
sharedDragMode = false
}: {
tab: BrowserTabState
isActive: boolean
@ -60,10 +65,22 @@ export default function BrowserTab({
onClose: () => void
onCloseToRight: () => void
onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void
dragData: TabDragItemData
groupId?: string
unifiedTabId?: string
sortableId?: string
dragData?: {
sourceGroupId: string
unifiedTabId: string
visibleId: string
contentType: 'browser'
worktreeId: string
label: string
}
dropIndicator?: DropIndicator
sharedDragMode?: boolean
}): React.JSX.Element {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: tab.id,
id: sortableId ?? tab.id,
data: dragData
})
const [menuOpen, setMenuOpen] = useState(false)
@ -103,15 +120,17 @@ export default function BrowserTab({
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : undefined,
opacity: isDragging ? 0.8 : 1
opacity: isDragging ? (sharedDragMode ? 0 : 0.8) : 1
}}
{...attributes}
{...listeners}
className={`group relative flex items-center h-full px-3 text-sm cursor-pointer select-none shrink-0 border-r border-border ${
className={`group relative flex items-center h-full px-3 text-sm cursor-pointer select-none shrink-0 border-r border-border ${getDropIndicatorClasses(dropIndicator ?? null)} ${
isActive
? 'bg-accent text-foreground border-b-transparent'
: 'bg-card text-muted-foreground hover:text-foreground hover:bg-accent/50'
}`}
data-tab-group-id={groupId}
data-unified-tab-id={unifiedTabId}
onPointerDown={(e) => {
if (e.button !== 0) {
return

View file

@ -26,7 +26,7 @@ import { STATUS_COLORS, STATUS_LABELS } from '../right-sidebar/status-display'
import type { GitFileStatus } from '../../../../shared/types'
import type { OpenFile } from '../../store/slices/editor'
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from './SortableTab'
import type { TabDragItemData } from '../tab-group/useTabDragSplit'
import { getDropIndicatorClasses, type DropIndicator } from './drop-indicator'
const isMac = navigator.userAgent.includes('Mac')
const isLinux = navigator.userAgent.includes('Linux')
@ -49,7 +49,12 @@ export default function EditorFileTab({
onCloseAll,
onPin,
onSplitGroup,
dragData
groupId,
unifiedTabId,
sortableId,
dragData,
dropIndicator,
sharedDragMode = false
}: {
file: OpenFile & { tabId?: string }
isActive: boolean
@ -61,13 +66,25 @@ export default function EditorFileTab({
onCloseAll: () => void
onPin?: () => void
onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void
dragData: TabDragItemData
groupId?: string
unifiedTabId?: string
sortableId?: string
dragData?: {
sourceGroupId: string
unifiedTabId: string
visibleId: string
contentType: 'editor' | 'diff' | 'conflict-review'
worktreeId: string
label: string
}
dropIndicator?: DropIndicator
sharedDragMode?: boolean
}): React.JSX.Element {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
// Why: split groups can duplicate the same open file into multiple visible
// tabs. Using the unified tab ID keeps each rendered tab draggable as a
// distinct item instead of collapsing every copy onto the file entity ID.
id: file.tabId ?? file.id,
id: sortableId ?? file.tabId ?? file.id,
data: dragData
})
@ -75,7 +92,7 @@ export default function EditorFileTab({
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : undefined,
opacity: isDragging ? 0.8 : 1
opacity: isDragging ? (sharedDragMode ? 0 : 0.8) : 1
}
const isDiff = file.mode === 'diff'
@ -183,11 +200,13 @@ export default function EditorFileTab({
style={style}
{...attributes}
{...listeners}
className={`group relative flex items-center h-full px-3 text-sm cursor-pointer select-none shrink-0 border-r border-border ${
className={`group relative flex items-center h-full px-3 text-sm cursor-pointer select-none shrink-0 border-r border-border ${getDropIndicatorClasses(dropIndicator ?? null)} ${
isActive
? 'bg-accent text-foreground border-b-transparent'
: 'bg-card text-muted-foreground hover:text-foreground hover:bg-accent/50'
}`}
data-tab-group-id={groupId}
data-unified-tab-id={unifiedTabId}
onPointerDown={(e) => {
if (e.button !== 0) {
return

View file

@ -11,7 +11,7 @@ import {
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import type { TerminalTab } from '../../../../shared/types'
import type { TabDragItemData } from '../tab-group/useTabDragSplit'
import { getDropIndicatorClasses, type DropIndicator } from './drop-indicator'
type SortableTabProps = {
tab: TerminalTab
@ -27,7 +27,19 @@ type SortableTabProps = {
onSetTabColor: (tabId: string, color: string | null) => void
onToggleExpand: (tabId: string) => void
onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void
dragData: TabDragItemData
groupId?: string
unifiedTabId?: string
sortableId?: string
dragData?: {
sourceGroupId: string
unifiedTabId: string
visibleId: string
contentType: 'terminal'
worktreeId: string
label: string
}
dropIndicator?: DropIndicator
sharedDragMode?: boolean
}
export const TAB_COLORS = [
@ -59,10 +71,15 @@ export default function SortableTab({
onSetTabColor,
onToggleExpand,
onSplitGroup,
dragData
groupId,
unifiedTabId,
sortableId,
dragData,
dropIndicator,
sharedDragMode = false
}: SortableTabProps): React.JSX.Element {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: tab.id,
id: sortableId ?? tab.id,
data: dragData
})
@ -70,7 +87,7 @@ export default function SortableTab({
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : undefined,
opacity: isDragging ? 0.8 : 1
opacity: isDragging ? (sharedDragMode ? 0 : 0.8) : 1
}
const [menuOpen, setMenuOpen] = useState(false)
const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 })
@ -155,11 +172,13 @@ export default function SortableTab({
data-tab-title={tab.customTitle ?? tab.title}
{...attributes}
{...dragListeners}
className={`group relative flex items-center h-full px-3 text-sm cursor-pointer select-none shrink-0 border-r border-border ${
className={`group relative flex items-center h-full px-3 text-sm cursor-pointer select-none shrink-0 border-r border-border ${getDropIndicatorClasses(dropIndicator ?? null)} ${
isActive
? 'bg-accent text-foreground border-b-transparent'
: 'bg-card text-muted-foreground hover:text-foreground hover:bg-accent/50'
}`}
data-tab-group-id={groupId}
data-unified-tab-id={unifiedTabId}
onDoubleClick={(e) => {
if (isEditing) {
return

View file

@ -1,8 +1,22 @@
/* eslint-disable max-lines -- Why: cross-group drag support requires shared
* sortable-ID mapping, drop-indicator state, and dual DnD-context switching to
* live alongside the existing tab-bar ordering logic. Splitting into separate
* files would fragment the rendering pipeline these pieces must coordinate. */
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable'
import {
DndContext,
closestCenter,
PointerSensor,
useDroppable,
useSensor,
useSensors,
type DragEndEvent
} from '@dnd-kit/core'
import { SortableContext, horizontalListSortingStrategy, arrayMove } from '@dnd-kit/sortable'
import { FilePlus, Globe, Plus, TerminalSquare } from 'lucide-react'
import type {
BrowserTab as BrowserTabState,
TabContentType,
TerminalTab,
WorkspaceVisibleTabType
} from '../../../../shared/types'
@ -11,9 +25,9 @@ import { buildStatusMap } from '../right-sidebar/status-display'
import type { OpenFile } from '../../store/slices/editor'
import SortableTab from './SortableTab'
import EditorFileTab from './EditorFileTab'
import BrowserTab from './BrowserTab'
import BrowserTab, { getBrowserTabLabel } from './BrowserTab'
import { reconcileTabOrder } from './reconcile-order'
import type { TabDragItemData } from '../tab-group/useTabDragSplit'
import type { DropIndicator } from './drop-indicator'
import {
DropdownMenu,
DropdownMenuContent,
@ -21,22 +35,35 @@ import {
DropdownMenuShortcut,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { getEditorDisplayLabel } from '../editor/editor-labels'
import {
buildGroupDropId,
buildSharedSortableId,
useCrossGroupDragState,
useIsCrossGroupDndPresent
} from '../tab-group/CrossGroupDragContext'
const isMac = navigator.userAgent.includes('Mac')
const NEW_TERMINAL_SHORTCUT = isMac ? '⌘T' : 'Ctrl+T'
const NEW_BROWSER_SHORTCUT = isMac ? '⌘⇧B' : 'Ctrl+Shift+B'
// Why: main moved the New Markdown shortcut to ⌘⇧M to avoid clashing with the
// OS-level "new window" chord. Keep that binding even as cross-group DnD lands.
const NEW_FILE_SHORTCUT = isMac ? '⌘⇧M' : 'Ctrl+Shift+M'
type TabBarProps = {
tabs: (TerminalTab & { unifiedTabId?: string })[]
tabs: TerminalTab[]
activeTabId: string | null
groupId?: string
worktreeId: string
expandedPaneByTabId: Record<string, boolean>
onActivate: (tabId: string) => void
onClose: (tabId: string) => void
onCloseOthers: (tabId: string) => void
onCloseToRight: (tabId: string) => void
// Why: the legacy single-strip DnDContext inside TabBar fires this when the
// tab bar renders outside split-group mode (Terminal.tsx portal fallback).
// Split groups wrap the bar in TabGroupDndContext, which owns reorder itself
// — so this stays optional and only the fallback path needs to wire it.
onReorder?: (worktreeId: string, order: string[]) => void
onNewTerminalTab: () => void
onNewBrowserTab: () => void
onNewFileTab?: () => void
@ -44,7 +71,7 @@ type TabBarProps = {
onSetTabColor: (tabId: string, color: string | null) => void
onTogglePaneExpand: (tabId: string) => void
editorFiles?: (OpenFile & { tabId?: string })[]
browserTabs?: (BrowserTabState & { tabId?: string })[]
browserTabs?: BrowserTabState[]
activeFileId?: string | null
activeBrowserTabId?: string | null
activeTabType?: WorkspaceVisibleTabType
@ -55,6 +82,8 @@ type TabBarProps = {
onCloseAllFiles?: () => void
onPinFile?: (fileId: string, tabId?: string) => void
tabBarOrder?: string[]
groupId?: string
unifiedTabIdByVisibleId?: Record<string, string>
onCreateSplitGroup?: (
direction: 'left' | 'right' | 'up' | 'down',
sourceVisibleTabId?: string
@ -64,28 +93,46 @@ type TabBarProps = {
type TabItem =
| {
type: 'terminal'
id: string
visibleId: string
unifiedTabId: string
data: TerminalTab & { unifiedTabId?: string }
sortableId: string
data: TerminalTab
}
| {
type: 'editor'
visibleId: string
unifiedTabId: string
sortableId: string
data: OpenFile & { tabId?: string }
}
| { type: 'editor'; id: string; unifiedTabId: string; data: OpenFile & { tabId?: string } }
| {
type: 'browser'
id: string
visibleId: string
unifiedTabId: string
data: BrowserTabState & { tabId?: string }
sortableId: string
data: BrowserTabState
}
function getEditorDragContentType(
file: OpenFile & { tabId?: string }
): 'editor' | 'diff' | 'conflict-review' {
return file.mode === 'diff'
? 'diff'
: file.mode === 'conflict-review'
? 'conflict-review'
: 'editor'
}
function TabBarInner({
tabs,
activeTabId,
groupId,
worktreeId,
expandedPaneByTabId,
onActivate,
onClose,
onCloseOthers,
onCloseToRight,
onReorder,
onNewTerminalTab,
onNewBrowserTab,
onNewFileTab,
@ -104,10 +151,23 @@ function TabBarInner({
onCloseAllFiles,
onPinFile,
tabBarOrder,
groupId,
unifiedTabIdByVisibleId,
onCreateSplitGroup
}: TabBarProps): React.JSX.Element {
const isSharedDnd = useIsCrossGroupDndPresent() && Boolean(groupId)
const dragState = useCrossGroupDragState()
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 }
})
)
const { setNodeRef: setGroupDropNodeRef } = useDroppable({
id: buildGroupDropId(groupId ?? `legacy-${worktreeId}`),
disabled: !isSharedDnd
})
const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree)
const resolvedGroupId = groupId ?? worktreeId
const statusByRelativePath = useMemo(
() => buildStatusMap(gitStatusByWorktree[worktreeId] ?? []),
[worktreeId, gitStatusByWorktree]
@ -136,31 +196,110 @@ function TabBarInner({
if (terminal) {
items.push({
type: 'terminal',
id,
unifiedTabId: terminal.unifiedTabId ?? terminal.id,
visibleId: id,
unifiedTabId: unifiedTabIdByVisibleId?.[id] ?? id,
sortableId: isSharedDnd && groupId ? buildSharedSortableId(groupId, id) : id,
data: terminal
})
continue
}
const file = editorMap.get(id)
if (file) {
items.push({ type: 'editor', id, unifiedTabId: file.tabId ?? file.id, data: file })
items.push({
type: 'editor',
visibleId: id,
unifiedTabId: unifiedTabIdByVisibleId?.[id] ?? file.tabId ?? file.id,
sortableId: isSharedDnd && groupId ? buildSharedSortableId(groupId, id) : id,
data: file
})
continue
}
const browserTab = browserMap.get(id)
if (browserTab) {
items.push({
type: 'browser',
id,
unifiedTabId: browserTab.tabId ?? browserTab.id,
visibleId: id,
unifiedTabId: unifiedTabIdByVisibleId?.[id] ?? id,
sortableId: isSharedDnd && groupId ? buildSharedSortableId(groupId, id) : id,
data: browserTab
})
}
}
return items
}, [tabBarOrder, terminalIds, editorFileIds, browserTabIds, terminalMap, editorMap, browserMap])
}, [
browserMap,
browserTabIds,
editorFileIds,
editorMap,
groupId,
isSharedDnd,
tabBarOrder,
terminalIds,
terminalMap,
unifiedTabIdByVisibleId
])
const sortableIds = useMemo(() => orderedItems.map((item) => item.id), [orderedItems])
const sortableIds = useMemo(() => orderedItems.map((item) => item.sortableId), [orderedItems])
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event
// Why: in shared-DnD mode, the outer TabGroupDndContext owns drag-end and
// sortableIds are composite ${groupId}::${visibleId} strings. This legacy
// handler is only attached to the inner DndContext at line ~563, which is not
// rendered in shared mode — but guard defensively so composite IDs can never
// be persisted as tab order if the shared-mode flag toggles mid-render.
if (isSharedDnd) {
return
}
if (!over || active.id === over.id) {
return
}
const oldIndex = sortableIds.indexOf(active.id as string)
const newIndex = sortableIds.indexOf(over.id as string)
if (oldIndex === -1 || newIndex === -1) {
return
}
const newOrder = arrayMove(sortableIds, oldIndex, newIndex)
onReorder?.(worktreeId, newOrder)
},
[isSharedDnd, sortableIds, worktreeId, onReorder]
)
// Why: VS Code marks both adjacent tabs at the insertion point — the left
// tab gets a right-edge indicator and the right tab gets a left-edge
// indicator — so the two pseudo-elements form one continuous vertical line
// between them. This mirrors that pattern for visual clarity.
const dropIndicatorByVisibleId = useMemo(() => {
const indicators = new Map<string, DropIndicator>()
if (
!isSharedDnd ||
dragState.activeTab == null ||
dragState.overGroupId !== groupId ||
dragState.overTabBarIndex == null ||
orderedItems.length === 0
) {
return indicators
}
const insertionIndex = Math.max(0, Math.min(dragState.overTabBarIndex, orderedItems.length))
if (insertionIndex > 0) {
indicators.set(orderedItems[insertionIndex - 1]!.visibleId, 'right')
}
if (insertionIndex < orderedItems.length) {
indicators.set(orderedItems[insertionIndex]!.visibleId, 'left')
}
return indicators
}, [
dragState.activeTab,
dragState.overGroupId,
dragState.overTabBarIndex,
groupId,
isSharedDnd,
orderedItems
])
const focusTerminalTabSurface = useCallback((tabId: string) => {
// Why: creating a terminal from the "+" menu is a two-step focus race:
@ -185,6 +324,11 @@ function TabBarInner({
// Horizontal wheel scrolling for the tab strip
const tabStripRef = useRef<HTMLDivElement>(null)
// Why: auto-scroll-to-end bookkeeping from main. `prevStripLenRef` tracks the
// previous strip length per worktree so we only auto-scroll when a tab was
// genuinely added (not on worktree switches); `stickToEndRef` captures the
// "user is parked at the right edge" state so label-width growth (e.g.
// "Terminal 5" → branch name) keeps the close button visible.
const prevStripLenRef = useRef<{ worktreeId: string; len: number } | null>(null)
const stickToEndRef = useRef(false)
@ -281,8 +425,137 @@ function TabBarInner({
prevStripLenRef.current = { worktreeId, len }
}, [orderedItems, worktreeId])
const setTabStripNode = useCallback((node: HTMLDivElement | null) => {
tabStripRef.current = node
}, [])
const getDragData = useCallback(
<TContentType extends TabContentType>(
item: TabItem,
contentType: TContentType,
label: string
):
| {
sourceGroupId: string
unifiedTabId: string
visibleId: string
contentType: TContentType
worktreeId: string
label: string
}
| undefined => {
if (!isSharedDnd || !groupId) {
return undefined
}
return {
sourceGroupId: groupId,
unifiedTabId: item.unifiedTabId,
visibleId: item.visibleId,
contentType,
worktreeId,
label
}
},
[groupId, isSharedDnd, worktreeId]
)
const tabStrip = (
<SortableContext items={sortableIds} strategy={horizontalListSortingStrategy}>
{/* Why: no-drag lets tab interactions work inside the titlebar's drag
region. The outer container inherits drag so empty space after the
"+" button remains window-draggable. */}
<div
ref={setTabStripNode}
className="terminal-tab-strip flex items-stretch overflow-x-auto overflow-y-hidden"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
{orderedItems.map((item, index) => {
const dropIndicator = dropIndicatorByVisibleId.get(item.visibleId) ?? null
if (item.type === 'terminal') {
return (
<SortableTab
key={item.visibleId}
tab={item.data}
groupId={groupId}
unifiedTabId={item.unifiedTabId}
sortableId={item.sortableId}
dragData={getDragData(item, 'terminal', item.data.customTitle ?? item.data.title)}
dropIndicator={dropIndicator}
sharedDragMode={isSharedDnd}
tabCount={tabs.length}
hasTabsToRight={index < orderedItems.length - 1}
isActive={activeTabType === 'terminal' && item.visibleId === activeTabId}
isExpanded={expandedPaneByTabId[item.visibleId] === true}
onActivate={onActivate}
onClose={onClose}
onCloseOthers={onCloseOthers}
onCloseToRight={onCloseToRight}
onSetCustomTitle={onSetCustomTitle}
onSetTabColor={onSetTabColor}
onToggleExpand={onTogglePaneExpand}
onSplitGroup={(direction, sourceVisibleTabId) =>
onCreateSplitGroup?.(direction, sourceVisibleTabId)
}
/>
)
}
if (item.type === 'browser') {
return (
<BrowserTab
key={item.visibleId}
tab={item.data}
groupId={groupId}
unifiedTabId={item.unifiedTabId}
sortableId={item.sortableId}
dragData={getDragData(item, 'browser', getBrowserTabLabel(item.data))}
dropIndicator={dropIndicator}
sharedDragMode={isSharedDnd}
isActive={activeTabType === 'browser' && activeBrowserTabId === item.visibleId}
hasTabsToRight={index < orderedItems.length - 1}
onActivate={() => onActivateBrowserTab?.(item.visibleId)}
onClose={() => onCloseBrowserTab?.(item.visibleId)}
onCloseToRight={() => onCloseToRight(item.visibleId)}
onSplitGroup={(direction, sourceVisibleTabId) =>
onCreateSplitGroup?.(direction, sourceVisibleTabId)
}
/>
)
}
return (
<EditorFileTab
key={item.visibleId}
file={item.data}
groupId={groupId}
unifiedTabId={item.unifiedTabId}
sortableId={item.sortableId}
dragData={getDragData(
item,
getEditorDragContentType(item.data),
getEditorDisplayLabel(item.data)
)}
dropIndicator={dropIndicator}
sharedDragMode={isSharedDnd}
isActive={activeTabType === 'editor' && activeFileId === item.visibleId}
hasTabsToRight={index < orderedItems.length - 1}
statusByRelativePath={statusByRelativePath}
onActivate={() => onActivateFile?.(item.visibleId)}
onClose={() => onCloseFile?.(item.visibleId)}
onCloseToRight={() => onCloseToRight(item.visibleId)}
onCloseAll={() => onCloseAllFiles?.()}
onPin={() => onPinFile?.(item.data.id, item.data.tabId)}
onSplitGroup={(direction, sourceVisibleTabId) =>
onCreateSplitGroup?.(direction, sourceVisibleTabId)
}
/>
)
})}
</div>
</SortableContext>
)
return (
<div
ref={setGroupDropNodeRef}
className="flex items-stretch h-full overflow-hidden flex-1 min-w-0"
// Why: only drops aimed at the top tab/session strip should open files in
// Orca's editor. Terminal-pane drops need to keep inserting file paths
@ -290,87 +563,19 @@ function TabBarInner({
// this explicit surface marker instead of treating the whole app as an
// editor drop zone.
data-native-file-drop-target="editor"
// Why: the group droppable rect is registered on this outer row (not on
// the inner tab strip) so dropping past the last tab — including the
// empty area to the right of the "+" button — still lands in this
// group. Attaching to the strip alone leaves no droppable gutter once
// the strip is sized to its tabs.
>
<SortableContext items={sortableIds} strategy={horizontalListSortingStrategy}>
{/* Why: no-drag lets tab interactions work inside the titlebar's drag
region. The outer container inherits drag so empty space after the
"+" button remains window-draggable. */}
<div
ref={tabStripRef}
className="terminal-tab-strip flex items-stretch overflow-x-auto overflow-y-hidden"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
{orderedItems.map((item, index) => {
const dragData: TabDragItemData = {
kind: 'tab',
worktreeId,
groupId: resolvedGroupId,
unifiedTabId: item.unifiedTabId,
visibleTabId: item.id,
tabType: item.type
}
if (item.type === 'terminal') {
return (
<SortableTab
key={item.id}
tab={item.data}
tabCount={tabs.length}
hasTabsToRight={index < orderedItems.length - 1}
isActive={activeTabType === 'terminal' && item.id === activeTabId}
isExpanded={expandedPaneByTabId[item.id] === true}
onActivate={onActivate}
onClose={onClose}
onCloseOthers={onCloseOthers}
onCloseToRight={onCloseToRight}
onSetCustomTitle={onSetCustomTitle}
onSetTabColor={onSetTabColor}
onToggleExpand={onTogglePaneExpand}
onSplitGroup={(direction, sourceVisibleTabId) =>
onCreateSplitGroup?.(direction, sourceVisibleTabId)
}
dragData={dragData}
/>
)
}
if (item.type === 'browser') {
return (
<BrowserTab
key={item.id}
tab={item.data}
isActive={activeTabType === 'browser' && activeBrowserTabId === item.id}
hasTabsToRight={index < orderedItems.length - 1}
onActivate={() => onActivateBrowserTab?.(item.id)}
onClose={() => onCloseBrowserTab?.(item.id)}
onCloseToRight={() => onCloseToRight(item.id)}
onSplitGroup={(direction, sourceVisibleTabId) =>
onCreateSplitGroup?.(direction, sourceVisibleTabId)
}
dragData={dragData}
/>
)
}
return (
<EditorFileTab
key={item.id}
file={item.data}
isActive={activeTabType === 'editor' && activeFileId === item.id}
hasTabsToRight={index < orderedItems.length - 1}
statusByRelativePath={statusByRelativePath}
onActivate={() => onActivateFile?.(item.id)}
onClose={() => onCloseFile?.(item.id)}
onCloseToRight={() => onCloseToRight(item.id)}
onCloseAll={() => onCloseAllFiles?.()}
onPin={() => onPinFile?.(item.data.id, item.data.tabId)}
onSplitGroup={(direction, sourceVisibleTabId) =>
onCreateSplitGroup?.(direction, sourceVisibleTabId)
}
dragData={dragData}
/>
)
})}
</div>
</SortableContext>
{isSharedDnd ? (
tabStrip
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
{tabStrip}
</DndContext>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button

View file

@ -0,0 +1,14 @@
export type DropIndicator = 'left' | 'right' | null
// Why: the theme's accent color (#404040 in dark mode) is too subtle for a
// drag-and-drop insertion cue. Using a vivid blue matches VS Code's
// tab.dragAndDropBorder and is immediately visible against all tab backgrounds.
export function getDropIndicatorClasses(dropIndicator: DropIndicator): string {
if (dropIndicator === 'left') {
return "before:absolute before:inset-y-0 before:left-0 before:w-[2px] before:bg-blue-500 before:z-10 before:content-['']"
}
if (dropIndicator === 'right') {
return "after:absolute after:inset-y-0 after:right-0 after:w-[2px] after:bg-blue-500 after:z-10 after:content-['']"
}
return ''
}

View file

@ -0,0 +1,100 @@
import { createContext, useContext } from 'react'
import type { TabContentType } from '../../../../shared/types'
import type { TabDropZone } from './useTabDragSplit'
export type TabDragData = {
sourceGroupId: string
unifiedTabId: string
visibleId: string
contentType: TabContentType
worktreeId: string
label: string
}
export type PaneSplitTarget = {
groupId: string
zone: TabDropZone
}
export type CrossGroupDragState = {
activeTab: TabDragData | null
overGroupId: string | null
overTabBarIndex: number | null
// Why: when the pointer hovers a pane body (not the tab strip) during a tab
// drag, the drop should create or join a split instead of reordering tabs.
// Tracking the hovered pane + zone here lets TabGroupPanel render the split
// preview overlay while keeping the drop decision in one place.
paneSplit: PaneSplitTarget | null
}
export const GROUP_DROP_PREFIX = 'group-drop::'
export const EMPTY_DRAG_STATE: CrossGroupDragState = {
activeTab: null,
overGroupId: null,
overTabBarIndex: null,
paneSplit: null
}
// Why: `event.active.data.current` is typed as `Record<string, unknown> | undefined`
// by dnd-kit. Casting without validation would let a malformed draggable (e.g. a
// future non-tab draggable sharing this DnD context) silently pass through as a
// TabDragData and corrupt store invocations downstream.
export function isTabDragData(value: unknown): value is TabDragData {
if (!value || typeof value !== 'object') {
return false
}
const v = value as Record<string, unknown>
return (
typeof v.sourceGroupId === 'string' &&
typeof v.unifiedTabId === 'string' &&
typeof v.visibleId === 'string' &&
typeof v.contentType === 'string' &&
typeof v.worktreeId === 'string' &&
typeof v.label === 'string'
)
}
export const CrossGroupDragStateContext = createContext<CrossGroupDragState>(EMPTY_DRAG_STATE)
export const CrossGroupDndContextPresent = createContext(false)
// Why: once split groups share a single DnD context, every sortable item must
// be globally unique across the whole workspace tree, not just within its tab
// strip. Prefixing the visible ID with the group ID preserves existing tab-bar
// ordering logic while letting dnd-kit distinguish same-entity tabs in
// different groups.
export function buildSharedSortableId(groupId: string, visibleId: string): string {
return `${groupId}::${visibleId}`
}
export function parseSharedSortableId(id: string): { groupId: string; visibleId: string } | null {
// Group-drop IDs (e.g. 'group-drop::someGroup') share the '::' delimiter with
// sortable IDs, so without this guard we'd mis-parse them as { groupId: 'group-drop', visibleId: 'someGroup' }.
if (id.startsWith(GROUP_DROP_PREFIX)) {
return null
}
const separatorIndex = id.indexOf('::')
if (separatorIndex <= 0) {
return null
}
return {
groupId: id.slice(0, separatorIndex),
visibleId: id.slice(separatorIndex + 2)
}
}
export function buildGroupDropId(groupId: string): string {
return `${GROUP_DROP_PREFIX}${groupId}`
}
export function parseGroupDropId(id: string): { groupId: string } | null {
return id.startsWith(GROUP_DROP_PREFIX) ? { groupId: id.slice(GROUP_DROP_PREFIX.length) } : null
}
export function useCrossGroupDragState(): CrossGroupDragState {
return useContext(CrossGroupDragStateContext)
}
export function useIsCrossGroupDndPresent(): boolean {
return useContext(CrossGroupDndContextPresent)
}

View file

@ -0,0 +1,522 @@
/* oxlint-disable max-lines -- Why: the cross-group DnD context coordinates
* collision detection, drag-state tracking, three distinct drop targets
* (sortable tab, group droppable, pane-body split), the cross-group drop
* gate, and the drag overlay splitting would fragment the reducer-like
* state machine these callbacks share via dragStateRef. */
import { useCallback, useMemo, useRef, useState } from 'react'
import {
closestCenter,
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
type ClientRect,
type CollisionDetection,
type DragCancelEvent,
type DragEndEvent,
type DragMoveEvent,
type DragOverEvent,
type DragStartEvent,
type UniqueIdentifier
} from '@dnd-kit/core'
import { FileCode, Globe, TerminalSquare } from 'lucide-react'
import type { Tab, TabGroup } from '../../../../shared/types'
import { useAppStore } from '../../store'
import {
CrossGroupDndContextPresent,
CrossGroupDragStateContext,
EMPTY_DRAG_STATE,
isTabDragData,
parseGroupDropId,
parseSharedSortableId,
type CrossGroupDragState,
type TabDragData
} from './CrossGroupDragContext'
import {
canDropTabIntoPaneBody,
getTabPaneBodyDroppableId,
parsePaneBodyDroppableId,
resolveDropZone,
type TabDropZone
} from './useTabDragSplit'
import {
applyInsertionIndex,
computeInsertionFromPointer,
computeInsertionIndex,
makeCollision,
pointInRect,
toVisibleTabId
} from './tab-drag-helpers'
// Why: referential stability for useAppStore selector defaults — avoids a fresh
// `[]` on every render, which would invalidate downstream `useMemo` deps.
const EMPTY_GROUPS: TabGroup[] = []
const EMPTY_TABS: Tab[] = []
// Why: cross-group drag requires the terminal daemon to be enabled. The
// flaky part is terminal-tab drag — moving a live terminal between groups
// remounts the pane, and the non-daemon renderer-side reattach path can
// leave the destination blank. The daemon's snapshot-based reattach fixes
// that. Strictly speaking, browser and editor tabs have no PTY lifecycle
// and could drag cross-group regardless, but we deliberately gate all tab
// types on the same flag so the UX is consistent.
//
// The consistency matters for user trust: if some tab types dragged across
// groups and others silently refused, users would reasonably think the
// refused drags are a bug ("my browser tab moved but my terminal tab
// didn't — is the drag broken?") and either file bug reports or lose
// confidence in the feature altogether. A single binary behavior
// ("cross-group drag works" or "cross-group drag doesn't, turn on the
// daemon") is easier to learn, document, and support.
//
// - daemon ON → cross-group drag works for all tab types
// - daemon OFF → cross-group drag is refused silently for all tab types
// (no drop indicator, no overlay); same-group reorder is
// unaffected (the pane never remounts in that case)
//
// Pane-body split drops that land in a non-center zone create a new group
// and remount the active pane, so they're treated as cross-group moves
// even when the target group matches the source. Center-zone drops do not
// create a new group and reduce to the tab-bar insertion path.
//
// When the daemon flips on by default, this gate becomes a no-op for most
// users and can be deleted as a follow-up cleanup.
function isCrossGroupDropBlocked(
drag: TabDragData,
targetGroupId: string,
splitZone: TabDropZone | null,
daemonEnabled: boolean
): boolean {
if (daemonEnabled) {
return false
}
// Split (non-center) into any group, including the source, creates a new
// group and remounts the pane — block it the same way as a cross-group move.
if (splitZone && splitZone !== 'center') {
return true
}
return drag.sourceGroupId !== targetGroupId
}
export default function TabGroupDndContext({
worktreeId,
children
}: {
worktreeId: string
children: React.ReactNode
}): React.JSX.Element {
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 }
})
)
const reorderUnifiedTabs = useAppStore((state) => state.reorderUnifiedTabs)
const moveUnifiedTabToGroup = useAppStore((state) => state.moveUnifiedTabToGroup)
const dropUnifiedTab = useAppStore((state) => state.dropUnifiedTab)
const closeEmptyGroup = useAppStore((state) => state.closeEmptyGroup)
const groups = useAppStore((state) => state.groupsByWorktree[worktreeId] ?? EMPTY_GROUPS)
const unifiedTabs = useAppStore((state) => state.unifiedTabsByWorktree[worktreeId] ?? EMPTY_TABS)
const daemonEnabled = useAppStore((state) => state.settings?.experimentalTerminalDaemon === true)
const [dragState, setDragState] = useState<CrossGroupDragState>(EMPTY_DRAG_STATE)
const dragStateRef = useRef(dragState)
dragStateRef.current = dragState
const pointerRef = useRef<{ x: number; y: number } | null>(null)
const droppableRectsRef = useRef<Map<UniqueIdentifier, ClientRect>>(new Map())
const groupVisibleOrder = useMemo(() => {
const tabsById = new Map(unifiedTabs.map((tab) => [tab.id, tab]))
return new Map(
groups.map((group) => [
group.id,
group.tabOrder
.map((tabId) => tabsById.get(tabId))
.filter((tab): tab is Tab => tab !== undefined)
.map((tab) => ({
unifiedTabId: tab.id,
visibleId: toVisibleTabId(tab)
}))
])
)
}, [groups, unifiedTabs])
const maybeCloseEmptySourceGroup = useCallback(
(sourceGroupId: string) => {
const state = useAppStore.getState()
const nextGroups = state.groupsByWorktree[worktreeId] ?? []
const sourceGroup = nextGroups.find((group) => group.id === sourceGroupId)
if (!sourceGroup || sourceGroup.tabOrder.length > 0 || nextGroups.length <= 1) {
return
}
closeEmptyGroup(worktreeId, sourceGroupId)
},
[closeEmptyGroup, worktreeId]
)
// Why: pointer-based hit testing — closestCenter measures distance from the
// ORIGINAL tab position (hidden by DragOverlay), not the cursor. Priority:
// sortable tab > tab-bar empty space > pane body (split target, last so the
// tab strip still wins reordering hits within the same group).
const collisionDetection = useCallback<CollisionDetection>(
(args: Parameters<CollisionDetection>[0]) => {
if (args.pointerCoordinates) {
pointerRef.current = args.pointerCoordinates
}
droppableRectsRef.current = args.droppableRects
if (!args.pointerCoordinates) {
return closestCenter(args)
}
const pointer = args.pointerCoordinates
const getRect = (id: string | number) => args.droppableRects.get(id)
const hit = (matches: (id: string) => boolean): { id: UniqueIdentifier } | undefined =>
args.droppableContainers.find(
(c) => matches(String(c.id)) && pointInRect(pointer, getRect(c.id))
)
const sortableHit = hit((id) => parseSharedSortableId(id) !== null)
if (sortableHit) {
return makeCollision(String(sortableHit.id), args)
}
const tabBarHit = hit((id) => parseGroupDropId(id) !== null)
if (tabBarHit) {
return makeCollision(String(tabBarHit.id), args)
}
const paneBodyHit = hit((id) => parsePaneBodyDroppableId(id) !== null)
if (paneBodyHit) {
return makeCollision(String(paneBodyHit.id), args)
}
return []
},
[]
)
const clearDragState = useCallback(() => {
pointerRef.current = null
setDragState(EMPTY_DRAG_STATE)
}, [])
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const activeTab = isTabDragData(event.active.data.current)
? event.active.data.current
: undefined
if (!activeTab) {
clearDragState()
return
}
setDragState({
activeTab,
overGroupId: activeTab.sourceGroupId,
overTabBarIndex: null,
paneSplit: null
})
},
[clearDragState]
)
// Why: onDragOver only fires when the "over" droppable changes. Insertion
// index within a tab (midpoint-based) and pane-body split zone (quadrant-
// based) both depend on the pointer's position inside a single droppable, so
// they have to be recomputed every move.
const handleDragMove = useCallback(
(_event: DragMoveEvent) => {
setDragState((current) => {
if (!current.activeTab || !current.overGroupId) {
return current
}
const pointer = pointerRef.current
if (!pointer) {
return current
}
if (current.paneSplit) {
const paneRect = droppableRectsRef.current.get(
getTabPaneBodyDroppableId(current.paneSplit.groupId)
)
if (!paneRect) {
return current
}
const zone = resolveDropZone(paneRect, pointer)
if (zone === current.paneSplit.zone) {
return current
}
return { ...current, paneSplit: { groupId: current.paneSplit.groupId, zone } }
}
const orderedTabs = groupVisibleOrder.get(current.overGroupId) ?? []
const newIndex =
computeInsertionFromPointer(
orderedTabs,
pointer,
droppableRectsRef.current,
current.overGroupId,
current.activeTab.visibleId
) ?? orderedTabs.length
if (newIndex === current.overTabBarIndex) {
return current
}
return { ...current, overTabBarIndex: newIndex }
})
},
[groupVisibleOrder]
)
const handleDragOver = useCallback(
(event: DragOverEvent) => {
const activeTab = isTabDragData(event.active.data.current)
? event.active.data.current
: undefined
// Why: non-destructive reset — keep activeTab so handleDragMove keeps
// running and the overlay keeps tracking the cursor after leaving every
// droppable; clearing activeTab would freeze the overlay until drop.
const resetOver = (): void =>
setDragState((current) =>
current.activeTab
? { ...current, overGroupId: null, overTabBarIndex: null, paneSplit: null }
: EMPTY_DRAG_STATE
)
if (!activeTab || !event.over) {
resetOver()
return
}
const overId = String(event.over.id)
const sortableTarget = parseSharedSortableId(overId)
if (sortableTarget) {
if (isCrossGroupDropBlocked(activeTab, sortableTarget.groupId, null, daemonEnabled)) {
resetOver()
return
}
const orderedTabs = groupVisibleOrder.get(sortableTarget.groupId) ?? []
const insertionIndex = computeInsertionIndex(
orderedTabs,
sortableTarget.visibleId,
pointerRef.current?.x ?? null,
droppableRectsRef.current.get(overId)
)
setDragState({
activeTab,
overGroupId: sortableTarget.groupId,
overTabBarIndex: insertionIndex,
paneSplit: null
})
return
}
const groupDropTarget = parseGroupDropId(overId)
if (groupDropTarget) {
if (isCrossGroupDropBlocked(activeTab, groupDropTarget.groupId, null, daemonEnabled)) {
resetOver()
return
}
const orderedTabs = groupVisibleOrder.get(groupDropTarget.groupId) ?? []
const insertionIndex = computeInsertionFromPointer(
orderedTabs,
pointerRef.current,
droppableRectsRef.current,
groupDropTarget.groupId,
activeTab.visibleId
)
setDragState({
activeTab,
overGroupId: groupDropTarget.groupId,
overTabBarIndex: insertionIndex ?? orderedTabs.length,
paneSplit: null
})
return
}
const paneBodyTarget = parsePaneBodyDroppableId(overId)
if (paneBodyTarget) {
const allowed = canDropTabIntoPaneBody({
activeDrag: {
kind: 'tab',
worktreeId,
groupId: activeTab.sourceGroupId,
unifiedTabId: activeTab.unifiedTabId,
visibleTabId: activeTab.visibleId,
// canDropTabIntoPaneBody ignores tabType — value is irrelevant here.
tabType:
activeTab.contentType === 'terminal' || activeTab.contentType === 'browser'
? activeTab.contentType
: 'editor'
},
groupsByWorktree: useAppStore.getState().groupsByWorktree,
overGroupId: paneBodyTarget.groupId,
worktreeId
})
if (!allowed) {
resetOver()
return
}
const paneRect = droppableRectsRef.current.get(overId)
const pointer = pointerRef.current
const zone: TabDropZone =
paneRect && pointer ? resolveDropZone(paneRect, pointer) : 'center'
// Why: same-group center-zone is a silent no-op in handleDragEnd — hide the
// "merge" overlay so the user doesn't see a preview that can't be applied.
if (paneBodyTarget.groupId === activeTab.sourceGroupId && zone === 'center') {
resetOver()
return
}
if (isCrossGroupDropBlocked(activeTab, paneBodyTarget.groupId, zone, daemonEnabled)) {
resetOver()
return
}
setDragState({
activeTab,
overGroupId: paneBodyTarget.groupId,
overTabBarIndex: null,
paneSplit: { groupId: paneBodyTarget.groupId, zone }
})
return
}
resetOver()
},
[daemonEnabled, groupVisibleOrder, worktreeId]
)
const handleDragCancel = useCallback(
(_event: DragCancelEvent) => {
clearDragState()
},
[clearDragState]
)
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const activeTab = isTabDragData(event.active.data.current)
? event.active.data.current
: undefined
const currentDragState = dragStateRef.current
const sourceGroupId = activeTab?.sourceGroupId
clearDragState()
if (!activeTab || !event.over || !currentDragState.overGroupId) {
return
}
const targetGroupId = currentDragState.overGroupId
// Why: pane-body split takes precedence over tab-strip insertion. A
// center-zone drop on the source group's own pane is a no-op (store
// rejects it), so skip the call there to avoid misleading the user.
if (currentDragState.paneSplit) {
const { zone } = currentDragState.paneSplit
if (zone === 'center' && activeTab.sourceGroupId === targetGroupId) {
return
}
if (isCrossGroupDropBlocked(activeTab, targetGroupId, zone, daemonEnabled)) {
return
}
// Why: store actions can throw (e.g. group disappeared mid-drag) — catch
// so the drag still ends cleanly (clearDragState already ran above).
try {
dropUnifiedTab(activeTab.unifiedTabId, {
groupId: targetGroupId,
splitDirection: zone === 'center' ? undefined : zone
})
} catch (err) {
console.error('Cross-group drag drop failed:', err)
}
if (sourceGroupId && sourceGroupId !== targetGroupId) {
maybeCloseEmptySourceGroup(sourceGroupId)
}
return
}
if (currentDragState.overTabBarIndex !== null) {
if (activeTab.sourceGroupId === targetGroupId) {
// Why: read the live store state here rather than the `groupVisibleOrder`
// closure — the memoized map can be one frame stale relative to the
// store (e.g. if a tab closed mid-drag). Matches the
// `maybeCloseEmptySourceGroup` pattern below.
const liveState = useAppStore.getState()
const liveGroup = (liveState.groupsByWorktree[worktreeId] ?? []).find(
(g) => g.id === targetGroupId
)
const currentOrder = liveGroup?.tabOrder ?? []
const nextOrder = applyInsertionIndex(
currentOrder,
activeTab.unifiedTabId,
currentDragState.overTabBarIndex
)
if (nextOrder) {
try {
reorderUnifiedTabs(targetGroupId, nextOrder)
} catch (err) {
console.error('Cross-group drag drop failed:', err)
}
}
return
}
if (isCrossGroupDropBlocked(activeTab, targetGroupId, null, daemonEnabled)) {
return
}
try {
moveUnifiedTabToGroup(activeTab.unifiedTabId, targetGroupId, {
index: currentDragState.overTabBarIndex,
activate: true
})
} catch (err) {
console.error('Cross-group drag drop failed:', err)
}
maybeCloseEmptySourceGroup(activeTab.sourceGroupId)
}
},
[
clearDragState,
daemonEnabled,
dropUnifiedTab,
maybeCloseEmptySourceGroup,
moveUnifiedTabToGroup,
reorderUnifiedTabs,
worktreeId
]
)
const overlayIcon = (() => {
if (!dragState.activeTab) {
return null
}
if (dragState.activeTab.contentType === 'terminal') {
return <TerminalSquare className="size-3.5 shrink-0" />
}
if (dragState.activeTab.contentType === 'browser') {
return <Globe className="size-3.5 shrink-0" />
}
return <FileCode className="size-3.5 shrink-0" />
})()
return (
<CrossGroupDndContextPresent.Provider value={true}>
<CrossGroupDragStateContext.Provider value={dragState}>
<DndContext
sensors={sensors}
collisionDetection={collisionDetection}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
{children}
<DragOverlay>
{dragState.activeTab ? (
<div className="flex items-center gap-2 rounded-md border border-border bg-card/95 px-3 py-1.5 text-sm shadow-lg">
{overlayIcon}
<span className="max-w-[240px] truncate">{dragState.activeTab.label}</span>
</div>
) : null}
</DragOverlay>
</DndContext>
</CrossGroupDragStateContext.Provider>
</CrossGroupDndContextPresent.Provider>
)
}

View file

@ -1,4 +1,4 @@
import { lazy, Suspense } from 'react'
import { lazy, Suspense, useMemo } from 'react'
import { useDroppable } from '@dnd-kit/core'
import { Columns2, Ellipsis, Rows2, X } from 'lucide-react'
import { useAppStore } from '../../store'
@ -13,8 +13,10 @@ import TabBar from '../tab-bar/TabBar'
import TerminalPane from '../terminal-pane/TerminalPane'
import BrowserPane from '../browser-pane/BrowserPane'
import { useTabGroupWorkspaceModel } from './useTabGroupWorkspaceModel'
import { useCrossGroupDragState } from './CrossGroupDragContext'
import { getTabPaneBodyDroppableId } from './useTabDragSplit'
import TabGroupDropOverlay from './TabGroupDropOverlay'
import { getTabPaneBodyDroppableId, type TabDropZone } from './useTabDragSplit'
import { toVisibleTabId } from './tab-drag-helpers'
const EditorPanel = lazy(() => import('../editor/EditorPanel'))
@ -25,23 +27,23 @@ export default function TabGroupPanel({
isFocused,
hasSplitGroups,
reserveClosedExplorerToggleSpace,
reserveCollapsedSidebarHeaderSpace,
isTabDragActive = false,
activeDropZone = null
reserveCollapsedSidebarHeaderSpace
}: {
groupId: string
worktreeId: string
// Why: preserved from main (PR #777) so TerminalPane.isVisible and
// BrowserPane display/isActive can suppress hidden worktrees. Without this
// guard, detached panes keep their WebGL renderers alive and exhaust
// Chromium's context budget across worktrees.
isWorktreeActive: boolean
isFocused: boolean
hasSplitGroups: boolean
reserveClosedExplorerToggleSpace: boolean
reserveCollapsedSidebarHeaderSpace: boolean
isTabDragActive?: boolean
activeDropZone?: TabDropZone | null
}): React.JSX.Element {
const rightSidebarOpen = useAppStore((state) => state.rightSidebarOpen)
const sidebarOpen = useAppStore((state) => state.sidebarOpen)
const dragState = useCrossGroupDragState()
const model = useTabGroupWorkspaceModel({ groupId, worktreeId })
const {
activeBrowserTab,
@ -54,13 +56,19 @@ export default function TabGroupPanel({
terminalTabs,
worktreePath
} = model
const { setNodeRef: setBodyDropRef } = useDroppable({
const unifiedTabIdByVisibleId = useMemo(
() => Object.fromEntries(model.groupTabs.map((item) => [toVisibleTabId(item), item.id])),
[model.groupTabs]
)
const isForeignDragTarget =
dragState.activeTab != null &&
dragState.overGroupId === groupId &&
dragState.activeTab.sourceGroupId !== groupId
const isTabDragActive = dragState.activeTab != null
const paneDropZone = dragState.paneSplit?.groupId === groupId ? dragState.paneSplit.zone : null
const { setNodeRef: setPaneBodyDropRef } = useDroppable({
id: getTabPaneBodyDroppableId(groupId),
data: {
kind: 'pane-body',
groupId,
worktreeId
},
disabled: !isTabDragActive
})
@ -68,8 +76,9 @@ export default function TabGroupPanel({
<TabBar
tabs={terminalTabs}
activeTabId={activeTab?.contentType === 'terminal' ? activeTab.entityId : null}
groupId={groupId}
worktreeId={worktreeId}
groupId={groupId}
unifiedTabIdByVisibleId={unifiedTabIdByVisibleId}
expandedPaneByTabId={model.expandedPaneByTabId}
onActivate={commands.activateTerminal}
onClose={(terminalId) => {
@ -154,8 +163,12 @@ export default function TabGroupPanel({
return (
<div
className={`group/tab-group flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${
hasSplitGroups ? ` border-x border-b ${isFocused ? 'border-accent' : 'border-border'}` : ''
className={`flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${
hasSplitGroups
? ` group/tab-group border-x border-b ${
isFocused || isForeignDragTarget ? 'border-accent' : 'border-border'
}`
: ''
}`}
onPointerDown={commands.focusGroup}
// Why: keyboard and assistive-tech users can move focus into an unfocused
@ -193,7 +206,10 @@ export default function TabGroupPanel({
{/* Why: pane-scoped layout actions belong with the active 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. */}
close-group without changing the existing tab-creation affordance.
Drag-to-split (via TabGroupDndContext) is the primary way to
create splits, but this menu remains the only way to spawn an
empty split pane without an originating tab drag. */}
<div
className={actionChromeClassName}
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
@ -267,8 +283,8 @@ export default function TabGroupPanel({
</div>
</div>
<div ref={setBodyDropRef} className="relative flex-1 min-h-0 overflow-hidden">
{activeDropZone ? <TabGroupDropOverlay zone={activeDropZone} /> : null}
<div ref={setPaneBodyDropRef} className="relative flex-1 min-h-0 overflow-hidden">
{paneDropZone ? <TabGroupDropOverlay zone={paneDropZone} /> : null}
{model.groupTabs
.filter((item) => item.contentType === 'terminal')
.map((item) => (
@ -328,6 +344,10 @@ export default function TabGroupPanel({
<div
key={bt.id}
className="absolute inset-0 flex min-h-0 min-w-0"
// Why: hidden worktrees stay mounted so their browser tabs survive
// worktree switches, but detached BrowserViews must be hidden or
// they leak WebContents and steal keyboard focus. Guarding display
// and isActive on isWorktreeActive keeps offscreen worktrees silent.
style={{
display: isWorktreeActive && activeBrowserTab?.id === bt.id ? undefined : 'none'
}}

View file

@ -13,19 +13,8 @@ vi.mock('../../store', () => ({
vi.mock('./TabGroupPanel', () => ({
default: (props: unknown) => ({ __mock: 'TabGroupPanel', props })
}))
vi.mock('./useTabDragSplit', () => ({
useTabDragSplit: () => ({
activeDrag: null,
collisionDetection: vi.fn(),
hoveredDropTarget: null,
onDragCancel: vi.fn(),
onDragEnd: vi.fn(),
onDragMove: vi.fn(),
onDragOver: vi.fn(),
onDragStart: vi.fn(),
sensors: []
})
vi.mock('./TabGroupDndContext', () => ({
default: ({ children }: { children: unknown }) => children
}))
import TabGroupSplitLayout from './TabGroupSplitLayout'
@ -39,8 +28,12 @@ describe('TabGroupSplitLayout', () => {
isWorktreeActive
})
const splitNodeElement = element.props.children.props.children
const tabGroupPanelElement = splitNodeElement.type(splitNodeElement.props)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const innerDiv = (element.type as any)(element.props) as React.ReactElement
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const splitNodeElement = (innerDiv.props as any).children
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tabGroupPanelElement = (splitNodeElement.type as any)(splitNodeElement.props)
return tabGroupPanelElement.props as {
groupId: string
worktreeId: string
@ -94,8 +87,12 @@ describe('TabGroupSplitLayout', () => {
isWorktreeActive: true
})
const splitNodeElement = element.props.children.props.children
const rootElement = splitNodeElement.type(splitNodeElement.props)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const innerDiv = (element.type as any)(element.props) as React.ReactElement
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const splitNodeElement = (innerDiv.props as any).children
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rootElement = (splitNodeElement.type as any)(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 {

View file

@ -1,9 +1,8 @@
import { useCallback, useState } from 'react'
import { DndContext } from '@dnd-kit/core'
import type { TabGroupLayoutNode } from '../../../../shared/types'
import { useAppStore } from '../../store'
import TabGroupPanel from './TabGroupPanel'
import { type TabDropZone, useTabDragSplit } from './useTabDragSplit'
import TabGroupDndContext from './TabGroupDndContext'
const MIN_RATIO = 0.15
const MAX_RATIO = 0.85
@ -90,10 +89,7 @@ function SplitNode({
hasSplitGroups,
touchesTopEdge,
touchesRightEdge,
touchesLeftEdge,
isTabDragActive,
activeDropGroupId,
activeDropZone
touchesLeftEdge
}: {
node: TabGroupLayoutNode
nodePath: string
@ -104,9 +100,6 @@ function SplitNode({
touchesTopEdge: boolean
touchesRightEdge: boolean
touchesLeftEdge: boolean
isTabDragActive: boolean
activeDropGroupId: string | null
activeDropZone: TabDropZone | null
}): React.JSX.Element {
const setTabGroupSplitRatio = useAppStore((state) => state.setTabGroupSplitRatio)
@ -115,6 +108,9 @@ function SplitNode({
<TabGroupPanel
groupId={node.groupId}
worktreeId={worktreeId}
// Why: preserved from main (PR #777). TabGroupPanel still needs
// isWorktreeActive to gate TerminalPane.isVisible and BrowserPane so
// hidden worktrees do not leak WebGL contexts / WebContents.
isWorktreeActive={isWorktreeActive}
// Why: hidden worktrees stay mounted so their PTYs and split layouts
// survive worktree switches, but only the visible worktree may own the
@ -124,8 +120,6 @@ function SplitNode({
hasSplitGroups={hasSplitGroups}
reserveClosedExplorerToggleSpace={touchesTopEdge && touchesRightEdge}
reserveCollapsedSidebarHeaderSpace={touchesTopEdge && touchesLeftEdge}
isTabDragActive={isTabDragActive}
activeDropZone={activeDropGroupId === node.groupId ? activeDropZone : null}
/>
)
}
@ -149,9 +143,6 @@ function SplitNode({
touchesTopEdge={touchesTopEdge}
touchesRightEdge={isHorizontal ? false : touchesRightEdge}
touchesLeftEdge={touchesLeftEdge}
isTabDragActive={isTabDragActive}
activeDropGroupId={activeDropGroupId}
activeDropZone={activeDropZone}
/>
</div>
<ResizeHandle
@ -169,9 +160,6 @@ function SplitNode({
touchesTopEdge={isHorizontal ? touchesTopEdge : false}
touchesRightEdge={touchesRightEdge}
touchesLeftEdge={isHorizontal ? false : touchesLeftEdge}
isTabDragActive={isTabDragActive}
activeDropGroupId={activeDropGroupId}
activeDropZone={activeDropZone}
/>
</div>
</div>
@ -189,18 +177,8 @@ export default function TabGroupSplitLayout({
focusedGroupId?: string
isWorktreeActive: boolean
}): React.JSX.Element {
const dragSplit = useTabDragSplit({ worktreeId, enabled: isWorktreeActive })
return (
<DndContext
sensors={dragSplit.sensors}
collisionDetection={dragSplit.collisionDetection}
onDragStart={dragSplit.onDragStart}
onDragMove={dragSplit.onDragMove}
onDragOver={dragSplit.onDragOver}
onDragEnd={dragSplit.onDragEnd}
onDragCancel={dragSplit.onDragCancel}
>
<TabGroupDndContext worktreeId={worktreeId}>
<div className="flex flex-1 min-w-0 min-h-0 overflow-hidden">
<SplitNode
node={layout}
@ -212,11 +190,8 @@ export default function TabGroupSplitLayout({
touchesTopEdge={true}
touchesRightEdge={true}
touchesLeftEdge={true}
isTabDragActive={dragSplit.activeDrag !== null}
activeDropGroupId={dragSplit.hoveredDropTarget?.groupId ?? null}
activeDropZone={dragSplit.hoveredDropTarget?.zone ?? null}
/>
</div>
</DndContext>
</TabGroupDndContext>
)
}

View file

@ -0,0 +1,122 @@
import type { ClientRect, Collision, CollisionDetection, UniqueIdentifier } from '@dnd-kit/core'
import type { Tab } from '../../../../shared/types'
import { buildSharedSortableId } from './CrossGroupDragContext'
export type GroupVisibleTab = {
unifiedTabId: string
visibleId: string
}
export function pointInRect(
point: { x: number; y: number },
rect?: ClientRect | null
): boolean {
if (!rect) {
return false
}
return (
point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom
)
}
export function makeCollision(
containerId: string,
args: Parameters<CollisionDetection>[0]
): Collision[] {
const container = args.droppableContainers.find(
(candidate) => String(candidate.id) === containerId
)
return container ? [{ id: container.id, data: { droppableContainer: container, value: 1 } }] : []
}
export function toVisibleTabId(tab: Tab): string {
return tab.contentType === 'terminal' || tab.contentType === 'browser' ? tab.entityId : tab.id
}
export function computeInsertionIndex(
orderedTabs: GroupVisibleTab[],
overVisibleId: string,
pointerX: number | null,
hoveredRect?: ClientRect | null
): number | null {
const hoveredIndex = orderedTabs.findIndex((item) => item.visibleId === overVisibleId)
if (hoveredIndex === -1) {
return null
}
if (pointerX == null || !hoveredRect) {
return hoveredIndex
}
const midpoint = hoveredRect.left + hoveredRect.width / 2
return hoveredIndex + (pointerX >= midpoint ? 1 : 0)
}
// Why: when the pointer is in the tab-bar container but not directly over a
// sortable item (e.g. empty space after the last tab), we still need a usable
// insertion index. Picks the closest tab by horizontal distance, then uses
// pointer-vs-midpoint to decide whether to insert before or after it.
export function computeInsertionFromPointer(
orderedTabs: GroupVisibleTab[],
pointer: { x: number; y: number } | null,
rects: Map<UniqueIdentifier, ClientRect>,
groupId: string,
activeVisibleId?: string
): number | null {
if (!pointer || orderedTabs.length === 0) {
return null
}
let bestIndex = -1
let bestDistance = Infinity
for (let i = 0; i < orderedTabs.length; i++) {
const item = orderedTabs[i]!
// Why: skip the dragged tab's own rect so nearest-neighbor picks an actual
// target instead of the stale self-rect (which sits at the original position
// while the overlay floats under the cursor).
if (activeVisibleId && item.visibleId === activeVisibleId) {
continue
}
const sortableId = buildSharedSortableId(groupId, item.visibleId)
const rect = rects.get(sortableId)
if (!rect) {
continue
}
const midpoint = rect.left + rect.width / 2
const distance = Math.abs(pointer.x - midpoint)
if (distance < bestDistance) {
bestDistance = distance
bestIndex = i
}
}
if (bestIndex === -1) {
return orderedTabs.length
}
const sortableId = buildSharedSortableId(groupId, orderedTabs[bestIndex]!.visibleId)
const rect = rects.get(sortableId)
if (!rect) {
return bestIndex
}
const midpoint = rect.left + rect.width / 2
return bestIndex + (pointer.x >= midpoint ? 1 : 0)
}
export function applyInsertionIndex(
currentOrder: string[],
tabId: string,
insertionIndex: number
): string[] | null {
const currentIndex = currentOrder.indexOf(tabId)
if (currentIndex === -1) {
return null
}
const nextOrder = currentOrder.filter((candidate) => candidate !== tabId)
const adjustedInsertionIndex = currentIndex < insertionIndex ? insertionIndex - 1 : insertionIndex
const clampedInsertionIndex = Math.max(0, Math.min(adjustedInsertionIndex, nextOrder.length))
nextOrder.splice(clampedInsertionIndex, 0, tabId)
return nextOrder.every((candidate, index) => candidate === currentOrder[index]) ? null : nextOrder
}

View file

@ -1,20 +1,5 @@
import { useCallback, useMemo, useState } from 'react'
import {
closestCenter,
pointerWithin,
PointerSensor,
type CollisionDetection,
type DragEndEvent,
type DragMoveEvent,
type DragOverEvent,
type DragStartEvent,
type UniqueIdentifier,
useSensor,
useSensors
} from '@dnd-kit/core'
import { arrayMove } from '@dnd-kit/sortable'
import type { UniqueIdentifier } from '@dnd-kit/core'
import type { TabGroup } from '../../../../shared/types'
import { useAppStore } from '../../store'
import type { TabSplitDirection } from '../../store/slices/tabs'
export type TabDropZone = 'center' | TabSplitDirection
@ -70,39 +55,7 @@ export function canDropTabIntoPaneBody({
return true
}
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 {
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
}
}
function resolveDropZone(
export function resolveDropZone(
rect: { left: number; top: number; width: number; height: number },
point: { x: number; y: number }
): TabDropZone {
@ -133,241 +86,14 @@ function resolveDropZone(
return localY < rect.height / 2 ? 'up' : 'down'
}
const collisionDetection: CollisionDetection = (args) => {
const pointerCollisions = pointerWithin(args)
return pointerCollisions.length > 0 ? pointerCollisions : closestCenter(args)
}
const PANE_BODY_PREFIX = 'tab-group-pane-body:'
export function getTabPaneBodyDroppableId(groupId: string): UniqueIdentifier {
return `tab-group-pane-body:${groupId}`
return `${PANE_BODY_PREFIX}${groupId}`
}
export function useTabDragSplit({
worktreeId,
enabled = true
}: {
worktreeId: string
/** When false (e.g. for hidden worktrees), returns empty sensors so no
* DndContext pointer listeners are registered on the document. Multiple
* simultaneous DndContext instances with active sensors can interfere. */
enabled?: boolean
}): {
activeDrag: TabDragItemData | null
collisionDetection: CollisionDetection
hoveredDropTarget: HoveredTabDropTarget | null
onDragCancel: () => void
onDragEnd: (event: DragEndEvent) => void
onDragMove: (event: DragMoveEvent) => void
onDragOver: (event: DragOverEvent) => void
onDragStart: (event: DragStartEvent) => void
sensors: ReturnType<typeof useSensors>
} {
const reorderUnifiedTabs = useAppStore((state) => state.reorderUnifiedTabs)
const dropUnifiedTab = useAppStore((state) => state.dropUnifiedTab)
const [activeDrag, setActiveDrag] = useState<TabDragItemData | null>(null)
const [hoveredDropTarget, setHoveredDropTarget] = useState<HoveredTabDropTarget | null>(null)
const pointerSensor = useSensor(PointerSensor, {
activationConstraint: { distance: 5 }
})
const activeSensors = useSensors(pointerSensor)
const emptySensors = useSensors()
// Why: hidden worktrees stay mounted so their PTYs survive worktree
// switches, but their DndContext should not register pointer listeners
// on the document. Multiple active DndContext instances can interfere
// with each other.
const sensors = enabled ? activeSensors : emptySensors
const clearDragState = useCallback(() => {
setActiveDrag(null)
setHoveredDropTarget(null)
}, [])
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))
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 }
})
},
[worktreeId]
)
const onDragStart = useCallback(
(event: DragStartEvent) => {
const dragData = event.active.data.current
if (!isTabDragData(dragData) || dragData.worktreeId !== worktreeId) {
clearDragState()
return
}
setActiveDrag(dragData)
},
[clearDragState, worktreeId]
)
const onDragMove = useCallback(
(event: DragMoveEvent) => {
updateHoveredPane(event)
},
[updateHoveredPane]
)
const onDragOver = useCallback(
(event: DragOverEvent) => {
updateHoveredPane(event)
},
[updateHoveredPane]
)
const onDragEnd = useCallback(
(event: DragEndEvent) => {
const activeData = event.active.data.current
const overData = event.over?.data.current
if (!event.over || !isTabDragData(activeData) || activeData.worktreeId !== worktreeId) {
clearDragState()
return
}
if (isTabDragData(overData)) {
if (activeData.unifiedTabId === overData.unifiedTabId) {
clearDragState()
return
}
const state = useAppStore.getState()
const groups = state.groupsByWorktree[worktreeId] ?? []
const targetGroup = groups.find((group) => group.id === overData.groupId)
if (!targetGroup) {
clearDragState()
return
}
if (activeData.groupId === overData.groupId) {
const oldIndex = targetGroup.tabOrder.indexOf(activeData.unifiedTabId)
const newIndex = targetGroup.tabOrder.indexOf(overData.unifiedTabId)
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
reorderUnifiedTabs(
overData.groupId,
arrayMove(targetGroup.tabOrder, oldIndex, newIndex)
)
}
} else {
const targetIndex = targetGroup.tabOrder.indexOf(overData.unifiedTabId)
dropUnifiedTab(activeData.unifiedTabId, {
groupId: overData.groupId,
index: targetIndex === -1 ? targetGroup.tabOrder.length : targetIndex
})
}
clearDragState()
return
}
if (isPaneDropData(overData)) {
if (
!canDropTabIntoPaneBody({
activeDrag: activeData,
groupsByWorktree: useAppStore.getState().groupsByWorktree,
overGroupId: overData.groupId,
worktreeId
})
) {
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) {
dropUnifiedTab(activeData.unifiedTabId, {
groupId: overData.groupId,
splitDirection: zone === 'center' ? undefined : zone
})
}
}
}
clearDragState()
},
[clearDragState, dropUnifiedTab, reorderUnifiedTabs, worktreeId]
)
// Why: dnd-kit fires onDragCancel (not onDragEnd) when the user presses
// Escape or the drag is otherwise aborted. Without this handler the
// activeDrag and hoveredDropTarget state would remain stale, leaving the
// drop overlay visible indefinitely.
const onDragCancel = useCallback(() => {
clearDragState()
}, [clearDragState])
return useMemo(
() => ({
activeDrag,
collisionDetection,
hoveredDropTarget,
onDragCancel,
onDragEnd,
onDragMove,
onDragOver,
onDragStart,
sensors
}),
[
activeDrag,
hoveredDropTarget,
onDragCancel,
onDragEnd,
onDragMove,
onDragOver,
onDragStart,
sensors
]
)
export function parsePaneBodyDroppableId(id: string): { groupId: string } | null {
return id.startsWith(PANE_BODY_PREFIX)
? { groupId: id.slice(PANE_BODY_PREFIX.length) }
: null
}

View file

@ -468,6 +468,136 @@ describe('TabsSlice', () => {
).toEqual([tab.id])
})
it('updates active editor surface state when moving with activation', () => {
store.setState({
activeWorktreeId: WT,
openFiles: [
{
id: 'file-a.ts',
filePath: '/tmp/feature/file-a.ts',
relativePath: 'file-a.ts',
worktreeId: WT,
language: 'typescript',
isDirty: false,
isPreview: false,
mode: 'edit'
}
]
})
const tab = store.getState().createUnifiedTab(WT, 'editor', {
id: 'file-a.ts',
entityId: 'file-a.ts',
label: 'file-a.ts'
})
const sourceGroupId = store.getState().groupsByWorktree[WT][0].id
const targetGroupId = store.getState().createEmptySplitGroup(WT, sourceGroupId, 'right')
store.getState().moveUnifiedTabToGroup(tab.id, targetGroupId!, { activate: true })
const state = store.getState()
expect(state.activeGroupIdByWorktree[WT]).toBe(targetGroupId)
expect(state.activeFileIdByWorktree[WT]).toBe('file-a.ts')
expect(state.activeTabTypeByWorktree[WT]).toBe('editor')
expect(state.activeFileId).toBe('file-a.ts')
expect(state.activeTabType).toBe('editor')
})
it('updates active browser surface state when moving with activation', () => {
store.setState({ activeWorktreeId: WT })
const browserTab = store.getState().createBrowserTab(WT, 'https://example.com', {
title: 'Example',
activate: true
})
const unifiedTab = store.getState().createUnifiedTab(WT, 'browser', {
entityId: browserTab.id,
label: 'Example'
})
const sourceGroupId = store.getState().groupsByWorktree[WT][0].id
const targetGroupId = store.getState().createEmptySplitGroup(WT, sourceGroupId, 'right')
store.getState().moveUnifiedTabToGroup(unifiedTab.id, targetGroupId!, { activate: true })
const state = store.getState()
expect(state.activeGroupIdByWorktree[WT]).toBe(targetGroupId)
expect(state.activeBrowserTabIdByWorktree[WT]).toBe(browserTab.id)
expect(state.activeTabTypeByWorktree[WT]).toBe('browser')
expect(state.activeBrowserTabId).toBe(browserTab.id)
expect(state.activeTabType).toBe('browser')
})
it('updates active terminal surface state when moving with activation', () => {
store.setState({ activeWorktreeId: WT })
const runtimeTerminal = {
id: 'term-1',
ptyId: 'pty-1',
worktreeId: WT,
title: 'Terminal 1',
sortOrder: 0,
createdAt: Date.now(),
customTitle: null,
color: null
}
store.setState({ tabsByWorktree: { [WT]: [runtimeTerminal] } })
const terminalTab = store.getState().createUnifiedTab(WT, 'terminal', {
entityId: runtimeTerminal.id,
label: 'Terminal 1'
})
const sourceGroupId = store.getState().groupsByWorktree[WT][0].id
const targetGroupId = store.getState().createEmptySplitGroup(WT, sourceGroupId, 'right')
store.getState().moveUnifiedTabToGroup(terminalTab.id, targetGroupId!, { activate: true })
const state = store.getState()
expect(state.activeGroupIdByWorktree[WT]).toBe(targetGroupId)
expect(state.activeTabIdByWorktree[WT]).toBe(runtimeTerminal.id)
expect(state.activeTabTypeByWorktree[WT]).toBe('terminal')
expect(state.activeTabId).toBe(runtimeTerminal.id)
expect(state.activeTabType).toBe('terminal')
})
it('does not overwrite global surface state when activating in a non-active worktree', () => {
// When the moved tab's worktree is not the active worktree, per-worktree
// state should still update but global surface state must remain untouched.
// This prevents a background worktree move from hijacking the user's
// current focus.
const runtimeTerminal = {
id: 'term-1',
ptyId: 'pty-1',
worktreeId: WT,
title: 'Terminal 1',
sortOrder: 0,
createdAt: Date.now(),
customTitle: null,
color: null
}
store.setState({
activeWorktreeId: 'other-worktree',
tabsByWorktree: { [WT]: [runtimeTerminal] }
})
const terminalTab = store.getState().createUnifiedTab(WT, 'terminal', {
entityId: runtimeTerminal.id,
label: 'Terminal 1'
})
const sourceGroupId = store.getState().groupsByWorktree[WT][0].id
const targetGroupId = store.getState().createEmptySplitGroup(WT, sourceGroupId, 'right')
// Capture global surface state before the move
const beforeState = store.getState()
const prevActiveTabId = beforeState.activeTabId
const prevActiveTabType = beforeState.activeTabType
const prevActiveFileId = beforeState.activeFileId
store.getState().moveUnifiedTabToGroup(terminalTab.id, targetGroupId!, { activate: true })
const state = store.getState()
// Per-worktree state should still update
expect(state.activeGroupIdByWorktree[WT]).toBe(targetGroupId)
// Global surface state should NOT have changed
expect(state.activeTabId).toBe(prevActiveTabId)
expect(state.activeTabType).toBe(prevActiveTabType)
expect(state.activeFileId).toBe(prevActiveFileId)
})
it('copies a unified tab into another group', () => {
const tab = store.getState().createUnifiedTab(WT, 'editor', {
id: 'file-a.ts',

View file

@ -941,7 +941,8 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
}
return group
})
return {
const nextState = {
...state,
unifiedTabsByWorktree: {
...state.unifiedTabsByWorktree,
[worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) =>
@ -954,6 +955,20 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
},
activeGroupIdByWorktree: nextActiveGroupIdByWorktree
}
return {
unifiedTabsByWorktree: nextState.unifiedTabsByWorktree,
groupsByWorktree: nextState.groupsByWorktree,
activeGroupIdByWorktree: nextState.activeGroupIdByWorktree,
// Only update the active surface when this worktree is the active one,
// matching the guard pattern used by other buildActiveSurfacePatch callers.
// This must run even when opts.activate is false — moving the currently-
// active tab out of the active group changes the source group's activeTabId
// via pickNextActiveTab, so the workspace-level active surface can become
// stale otherwise.
...(state.activeWorktreeId === worktreeId
? buildActiveSurfacePatch(nextState, worktreeId, targetGroupId)
: {})
}
})
return moved
},