fix: restore worktrees through tab group ownership (#645)

This commit is contained in:
Brennan Benson 2026-04-14 17:01:58 -07:00 committed by GitHub
parent c4460f8820
commit cfe49ff404
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 491 additions and 43 deletions

View file

@ -0,0 +1,23 @@
# Split Groups PR 3: Worktree Restore Ownership
This branch moves worktree activation and restore logic onto the reconciled
tab-group model.
Scope:
- reconcile stale unified tabs before restore
- restore active surfaces from the group model first
- fall back to terminal when a grouped worktree has no renderable surface
- create a root group before initial terminal fallback attaches a new tab
What Is Actually Hooked Up In This PR:
- opening an existing worktree restores from the reconciled group/tab model
- reopening an empty grouped worktree falls back to a terminal instead of a blank pane
- initial terminal creation is now driven by renderable grouped content instead of one-time init guards
What Is Not Hooked Up Yet:
- no split-group layout is rendered
- the visible workspace host is still the legacy terminal/browser/editor surface path
- tab-group UI components still are not mounted here
Non-goals:
- no split-group UI enablement yet

View file

@ -27,6 +27,7 @@ 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 { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal'
const EditorPanel = lazy(() => import('./editor/EditorPanel'))
@ -49,6 +50,8 @@ 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)
@ -80,6 +83,16 @@ 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)
@ -170,12 +183,6 @@ function Terminal(): React.JSX.Element | null {
mountedWorktreeIdsRef.current.delete(id)
}
}
// Why: tracks worktrees that have already been initialized (either by
// auto-creating a first tab or by having tabs on first activation). Once a
// worktree is in this set, closing all its tabs will NOT auto-spawn a
// replacement — the user explicitly chose to close them.
const initializedWorktreesRef = useRef(new Set<string>())
// Auto-create first tab when worktree activates
useEffect(() => {
if (!workspaceSessionReady) {
@ -185,27 +192,17 @@ function Terminal(): React.JSX.Element | null {
return
}
if (tabs.length > 0 || worktreeFiles.length > 0 || worktreeBrowserTabs.length > 0) {
initializedWorktreesRef.current.add(activeWorktreeId)
const { renderableTabCount } = reconcileWorktreeTabModel(activeWorktreeId)
if (!shouldAutoCreateInitialTerminal(renderableTabCount)) {
return
}
// Why: once a worktree has been initialized (had tabs or auto-created one),
// don't auto-create again. This prevents a new terminal from spawning
// immediately after the user closes the last tab. Also guards against
// React StrictMode double-invocation.
if (initializedWorktreesRef.current.has(activeWorktreeId)) {
return
}
initializedWorktreesRef.current.add(activeWorktreeId)
createTab(activeWorktreeId)
}, [
workspaceSessionReady,
activeWorktreeId,
tabs.length,
worktreeFiles.length,
worktreeBrowserTabs.length,
createTab
createTab,
reconcileWorktreeTabModel
])
const handleNewTab = useCallback(() => {

View file

@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { shouldAutoCreateInitialTerminal } from './initial-terminal'
describe('shouldAutoCreateInitialTerminal', () => {
it('creates a terminal when the tab-group model has no renderable tabs', () => {
expect(shouldAutoCreateInitialTerminal(0)).toBe(true)
})
it('does not create a terminal when the tab-group model already has content', () => {
expect(shouldAutoCreateInitialTerminal(1)).toBe(false)
expect(shouldAutoCreateInitialTerminal(2)).toBe(false)
})
})

View file

@ -0,0 +1,7 @@
export function shouldAutoCreateInitialTerminal(renderableTabCount: number): boolean {
// Why: the tab-group model is now the source of truth for visible worktree
// content. If it has no renderable tabs, the workspace must synthesize a
// terminal instead of deferring to legacy editor/browser restore state,
// which can otherwise leave an empty split group with nothing mounted.
return renderableTabCount === 0
}

View file

@ -6,6 +6,7 @@ function createMockStore(overrides: Record<string, unknown> = {}) {
tabsByWorktree: {} as Record<string, { id: string }[]>,
createTab: vi.fn(() => ({ id: 'tab-1' })),
setActiveTab: vi.fn(),
reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 0 })),
queueTabSetupSplit: vi.fn(),
queueTabIssueCommandSplit: vi.fn(),
...overrides
@ -45,9 +46,9 @@ describe('ensureWorktreeHasInitialTerminal', () => {
expect(store.queueTabSetupSplit).not.toHaveBeenCalled()
})
it('does not create or queue anything when the worktree already has tabs', () => {
it('does not create or queue anything when the worktree already has renderable content', () => {
const store = createMockStore({
tabsByWorktree: { 'wt-1': [{ id: 'tab-existing' }] }
reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 1 }))
})
ensureWorktreeHasInitialTerminal(store, 'wt-1', {
@ -61,6 +62,18 @@ describe('ensureWorktreeHasInitialTerminal', () => {
expect(store.queueTabIssueCommandSplit).not.toHaveBeenCalled()
})
it('does not create a terminal just because the legacy terminal slice is empty', () => {
const store = createMockStore({
tabsByWorktree: { 'wt-1': [] },
reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 2 }))
})
ensureWorktreeHasInitialTerminal(store, 'wt-1')
expect(store.createTab).not.toHaveBeenCalled()
expect(store.setActiveTab).not.toHaveBeenCalled()
})
it('queues an issue command split when issueCommand is provided', () => {
const store = createMockStore()

View file

@ -1,4 +1,5 @@
import type { WorktreeSetupLaunch } from '../../../shared/types'
import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal'
import { buildSetupRunnerCommand } from './setup-runner'
import { useAppStore } from '@/store'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
@ -7,6 +8,7 @@ type WorktreeActivationStore = {
tabsByWorktree: Record<string, { id: string }[]>
createTab: (worktreeId: string) => { id: string }
setActiveTab: (tabId: string) => void
reconcileWorktreeTabModel: (worktreeId: string) => { renderableTabCount: number }
queueTabSetupSplit: (
tabId: string,
startup: { command: string; env?: Record<string, string> }
@ -85,8 +87,11 @@ export function ensureWorktreeHasInitialTerminal(
setup?: WorktreeSetupLaunch,
issueCommand?: WorktreeSetupLaunch
): void {
const existingTabs = store.tabsByWorktree[worktreeId] ?? []
if (existingTabs.length > 0) {
const { renderableTabCount } = store.reconcileWorktreeTabModel(worktreeId)
// Why: activation can now restore editor- or browser-only worktrees from the
// reconciled tab-group model. Creating a terminal just because the legacy
// terminal slice is empty would reopen worktrees with an unexpected extra tab.
if (!shouldAutoCreateInitialTerminal(renderableTabCount)) {
return
}

View file

@ -10,6 +10,7 @@ function createEditorStore(): StoreApi<AppState> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return createStore<any>()((...args: any[]) => ({
activeWorktreeId: 'wt-1',
tabsByWorktree: {},
browserTabsByWorktree: {},
activeBrowserTabId: null,
activeBrowserTabIdByWorktree: {},
@ -257,6 +258,25 @@ describe('createEditorSlice editor drafts', () => {
expect(store.getState().activeBrowserTabId).toBe('browser-1')
})
it('returns to the landing state when closing the last editor in a worktree with no other surfaces', () => {
const store = createEditorStore()
store.getState().openFile({
filePath: '/repo/notes.md',
relativePath: 'notes.md',
worktreeId: 'wt-1',
language: 'markdown',
mode: 'edit'
})
store.getState().closeFile('/repo/notes.md')
expect(store.getState().activeWorktreeId).toBeNull()
expect(store.getState().activeFileId).toBeNull()
expect(store.getState().activeBrowserTabId).toBeNull()
expect(store.getState().activeTabType).toBe('terminal')
})
it('falls back to a browser tab when closing all editors in the active worktree', () => {
const store = createEditorStore()
@ -293,6 +313,25 @@ describe('createEditorSlice editor drafts', () => {
expect(store.getState().activeTabType).toBe('browser')
expect(store.getState().activeBrowserTabId).toBe('browser-1')
})
it('returns to the landing state when closing all editors and no other surfaces remain', () => {
const store = createEditorStore()
store.getState().openFile({
filePath: '/repo/a.md',
relativePath: 'a.md',
worktreeId: 'wt-1',
language: 'markdown',
mode: 'edit'
})
store.getState().closeAllFiles()
expect(store.getState().activeWorktreeId).toBeNull()
expect(store.getState().activeFileId).toBeNull()
expect(store.getState().activeBrowserTabId).toBeNull()
expect(store.getState().activeTabType).toBe('terminal')
})
})
describe('createEditorSlice conflict status reconciliation', () => {

View file

@ -590,6 +590,9 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
const browserTabsForWorktree = activeWorktreeId
? (s.browserTabsByWorktree[activeWorktreeId] ?? [])
: []
const terminalTabsForWorktree = activeWorktreeId
? (s.tabsByWorktree[activeWorktreeId] ?? [])
: []
const fallbackBrowserTabId =
activeWorktreeId && browserTabsForWorktree.length > 0
? (s.activeBrowserTabIdByWorktree[activeWorktreeId] ??
@ -607,6 +610,11 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
newActiveTabTypeByWorktree[activeWorktreeId] =
browserTabsForWorktree.length > 0 ? 'browser' : 'terminal'
}
const shouldDeactivateWorktree =
activeWorktreeId !== null &&
remainingForWorktree.length === 0 &&
browserTabsForWorktree.length === 0 &&
terminalTabsForWorktree.length === 0
// Why: keep tabBarOrderByWorktree in sync so stale editor IDs don't
// linger and cause position shifts the next time the order is reconciled.
@ -625,8 +633,14 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
openFiles: newFiles,
editorDrafts: newEditorDrafts,
activeFileId: newActiveId,
activeBrowserTabId:
activeWorktreeId && remainingForWorktree.length === 0
// Why: if closing the last editor also leaves the worktree without any
// browser or terminal surface, keep parity with the terminal/browser
// close handlers and return to the Orca landing state instead of
// leaving an active worktree selected with nothing renderable.
activeWorktreeId: shouldDeactivateWorktree ? null : s.activeWorktreeId,
activeBrowserTabId: shouldDeactivateWorktree
? null
: activeWorktreeId && remainingForWorktree.length === 0
? fallbackBrowserTabId
: s.activeBrowserTabId,
activeTabType: newActiveTabType,
@ -676,8 +690,11 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
delete newActiveFileIdByWorktree[activeWorktreeId]
const newActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree }
const browserTabsForWorktree = s.browserTabsByWorktree[activeWorktreeId] ?? []
const terminalTabsForWorktree = s.tabsByWorktree[activeWorktreeId] ?? []
newActiveTabTypeByWorktree[activeWorktreeId] =
browserTabsForWorktree.length > 0 ? 'browser' : 'terminal'
const shouldDeactivateWorktree =
browserTabsForWorktree.length === 0 && terminalTabsForWorktree.length === 0
// Why: remove all closed editor file IDs from tab bar order so stale
// entries don't cause position shifts on subsequent tab operations.
@ -697,8 +714,13 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
openFiles: newFiles,
editorDrafts: newEditorDrafts,
activeFileId: null,
activeBrowserTabId:
browserTabsForWorktree.length > 0
// Why: closing every editor in the active worktree can leave no
// renderable surface at all. Clear the active worktree in that case so
// the renderer shows the landing page instead of a blank workspace.
activeWorktreeId: shouldDeactivateWorktree ? null : s.activeWorktreeId,
activeBrowserTabId: shouldDeactivateWorktree
? null
: browserTabsForWorktree.length > 0
? (s.activeBrowserTabIdByWorktree[activeWorktreeId] ??
browserTabsForWorktree[0]?.id ??
null)

View file

@ -49,6 +49,8 @@ import {
makeLayout,
makeOpenFile,
makeTab,
makeTabGroup,
makeUnifiedTab,
makeWorktree,
seedStore
} from './store-test-helpers'
@ -366,6 +368,201 @@ describe('setActiveWorktree', () => {
expect(s.activeFileId).toBeNull()
})
it('prefers the unified active tab over stale legacy browser restore state', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
const groupId = 'group-1'
const terminalId = 'terminal-1'
const browserTabId = 'browser-1'
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[wt]: [makeTab({ id: terminalId, worktreeId: wt })]
},
browserTabsByWorktree: {
[wt]: [
{
id: browserTabId,
worktreeId: wt,
url: 'https://example.com',
title: 'Example',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 0
}
]
},
activeBrowserTabIdByWorktree: { [wt]: browserTabId },
activeTabTypeByWorktree: { [wt]: 'browser' },
unifiedTabsByWorktree: {
[wt]: [
makeUnifiedTab({
id: 'tab-terminal-1',
entityId: terminalId,
worktreeId: wt,
groupId,
contentType: 'terminal'
}),
makeUnifiedTab({
id: 'tab-browser-1',
entityId: browserTabId,
worktreeId: wt,
groupId,
contentType: 'browser'
})
]
},
groupsByWorktree: {
[wt]: [
makeTabGroup({
id: groupId,
worktreeId: wt,
activeTabId: 'tab-terminal-1',
tabOrder: ['tab-terminal-1', 'tab-browser-1']
})
]
},
activeGroupIdByWorktree: { [wt]: groupId }
})
store.getState().setActiveWorktree(wt)
const s = store.getState()
expect(s.activeWorktreeId).toBe(wt)
expect(s.activeTabType).toBe('terminal')
expect(s.activeTabTypeByWorktree[wt]).toBe('terminal')
expect(s.activeTabId).toBe(terminalId)
expect(s.activeBrowserTabId).toBe(browserTabId)
})
it('ignores stale unified tabs and falls back to terminal-first activation for empty groups', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
const groupId = 'group-1'
const browserTabId = 'browser-1'
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
browserTabsByWorktree: {
[wt]: [
{
id: browserTabId,
worktreeId: wt,
url: 'https://example.com',
title: 'Example',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 0
}
]
},
activeBrowserTabIdByWorktree: { [wt]: browserTabId },
activeTabTypeByWorktree: { [wt]: 'browser' },
unifiedTabsByWorktree: {
[wt]: [
makeUnifiedTab({
id: 'stale-terminal-tab',
entityId: 'missing-terminal',
worktreeId: wt,
groupId,
contentType: 'terminal'
})
]
},
groupsByWorktree: {
[wt]: [
makeTabGroup({
id: groupId,
worktreeId: wt,
activeTabId: 'stale-terminal-tab',
tabOrder: ['stale-terminal-tab']
})
]
},
activeGroupIdByWorktree: { [wt]: groupId }
})
store.getState().setActiveWorktree(wt)
const s = store.getState()
expect(s.activeWorktreeId).toBe(wt)
expect(s.activeTabType).toBe('terminal')
expect(s.activeBrowserTabId).toBe(browserTabId)
expect(s.activeTabId).toBeNull()
expect(s.unifiedTabsByWorktree[wt]).toEqual([])
expect(s.groupsByWorktree[wt][0].activeTabId).toBeNull()
})
it('creates a root tab group when the first terminal opens in a worktree', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
groupsByWorktree: {},
activeGroupIdByWorktree: {},
unifiedTabsByWorktree: {}
})
const terminal = store.getState().createTab(wt)
const state = store.getState()
const groups = state.groupsByWorktree[wt] ?? []
const unifiedTabs = state.unifiedTabsByWorktree[wt] ?? []
expect(groups).toHaveLength(1)
expect(state.activeGroupIdByWorktree[wt]).toBe(groups[0].id)
expect(state.layoutByWorktree[wt]).toEqual({ type: 'leaf', groupId: groups[0].id })
expect(unifiedTabs).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: terminal.id,
entityId: terminal.id,
worktreeId: wt,
groupId: groups[0].id,
contentType: 'terminal'
})
])
)
expect(groups[0].activeTabId).toBe(terminal.id)
expect(groups[0].tabOrder).toEqual([terminal.id])
})
it('reuses the lowest available terminal number after closes', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
}
})
const first = store.getState().createTab(wt)
const second = store.getState().createTab(wt)
expect(first.title).toBe('Terminal 1')
expect(second.title).toBe('Terminal 2')
store.getState().closeTab(first.id)
store.getState().closeTab(second.id)
const replacement = store.getState().createTab(wt)
expect(replacement.title).toBe('Terminal 1')
})
it('clears stale background browser tab type when closing the last browser tab', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'

View file

@ -14,6 +14,23 @@ import {
ensurePtyDispatcher
} from '@/components/terminal-pane/pty-transport'
function getNextTerminalOrdinal(tabs: TerminalTab[]): number {
const usedOrdinals = new Set<number>()
for (const tab of tabs) {
const match = /^Terminal (\d+)$/.exec(tab.customTitle ?? tab.title)
if (!match) {
continue
}
usedOrdinals.add(Number(match[1]))
}
let nextOrdinal = 1
while (usedOrdinals.has(nextOrdinal)) {
nextOrdinal += 1
}
return nextOrdinal
}
export type TerminalSlice = {
tabsByWorktree: Record<string, TerminalTab[]>
activeTabId: string | null
@ -46,7 +63,7 @@ export type TerminalSlice = {
workspaceSessionReady: boolean
pendingReconnectWorktreeIds: string[]
pendingReconnectTabByWorktree: Record<string, string[]>
createTab: (worktreeId: string) => TerminalTab
createTab: (worktreeId: string, targetGroupId?: string) => TerminalTab
closeTab: (tabId: string) => void
reorderTabs: (worktreeId: string, tabIds: string[]) => void
setTabBarOrder: (worktreeId: string, order: string[]) => void
@ -175,16 +192,20 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
}
},
createTab: (worktreeId) => {
createTab: (worktreeId, targetGroupId) => {
const id = globalThis.crypto.randomUUID()
let tab!: TerminalTab
set((s) => {
const existing = s.tabsByWorktree[worktreeId] ?? []
const nextOrdinal = getNextTerminalOrdinal(existing)
tab = {
id,
ptyId: null,
worktreeId,
title: `Terminal ${existing.length + 1}`,
// Why: users expect terminal labels to reflect the currently open set,
// not a monotonic creation counter. Reusing the lowest free ordinal
// keeps a lone fresh terminal at "Terminal 1" after older tabs close.
title: `Terminal ${nextOrdinal}`,
customTitle: null,
color: null,
sortOrder: existing.length,
@ -195,12 +216,40 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
...s.tabsByWorktree,
[worktreeId]: [...existing, tab]
},
activeGroupIdByWorktree:
targetGroupId &&
s.groupsByWorktree[worktreeId]?.some((group) => group.id === targetGroupId)
? { ...s.activeGroupIdByWorktree, [worktreeId]: targetGroupId }
: s.activeGroupIdByWorktree,
activeTabId: tab.id,
activeTabIdByWorktree: { ...s.activeTabIdByWorktree, [worktreeId]: tab.id },
ptyIdsByTabId: { ...s.ptyIdsByTabId, [tab.id]: [] },
terminalLayoutsByTabId: { ...s.terminalLayoutsByTabId, [tab.id]: emptyLayoutSnapshot() }
}
})
const state = get()
const resolvedTargetGroupId =
targetGroupId ??
state.activeGroupIdByWorktree[worktreeId] ??
state.groupsByWorktree[worktreeId]?.[0]?.id ??
state.ensureWorktreeRootGroup?.(worktreeId)
if (
resolvedTargetGroupId &&
!state.findTabForEntityInGroup(worktreeId, resolvedTargetGroupId, id, 'terminal')
) {
// Why: a brand-new worktree can auto-create its first terminal before
// Terminal.tsx has mounted and seeded a root tab group. Force a root
// group here so the first terminal always gets a visible unified tab
// instead of existing only in the legacy terminal slice.
state.createUnifiedTab(worktreeId, 'terminal', {
id,
entityId: id,
label: tab.title,
customLabel: tab.customTitle,
color: tab.color,
targetGroupId: resolvedTargetGroupId
})
}
return tab
},
@ -276,6 +325,14 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
tabBarOrderByWorktree: nextTabBarOrderByWorktree
}
})
for (const tabs of Object.values(get().unifiedTabsByWorktree)) {
const workspaceItem = tabs.find(
(entry) => entry.contentType === 'terminal' && entry.entityId === tabId
)
if (workspaceItem) {
get().closeUnifiedTab(workspaceItem.id)
}
}
},
reorderTabs: (worktreeId, tabIds) => {
@ -324,7 +381,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
})
},
setActiveTab: (tabId) =>
setActiveTab: (tabId) => {
set((s) => {
const worktreeId = s.activeWorktreeId
return {
@ -333,7 +390,14 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
? { ...s.activeTabIdByWorktree, [worktreeId]: tabId }
: s.activeTabIdByWorktree
}
}),
})
const item = Object.values(get().unifiedTabsByWorktree)
.flat()
.find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId)
if (item) {
get().activateTab(item.id)
}
},
updateTabTitle: (tabId, title) => {
set((s) => {
@ -366,6 +430,12 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
? { tabsByWorktree: next }
: { tabsByWorktree: next, sortEpoch: s.sortEpoch + 1 }
})
const item = Object.values(get().unifiedTabsByWorktree)
.flat()
.find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId)
if (item) {
get().setTabLabel(item.id, title)
}
},
setRuntimePaneTitle: (tabId, paneId, title) => {
@ -412,6 +482,12 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
scheduleRuntimeGraphSync()
return { tabsByWorktree: next }
})
const item = Object.values(get().unifiedTabsByWorktree)
.flat()
.find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId)
if (item) {
get().setTabCustomLabel(item.id, title)
}
},
setTabColor: (tabId, color) => {
@ -422,6 +498,12 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
}
return { tabsByWorktree: next }
})
const item = Object.values(get().unifiedTabsByWorktree)
.flat()
.find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId)
if (item) {
get().setUnifiedTabColor(item.id, color)
}
},
updateTabPtyId: (tabId, ptyId) => {

View file

@ -37,6 +37,10 @@ function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): b
})
}
function toVisibleTabType(contentType: string): WorkspaceVisibleTabType {
return contentType === 'browser' ? 'browser' : contentType === 'terminal' ? 'terminal' : 'editor'
}
export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> = (set, get) => ({
worktreesByRepo: {},
activeWorktreeId: null,
@ -162,10 +166,10 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
delete nextUnifiedTabsByWorktree[worktreeId]
const nextGroupsByWorktree = { ...s.groupsByWorktree }
delete nextGroupsByWorktree[worktreeId]
const nextActiveGroupIdByWorktree = { ...s.activeGroupIdByWorktree }
delete nextActiveGroupIdByWorktree[worktreeId]
const nextLayoutByWorktree = { ...s.layoutByWorktree }
delete nextLayoutByWorktree[worktreeId]
const nextActiveGroupIdByWorktree = { ...s.activeGroupIdByWorktree }
delete nextActiveGroupIdByWorktree[worktreeId]
// Why: git status / compare caches are keyed by worktree and stop being
// refreshed once the worktree is deleted. Remove them here so deleted
// worktrees cannot retain stale conflict badges, branch diffs, or compare
@ -232,8 +236,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
pendingReconnectTabByWorktree: nextPendingReconnectTabByWorktree,
unifiedTabsByWorktree: nextUnifiedTabsByWorktree,
groupsByWorktree: nextGroupsByWorktree,
activeGroupIdByWorktree: nextActiveGroupIdByWorktree,
layoutByWorktree: nextLayoutByWorktree,
activeGroupIdByWorktree: nextActiveGroupIdByWorktree,
editorDrafts: nextEditorDrafts,
markdownViewMode: nextMarkdownViewMode,
expandedDirs: nextExpandedDirs,
@ -370,6 +374,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
},
setActiveWorktree: (worktreeId) => {
const reconciledActiveTabId = worktreeId
? get().reconcileWorktreeTabModel(worktreeId).activeRenderableTabId
: null
let shouldClearUnread = false
set((s) => {
if (!worktreeId) {
@ -385,6 +392,20 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
const restoredFileId = s.activeFileIdByWorktree[worktreeId] ?? null
const restoredBrowserTabId = s.activeBrowserTabIdByWorktree[worktreeId] ?? null
const restoredTabType = s.activeTabTypeByWorktree[worktreeId] ?? 'terminal'
const activeGroupId =
s.activeGroupIdByWorktree[worktreeId] ?? s.groupsByWorktree[worktreeId]?.[0]?.id ?? null
const activeGroup = activeGroupId
? ((s.groupsByWorktree[worktreeId] ?? []).find((group) => group.id === activeGroupId) ??
null)
: null
const activeUnifiedTabId = reconciledActiveTabId ?? activeGroup?.activeTabId ?? null
const activeUnifiedTab =
activeUnifiedTabId != null
? ((s.unifiedTabsByWorktree[worktreeId] ?? []).find(
(tab) =>
tab.id === activeUnifiedTabId && (!activeGroup || tab.groupId === activeGroup.id)
) ?? null)
: null
// Verify the restored file still exists in openFiles
const fileStillOpen = restoredFileId
? s.openFiles.some((f) => f.id === restoredFileId && f.worktreeId === worktreeId)
@ -393,16 +414,40 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
const browserTabStillOpen = restoredBrowserTabId
? browserTabs.some((tab) => tab.id === restoredBrowserTabId)
: false
const hasGroupOwnedSurface =
(s.groupsByWorktree[worktreeId]?.length ?? 0) > 0 || Boolean(s.layoutByWorktree[worktreeId])
// Why: restore the visible tab surface the user last had active in this
// worktree. The 'terminal' case must be handled explicitly — without it,
// the fallback branches below see that a file is still open and promote
// the surface to 'editor', so the user always lands on a file tab instead
// of the terminal they were working in.
// Why: worktree activation must restore from the reconciled tab-group
// model first. Split groups are now the ownership model for visible
// content; if we prefer the legacy activeTabType/browser/file fallbacks
// when the two models disagree, the renderer can reopen a surface that
// has no backing unified tab and show a blank worktree.
let activeFileId: string | null
let activeBrowserTabId: string | null
let activeTabType: WorkspaceVisibleTabType
if (restoredTabType === 'terminal') {
if (activeUnifiedTab) {
activeFileId =
activeUnifiedTab.contentType === 'editor' ||
activeUnifiedTab.contentType === 'diff' ||
activeUnifiedTab.contentType === 'conflict-review'
? activeUnifiedTab.entityId
: fileStillOpen
? restoredFileId
: null
activeBrowserTabId =
activeUnifiedTab.contentType === 'browser'
? activeUnifiedTab.entityId
: browserTabStillOpen
? restoredBrowserTabId
: (browserTabs[0]?.id ?? null)
activeTabType = toVisibleTabType(activeUnifiedTab.contentType)
} else if (hasGroupOwnedSurface) {
activeFileId = fileStillOpen ? restoredFileId : null
activeBrowserTabId = browserTabStillOpen
? restoredBrowserTabId
: (browserTabs[0]?.id ?? null)
activeTabType = 'terminal'
} else if (restoredTabType === 'terminal') {
activeFileId = fileStillOpen ? restoredFileId : null
activeBrowserTabId = browserTabStillOpen
? restoredBrowserTabId
@ -443,7 +488,12 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
const tabStillExists = restoredTabId
? worktreeTabs.some((t) => t.id === restoredTabId)
: false
const activeTabId = tabStillExists ? restoredTabId : (worktreeTabs[0]?.id ?? null)
const activeTabId =
activeUnifiedTab?.contentType === 'terminal'
? activeUnifiedTab.entityId
: tabStillExists
? restoredTabId
: (worktreeTabs[0]?.id ?? null)
return {
activeWorktreeId: worktreeId,