diff --git a/docs/split-groups-rollout-pr5.md b/docs/split-groups-rollout-pr5.md
new file mode 100644
index 00000000..09ea2543
--- /dev/null
+++ b/docs/split-groups-rollout-pr5.md
@@ -0,0 +1,19 @@
+# Split Groups PR 5: Hook Group Surfaces Into Flagged Path
+
+This branch wires terminal, editor, and browser surfaces into the split-group
+ownership path inside `Terminal.tsx`, but holds that path behind a temporary
+local gate.
+
+Scope:
+- remove duplicate legacy ownership under the flagged path
+- route group-local surface creation and restore through the new model
+- preserve existing default behavior while the flag stays off
+
+What Is Actually Hooked Up In This PR:
+- `Terminal.tsx` now contains the real split-group surface path
+- the new path mounts `TabGroupSplitLayout` and avoids keeping duplicate legacy surfaces mounted underneath
+- the old legacy surface path is still present as the active runtime path in this branch
+
+What Is Not Hooked Up Yet:
+- the split-group path is still disabled by the temporary local rollout gate in `Terminal.tsx`
+- users should still get legacy behavior by default in this branch
diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx
index 2a857e94..2c261f14 100644
--- a/src/renderer/src/components/Terminal.tsx
+++ b/src/renderer/src/components/Terminal.tsx
@@ -27,9 +27,14 @@ 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 { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal'
const EditorPanel = lazy(() => import('./editor/EditorPanel'))
+// Why: the split-group ownership path lands before the rollout switch so we
+// can exercise the full renderer/model integration in code review without
+// exposing partial behavior to users. PR6 flips this to true.
+const ENABLE_SPLIT_GROUPS = false
function Terminal(): React.JSX.Element | null {
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
@@ -50,8 +55,6 @@ function Terminal(): React.JSX.Element | null {
const clearCodexRestartNotice = useAppStore((s) => s.clearCodexRestartNotice)
const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId)
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
- const ensureWorktreeRootGroup = useAppStore((s) => s.ensureWorktreeRootGroup)
- const reconcileWorktreeTabModel = useAppStore((s) => s.reconcileWorktreeTabModel)
const openFiles = useAppStore((s) => s.openFiles)
const activeFileId = useAppStore((s) => s.activeFileId)
const activeBrowserTabId = useAppStore((s) => s.activeBrowserTabId)
@@ -66,6 +69,11 @@ 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)
const setTabBarOrder = useAppStore((s) => s.setTabBarOrder)
@@ -100,6 +108,90 @@ 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 (
+
+
+
+ Codex is using the previous account
+
+
+
+
+
+
+
+ )
+ },
+ [
+ clearCodexRestartNotice,
+ codexRestartNoticeByPtyId,
+ ptyIdsByTabId,
+ queueCodexPaneRestarts,
+ tabsByWorktree
+ ]
+ )
const activeWorktreeBrowserTabIdsKey = activeWorktreeId
? (browserTabsByWorktree[activeWorktreeId] ?? []).map((tab) => tab.id).join(',')
: ''
@@ -197,13 +289,7 @@ function Terminal(): React.JSX.Element | null {
return
}
createTab(activeWorktreeId)
- }, [
- workspaceSessionReady,
- activeWorktreeId,
- tabs.length,
- createTab,
- reconcileWorktreeTabModel
- ])
+ }, [workspaceSessionReady, activeWorktreeId, tabs.length, createTab, reconcileWorktreeTabModel])
const handleNewTab = useCallback(() => {
if (!activeWorktreeId) {
@@ -771,10 +857,11 @@ function Terminal(): React.JSX.Element | null {
>
- {/* Why: the tab bar is rendered into the titlebar via a portal so it
- shares the same visual row as the "Orca" title. The portal target
- (#titlebar-tabs) lives in App.tsx's titlebar. */}
+ {/* Why: once split groups are enabled, each group owns its own tab strip
+ inline like VS Code. The old titlebar portal stays only as a fallback
+ before the root-group layout has been established. */}
{activeWorktreeId &&
+ !effectiveActiveLayout &&
titlebarTabsTarget &&
createPortal(
0) ||
- (activeTabType === 'browser' && worktreeBrowserTabs.length > 0)
- ? 'hidden'
- : ''
- }`}
- >
- {allWorktrees
- .filter((wt) => mountedWorktreeIdsRef.current.has(wt.id))
- .map((worktree) => {
- const worktreeTabs = tabsByWorktree[worktree.id] ?? []
- const isVisible = activeView !== 'settings' && worktree.id === activeWorktreeId
+ {activeWorktreeId &&
+ effectiveActiveLayout &&
+ titlebarTabsTarget &&
+ createPortal(
+
+ {/* 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. */}
+
+ {activeWorktree?.displayName ?? 'Workspace'}
+
+ /
+ {activeSurfaceLabel}
+
,
+ titlebarTabsTarget
+ )}
- return (
-
- {(() => {
- const staleWorktreePtyIds = worktreeTabs.flatMap((tab) =>
- (ptyIdsByTabId[tab.id] ?? []).filter((ptyId) =>
- Boolean(codexRestartNoticeByPtyId[ptyId])
- )
- )
- if (staleWorktreePtyIds.length === 0) {
- return null
- }
- // Why: account switching is global, but repeating the same
- // stale-session prompt in every affected Codex pane quickly
- // turns into noise. Keep one worktree-scoped chip in the
- // same visual corner so users get the same prompt style
- // without having to dismiss it in every pane.
- return (
-
+
+ )
+ })}
+
+ ) : null}
+
+ {!effectiveActiveLayout && (
+ <>
+ {/* Why: split-group layouts render their own terminal/browser/editor
+ surfaces inside TabGroupPanel. Keeping the legacy workspace-level
+ panes mounted underneath as hidden DOM creates duplicate
+ TerminalPane/BrowserPane instances for the same tab, which lets
+ two React trees race over one PTY or webview. Render only one
+ surface model at a time. */}
+ {/* Terminal panes container - hidden when editor tab active */}
+
0) ||
+ (activeTabType === 'browser' && worktreeBrowserTabs.length > 0)
+ ? 'hidden'
+ : ''
+ }`}
+ >
+ {allWorktrees
+ .filter((wt) => mountedWorktreeIdsRef.current.has(wt.id))
+ .map((worktree) => {
+ const worktreeTabs = tabsByWorktree[worktree.id] ?? []
+ const isVisible = activeView !== 'settings' && worktree.id === activeWorktreeId
- {/* Browser panes container — all browser panes for the active worktree
- stay mounted so webview DOM state (scroll position, form inputs, etc.)
- survives tab switches. BrowserPagePane uses isActive + CSS to show/hide. */}
-