terminal: keep the pty alive while its tab is open, whatever the view does
The close frame was sent on every unmount, so switching conversations (or refreshing) killed the shell even though the tab was still open; coming back spawned a fresh terminal. Now the unmount close frame only fires when the tab is truly gone (checked against the per-session store state), so a bare unmount hands the process to the host's reconnect grace and the next connect reattaches the SAME shell with its transcript. Closing a tab releases the quota immediately: the socket-open path keeps the close frame, and a new pty.close API covers the socket-down (reconnect loop) case that previously left a 30s zombie holding the quota. The host also records the spawn cwd and respawns when a reconnect carries a different authoritative cwd — the page-load hydrate race can attach the real session cwd after the first connect fell back to the process cwd, and a shell in the wrong directory must not linger.
This commit is contained in:
@@ -125,6 +125,11 @@ export function allLeaves(node: SplitNode): SidebarLeaf[] {
|
||||
return node.children.flatMap(allLeaves)
|
||||
}
|
||||
|
||||
/** Whether a tab exists anywhere in a state tree (any pane). */
|
||||
export function tabOpenIn(state: SidebarState, tabId: string): boolean {
|
||||
return allLeaves(state.splits).some(leaf => leaf.tabs.some(tab => tab.id === tabId))
|
||||
}
|
||||
|
||||
/** Replace a leaf with a split of it plus a fresh empty leaf. */
|
||||
export function splitLeafAt(node: SplitNode, paneId: string, dir: 'row' | 'col'): SplitNode {
|
||||
const fresh: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
|
||||
@@ -506,6 +511,21 @@ export class SidebarStore {
|
||||
this.notify()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a tab still exists in its session's state. Views use this on
|
||||
* unmount to tell "the tab was closed" (release the terminal now) from
|
||||
* "the tree re-rendered / the conversation switched" (the tab is still
|
||||
* open — keep the terminal alive through the host's reconnect grace).
|
||||
* Checks the session's own map entry (the current snapshot may already
|
||||
* point at another session when a conversation switch unmounts the old
|
||||
* one's tabs).
|
||||
*/
|
||||
tabOpen(sessionId: string, tabId: string): boolean {
|
||||
const state = this.bySession.get(sessionId)
|
||||
?? (this.snapshot.sessionId === sessionId ? this.snapshot.state : undefined)
|
||||
return state !== undefined && tabOpenIn(state, tabId)
|
||||
}
|
||||
|
||||
/** Apply a pure reducer (returns the next state). */
|
||||
reduce(reducer: (state: SidebarState) => SidebarState): void {
|
||||
const sessionId = this.snapshot.sessionId
|
||||
|
||||
Reference in New Issue
Block a user