From b60341ff0ce440bec93cb69180a824cdbed1f8a5 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Wed, 8 Apr 2026 20:45:23 -0700
Subject: [PATCH] Revert "fix: prevent terminal reset when splitting tab
groups" (#403)
---
src/renderer/src/App.tsx | 27 +-
src/renderer/src/components/Terminal.tsx | 198 ++++--------
.../src/components/editor/EditorPanel.tsx | 61 +---
.../src/components/editor/MonacoEditor.tsx | 5 -
.../editor/editor-autosave-controller.ts | 1 -
.../src/components/tab-bar/EditorFileTab.tsx | 33 +-
.../src/components/tab-bar/SortableTab.tsx | 31 +-
.../src/components/tab-bar/TabBar.tsx | 8 +-
.../components/tab-group/TabGroupPanel.tsx | 286 ------------------
.../tab-group/TabGroupSplitLayout.tsx | 111 -------
.../components/terminal-pane/TerminalPane.tsx | 17 +-
.../terminal-pane/pty-connection.ts | 7 +-
.../use-terminal-pane-global-effects.ts | 31 +-
.../use-terminal-pane-lifecycle.ts | 3 -
src/renderer/src/store/slices/editor.ts | 66 +---
.../src/store/slices/tab-group-layout-ops.ts | 108 -------
.../src/store/slices/tabs-bulk-actions.ts | 115 -------
src/renderer/src/store/slices/tabs-helpers.ts | 21 +-
.../src/store/slices/tabs-hydration.ts | 42 +--
.../src/store/slices/tabs-split-actions.ts | 176 -----------
src/renderer/src/store/slices/tabs.test.ts | 3 +-
src/renderer/src/store/slices/tabs.ts | 182 ++++++-----
src/renderer/src/store/slices/terminals.ts | 6 +-
src/shared/types.ts | 17 --
24 files changed, 219 insertions(+), 1336 deletions(-)
delete mode 100644 src/renderer/src/components/tab-group/TabGroupPanel.tsx
delete mode 100644 src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx
delete mode 100644 src/renderer/src/store/slices/tab-group-layout-ops.ts
delete mode 100644 src/renderer/src/store/slices/tabs-bulk-actions.ts
delete mode 100644 src/renderer/src/store/slices/tabs-split-actions.ts
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index cfcf61e90a0..4fa2418fa50 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -100,7 +100,6 @@ function App(): React.JSX.Element {
const refreshAllGitHub = useAppStore((s) => s.refreshAllGitHub)
const hydrateWorkspaceSession = useAppStore((s) => s.hydrateWorkspaceSession)
const hydrateEditorSession = useAppStore((s) => s.hydrateEditorSession)
- const hydrateTabsSession = useAppStore((s) => s.hydrateTabsSession)
const reconnectPersistedTerminals = useAppStore((s) => s.reconnectPersistedTerminals)
const hydratePersistedUI = useAppStore((s) => s.hydratePersistedUI)
const openModal = useAppStore((s) => s.openModal)
@@ -119,10 +118,6 @@ function App(): React.JSX.Element {
const activeTabTypeByWorktree = useAppStore((s) => s.activeTabTypeByWorktree)
const activeTabIdByWorktree = useAppStore((s) => s.activeTabIdByWorktree)
- // Unified tab state for session persistence and titlebar hiding
- const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree)
- const groupsByWorktree = useAppStore((s) => s.groupsByWorktree)
- const layoutByWorktree = useAppStore((s) => s.layoutByWorktree)
// Right sidebar + editor state
const toggleRightSidebar = useAppStore((s) => s.toggleRightSidebar)
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
@@ -161,7 +156,6 @@ function App(): React.JSX.Element {
hydratePersistedUI(persistedUI)
hydrateWorkspaceSession(session)
hydrateEditorSession(session)
- hydrateTabsSession(session)
await reconnectPersistedTerminals(abortController.signal)
syncZoomCSSVar()
}
@@ -212,7 +206,6 @@ function App(): React.JSX.Element {
hydratePersistedUI,
hydrateWorkspaceSession,
hydrateEditorSession,
- hydrateTabsSession,
reconnectPersistedTerminals
])
@@ -251,10 +244,7 @@ function App(): React.JSX.Element {
terminalLayoutsByTabId,
activeWorktreeIdsOnShutdown,
activeTabIdByWorktree,
- ...buildEditorSessionData(openFiles, activeFileIdByWorktree, activeTabTypeByWorktree),
- unifiedTabs: unifiedTabsByWorktree,
- tabGroups: groupsByWorktree,
- tabGroupLayouts: layoutByWorktree
+ ...buildEditorSessionData(openFiles, activeFileIdByWorktree, activeTabTypeByWorktree)
})
}, 150)
@@ -269,10 +259,7 @@ function App(): React.JSX.Element {
openFiles,
activeFileIdByWorktree,
activeTabTypeByWorktree,
- activeTabIdByWorktree,
- unifiedTabsByWorktree,
- groupsByWorktree,
- layoutByWorktree
+ activeTabIdByWorktree
])
// On shutdown, capture terminal scrollback buffers and flush to disk.
@@ -305,10 +292,7 @@ function App(): React.JSX.Element {
state.openFiles,
state.activeFileIdByWorktree,
state.activeTabTypeByWorktree
- ),
- unifiedTabs: state.unifiedTabsByWorktree,
- tabGroups: state.groupsByWorktree,
- tabGroupLayouts: state.layoutByWorktree
+ )
})
}
window.addEventListener('beforeunload', captureAndFlush)
@@ -549,10 +533,7 @@ function App(): React.JSX.Element {
{/* Why: portal target for the TabBar rendered by Terminal.tsx.
Hidden when tabs should not be visible (settings view, no active worktree)
- so the portal content does not leak through. When splits are active,
- Terminal.tsx skips the portal so the div stays empty, but it must
- remain visible as a flex-1 spacer to keep the right sidebar toggle
- pushed to the far right. */}
+ so the portal content does not leak through. */}
import('./editor/EditorPanel'))
+
export default function Terminal(): React.JSX.Element | null {
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
-
+ const activeView = useAppStore((s) => s.activeView)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const activeTabId = useAppStore((s) => s.activeTabId)
@@ -52,18 +53,6 @@ export default function Terminal(): React.JSX.Element | null {
const tabBarOrderByWorktree = useAppStore((s) => s.tabBarOrderByWorktree)
const tabBarOrder = activeWorktreeId ? tabBarOrderByWorktree[activeWorktreeId] : undefined
- // Tab group split state
- const layout = useAppStore((s) =>
- activeWorktreeId ? s.layoutByWorktree[activeWorktreeId] : undefined
- )
- const hasSplitGroups = layout?.type === 'split'
- const focusedGroupId = useAppStore((s) =>
- activeWorktreeId ? s.activeGroupIdByWorktree[activeWorktreeId] : undefined
- )
- const splitTabToGroup = useAppStore((s) => s.splitTabToGroup)
- const createUnifiedTab = useAppStore((s) => s.createUnifiedTab)
- const closeUnifiedTab = useAppStore((s) => s.closeUnifiedTab)
-
const tabs = activeWorktreeId ? (tabsByWorktree[activeWorktreeId] ?? []) : []
const allWorktrees = Object.values(worktreesByRepo).flat()
@@ -96,9 +85,8 @@ export default function Terminal(): React.JSX.Element | null {
return
}
closeFile(fileId)
- closeUnifiedTab(fileId)
},
- [closeFile, closeUnifiedTab]
+ [closeFile]
)
const handleSaveDialogSave = useCallback(async () => {
@@ -129,9 +117,8 @@ export default function Terminal(): React.JSX.Element | null {
await requestEditorSaveQuiesce({ fileId: saveDialogFileId })
markFileDirty(saveDialogFileId, false)
closeFile(saveDialogFileId)
- closeUnifiedTab(saveDialogFileId)
setSaveDialogFileId(null)
- }, [saveDialogFileId, closeFile, closeUnifiedTab, markFileDirty])
+ }, [saveDialogFileId, closeFile, markFileDirty])
const handleSaveDialogCancel = useCallback(() => {
setSaveDialogFileId(null)
@@ -190,25 +177,14 @@ export default function Terminal(): React.JSX.Element | null {
return
}
initialTabCreationGuardRef.current = activeWorktreeId
- const newTab = createTab(activeWorktreeId)
- // Why: keep TabsSlice in sync so tab group splits can find this tab.
- createUnifiedTab(activeWorktreeId, 'terminal', { id: newTab.id, label: newTab.title })
- }, [
- workspaceSessionReady,
- activeWorktreeId,
- tabs.length,
- worktreeFiles.length,
- createTab,
- createUnifiedTab
- ])
+ createTab(activeWorktreeId)
+ }, [workspaceSessionReady, activeWorktreeId, tabs.length, worktreeFiles.length, createTab])
const handleNewTab = useCallback(() => {
if (!activeWorktreeId) {
return
}
const newTab = createTab(activeWorktreeId)
- // Why: keep TabsSlice in sync so tab group splits can find this tab.
- createUnifiedTab(activeWorktreeId, 'terminal', { id: newTab.id, label: newTab.title })
setActiveTabType('terminal')
// Why: persist the tab bar order with the new terminal at the end of the
// current visual order. Without this, reconcileOrder falls back to
@@ -233,7 +209,7 @@ export default function Terminal(): React.JSX.Element | null {
const order = base.filter((id) => id !== newTab.id)
order.push(newTab.id)
setTabBarOrder(activeWorktreeId, order)
- }, [activeWorktreeId, createTab, createUnifiedTab, setActiveTabType, setTabBarOrder])
+ }, [activeWorktreeId, createTab, setActiveTabType, setTabBarOrder])
const handleCloseTab = useCallback(
(tabId: string) => {
@@ -250,7 +226,6 @@ export default function Terminal(): React.JSX.Element | null {
const currentTabs = state.tabsByWorktree[owningWorktreeId] ?? []
if (currentTabs.length <= 1) {
closeTab(tabId)
- closeUnifiedTab(tabId)
if (state.activeWorktreeId === owningWorktreeId) {
// Why: only deactivate the worktree when no tabs of any kind remain.
// Editor files are a separate tab type; closing the last terminal tab
@@ -275,9 +250,8 @@ export default function Terminal(): React.JSX.Element | null {
}
}
closeTab(tabId)
- closeUnifiedTab(tabId)
},
- [closeTab, closeUnifiedTab, setActiveTab, setActiveFile, setActiveTabType, setActiveWorktree]
+ [closeTab, setActiveTab, setActiveFile, setActiveTabType, setActiveWorktree]
)
const handlePtyExit = useCallback(
@@ -300,11 +274,10 @@ export default function Terminal(): React.JSX.Element | null {
for (const tab of currentTabs) {
if (tab.id !== tabId) {
closeTab(tab.id)
- closeUnifiedTab(tab.id)
}
}
},
- [activeWorktreeId, closeTab, closeUnifiedTab, setActiveTab]
+ [activeWorktreeId, closeTab, setActiveTab]
)
const handleCloseTabsToRight = useCallback(
@@ -320,10 +293,9 @@ export default function Terminal(): React.JSX.Element | null {
const rightTabs = currentTabs.slice(index + 1)
for (const tab of rightTabs) {
closeTab(tab.id)
- closeUnifiedTab(tab.id)
}
},
- [activeWorktreeId, closeTab, closeUnifiedTab]
+ [activeWorktreeId, closeTab]
)
const handleActivateTab = useCallback(
@@ -348,31 +320,6 @@ export default function Terminal(): React.JSX.Element | null {
[setActiveTab]
)
- const handleSplitTab = useCallback(
- (tabId: string, direction: 'left' | 'right' | 'up' | 'down') => {
- splitTabToGroup(tabId, direction)
- // Why: after splitting, the new group's terminal tab needs a PTY.
- // The new tab was created in TabsSlice but TerminalSlice also needs
- // a matching entry for PTY lifecycle. We create it in TerminalSlice
- // so TerminalPane can mount and spawn a PTY.
- if (activeWorktreeId) {
- const state = useAppStore.getState()
- const newGroupId = state.activeGroupIdByWorktree[activeWorktreeId]
- const newGroupTabs = (state.unifiedTabsByWorktree[activeWorktreeId] ?? []).filter(
- (t) => t.groupId === newGroupId && t.contentType === 'terminal'
- )
- // The newest terminal tab in the new group needs a TerminalSlice entry
- for (const ut of newGroupTabs) {
- const exists = (state.tabsByWorktree[activeWorktreeId] ?? []).some((t) => t.id === ut.id)
- if (!exists) {
- createTab(activeWorktreeId, ut.id)
- }
- }
- }
- },
- [splitTabToGroup, activeWorktreeId, createTab]
- )
-
// Keyboard shortcuts
useEffect(() => {
if (!activeWorktreeId) {
@@ -406,36 +353,16 @@ export default function Terminal(): React.JSX.Element | null {
// Cmd/Ctrl+Shift+] and Cmd/Ctrl+Shift+[ - switch tabs
if (mod && e.shiftKey && (e.key === ']' || e.key === '[') && !e.repeat) {
const state = useAppStore.getState()
+ const currentTerminalTabs = state.tabsByWorktree[activeWorktreeId] ?? []
+ const currentEditorFiles = activeWorktreeId
+ ? state.openFiles.filter((f) => f.worktreeId === activeWorktreeId)
+ : []
- // Why: when splits are active, tab cycling must stay within the
- // focused group so the user doesn't jump between panels unexpectedly.
- const currentGroupId = state.activeGroupIdByWorktree[activeWorktreeId]
- const hasLayout = state.layoutByWorktree[activeWorktreeId]?.type === 'split'
- const groupFilter =
- hasLayout && currentGroupId
- ? (t: { groupId?: string }) => t.groupId === currentGroupId
- : () => true
-
- const unifiedTabs = (state.unifiedTabsByWorktree[activeWorktreeId] ?? []).filter(
- groupFilter
- )
-
- const allTabIds: { type: 'terminal' | 'editor'; id: string }[] = unifiedTabs.map((t) => ({
- type: t.contentType === 'terminal' ? ('terminal' as const) : ('editor' as const),
- id: t.id
- }))
-
- // Fallback for single-group mode without unified tabs populated
- if (allTabIds.length === 0) {
- const currentTerminalTabs = state.tabsByWorktree[activeWorktreeId] ?? []
- const currentEditorFiles = activeWorktreeId
- ? state.openFiles.filter((f) => f.worktreeId === activeWorktreeId)
- : []
- allTabIds.push(
- ...currentTerminalTabs.map((t) => ({ type: 'terminal' as const, id: t.id })),
- ...currentEditorFiles.map((f) => ({ type: 'editor' as const, id: f.id }))
- )
- }
+ // Build unified tab list: terminal tabs then editor tabs
+ const allTabIds: { type: 'terminal' | 'editor'; id: string }[] = [
+ ...currentTerminalTabs.map((t) => ({ type: 'terminal' as const, id: t.id })),
+ ...currentEditorFiles.map((f) => ({ type: 'editor' as const, id: f.id }))
+ ]
if (allTabIds.length > 1) {
e.preventDefault()
@@ -508,11 +435,10 @@ export default function Terminal(): React.JSX.Element | null {
>
- {/* Why: when tab groups are split, each group renders its own inline
- tab bar inside TabGroupPanel. The titlebar portal is skipped so it
- doesn't show a duplicate set of tabs. */}
- {!hasSplitGroups &&
- activeWorktreeId &&
+ {/* 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. */}
+ {activeWorktreeId &&
titlebarTabsTarget &&
createPortal(
,
titlebarTabsTarget
)}
- {/* Why: always render through TabGroupSplitLayout — even for a single
- group — so that splitting never unmounts the original TabGroupPanel.
- The CSS Grid flat rendering in TabGroupSplitLayout keeps all
- TabGroupPanels as stable keyed siblings, preserving xterm instances
- and PTY connections across layout changes. */}
- {activeWorktreeId && layout && (
-
- )}
-
- {/* Why: non-active worktrees keep their TerminalPanes mounted (hidden)
- so PTY connections survive worktree switches. The active worktree's
- terminals are rendered by TabGroupPanel inside TabGroupSplitLayout. */}
-
+ {/* Terminal panes container - hidden when editor tab active */}
+
0 ? 'hidden' : ''}`}
+ >
{allWorktrees
- .filter((wt) => mountedWorktreeIdsRef.current.has(wt.id) && wt.id !== activeWorktreeId)
+ .filter((wt) => mountedWorktreeIdsRef.current.has(wt.id))
.map((worktree) => {
const worktreeTabs = tabsByWorktree[worktree.id] ?? []
- return worktreeTabs.map((tab) => (
-
handlePtyExit(tab.id, ptyId)}
- onCloseTab={() => handleCloseTab(tab.id)}
- />
- ))
+ const isVisible = activeView !== 'settings' && worktree.id === activeWorktreeId
+
+ return (
+
+ {worktreeTabs.map((tab) => (
+ handlePtyExit(tab.id, ptyId)}
+ onCloseTab={() => handleCloseTab(tab.id)}
+ />
+ ))}
+
+ )
})}
+ {/* Why: v1.0.85 only mounted the visible editor surface, which kept
+ hidden editor effects out of app shutdown. Autosave now lives in the
+ narrow EditorAutosaveController above, so the full EditorPanel can go
+ back to the safer "mount only while visible" lifecycle. */}
+ {activeWorktreeId && activeTabType === 'editor' && worktreeFiles.length > 0 && (
+
+ Loading editor...
+
+ }
+ >
+
+
+ )}
+
{/* Save confirmation dialog */}