refactor: add split group model foundations (#644)

This commit is contained in:
Brennan Benson 2026-04-14 14:39:01 -07:00 committed by GitHub
parent 901c7335a2
commit 741974c59c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1075 additions and 276 deletions

View file

@ -0,0 +1,26 @@
# Split Groups PR 1: Model Foundations
This branch lands the behavior-neutral tab-group model groundwork.
Scope:
- add persisted tab-group layout state
- add active-group persistence and hydration
- add group-aware unified-tab helpers in the store
- connect editor/open-file flows to the unified tab-group model
What Is Actually Hooked Up In This PR:
- the store persists `groupsByWorktree`, `layoutByWorktree`, and `activeGroupIdByWorktree`
- workspace session save/hydration includes tab-group layouts
- editor actions create and activate unified tabs through the group model
- the visible workspace renderer is still the legacy single-surface path
What Is Not Hooked Up Yet:
- `Terminal.tsx` does not render split groups
- no split-group UI components are mounted
- no PTY lifecycle changes land here
- no worktree activation fallback changes land here
Non-goals:
- no split-group UI rollout
- no terminal PTY lifecycle changes
- no worktree activation changes

View file

@ -24,6 +24,7 @@ type WorkspaceSessionSnapshot = Pick<
| 'activeBrowserTabIdByWorktree'
| 'unifiedTabsByWorktree'
| 'groupsByWorktree'
| 'layoutByWorktree'
| 'activeGroupIdByWorktree'
>
@ -113,6 +114,8 @@ export function buildWorkspaceSessionPayload(
snapshot.activeBrowserTabIdByWorktree
),
unifiedTabs: snapshot.unifiedTabsByWorktree,
tabGroups: snapshot.groupsByWorktree
tabGroups: snapshot.groupsByWorktree,
tabGroupLayouts: snapshot.layoutByWorktree,
activeGroupIdByWorktree: snapshot.activeGroupIdByWorktree
}
}

View file

@ -258,7 +258,34 @@ export type EditorSlice = {
hydrateEditorSession: (session: WorkspaceSessionState) => void
}
export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (set) => ({
function openWorkspaceEditorItem(
state: AppState,
fileId: string,
worktreeId: string,
label: string,
contentType: 'editor' | 'diff' | 'conflict-review',
isPreview?: boolean
): string {
const targetGroupId =
state.activeGroupIdByWorktree?.[worktreeId] ?? state.groupsByWorktree?.[worktreeId]?.[0]?.id
if (!targetGroupId) {
return fileId
}
const existing = state.findTabForEntityInGroup?.(worktreeId, targetGroupId, fileId, contentType)
if (existing) {
state.activateTab?.(existing.id)
return existing.id
}
const created = state.createUnifiedTab?.(worktreeId, contentType, {
entityId: fileId,
label,
isPreview,
targetGroupId
})
return created?.id ?? fileId
}
export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (set, get) => ({
editorDrafts: {},
setEditorDraft: (fileId, content) =>
set((s) => ({
@ -346,7 +373,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
}
}),
openFile: (file, options) =>
openFile: (file, options) => {
set((s) => {
const id = file.filePath
const existing = s.openFiles.find((f) => f.id === id)
@ -476,9 +503,22 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
...tabBarUpdate,
...activeResult
}
}),
})
void openWorkspaceEditorItem(
get(),
file.filePath,
file.worktreeId,
file.relativePath,
file.mode === 'conflict-review'
? 'conflict-review'
: file.mode === 'diff'
? 'diff'
: 'editor',
options?.preview ?? false
)
},
pinFile: (fileId, _tabId) =>
pinFile: (fileId, tabId) => {
set((s) => {
const file = s.openFiles.find((f) => f.id === fileId)
if (!file?.isPreview) {
@ -487,7 +527,16 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
return {
openFiles: s.openFiles.map((f) => (f.id === fileId ? { ...f, isPreview: undefined } : f))
}
}),
})
const state = get()
for (const tabs of Object.values(state.unifiedTabsByWorktree ?? {})) {
for (const item of tabs) {
if (item.entityId === fileId && (!tabId || item.id === tabId)) {
state.pinTab?.(item.id)
}
}
}
},
// Why: closing a tab does NOT clear Resolved locally state. If the file is
// still present in Changes or Staged Changes, the continuity badge should
@ -589,7 +638,19 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
}
}),
closeAllFiles: () =>
closeAllFiles: () => {
const state = get()
const activeWorktreeId = state.activeWorktreeId
const closingItemIds = Object.values(state.unifiedTabsByWorktree ?? {})
.flat()
.filter(
(item) =>
(item.contentType === 'editor' ||
item.contentType === 'diff' ||
item.contentType === 'conflict-review') &&
(!activeWorktreeId || item.worktreeId === activeWorktreeId)
)
.map((item) => item.id)
set((s) => {
const activeWorktreeId = s.activeWorktreeId
if (!activeWorktreeId) {
@ -653,9 +714,13 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
// to an old match unexpectedly.
pendingEditorReveal: null
}
}),
})
for (const itemId of closingItemIds) {
get().closeUnifiedTab?.(itemId)
}
},
setActiveFile: (fileId) =>
setActiveFile: (fileId) => {
set((s) => {
const file = s.openFiles.find((f) => f.id === fileId)
const worktreeId = file?.worktreeId
@ -665,7 +730,25 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
? { ...s.activeFileIdByWorktree, [worktreeId]: fileId }
: s.activeFileIdByWorktree
}
}),
})
const state = get()
const worktreeId = state.activeWorktreeId
if (!worktreeId) {
return
}
const groupId =
state.activeGroupIdByWorktree?.[worktreeId] ?? state.groupsByWorktree?.[worktreeId]?.[0]?.id
if (!groupId) {
return
}
const item =
state.findTabForEntityInGroup?.(worktreeId, groupId, fileId, 'editor') ??
state.findTabForEntityInGroup?.(worktreeId, groupId, fileId, 'diff') ??
state.findTabForEntityInGroup?.(worktreeId, groupId, fileId, 'conflict-review')
if (item) {
state.activateTab?.(item.id)
}
},
reorderFiles: (fileIds) =>
set((s) => {
@ -699,7 +782,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
openFiles: s.openFiles.map((f) => (f.id === fileId ? { ...f, isUntitled: undefined } : f))
})),
openDiff: (worktreeId, filePath, relativePath, language, staged) =>
openDiff: (worktreeId, filePath, relativePath, language, staged) => {
set((s) => {
const diffSource: DiffSource = staged ? 'staged' : 'unstaged'
const id = `${worktreeId}::diff::${diffSource}::${relativePath}`
@ -747,12 +830,20 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' }
}
}),
})
void openWorkspaceEditorItem(
get(),
`${worktreeId}::diff::${staged ? 'staged' : 'unstaged'}::${relativePath}`,
worktreeId,
relativePath,
'diff'
)
},
openBranchDiff: (worktreeId, worktreePath, entry, compare, language) =>
openBranchDiff: (worktreeId, worktreePath, entry, compare, language) => {
const branchCompare = toBranchCompareSnapshot(compare)
const id = `${worktreeId}::diff::branch::${compare.baseRef}::${branchCompare.compareVersion}::${entry.path}`
set((s) => {
const branchCompare = toBranchCompareSnapshot(compare)
const id = `${worktreeId}::diff::branch::${compare.baseRef}::${branchCompare.compareVersion}::${entry.path}`
const existing = s.openFiles.find((f) => f.id === id)
if (existing) {
return {
@ -798,9 +889,19 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' }
}
}),
})
void openWorkspaceEditorItem(get(), id, worktreeId, entry.path, 'diff')
},
openAllDiffs: (worktreeId, worktreePath, alternate, areaFilter) =>
openAllDiffs: (worktreeId, worktreePath, alternate, areaFilter) => {
const id = areaFilter
? `${worktreeId}::all-diffs::uncommitted::${areaFilter}`
: `${worktreeId}::all-diffs::uncommitted`
const label = areaFilter
? ({ staged: 'Staged Changes', unstaged: 'Changes', untracked: 'Untracked Files' }[
areaFilter
] ?? 'All Changes')
: 'All Changes'
set((s) => {
const relevantEntries = (s.gitStatusByWorktree[worktreeId] ?? []).filter((entry) => {
if (areaFilter) {
@ -868,11 +969,13 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' }
}
}),
})
void openWorkspaceEditorItem(get(), id, worktreeId, label, 'diff')
},
openConflictFile: (worktreeId, worktreePath, entry, language) =>
openConflictFile: (worktreeId, worktreePath, entry, language) => {
const absolutePath = joinPath(worktreePath, entry.path)
set((s) => {
const absolutePath = joinPath(worktreePath, entry.path)
const id = absolutePath
const conflict = toOpenConflictMetadata(entry)
const existing = s.openFiles.find((f) => f.id === id)
@ -938,16 +1041,18 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
? s.trackedConflictPathsByWorktree
: { ...s.trackedConflictPathsByWorktree, [worktreeId]: nextTracked }
}
}),
})
void openWorkspaceEditorItem(get(), absolutePath, worktreeId, entry.path, 'editor')
},
// Why: Review conflicts is launched from Source Control into the editor area,
// not from Checks. Merge-conflict review is source-control work, not CI/PR
// status. The tab renders from a stored snapshot (entries + timestamp), not
// from live status on every paint, so the list is stable even if the live
// unresolved set changes between polls.
openConflictReview: (worktreeId, worktreePath, entries, source) =>
openConflictReview: (worktreeId, worktreePath, entries, source) => {
const id = `${worktreeId}::conflict-review`
set((s) => {
const id = `${worktreeId}::conflict-review`
const conflictReview: ConflictReviewState = {
source,
snapshotTimestamp: Date.now(),
@ -996,13 +1101,15 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' }
}
}),
})
void openWorkspaceEditorItem(get(), id, worktreeId, 'Conflict Review', 'conflict-review')
},
openBranchAllDiffs: (worktreeId, worktreePath, compare, alternate) =>
openBranchAllDiffs: (worktreeId, worktreePath, compare, alternate) => {
const branchCompare = toBranchCompareSnapshot(compare)
const id = `${worktreeId}::all-diffs::branch::${compare.baseRef}::${branchCompare.compareVersion}`
set((s) => {
const branchCompare = toBranchCompareSnapshot(compare)
const branchEntriesSnapshot = s.gitBranchChangesByWorktree[worktreeId] ?? []
const id = `${worktreeId}::all-diffs::branch::${compare.baseRef}::${branchCompare.compareVersion}`
const existing = s.openFiles.find((f) => f.id === id)
if (existing) {
return {
@ -1048,7 +1155,15 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' }
}
}),
})
void openWorkspaceEditorItem(
get(),
id,
worktreeId,
`Branch Changes (${compare.baseRef})`,
'diff'
)
},
// Cursor line tracking
editorCursorLine: {},

View file

@ -1,4 +1,4 @@
import type { Tab, TabGroup } from '../../../../shared/types'
import type { Tab, TabContentType, TabGroup, WorkspaceSessionState } from '../../../../shared/types'
export function findTabAndWorktree(
tabsByWorktree: Record<string, Tab[]>,
@ -22,16 +22,50 @@ export function findGroupForTab(
return groups.find((g) => g.id === groupId) ?? null
}
export function findGroupAndWorktree(
groupsByWorktree: Record<string, TabGroup[]>,
groupId: string
): { group: TabGroup; worktreeId: string } | null {
for (const [worktreeId, groups] of Object.entries(groupsByWorktree)) {
const group = groups.find((candidate) => candidate.id === groupId)
if (group) {
return { group, worktreeId }
}
}
return null
}
export function findTabByEntityInGroup(
tabsByWorktree: Record<string, Tab[]>,
worktreeId: string,
groupId: string,
entityId: string,
contentType?: Tab['contentType']
): Tab | null {
const tabs = tabsByWorktree[worktreeId] ?? []
return (
tabs.find(
(tab) =>
tab.groupId === groupId &&
tab.entityId === entityId &&
(contentType ? tab.contentType === contentType : true)
) ?? null
)
}
export function ensureGroup(
groupsByWorktree: Record<string, TabGroup[]>,
activeGroupIdByWorktree: Record<string, string>,
worktreeId: string
worktreeId: string,
preferredGroupId?: string
): {
group: TabGroup
groupsByWorktree: Record<string, TabGroup[]>
activeGroupIdByWorktree: Record<string, string>
} {
const existing = groupsByWorktree[worktreeId]?.[0]
const existing =
groupsByWorktree[worktreeId]?.find((group) => group.id === preferredGroupId) ??
groupsByWorktree[worktreeId]?.[0]
if (existing) {
return { group: existing, groupsByWorktree, activeGroupIdByWorktree }
}
@ -63,6 +97,33 @@ export function updateGroup(groups: TabGroup[], updated: TabGroup): TabGroup[] {
return groups.map((g) => (g.id === updated.id ? updated : g))
}
export function isTransientEditorContentType(contentType: TabContentType): boolean {
return contentType === 'diff' || contentType === 'conflict-review'
}
export function getPersistedEditFileIdsByWorktree(
session: WorkspaceSessionState
): Record<string, Set<string>> {
return Object.fromEntries(
Object.entries(session.openFilesByWorktree ?? {}).map(([worktreeId, files]) => [
worktreeId,
new Set(files.map((file) => file.filePath))
])
)
}
export function selectHydratedActiveGroupId(
groups: TabGroup[],
persistedActiveGroupId?: string
): string | undefined {
const preferredGroups = groups.filter((group) => group.tabOrder.length > 0)
const candidates = preferredGroups.length > 0 ? preferredGroups : groups
if (persistedActiveGroupId && candidates.some((group) => group.id === persistedActiveGroupId)) {
return persistedActiveGroupId
}
return candidates[0]?.id
}
/**
* Apply a partial update to a single tab, returning the new `unifiedTabsByWorktree`
* map. Returns `null` if the tab is not found (callers should return `{}` to the

View file

@ -1,9 +1,20 @@
import type { Tab, TabGroup, WorkspaceSessionState } from '../../../../shared/types'
import type {
Tab,
TabGroup,
TabGroupLayoutNode,
WorkspaceSessionState
} from '../../../../shared/types'
import {
getPersistedEditFileIdsByWorktree,
isTransientEditorContentType,
selectHydratedActiveGroupId
} from './tabs-helpers'
type HydratedTabState = {
unifiedTabsByWorktree: Record<string, Tab[]>
groupsByWorktree: Record<string, TabGroup[]>
activeGroupIdByWorktree: Record<string, string>
layoutByWorktree: Record<string, TabGroupLayoutNode>
}
function hydrateUnifiedFormat(
@ -13,6 +24,8 @@ function hydrateUnifiedFormat(
const tabsByWorktree: Record<string, Tab[]> = {}
const groupsByWorktree: Record<string, TabGroup[]> = {}
const activeGroupIdByWorktree: Record<string, string> = {}
const layoutByWorktree: Record<string, TabGroupLayoutNode> = {}
const persistedEditFileIdsByWorktree = getPersistedEditFileIdsByWorktree(session)
for (const [worktreeId, tabs] of Object.entries(session.unifiedTabs!)) {
if (!validWorktreeIds.has(worktreeId)) {
@ -21,9 +34,22 @@ function hydrateUnifiedFormat(
if (tabs.length === 0) {
continue
}
tabsByWorktree[worktreeId] = [...tabs].sort(
(a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt
)
const persistedEditFileIds = persistedEditFileIdsByWorktree[worktreeId] ?? new Set<string>()
tabsByWorktree[worktreeId] = [...tabs]
.map((tab) => ({
...tab,
entityId: tab.entityId ?? tab.id
}))
.filter((tab) => {
if (!isTransientEditorContentType(tab.contentType)) {
return true
}
// Why: restore skips backing editor state for transient diff/conflict
// items. Hydration must drop their tab chrome too or the split group
// comes back pointing at a document that no longer exists.
return persistedEditFileIds.has(tab.entityId)
})
.sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt)
}
for (const [worktreeId, groups] of Object.entries(session.tabGroups!)) {
@ -35,17 +61,35 @@ function hydrateUnifiedFormat(
}
const validTabIds = new Set((tabsByWorktree[worktreeId] ?? []).map((t) => t.id))
const validatedGroups = groups.map((g) => ({
...g,
tabOrder: g.tabOrder.filter((tid) => validTabIds.has(tid)),
activeTabId: g.activeTabId && validTabIds.has(g.activeTabId) ? g.activeTabId : null
}))
const validatedGroups = groups.map((g) => {
const tabOrder = g.tabOrder.filter((tid) => validTabIds.has(tid))
return {
...g,
tabOrder,
activeTabId: g.activeTabId && validTabIds.has(g.activeTabId) ? g.activeTabId : null
}
})
groupsByWorktree[worktreeId] = validatedGroups
activeGroupIdByWorktree[worktreeId] = validatedGroups[0].id
const activeGroupId = selectHydratedActiveGroupId(
validatedGroups,
session.activeGroupIdByWorktree?.[worktreeId]
)
if (activeGroupId) {
activeGroupIdByWorktree[worktreeId] = activeGroupId
}
layoutByWorktree[worktreeId] = session.tabGroupLayouts?.[worktreeId] ?? {
type: 'leaf',
groupId: validatedGroups[0].id
}
}
return { unifiedTabsByWorktree: tabsByWorktree, groupsByWorktree, activeGroupIdByWorktree }
return {
unifiedTabsByWorktree: tabsByWorktree,
groupsByWorktree,
activeGroupIdByWorktree,
layoutByWorktree
}
}
function hydrateLegacyFormat(
@ -55,6 +99,7 @@ function hydrateLegacyFormat(
const tabsByWorktree: Record<string, Tab[]> = {}
const groupsByWorktree: Record<string, TabGroup[]> = {}
const activeGroupIdByWorktree: Record<string, string> = {}
const layoutByWorktree: Record<string, TabGroupLayoutNode> = {}
for (const worktreeId of validWorktreeIds) {
const terminalTabs = session.tabsByWorktree[worktreeId] ?? []
@ -118,9 +163,15 @@ function hydrateLegacyFormat(
tabsByWorktree[worktreeId] = tabs
groupsByWorktree[worktreeId] = [{ id: groupId, worktreeId, activeTabId, tabOrder }]
activeGroupIdByWorktree[worktreeId] = groupId
layoutByWorktree[worktreeId] = { type: 'leaf', groupId }
}
return { unifiedTabsByWorktree: tabsByWorktree, groupsByWorktree, activeGroupIdByWorktree }
return {
unifiedTabsByWorktree: tabsByWorktree,
groupsByWorktree,
activeGroupIdByWorktree,
layoutByWorktree
}
}
export function buildHydratedTabState(

View file

@ -293,6 +293,108 @@ describe('TabsSlice', () => {
})
})
describe('setTabGroupSplitRatio', () => {
it('updates the persisted ratio for the targeted split node', () => {
store.setState({
layoutByWorktree: {
[WT]: {
type: 'split',
direction: 'horizontal',
ratio: 0.5,
first: { type: 'leaf', groupId: 'g-1' },
second: {
type: 'split',
direction: 'vertical',
ratio: 0.5,
first: { type: 'leaf', groupId: 'g-2' },
second: { type: 'leaf', groupId: 'g-3' }
}
}
}
})
store.getState().setTabGroupSplitRatio(WT, 'second', 0.7)
const layout = store.getState().layoutByWorktree[WT]
expect(layout.type).toBe('split')
if (layout.type !== 'split' || layout.second.type !== 'split') {
throw new Error('expected nested split layout')
}
expect(layout.ratio).toBe(0.5)
expect(layout.second.ratio).toBe(0.7)
})
})
describe('move/copy/merge group operations', () => {
it('moves a unified tab into another group', () => {
const tab = store.getState().createUnifiedTab(WT, 'editor', {
id: 'file-a.ts',
label: 'file-a.ts'
})
const sourceGroupId = store.getState().groupsByWorktree[WT][0].id
const targetGroupId = store.getState().createEmptySplitGroup(WT, sourceGroupId, 'right')
expect(targetGroupId).toBeTruthy()
store.getState().moveUnifiedTabToGroup(tab.id, targetGroupId!)
const state = store.getState()
const moved = state.unifiedTabsByWorktree[WT].find((item) => item.id === tab.id)
expect(moved?.groupId).toBe(targetGroupId)
expect(
state.groupsByWorktree[WT].find((group) => group.id === sourceGroupId)?.tabOrder
).toEqual([])
expect(
state.groupsByWorktree[WT].find((group) => group.id === targetGroupId)?.tabOrder
).toEqual([tab.id])
})
it('copies a unified tab into another group', () => {
const tab = store.getState().createUnifiedTab(WT, 'editor', {
id: 'file-a.ts',
label: 'file-a.ts'
})
const sourceGroupId = store.getState().groupsByWorktree[WT][0].id
const targetGroupId = store.getState().createEmptySplitGroup(WT, sourceGroupId, 'right')
expect(targetGroupId).toBeTruthy()
const copied = store.getState().copyUnifiedTabToGroup(tab.id, targetGroupId!)
expect(copied).not.toBeNull()
const state = store.getState()
expect(state.unifiedTabsByWorktree[WT]).toHaveLength(2)
expect(
state.groupsByWorktree[WT].find((group) => group.id === sourceGroupId)?.tabOrder
).toEqual([tab.id])
expect(
state.groupsByWorktree[WT].find((group) => group.id === targetGroupId)?.tabOrder
).toEqual([copied!.id])
expect(copied?.entityId).toBe(tab.entityId)
})
it('merges a group into its sibling', () => {
const t1 = store.getState().createUnifiedTab(WT, 'editor', {
id: 'file-a.ts',
label: 'file-a.ts'
})
const sourceGroupId = store.getState().groupsByWorktree[WT][0].id
const targetGroupId = store.getState().createEmptySplitGroup(WT, sourceGroupId, 'right')
expect(targetGroupId).toBeTruthy()
store.getState().createUnifiedTab(WT, 'editor', {
id: 'file-b.ts',
label: 'file-b.ts',
targetGroupId: targetGroupId!
})
const mergedInto = store.getState().mergeGroupIntoSibling(WT, targetGroupId!)
expect(mergedInto).toBe(sourceGroupId)
const state = store.getState()
expect(state.groupsByWorktree[WT]).toHaveLength(1)
expect(state.groupsByWorktree[WT][0].tabOrder).toEqual([t1.id, 'file-b.ts'])
expect(state.layoutByWorktree[WT]).toEqual({ type: 'leaf', groupId: sourceGroupId })
})
})
// ─── setTabLabel / setTabCustomLabel / setUnifiedTabColor ─────────
describe('tab property setters', () => {
@ -661,4 +763,48 @@ describe('TabsSlice', () => {
expect(store.getState().groupsByWorktree[WT][0].activeTabId).toBe(term.id)
})
})
describe('reconcileWorktreeTabModel', () => {
it('drops unified tabs whose backing content no longer exists', () => {
const groupId = 'g-1'
store.setState({
unifiedTabsByWorktree: {
[WT]: [
{
id: 'stale-terminal',
entityId: 'stale-terminal',
groupId,
worktreeId: WT,
contentType: 'terminal',
label: 'Terminal 1',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
groupsByWorktree: {
[WT]: [
{
id: groupId,
worktreeId: WT,
activeTabId: 'stale-terminal',
tabOrder: ['stale-terminal']
}
]
},
activeGroupIdByWorktree: { [WT]: groupId },
tabsByWorktree: { [WT]: [] }
})
const result = store.getState().reconcileWorktreeTabModel(WT)
expect(result.renderableTabCount).toBe(0)
expect(result.activeRenderableTabId).toBeNull()
expect(store.getState().unifiedTabsByWorktree[WT]).toEqual([])
expect(store.getState().groupsByWorktree[WT][0].tabOrder).toEqual([])
expect(store.getState().groupsByWorktree[WT][0].activeTabId).toBeNull()
})
})
})

File diff suppressed because it is too large Load diff

View file

@ -58,6 +58,20 @@ export type WorktreeMeta = {
lastActivityAt: number
}
// ─── Tab Group Layout ───────────────────────────────────────────────
export type TabGroupSplitDirection = 'horizontal' | 'vertical'
export type TabGroupLayoutNode =
| { type: 'leaf'; groupId: string }
| {
type: 'split'
direction: TabGroupSplitDirection
first: TabGroupLayoutNode
second: TabGroupLayoutNode
/** Flex ratio of the first child (01). Defaults to 0.5 if absent. */
ratio?: number
}
// ─── Unified Tab ────────────────────────────────────────────────────
export type TabContentType = 'terminal' | 'editor' | 'diff' | 'conflict-review' | 'browser'
@ -197,6 +211,9 @@ export type TerminalLayoutSnapshot = {
root: TerminalPaneLayoutNode | null
activeLeafId: string | null
expandedLeafId: string | null
/** Live PTY IDs per leaf for in-session remounts such as tab-group moves.
* Not used for app restart because PTYs are transient processes. */
ptyIdsByLeafId?: Record<string, string>
/** Serialized terminal buffers per leaf for scrollback restoration on restart. */
buffersByLeafId?: Record<string, string>
/** User-assigned pane titles, keyed by leafId (e.g. "pane:3").
@ -246,6 +263,10 @@ export type WorkspaceSessionState = {
unifiedTabs?: Record<string, Tab[]>
/** Tab group model — present alongside unifiedTabs. */
tabGroups?: Record<string, TabGroup[]>
/** Persisted split layout tree per worktree. */
tabGroupLayouts?: Record<string, TabGroupLayoutNode>
/** Per-worktree focused group at shutdown. */
activeGroupIdByWorktree?: Record<string, string>
}
// ─── GitHub ──────────────────────────────────────────────────────────