refactor: extract tab group workspace model and host (#664)

This commit is contained in:
Brennan Benson
2026-04-14 21:39:45 -07:00
committed by GitHub
parent a7399cf11f
commit 66456acf8d
7 changed files with 536 additions and 412 deletions
@@ -0,0 +1,85 @@
import { RefreshCw } from 'lucide-react'
import { useMemo } from 'react'
import { useAppStore } from '../store'
export function collectStaleWorktreePtyIds({
tabsByWorktree,
ptyIdsByTabId,
codexRestartNoticeByPtyId,
worktreeId
}: {
tabsByWorktree: Record<string, { id: string }[]>
ptyIdsByTabId: Record<string, string[]>
codexRestartNoticeByPtyId: Record<string, unknown>
worktreeId: string
}): string[] {
return (tabsByWorktree[worktreeId] ?? []).flatMap((tab) =>
(ptyIdsByTabId[tab.id] ?? []).filter((ptyId) => Boolean(codexRestartNoticeByPtyId[ptyId]))
)
}
export function dismissStaleWorktreePtyIds(
staleWorktreePtyIds: string[],
clearCodexRestartNotice: (ptyId: string) => void
): void {
// Why: restart notices are stored per PTY, but the workspace host presents
// one shared prompt. Clearing all matching PTY notices keeps every pane in
// that worktree consistent with the dismissal.
for (const ptyId of staleWorktreePtyIds) {
clearCodexRestartNotice(ptyId)
}
}
export default function CodexRestartChip({
worktreeId
}: {
worktreeId: string
}): React.JSX.Element | null {
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
const codexRestartNoticeByPtyId = useAppStore((s) => s.codexRestartNoticeByPtyId)
const queueCodexPaneRestarts = useAppStore((s) => s.queueCodexPaneRestarts)
const clearCodexRestartNotice = useAppStore((s) => s.clearCodexRestartNotice)
const staleWorktreePtyIds = useMemo(
() =>
collectStaleWorktreePtyIds({
tabsByWorktree,
ptyIdsByTabId,
codexRestartNoticeByPtyId,
worktreeId
}),
[codexRestartNoticeByPtyId, ptyIdsByTabId, tabsByWorktree, worktreeId]
)
if (staleWorktreePtyIds.length === 0) {
return null
}
return (
<div className="pointer-events-none absolute right-3 top-3 z-20">
<div className="pointer-events-auto flex items-center gap-2 rounded-lg border border-border/80 bg-popover/95 px-2 py-1.5 shadow-lg backdrop-blur-sm">
<span className="text-[11px] text-muted-foreground">
Codex is using the previous account
</span>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => queueCodexPaneRestarts(staleWorktreePtyIds)}
className="inline-flex items-center gap-1.5 rounded-md bg-foreground px-2 py-1 text-[11px] font-medium text-background transition-colors hover:opacity-90"
>
<RefreshCw className="size-3" />
Restart
</button>
<button
type="button"
onClick={() => dismissStaleWorktreePtyIds(staleWorktreePtyIds, clearCodexRestartNotice)}
className="rounded-md px-1.5 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
Dismiss
</button>
</div>
</div>
</div>
)
}
+12 -153
View File
@@ -15,7 +15,6 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { RefreshCw } from 'lucide-react'
import TabBar from './tab-bar/TabBar'
import TerminalPane from './terminal-pane/TerminalPane'
import {
@@ -27,8 +26,9 @@ import { isUpdaterQuitAndInstallInProgress } from '@/lib/updater-beforeunload'
import EditorAutosaveController from './editor/EditorAutosaveController'
import BrowserPane, { destroyPersistentWebview } from './browser-pane/BrowserPane'
import { reconcileTabOrder } from './tab-bar/reconcile-order'
import TabGroupSplitLayout from './tab-group/TabGroupSplitLayout'
import TabGroupWorkspaceHost from './tab-group/TabGroupWorkspaceHost'
import { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal'
import CodexRestartChip from './CodexRestartChip'
const EditorPanel = lazy(() => import('./editor/EditorPanel'))
// Why: the split-group ownership path lands before the rollout switch so we
@@ -49,10 +49,6 @@ function Terminal(): React.JSX.Element | null {
const setTabCustomTitle = useAppStore((s) => s.setTabCustomTitle)
const setTabColor = useAppStore((s) => s.setTabColor)
const consumeSuppressedPtyExit = useAppStore((s) => s.consumeSuppressedPtyExit)
const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
const codexRestartNoticeByPtyId = useAppStore((s) => s.codexRestartNoticeByPtyId)
const queueCodexPaneRestarts = useAppStore((s) => s.queueCodexPaneRestarts)
const clearCodexRestartNotice = useAppStore((s) => s.clearCodexRestartNotice)
const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId)
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
const openFiles = useAppStore((s) => s.openFiles)
@@ -69,10 +65,6 @@ function Terminal(): React.JSX.Element | null {
const createBrowserTab = useAppStore((s) => s.createBrowserTab)
const closeBrowserTab = useAppStore((s) => s.closeBrowserTab)
const setActiveBrowserTab = useAppStore((s) => s.setActiveBrowserTab)
const groupsByWorktree = useAppStore((s) => s.groupsByWorktree)
const layoutByWorktree = useAppStore((s) => s.layoutByWorktree)
const activeGroupIdByWorktree = useAppStore((s) => s.activeGroupIdByWorktree)
const ensureWorktreeRootGroup = useAppStore((s) => s.ensureWorktreeRootGroup)
const reconcileWorktreeTabModel = useAppStore((s) => s.reconcileWorktreeTabModel)
const markFileDirty = useAppStore((s) => s.markFileDirty)
@@ -91,16 +83,6 @@ function Terminal(): React.JSX.Element | null {
setTitlebarTabsTarget(document.getElementById('titlebar-tabs'))
}, [])
useEffect(() => {
if (!activeWorktreeId) {
return
}
// Why: worktree restore now depends on the tab-group model even before the
// split-group UI is exposed. Ensure every active worktree has a root group
// so terminal-first fallback logic can attach new terminals to a real owner.
ensureWorktreeRootGroup(activeWorktreeId)
}, [activeWorktreeId, ensureWorktreeRootGroup])
// Filter editor files to only show those belonging to the active worktree
const worktreeFiles = activeWorktreeId
? openFiles.filter((f) => f.worktreeId === activeWorktreeId)
@@ -108,90 +90,6 @@ function Terminal(): React.JSX.Element | null {
const worktreeBrowserTabs = activeWorktreeId
? (browserTabsByWorktree[activeWorktreeId] ?? [])
: []
const getEffectiveLayoutForWorktree = useCallback(
(worktreeId: string) => {
const layout = layoutByWorktree[worktreeId]
if (layout) {
return layout
}
const groups = groupsByWorktree[worktreeId] ?? []
const fallbackGroupId = activeGroupIdByWorktree[worktreeId] ?? groups[0]?.id ?? null
if (!fallbackGroupId) {
return undefined
}
return { type: 'leaf', groupId: fallbackGroupId } as const
},
[activeGroupIdByWorktree, groupsByWorktree, layoutByWorktree]
)
const effectiveActiveLayout = activeWorktreeId
? ENABLE_SPLIT_GROUPS
? getEffectiveLayoutForWorktree(activeWorktreeId)
: undefined
: undefined
const activeWorktree = activeWorktreeId
? (allWorktrees.find((worktree) => worktree.id === activeWorktreeId) ?? null)
: null
const activeTerminalTab = tabs.find((tab) => tab.id === activeTabId) ?? null
const activeEditorFile = worktreeFiles.find((file) => file.id === activeFileId) ?? null
const activeBrowserTab = worktreeBrowserTabs.find((tab) => tab.id === activeBrowserTabId) ?? null
const activeSurfaceLabel =
activeTabType === 'browser'
? (activeBrowserTab?.title ?? activeBrowserTab?.url ?? 'Browser')
: activeTabType === 'editor'
? (activeEditorFile?.relativePath ?? activeEditorFile?.filePath ?? 'Editor')
: (activeTerminalTab?.customTitle ?? activeTerminalTab?.title ?? 'Terminal')
const renderStaleCodexRestartChip = useCallback(
(worktreeId: string) => {
const worktreeTabs = tabsByWorktree[worktreeId] ?? []
const staleWorktreePtyIds = worktreeTabs.flatMap((tab) =>
(ptyIdsByTabId[tab.id] ?? []).filter((ptyId) => Boolean(codexRestartNoticeByPtyId[ptyId]))
)
if (staleWorktreePtyIds.length === 0) {
return null
}
// Why: split-group and legacy workspace rendering both represent the
// same worktree-level Codex session state. Keeping one shared chip here
// preserves the single-prompt UX across rollout paths instead of letting
// one branch silently lose the restart/dismiss affordance.
return (
<div className="pointer-events-none absolute right-3 top-3 z-20">
<div className="pointer-events-auto flex items-center gap-2 rounded-lg border border-border/80 bg-popover/95 px-2 py-1.5 shadow-lg backdrop-blur-sm">
<span className="text-[11px] text-muted-foreground">
Codex is using the previous account
</span>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => queueCodexPaneRestarts(staleWorktreePtyIds)}
className="inline-flex items-center gap-1.5 rounded-md bg-foreground px-2 py-1 text-[11px] font-medium text-background transition-colors hover:opacity-90"
>
<RefreshCw className="size-3" />
Restart
</button>
<button
type="button"
onClick={() => {
for (const ptyId of staleWorktreePtyIds) {
clearCodexRestartNotice(ptyId)
}
}}
className="rounded-md px-1.5 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
Dismiss
</button>
</div>
</div>
</div>
)
},
[
clearCodexRestartNotice,
codexRestartNoticeByPtyId,
ptyIdsByTabId,
queueCodexPaneRestarts,
tabsByWorktree
]
)
const activeWorktreeBrowserTabIdsKey = activeWorktreeId
? (browserTabsByWorktree[activeWorktreeId] ?? []).map((tab) => tab.id).join(',')
: ''
@@ -861,7 +759,7 @@ function Terminal(): React.JSX.Element | null {
inline like VS Code. The old titlebar portal stays only as a fallback
before the root-group layout has been established. */}
{activeWorktreeId &&
!effectiveActiveLayout &&
!ENABLE_SPLIT_GROUPS &&
titlebarTabsTarget &&
createPortal(
<TabBar
@@ -899,53 +797,14 @@ function Terminal(): React.JSX.Element | null {
titlebarTabsTarget
)}
{activeWorktreeId &&
effectiveActiveLayout &&
titlebarTabsTarget &&
createPortal(
<div className="flex h-full min-w-0 items-center px-3 text-xs text-muted-foreground">
{/* Why: split layouts can show several independent tab rows, so the
titlebar cannot host the real tabs without collapsing multiple
groups into one shared surface. A lightweight summary still uses
that otherwise empty strip and keeps the window chrome balanced. */}
<span className="truncate font-medium text-foreground/80">
{activeWorktree?.displayName ?? 'Workspace'}
</span>
<span className="px-2 text-border">/</span>
<span className="truncate">{activeSurfaceLabel}</span>
</div>,
titlebarTabsTarget
)}
{effectiveActiveLayout ? (
<div className="relative flex flex-1 min-w-0 min-h-0 overflow-hidden">
{allWorktrees
.filter((wt) => mountedWorktreeIdsRef.current.has(wt.id))
.map((worktree) => {
const layout = getEffectiveLayoutForWorktree(worktree.id)
if (!layout) {
return null
}
const isVisible = activeView !== 'settings' && worktree.id === activeWorktreeId
return (
<div
key={`tab-groups-${worktree.id}`}
className={isVisible ? 'absolute inset-0 flex' : 'absolute inset-0 hidden'}
aria-hidden={!isVisible}
>
{renderStaleCodexRestartChip(worktree.id)}
<TabGroupSplitLayout
layout={layout}
worktreeId={worktree.id}
focusedGroupId={activeGroupIdByWorktree[worktree.id]}
/>
</div>
)
})}
</div>
) : null}
{!effectiveActiveLayout && (
{activeWorktreeId && ENABLE_SPLIT_GROUPS && titlebarTabsTarget ? (
<TabGroupWorkspaceHost
activeView={activeView}
activeWorktreeId={activeWorktreeId}
mountedWorktreeIds={[...mountedWorktreeIdsRef.current]}
titlebarTabsTarget={titlebarTabsTarget}
/>
) : (
<>
{/* Why: split-group layouts render their own terminal/browser/editor
surfaces inside TabGroupPanel. Keeping the legacy workspace-level
@@ -979,7 +838,7 @@ function Terminal(): React.JSX.Element | null {
className={isVisible ? 'absolute inset-0' : 'absolute inset-0 hidden'}
aria-hidden={!isVisible}
>
{renderStaleCodexRestartChip(worktree.id)}
<CodexRestartChip worktreeId={worktree.id} />
{worktreeTabs.map((tab) => (
<TerminalPane
key={`${tab.id}-${tab.generation ?? 0}`}
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from 'vitest'
import { collectStaleWorktreePtyIds, dismissStaleWorktreePtyIds } from './CodexRestartChip'
describe('CodexRestartChip helpers', () => {
it('collects all stale PTY ids for tabs in a worktree', () => {
expect(
collectStaleWorktreePtyIds({
tabsByWorktree: {
wt1: [{ id: 'tab-1' }, { id: 'tab-2' }],
wt2: [{ id: 'tab-3' }]
},
ptyIdsByTabId: {
'tab-1': ['pty-1', 'pty-2'],
'tab-2': ['pty-3'],
'tab-3': ['pty-4']
},
codexRestartNoticeByPtyId: {
'pty-1': { previousAccountLabel: 'a', nextAccountLabel: 'b' },
'pty-3': { previousAccountLabel: 'a', nextAccountLabel: 'b' },
'pty-4': { previousAccountLabel: 'a', nextAccountLabel: 'b' }
},
worktreeId: 'wt1'
})
).toEqual(['pty-1', 'pty-3'])
})
it('returns an empty list when a worktree has no stale PTYs', () => {
expect(
collectStaleWorktreePtyIds({
tabsByWorktree: {
wt1: [{ id: 'tab-1' }]
},
ptyIdsByTabId: {
'tab-1': ['pty-1']
},
codexRestartNoticeByPtyId: {},
worktreeId: 'wt1'
})
).toEqual([])
})
it('dismisses every stale PTY notice in the worktree prompt', () => {
const clearCodexRestartNotice = vi.fn()
dismissStaleWorktreePtyIds(['pty-1', 'pty-3'], clearCodexRestartNotice)
expect(clearCodexRestartNotice).toHaveBeenNthCalledWith(1, 'pty-1')
expect(clearCodexRestartNotice).toHaveBeenNthCalledWith(2, 'pty-3')
expect(clearCodexRestartNotice).toHaveBeenCalledTimes(2)
})
})
@@ -218,7 +218,7 @@ export default function MonacoEditor({
}
}
},
[queueReveal, setupCopy, filePath, setEditorCursorLine]
[queueReveal, setupCopy, filePath, setEditorCursorLine, viewStateKey]
)
const handleChange = useCallback(
@@ -1,23 +1,12 @@
/* eslint-disable max-lines -- Why: group panels intentionally co-locate group-scoped tab chrome, activation/close handlers, and surface rendering so split groups cannot drift into a separate behavior path from the original root group. */
import { lazy, Suspense, useCallback, useMemo } from 'react'
import { lazy, Suspense } from 'react'
import { X } from 'lucide-react'
import { useShallow } from 'zustand/react/shallow'
import type { OpenFile } from '@/store/slices/editor'
import type { BrowserTab as BrowserTabState } from '../../../../shared/types'
import { useAppStore } from '../../store'
import TabBar from '../tab-bar/TabBar'
import TerminalPane from '../terminal-pane/TerminalPane'
import BrowserPane from '../browser-pane/BrowserPane'
import { useTabGroupController } from './useTabGroupController'
import { useTabGroupWorkspaceModel } from './useTabGroupWorkspaceModel'
const EditorPanel = lazy(() => import('../editor/EditorPanel'))
type GroupEditorItem = OpenFile & { tabId: string }
const EMPTY_GROUPS: readonly never[] = []
const EMPTY_TABS: readonly never[] = []
const EMPTY_RUNTIME_TERMINALS: readonly never[] = []
const EMPTY_BROWSER_TABS: readonly never[] = []
export default function TabGroupPanel({
groupId,
worktreeId,
@@ -29,169 +18,55 @@ export default function TabGroupPanel({
isFocused: boolean
hasSplitGroups: boolean
}): React.JSX.Element {
const worktreeGroups = useAppStore(
useShallow((state) => state.groupsByWorktree[worktreeId] ?? EMPTY_GROUPS)
)
const worktreeUnifiedTabs = useAppStore(
useShallow((state) => state.unifiedTabsByWorktree[worktreeId] ?? EMPTY_TABS)
)
const openFiles = useAppStore((state) => state.openFiles)
const worktree = useAppStore(
useShallow(
(state) =>
Object.values(state.worktreesByRepo)
.flat()
.find((candidate) => candidate.id === worktreeId) ?? null
)
)
const focusGroup = useAppStore((state) => state.focusGroup)
const setTabCustomTitle = useAppStore((state) => state.setTabCustomTitle)
const setTabColor = useAppStore((state) => state.setTabColor)
const consumeSuppressedPtyExit = useAppStore((state) => state.consumeSuppressedPtyExit)
const expandedPaneByTabId = useAppStore((state) => state.expandedPaneByTabId)
const browserTabsByWorktree = useAppStore((state) => state.browserTabsByWorktree)
const runtimeTerminalTabs = useAppStore(
(state) => state.tabsByWorktree[worktreeId] ?? EMPTY_RUNTIME_TERMINALS
)
const group = useMemo(
() => worktreeGroups.find((item) => item.id === groupId) ?? null,
[groupId, worktreeGroups]
)
const groupTabs = useMemo(
() => worktreeUnifiedTabs.filter((item) => item.groupId === groupId),
[groupId, worktreeUnifiedTabs]
)
const activeItemId = group?.activeTabId ?? null
const activeTab = groupTabs.find((item) => item.id === activeItemId) ?? null
const terminalTabs = useMemo(
() =>
groupTabs
.filter((item) => item.contentType === 'terminal')
.map((item) => ({
id: item.entityId,
ptyId: null,
worktreeId,
title: item.label,
customTitle: item.customLabel,
color: item.color,
sortOrder: item.sortOrder,
createdAt: item.createdAt
})),
[groupTabs, worktreeId]
)
const editorItems = useMemo<GroupEditorItem[]>(
() =>
groupTabs
.filter(
(item) =>
item.contentType === 'editor' ||
item.contentType === 'diff' ||
item.contentType === 'conflict-review'
)
.map((item) => {
const file = openFiles.find((candidate) => candidate.id === item.entityId)
return file ? { ...file, tabId: item.id } : null
})
.filter((item): item is GroupEditorItem => item !== null),
[groupTabs, openFiles]
)
const worktreeBrowserTabs = useMemo(
() => browserTabsByWorktree[worktreeId] ?? EMPTY_BROWSER_TABS,
[browserTabsByWorktree, worktreeId]
)
const browserItems = useMemo(
() =>
groupTabs
.filter((item) => item.contentType === 'browser')
.map((item) => {
const bt = worktreeBrowserTabs.find((candidate) => candidate.id === item.entityId)
return bt ?? null
})
.filter((item): item is BrowserTabState => item !== null),
[groupTabs, worktreeBrowserTabs]
)
const activeBrowserTab = useMemo(
() =>
activeTab?.contentType === 'browser'
? (worktreeBrowserTabs.find((bt) => bt.id === activeTab.entityId) ?? null)
: null,
[activeTab, worktreeBrowserTabs]
)
const runtimeTerminalTabById = useMemo(
() => new Map(runtimeTerminalTabs.map((tab) => [tab.id, tab])),
[runtimeTerminalTabs]
)
const controller = useTabGroupController({
groupId,
worktreeId,
group,
groupTabs,
const model = useTabGroupWorkspaceModel({ groupId, worktreeId })
const {
activeBrowserTab,
activeTab,
worktreeBrowserTabs
})
const handleTerminalClose = useCallback(
(terminalId: string) => {
const item = groupTabs.find(
(candidate) => candidate.entityId === terminalId && candidate.contentType === 'terminal'
)
if (item) {
controller.closeItem(item.id)
}
},
[controller, groupTabs]
)
const handleBrowserClose = useCallback(
(browserTabId: string) => {
const item = groupTabs.find(
(candidate) => candidate.entityId === browserTabId && candidate.contentType === 'browser'
)
if (item) {
controller.closeItem(item.id)
}
},
[controller, groupTabs]
)
browserItems,
commands,
editorItems,
runtimeTerminalTabById,
tabBarOrder,
terminalTabs,
worktreePath
} = model
const tabBar = (
<TabBar
tabs={terminalTabs}
activeTabId={activeTab?.contentType === 'terminal' ? activeTab.entityId : null}
worktreeId={worktreeId}
expandedPaneByTabId={expandedPaneByTabId}
onActivate={controller.activateTerminal}
onClose={handleTerminalClose}
onCloseOthers={(terminalId) => {
const item = groupTabs.find(
expandedPaneByTabId={model.expandedPaneByTabId}
onActivate={commands.activateTerminal}
onClose={(terminalId) => {
const item = model.groupTabs.find(
(candidate) => candidate.entityId === terminalId && candidate.contentType === 'terminal'
)
if (item) {
controller.closeOthers(item.id)
commands.closeItem(item.id)
}
}}
onCloseOthers={(terminalId) => {
const item = model.groupTabs.find(
(candidate) => candidate.entityId === terminalId && candidate.contentType === 'terminal'
)
if (item) {
commands.closeOthers(item.id)
}
}}
onCloseToRight={(terminalId) => {
const item = groupTabs.find(
const item = model.groupTabs.find(
(candidate) => candidate.entityId === terminalId && candidate.contentType === 'terminal'
)
if (item) {
controller.closeToRight(item.id)
commands.closeToRight(item.id)
}
}}
onReorder={(_, order) => controller.reorderTabBar(order)}
onNewTerminalTab={controller.newTerminalTab}
onNewBrowserTab={controller.newBrowserTab}
onSetCustomTitle={setTabCustomTitle}
onSetTabColor={setTabColor}
onReorder={(_, order) => commands.reorderTabBar(order)}
onNewTerminalTab={commands.newTerminalTab}
onNewBrowserTab={commands.newBrowserTab}
onSetCustomTitle={commands.setTabCustomTitle}
onSetTabColor={commands.setTabColor}
onTogglePaneExpand={() => {}}
editorFiles={editorItems}
browserTabs={browserItems}
@@ -208,23 +83,30 @@ export default function TabGroupPanel({
? 'browser'
: 'editor'
}
onActivateFile={controller.activateEditor}
onCloseFile={controller.closeItem}
onActivateBrowserTab={controller.activateBrowser}
onCloseBrowserTab={handleBrowserClose}
onCloseAllFiles={controller.closeAllEditorTabsInGroup}
onActivateFile={commands.activateEditor}
onCloseFile={commands.closeItem}
onActivateBrowserTab={commands.activateBrowser}
onCloseBrowserTab={(browserTabId) => {
const item = model.groupTabs.find(
(candidate) => candidate.entityId === browserTabId && candidate.contentType === 'browser'
)
if (item) {
commands.closeItem(item.id)
}
}}
onCloseAllFiles={commands.closeAllEditorTabsInGroup}
onPinFile={(_fileId, tabId) => {
if (!tabId) {
return
}
const item = groupTabs.find((candidate) => candidate.id === tabId)
const item = model.groupTabs.find((candidate) => candidate.id === tabId)
if (!item) {
return
}
controller.pinFile(item.entityId, item.id)
commands.pinFile(item.entityId, item.id)
}}
tabBarOrder={controller.tabBarOrder}
onCreateSplitGroup={controller.createSplitGroup}
tabBarOrder={tabBarOrder}
onCreateSplitGroup={commands.createSplitGroup}
/>
)
@@ -235,7 +117,7 @@ export default function TabGroupPanel({
? ` group/tab-group border ${isFocused ? 'border-accent' : 'border-border'}`
: ''
}`}
onPointerDown={() => focusGroup(worktreeId, groupId)}
onPointerDown={commands.focusGroup}
>
{/* Why: every split group must keep its own real tab row because the app
can show multiple groups at once, while the window titlebar only has
@@ -251,7 +133,7 @@ export default function TabGroupPanel({
title="Close Group"
onClick={(event) => {
event.stopPropagation()
controller.closeGroup()
commands.closeGroup()
}}
className="mx-1 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"
>
@@ -262,14 +144,14 @@ export default function TabGroupPanel({
</div>
<div className="relative flex-1 min-h-0 overflow-hidden">
{groupTabs
{model.groupTabs
.filter((item) => item.contentType === 'terminal')
.map((item) => (
<TerminalPane
key={`${item.entityId}-${runtimeTerminalTabById.get(item.entityId)?.generation ?? 0}`}
tabId={item.entityId}
worktreeId={worktreeId}
cwd={worktree?.path}
cwd={worktreePath}
isActive={
isFocused && activeTab?.id === item.id && activeTab.contentType === 'terminal'
}
@@ -279,12 +161,12 @@ export default function TabGroupPanel({
// input. isVisible controls rendering; isActive controls focus.
isVisible={activeTab?.id === item.id && activeTab.contentType === 'terminal'}
onPtyExit={(ptyId) => {
if (consumeSuppressedPtyExit(ptyId)) {
if (commands.consumeSuppressedPtyExit(ptyId)) {
return
}
controller.closeItem(item.id)
commands.closeItem(item.id)
}}
onCloseTab={() => controller.closeItem(item.id)}
onCloseTab={() => commands.closeItem(item.id)}
/>
))}
@@ -0,0 +1,134 @@
import { createPortal } from 'react-dom'
import { useCallback, useEffect, useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '../../store'
import CodexRestartChip from '../CodexRestartChip'
import TabGroupSplitLayout from './TabGroupSplitLayout'
export default function TabGroupWorkspaceHost({
activeView,
activeWorktreeId,
mountedWorktreeIds,
titlebarTabsTarget
}: {
activeView: string
activeWorktreeId: string
mountedWorktreeIds: string[]
titlebarTabsTarget: HTMLElement
}): React.JSX.Element | null {
const {
activeBrowserTabId,
activeFileId,
activeGroupIdByWorktree,
activeTabId,
activeTabType,
browserTabsByWorktree,
groupsByWorktree,
layoutByWorktree,
openFiles,
worktreesByRepo
} = useAppStore(
useShallow((state) => ({
activeBrowserTabId: state.activeBrowserTabId,
activeFileId: state.activeFileId,
activeGroupIdByWorktree: state.activeGroupIdByWorktree,
activeTabId: state.activeTabId,
activeTabType: state.activeTabType,
browserTabsByWorktree: state.browserTabsByWorktree,
groupsByWorktree: state.groupsByWorktree,
layoutByWorktree: state.layoutByWorktree,
openFiles: state.openFiles,
worktreesByRepo: state.worktreesByRepo
}))
)
const ensureWorktreeRootGroup = useAppStore((state) => state.ensureWorktreeRootGroup)
const tabsByWorktree = useAppStore((state) => state.tabsByWorktree)
useEffect(() => {
// Why: the split host depends on the group model being present even when the
// worktree has only legacy terminal tabs. Keep the bootstrap here so the
// terminal host only decides which surface path to mount.
ensureWorktreeRootGroup(activeWorktreeId)
}, [activeWorktreeId, ensureWorktreeRootGroup])
const allWorktrees = useMemo(() => Object.values(worktreesByRepo).flat(), [worktreesByRepo])
const worktreeFiles = openFiles.filter((f) => f.worktreeId === activeWorktreeId)
const worktreeBrowserTabs = browserTabsByWorktree[activeWorktreeId] ?? []
const activeWorktree = allWorktrees.find((worktree) => worktree.id === activeWorktreeId) ?? null
const activeTerminalTab = (tabsByWorktree[activeWorktreeId] ?? []).find(
(tab) => tab.id === activeTabId
)
const activeEditorFile = worktreeFiles.find((file) => file.id === activeFileId) ?? null
const activeBrowserTab = worktreeBrowserTabs.find((tab) => tab.id === activeBrowserTabId) ?? null
const activeSurfaceLabel =
activeTabType === 'browser'
? (activeBrowserTab?.title ?? activeBrowserTab?.url ?? 'Browser')
: activeTabType === 'editor'
? (activeEditorFile?.relativePath ?? activeEditorFile?.filePath ?? 'Editor')
: (activeTerminalTab?.customTitle ?? activeTerminalTab?.title ?? 'Terminal')
const getEffectiveLayoutForWorktree = useCallback(
(worktreeId: string) => {
const layout = layoutByWorktree[worktreeId]
if (layout) {
return layout
}
const groups = groupsByWorktree[worktreeId] ?? []
const fallbackGroupId = activeGroupIdByWorktree[worktreeId] ?? groups[0]?.id ?? null
if (!fallbackGroupId) {
return undefined
}
return { type: 'leaf', groupId: fallbackGroupId } as const
},
[activeGroupIdByWorktree, groupsByWorktree, layoutByWorktree]
)
const effectiveActiveLayout = getEffectiveLayoutForWorktree(activeWorktreeId)
if (!effectiveActiveLayout) {
return null
}
return (
<>
{createPortal(
<div className="flex h-full min-w-0 items-center px-3 text-xs text-muted-foreground">
{/* Why: split layouts render a real tab row per group, so the titlebar
should only show lightweight workspace context instead of trying to
own tab selection for multiple groups at once. */}
<span className="truncate font-medium text-foreground/80">
{activeWorktree?.displayName ?? 'Workspace'}
</span>
<span className="px-2 text-border">/</span>
<span className="truncate">{activeSurfaceLabel}</span>
</div>,
titlebarTabsTarget
)}
<div className="relative flex flex-1 min-w-0 min-h-0 overflow-hidden">
{allWorktrees
.filter((worktree) => mountedWorktreeIds.includes(worktree.id))
.map((worktree) => {
const layout = getEffectiveLayoutForWorktree(worktree.id)
if (!layout) {
return null
}
const isVisible = activeView !== 'settings' && worktree.id === activeWorktreeId
return (
<div
key={`tab-groups-${worktree.id}`}
className={isVisible ? 'absolute inset-0 flex' : 'absolute inset-0 hidden'}
aria-hidden={!isVisible}
>
<CodexRestartChip worktreeId={worktree.id} />
<TabGroupSplitLayout
layout={layout}
worktreeId={worktree.id}
focusedGroupId={activeGroupIdByWorktree[worktree.id]}
/>
</div>
)
})}
</div>
</>
)
}
@@ -1,23 +1,48 @@
/* eslint-disable max-lines -- Why: the split-group workspace model intentionally keeps
group-scoped activation, close, split, and tab-order rules together so the extracted
controller cannot drift from the TabGroupPanel surface it coordinates. */
import { useCallback, useMemo } from 'react'
import type { BrowserTab as BrowserTabState, Tab } from '../../../../shared/types'
import { useShallow } from 'zustand/react/shallow'
import type { OpenFile } from '@/store/slices/editor'
import type { BrowserTab as BrowserTabState } from '../../../../shared/types'
import { useAppStore } from '../../store'
import { destroyPersistentWebview } from '../browser-pane/BrowserPane'
export function useTabGroupController({
export type GroupEditorItem = OpenFile & { tabId: string }
type TerminalTabItem = {
id: string
ptyId: null
worktreeId: string
title: string
customTitle: string | null
color: string | null
sortOrder: number
createdAt: number
}
export function useTabGroupWorkspaceModel({
groupId,
worktreeId,
group,
groupTabs,
activeTab,
worktreeBrowserTabs
worktreeId
}: {
groupId: string
worktreeId: string
group: { id: string; tabOrder: string[] } | null
groupTabs: Tab[]
activeTab: Tab | null
worktreeBrowserTabs: BrowserTabState[]
}) {
const worktreeState = useAppStore(
useShallow((state) => ({
groups: state.groupsByWorktree[worktreeId] ?? [],
unifiedTabs: state.unifiedTabsByWorktree[worktreeId] ?? [],
openFiles: state.openFiles,
browserTabs: state.browserTabsByWorktree[worktreeId] ?? [],
runtimeTerminalTabs: state.tabsByWorktree[worktreeId] ?? [],
expandedPaneByTabId: state.expandedPaneByTabId,
worktree:
Object.values(state.worktreesByRepo)
.flat()
.find((candidate) => candidate.id === worktreeId) ?? null
}))
)
const focusGroup = useAppStore((state) => state.focusGroup)
const activateTab = useAppStore((state) => state.activateTab)
const closeUnifiedTab = useAppStore((state) => state.closeUnifiedTab)
@@ -37,6 +62,79 @@ export function useTabGroupController({
const closeBrowserTab = useAppStore((state) => state.closeBrowserTab)
const setActiveBrowserTab = useAppStore((state) => state.setActiveBrowserTab)
const copyUnifiedTabToGroup = useAppStore((state) => state.copyUnifiedTabToGroup)
const setTabCustomTitle = useAppStore((state) => state.setTabCustomTitle)
const setTabColor = useAppStore((state) => state.setTabColor)
const consumeSuppressedPtyExit = useAppStore((state) => state.consumeSuppressedPtyExit)
const group = useMemo(
() => worktreeState.groups.find((item) => item.id === groupId) ?? null,
[groupId, worktreeState.groups]
)
const groupTabs = useMemo(
() => worktreeState.unifiedTabs.filter((item) => item.groupId === groupId),
[groupId, worktreeState.unifiedTabs]
)
const activeItemId = group?.activeTabId ?? null
const activeTab = groupTabs.find((item) => item.id === activeItemId) ?? null
const terminalTabs = useMemo<TerminalTabItem[]>(
() =>
groupTabs
.filter((item) => item.contentType === 'terminal')
.map((item) => ({
id: item.entityId,
ptyId: null,
worktreeId,
title: item.label,
customTitle: item.customLabel ?? null,
color: item.color ?? null,
sortOrder: item.sortOrder,
createdAt: item.createdAt
})),
[groupTabs, worktreeId]
)
const editorItems = useMemo<GroupEditorItem[]>(
() =>
groupTabs
.filter(
(item) =>
item.contentType === 'editor' ||
item.contentType === 'diff' ||
item.contentType === 'conflict-review'
)
.map((item) => {
const file = worktreeState.openFiles.find((candidate) => candidate.id === item.entityId)
return file ? { ...file, tabId: item.id } : null
})
.filter((item): item is GroupEditorItem => item !== null),
[groupTabs, worktreeState.openFiles]
)
const browserItems = useMemo(
() =>
groupTabs
.filter((item) => item.contentType === 'browser')
.map((item) => {
const bt = worktreeState.browserTabs.find((candidate) => candidate.id === item.entityId)
return bt ?? null
})
.filter((item): item is BrowserTabState => item !== null),
[groupTabs, worktreeState.browserTabs]
)
const activeBrowserTab = useMemo(
() =>
activeTab?.contentType === 'browser'
? (worktreeState.browserTabs.find((bt) => bt.id === activeTab.entityId) ?? null)
: null,
[activeTab, worktreeState.browserTabs]
)
const runtimeTerminalTabById = useMemo(
() => new Map(worktreeState.runtimeTerminalTabs.map((tab) => [tab.id, tab])),
[worktreeState.runtimeTerminalTabs]
)
const closeEditorIfUnreferenced = useCallback(
(entityId: string, closingTabId: string) => {
@@ -155,13 +253,9 @@ export function useTabGroupController({
return
}
// Why: tab context-menu split actions are scoped to the tab that opened
// the menu, not whichever tab was already active in the group. Falling
// back to the active tab preserves the "+" menu behavior, which creates
// a split from the current surface without a tab-specific source ID.
// Why: VS Code-style split actions leave the original group untouched and
// seed the new group with equivalent visible content when possible.
// Why: tab context-menu split actions belong to the visible tab that opened
// the menu. Keeping that decision inside the workspace model prevents the
// view layer from re-implementing "which tab is the source?" rules.
if (sourceTab.contentType === 'terminal') {
const terminal = createTab(worktreeId, newGroupId)
setActiveTab(terminal.id)
@@ -170,7 +264,7 @@ export function useTabGroupController({
}
if (sourceTab.contentType === 'browser') {
const browserTab = worktreeBrowserTabs.find(
const browserTab = worktreeState.browserTabs.find(
(candidate) => candidate.id === sourceTab.entityId
)
if (!browserTab) {
@@ -194,74 +288,49 @@ export function useTabGroupController({
setActiveTabType('editor')
},
[
activeTab,
copyUnifiedTabToGroup,
createBrowserTab,
createEmptySplitGroup,
createTab,
copyUnifiedTabToGroup,
focusGroup,
groupId,
groupTabs,
activeTab,
setActiveFile,
setActiveTab,
setActiveTabType,
worktreeBrowserTabs,
worktreeId
worktreeId,
worktreeState.browserTabs
]
)
const tabBarOrder = useMemo(
() =>
(group?.tabOrder ?? []).map((itemId) => {
const item = groupTabs.find((candidate) => candidate.id === itemId)
if (!item) {
return itemId
}
// Why: the tab bar renders terminals and browser workspaces by their
// backing runtime IDs, while editor tabs render by their unified tab
// IDs. Reorder callbacks must round-trip through the same visible IDs
// or dnd-kit cannot map the dragged tab back to the stored group order.
return item.contentType === 'terminal' || item.contentType === 'browser'
? item.entityId
: item.id
}),
[group, groupTabs]
)
const closeGroup = useCallback(() => {
const items = [...(useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? [])].filter(
(item) => item.groupId === groupId
)
for (const item of items) {
closeItem(item.id)
}
// Why: empty split groups are layout state, not tab state. The workspace
// model owns collapsing those placeholder panes so views do not need to
// understand when closing tabs is insufficient to remove a group shell.
closeEmptyGroup(worktreeId, groupId)
}, [closeEmptyGroup, closeItem, groupId, worktreeId])
return {
activateTerminal,
activateEditor,
activateBrowser,
closeItem,
closeGroup: () => {
const items = [...(useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? [])].filter(
(item) => item.groupId === groupId
)
for (const item of items) {
const closeAllEditorTabsInGroup = useCallback(() => {
for (const item of groupTabs) {
if (
item.contentType === 'editor' ||
item.contentType === 'diff' ||
item.contentType === 'conflict-review'
) {
closeItem(item.id)
}
// Why: split creation can intentionally leave empty placeholder groups
// behind. Closing the group chrome must collapse those panes even when
// no tabs remain to trigger `closeUnifiedTab` cleanup.
closeEmptyGroup(worktreeId, groupId)
},
closeOthers: (itemId: string) => closeMany(closeOtherTabs(itemId)),
closeToRight: (itemId: string) => closeMany(closeTabsToRight(itemId)),
closeAllEditorTabsInGroup: () => {
// Why: this action is launched from one split group's editor tab menu.
// In split layouts it must only close editor surfaces owned by that
// group, not every editor tab in the worktree.
for (const item of groupTabs) {
if (
item.contentType === 'editor' ||
item.contentType === 'diff' ||
item.contentType === 'conflict-review'
) {
closeItem(item.id)
}
}
},
reorderTabBar: (order: string[]) => {
}
}, [closeItem, groupTabs])
const reorderTabBar = useCallback(
(order: string[]) => {
if (!group) {
return
}
@@ -279,18 +348,62 @@ export function useTabGroupController({
const remainingIds = group.tabOrder.filter((itemId) => !orderedIds.has(itemId))
reorderUnifiedTabs(groupId, itemOrder.concat(remainingIds))
},
newTerminalTab: () => {
const terminal = createTab(worktreeId, groupId)
setActiveTab(terminal.id)
setActiveTabType('terminal')
},
newBrowserTab: () => {
const defaultUrl = useAppStore.getState().browserDefaultUrl ?? 'about:blank'
createBrowserTab(worktreeId, defaultUrl, { title: 'New Browser Tab' })
},
pinFile,
copyUnifiedTabToGroup,
[group, groupId, groupTabs, reorderUnifiedTabs]
)
const tabBarOrder = useMemo(
() =>
(group?.tabOrder ?? []).map((itemId) => {
const item = groupTabs.find((candidate) => candidate.id === itemId)
if (!item) {
return itemId
}
return item.contentType === 'terminal' || item.contentType === 'browser'
? item.entityId
: item.id
}),
[group, groupTabs]
)
return {
group,
activeTab,
activeBrowserTab,
browserItems,
editorItems,
terminalTabs,
tabBarOrder,
createSplitGroup
groupTabs,
worktreePath: worktreeState.worktree?.path,
runtimeTerminalTabById,
expandedPaneByTabId: worktreeState.expandedPaneByTabId,
commands: {
focusGroup: () => {
focusGroup(worktreeId, groupId)
},
activateBrowser,
activateEditor,
activateTerminal,
closeAllEditorTabsInGroup,
closeGroup,
closeItem,
closeOthers: (itemId: string) => closeMany(closeOtherTabs(itemId)),
closeToRight: (itemId: string) => closeMany(closeTabsToRight(itemId)),
consumeSuppressedPtyExit,
createSplitGroup,
newBrowserTab: () => {
const defaultUrl = useAppStore.getState().browserDefaultUrl ?? 'about:blank'
createBrowserTab(worktreeId, defaultUrl, { title: 'New Browser Tab' })
},
newTerminalTab: () => {
const terminal = createTab(worktreeId, groupId)
setActiveTab(terminal.id)
setActiveTabType('terminal')
},
pinFile,
reorderTabBar,
setTabColor,
setTabCustomTitle
}
}
}