fix(pane-manager): bump nextPaneId past highest persisted pane:N on replay

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 <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-04 16:24:23 -04:00
co-authored by Orca
parent 1c79b298f2
commit 4e60fa0a52
2 changed files with 56 additions and 0 deletions
@@ -283,6 +283,43 @@ export function restoreScrollbackBuffers(
}
}
/**
* Why: the snapshot stores leafIds as `pane:<N>` 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
}
@@ -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, {