From 38a3d94410d1126b8fcfee9ac6974f8626d5d739 Mon Sep 17 00:00:00 2001 From: wangqiongkaka <34996871+wangqiongkaka@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:11:57 +0800 Subject: [PATCH] fix: prune empty restored panes (#268) Co-authored-by: wangqiong --- src/client/state.ts | 29 ++++++++++++++++++++++++----- tests/state.spec.ts | 33 ++++++++++++++++++++++++++++----- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/src/client/state.ts b/src/client/state.ts index e6977ca..d576607 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -825,8 +825,9 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined { // bottom tree. const seen = new Set() const reid = new Map() - const splits = sanitizeNode(record.splits, seen, reid) - if (splits === undefined) return undefined + const restoredSplits = sanitizeNode(record.splits, seen, reid) + if (restoredSplits === undefined) return undefined + const splits = pruneEmptyPanes(restoredSplits) // Bottom-panel fields arrived in a later build: a missing or malformed // value on an OLDER persisted state defaults (closed / default height / // empty pane) so existing layouts keep loading, like nextBrowser. @@ -840,15 +841,23 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined { ? record.bottomHeight : BOTTOM_DEFAULT const bottomHeight = Math.min(bottomCap, Math.max(BOTTOM_MIN, Math.round(rawHeight))) - const bottomSplits = sanitizeNode(record.bottomSplits, seen, reid) - ?? { kind: 'leaf' as const, id: uid('pane'), tabs: [], active: null } + const bottomSplits = pruneEmptyPanes(sanitizeNode(record.bottomSplits, seen, reid) + ?? { kind: 'leaf' as const, id: uid('pane'), tabs: [], active: null }) + const requestedActivePane = typeof record.activePane === 'string' + ? (reid.get(record.activePane) ?? record.activePane) + : null + const activePane = requestedActivePane === null + ? null + : treeHasId(splits, requestedActivePane) || treeHasId(bottomSplits, requestedActivePane) + ? requestedActivePane + : firstLeaf(splits).id const maxWidth = typeof window !== 'undefined' ? window.innerWidth : Infinity return { panelOpen: record.panelOpen, width: Math.max(PANEL_MIN, Math.min(record.width, maxWidth)), // A stale duplicate pane id may have been re-ided; follow the rename so // new tabs still land in the pane the user was using. - activePane: typeof record.activePane === 'string' ? (reid.get(record.activePane) ?? record.activePane) : null, + activePane, nextTerminal: record.nextTerminal, nextBrowser, expanded: record.expanded as string[], @@ -863,6 +872,16 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined { } } +/** Collapse persisted split panes left empty after ephemeral diff tabs are dropped. */ +function pruneEmptyPanes(node: SplitNode): SplitNode { + const leaves = allLeaves(node) + if (!leaves.some(leaf => leaf.tabs.length > 0)) return node + return leaves.reduce( + (tree, leaf) => leaf.tabs.length === 0 ? removeLeafAt(tree, leaf.id) : tree, + node, + ) +} + /** * One tree node id, deduplicated against the ids already seen in this * state. Duplicates are exactly the pre-seeding counter-reset corruption diff --git a/tests/state.spec.ts b/tests/state.spec.ts index 50df04c..2d1f952 100644 --- a/tests/state.spec.ts +++ b/tests/state.spec.ts @@ -188,6 +188,29 @@ describe('sidebar state', () => { expect((onlyDiff?.splits as { tabs: SidebarTab[] }).tabs).toEqual([]) }) + it('sanitize removes a pane emptied by ephemeral diff tabs', () => { + const valid = sanitizeState({ + panelOpen: true, + width: 400, + nextTerminal: 1, + activePane: 'pane:diff', + expanded: [], + splits: { + kind: 'split', + id: 'split:1', + dir: 'col', + sizes: [0.5, 0.5], + children: [ + { kind: 'leaf', id: 'pane:git', active: 'git', tabs: [{ id: 'git', type: 'git', title: 'Git' }] }, + { kind: 'leaf', id: 'pane:diff', active: 'd1', tabs: [{ id: 'd1', type: 'diff', title: 'a.ts' }] }, + ], + }, + }) + expect(valid?.splits.kind).toBe('leaf') + expect((valid?.splits as { id: string; tabs: SidebarTab[] }).id).toBe('pane:git') + expect(valid?.activePane).toBe('pane:git') + }) + it('dedupes the single-instance subagent tab (focuses instead of duplicating)', () => { let s = state() s = openTabInActivePane(s, { id: 'subagent', type: 'subagent', title: 'Subagents' }) @@ -716,11 +739,11 @@ describe('persisted state sanitization', () => { const clean = sanitizeState(corrupted) expect(clean).toBeDefined() const leaves = allLeaves(clean!.splits) - // The first occurrence keeps its id; the repeat gets a fresh unique one - // (exact suffix depends on the module-level uid counter, so assert shape). - expect(leaves[0]!.id).toBe('pane:1') - expect(new Set(leaves.map(leaf => leaf.id)).size).toBe(2) - expect(clean!.activePane).toBe(leaves[1]!.id) + // The empty first occurrence is pruned; the populated repeat keeps its + // fresh unique id and becomes active. + expect(leaves).toHaveLength(1) + expect(leaves[0]!.id).not.toBe('pane:1') + expect(clean!.activePane).toBe(leaves[0]!.id) // And an open must land in exactly one pane of the healed tree. const opened = openTabInActivePane(clean!, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' }) const owners = allLeaves(opened.splits).filter(leaf => leaf.tabs.some(tab => tab.path === '/a.ts'))