From 4e60fa0a528095767378beb64c4b33334a051de2 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 3 May 2026 16:44:00 -0400 Subject: [PATCH] fix(pane-manager): bump nextPaneId past highest persisted pane:N on replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: PaneManager.nextPaneId is a per-instance monotonic counter that resets to 1 when the app restarts. replayTerminalLayout rewrites the persisted pane:N leafIds verbatim, so the next user-triggered split would mint a leafId colliding with an existing one. With the new deterministic sessionId path that collision matters materially: both panes would derive the same (worktreeId, tabId, leafId) triple and share one history directory, interleaving their scrollback. Adds PaneManager.advanceNextPaneIdTo(min) (forward-only) and a strict /^pane:(\d+)$/ parser that walks the replayed snapshot. Non-matching leafIds (legacy or corrupted) are skipped rather than thrown on — the counter advance is best-effort safety. Co-authored-by: Orca --- .../terminal-pane/layout-serialization.ts | 43 +++++++++++++++++++ .../src/lib/pane-manager/pane-manager.ts | 13 ++++++ 2 files changed, 56 insertions(+) diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts index bab1a94dd1d..2cc2cdf6409 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts @@ -283,6 +283,43 @@ export function restoreScrollbackBuffers( } } +/** + * Why: the snapshot stores leafIds as `pane:` strings rewritten verbatim + * on restore. After replay we must advance PaneManager.nextPaneId past + * `max(N)` so the next user-triggered split does not mint a pane whose + * `paneLeafId` collides with a persisted leaf — a collision would collapse + * the determinism of sessionIds derived from `(worktreeId, tabId, leafId)`. + * + * Parser: `/^pane:(\d+)$/`. Non-matching entries (e.g. a legacy custom + * leafId shape, or a corrupted snapshot) are skipped rather than thrown + * on — the counter advance is a best-effort safety; if the highest + * numeric leaf is missed we fall back to the per-instance monotonic + * default, which at worst surfaces the original collision bug, not a + * new crash. + */ +function highestPaneNInSnapshot(node: TerminalPaneLayoutNode | null | undefined): number { + if (!node) { + return 0 + } + let max = 0 + const visit = (n: TerminalPaneLayoutNode): void => { + if (n.type === 'leaf') { + const match = /^pane:(\d+)$/.exec(n.leafId) + if (match) { + const parsed = Number(match[1]) + if (Number.isFinite(parsed) && parsed > max) { + max = parsed + } + } + return + } + visit(n.first) + visit(n.second) + } + visit(node) + return max +} + export function replayTerminalLayout( manager: PaneManager, snapshot: TerminalLayoutSnapshot | null | undefined, @@ -315,5 +352,11 @@ export function replayTerminalLayout( } restoreNode(snapshot.root, initialPane.id) + + // Why: bump PaneManager's counter past the highest persisted `pane:N` + // before any user-triggered split runs. See highestPaneNInSnapshot. + const maxPersisted = highestPaneNInSnapshot(snapshot.root) + manager.advanceNextPaneIdTo(maxPersisted + 1) + return paneByLeafId } diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 9fe3c0fb7b5..e4802e48fc8 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -1,3 +1,7 @@ +/* eslint-disable max-lines -- Why: PaneManager owns the DOM, WebGL, +drag-reorder, and counter-advance state for the terminal-pane tree. Splitting +further would scatter invariants (active pane focus, nextPaneId monotonicity, +divider styles) across files with no cleaner ownership seam. */ import type { PaneManagerOptions, PaneStyleOptions, @@ -62,6 +66,15 @@ export class PaneManager { this.renderingSuspended = options.initialRenderingSuspended === true } + // Why: nextPaneId resets to 1 on restart; persisted pane:N leafIds are + // replayed verbatim. Bumping past max(N) prevents the next split from + // colliding — otherwise two panes derive the same deterministic history dir. + advanceNextPaneIdTo(minNextId: number): void { + if (minNextId > this.nextPaneId) { + this.nextPaneId = minNextId + } + } + createInitialPane(opts?: { focus?: boolean }): ManagedPane { const pane = this.createPaneInternal() Object.assign(pane.container.style, {