diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 9517e6ba9f1..213b0a600b6 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -6590,6 +6590,25 @@ export class Store { return normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID } + /** + * Re-run the partition hoist against current state. + * + * Why this is not construction-only: `ssh:` is a LIVE plane. A tab the CLI or a headless + * surface creates mid-session lands there, `local` never learns of it, and a reattach then + * refuses it as `noTab` — the shell runs on unbound and its tab stays invisible until the app + * restarts. Re-hoisting on that refusal closes the window without polling. + */ + hoistSshPartitionsNow(): boolean { + const changed = hoistSshPartitionsIntoLocalSession( + this.state, + this.state.workspaceSessionsByHostId + ) + if (changed) { + this.scheduleSave() + } + return changed + } + /** Read once, immediately after a refused `persistPtyBinding`. Clearing on read keeps a stale * reason from being attributed to a later call that never set one. */ consumePtyBindingRefusalReason(): PtyBindingRefusalReason | undefined { diff --git a/src/main/ssh-partition-upgrade-tab-loss.test.ts b/src/main/ssh-partition-upgrade-tab-loss.test.ts index 0a6843272c0..146ef040f44 100644 --- a/src/main/ssh-partition-upgrade-tab-loss.test.ts +++ b/src/main/ssh-partition-upgrade-tab-loss.test.ts @@ -439,4 +439,40 @@ describe('an SSH pane whose membership was persisted into the ssh partition', () 'a local-only layout edit reached through into the ssh partition' ).toBe(PTY) }) + + // `ssh:` is a LIVE plane: the CLI and headless surfaces still write it. A tab created + // there mid-session is not in `local`, so a reattach refuses it as noTab and its shell runs on + // unbound. Re-hoisting on demand closes that window without waiting for a restart. + it('folds in a tab the partition gained mid-session, without a restart', async () => { + const before = await createStore() + before.setWorkspaceSession(paneSession() as never, SSH_PARTITION) + before.flushOrThrow() + stripHoistMarker() + + const store = await reopenStore() + expect(store.getWorkspaceSession().tabsByWorktree?.[WORKTREE] ?? []).toHaveLength(1) + + // The CLI adds a pane to the partition while the app is running. + const grown = paneSession() + grown.tabsByWorktree[WORKTREE] = [ + ...grown.tabsByWorktree[WORKTREE], + { ...grown.tabsByWorktree[WORKTREE][0], id: 'tab-cli' } + ] + grown.terminalLayoutsByTabId = { + ...grown.terminalLayoutsByTabId, + 'tab-cli': { + root: { type: 'leaf' as const, leafId: LEAF_TWO }, + activeLeafId: LEAF_TWO, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_TWO]: PTY } + } + } as typeof grown.terminalLayoutsByTabId + store.setWorkspaceSession(grown as never, SSH_PARTITION) + + expect(store.hoistSshPartitionsNow(), 'the on-demand hoist reported no change').toBe(true) + expect( + (store.getWorkspaceSession().tabsByWorktree?.[WORKTREE] ?? []).map((tab) => tab.id).sort(), + 'a tab the partition gained mid-session stayed invisible until restart' + ).toEqual(['tab-1', 'tab-cli']) + }) }) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index a292184352a..bc1e3b98d0c 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -2618,6 +2618,21 @@ export class SshRelaySession { } catch (error) { console.error('[ssh-relay-session] Failed to persist reconnect incarnation:', error) } + if (bind?.bound === false && bind.refusalReason === 'noTab') { + // The partition is a live plane, so a tab created there mid-session is not in `local` yet. + // Fold it in and try once more before concluding no pane owns this shell. + if (this.store?.hoistSshPartitionsNow()) { + bind = bindPaneShell({ + store: this.store, + worktreeId: lease.worktreeId, + tabId, + leafId: lease.leafId, + ptyId: appPtyId, + mayCreate: false, + ...(incarnationId ? { incarnationId } : {}) + }) + } + } if (bind?.bound === false && bind.refusalReason === 'noTab') { // No durable tab owns this leaf, so there is nothing to publish it against — registering // would announce a pane that does not exist. Unresolved, not dead: the shell keeps running