fix(terminal): keep pty alive across conversation switches via park frame (#323)

Switching from session A to B unmounted A's TerminalView, whose socket
close the host treated as a transient drop and started the 30s reconnect-
grace countdown — leaving the user in B for >30s killed A's shell, so
switching back respawned a fresh one instead of reattaching.

Add a third control frame {type:'park'} distinct from refresh/crash
drops: the client sends it on unmount when the tab is still open in its
session's state but the active session has moved on; the host marks the
pty as parked and skips the grace countdown. Switching back (open()
cancels the parked state) or explicitly closing the tab (close frame's
scheduleClose clears it) resume the normal lifecycle. Agent terminals
skip park — their lifetime is already indefinite on a bare drop.
This commit is contained in:
多玩幻灵qwq
2026-08-22 17:21:27 +08:00
committed by GitHub
parent b7aab48deb
commit 29b8615bff
4 changed files with 163 additions and 19 deletions
+38 -13
View File
@@ -8,16 +8,28 @@
* retry, and repeated unreasoned failures surface the close code after three
* attempts, so the banner never spins forever.
*
* Three control frames shape the pty lifecycle on unmount:
* - `{type:'close'}` — the user closed the tab. The host kills the pty
* immediately (quota released).
* - `{type:'park'}` — the user switched to another conversation. The tab is
* still open in its session's persisted state but its view unmounted; the
* host keeps the pty alive indefinitely (no grace countdown), so switching
* back reattaches the same shell instead of respawning one.
* - bare socket drop (no frame) — page refresh, crash, plugin teardown, or a
* same-session re-render. The host's reconnect grace keeps the shell alive
* for a quick reconnect.
*
* Two attach modes share one upgrade endpoint:
* - `tabId` starting with `agent:` is an agent-owned terminal (created by
* the `terminal_create` tool). The uuid is the suffix after `agent:`; the
* view connects with `?uuid=...`. A close frame kills the pty (the agent's
* terminal closes when the user closes the tab); a bare socket drop
* leaves the pty alive (the agent owns the lifetime).
* leaves the pty alive (the agent owns the lifetime) — agent terminals
* never send park (their lifetime is already indefinite on bare drop).
* - Any other `tabId` is a UI-tab terminal (the user created it from the +
* menu). The view connects with `?tab=...&sessionId=...&cwd=...`. A close
* frame schedules a 0-ms close; a bare socket drop gets the host's
* reconnect grace.
* frame schedules a 0-ms close; a park frame marks the pty as parked; a
* bare socket drop gets the host's reconnect grace.
*/
import { useEffect, useRef, useState } from 'react'
import { Terminal, type ITheme } from '@xterm/xterm'
@@ -274,18 +286,31 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
fontSub()
schemeSub()
inputSub.dispose()
// The close frame tells the host the owning tab is GONE (immediate
// pty release). A bare unmount — conversation switch, re-render,
// page unload — leaves the tab open, so the socket drop alone hands
// the process to the host's reconnect grace: switching back or
// refreshing reattaches the SAME shell instead of respawning one.
// (The host respawns on its own when the authoritative cwd changed.)
// Agent terminals follow the same rule: a close frame kills the pty
// (the user closed the sidebar tab); a bare socket drop leaves it
// alive (the agent owns the lifetime).
if (!store.tabOpen(scope.sessionId, tabId)
// Three unmount cases, distinguished by the store's tab/open state and
// the active session id:
// 1. The tab was closed by the user (NOT in its session's state): send
// `{type:'close'}` — the host releases the pty immediately.
// 2. The user switched to another conversation (the tab IS still open
// in scope.sessionId's state, but the active session is now a
// different one): send `{type:'park'}` — the host keeps the pty
// alive indefinitely (no grace countdown), so switching back
// reattaches the SAME shell. Without this, the bare socket drop
// would start the 30s reconnect-grace countdown and kill the shell
// while the user is still actively working in the other session.
// 3. A same-session unmount (page refresh, crash, plugin teardown, a
// re-render that re-mounts the view): bare socket drop — the host's
// reconnect grace keeps the shell alive for a quick reconnect.
// Agent terminals follow the close-frame rule; their lifetime is owned
// by the agent, so a bare drop (case 3) already leaves them alive
// indefinitely — no park frame needed.
const tabStillOpen = store.tabOpen(scope.sessionId, tabId)
const sessionSwitched = store.getSnapshot().sessionId !== scope.sessionId
if (!tabStillOpen
&& socket !== null && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'close' }))
} else if (tabStillOpen && sessionSwitched && !isAgentTabId(tabId)
&& socket !== null && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'park' }))
}
socket?.close()
term.dispose()