fix(terminals): decouple PTY lifecycle from view lifecycle (no kill on navigation)

Navigating (layout/tab switch) tore the xterm view down and called
handle.close(), killing the backend PTY and cutting off running AIs. Now
the view's cleanup only detaches; only an explicit user action kills a PTY.

Backend:
- PortablePtyAdapter: per-session scrollback ring buffer (~100KB, most
  recent) + re-subscribable fan-out broadcast replacing the single-take
  output_rx. Reader thread feeds both the ring buffer and current
  subscribers; on EOF it closes subscribers (streams end) while keeping
  scrollback for late re-attach.
- PtyPort: new scrollback() method; subscribe_output is now re-subscribable
  (all impls + test fakes updated).
- reattach_terminal IPC command: returns scrollback and re-wires a fresh
  output channel on the live session without re-spawning.
- CloseRequested hook kills all live PTYs cleanly on app shutdown.
- TerminalSessions::handles() to enumerate live sessions at shutdown.

Frontend:
- TerminalHandle.detach(); TerminalGateway/AgentGateway.reattach() + mocks.
- TerminalView cleanup detaches (never close); on mount it re-attaches to a
  persisted session (repainting scrollback) instead of opening a new PTY.
- LayoutGrid persists the cell's session id via setSession; AgentsPanel
  tracks per-agent session ids — both drive reattach-vs-open.

Tests: ring buffer bounds to 100KB keeping newest bytes; scrollback retained;
re-subscription delivers post-reattach output; TerminalView detaches (not
closes) on unmount and reattaches with a known session; mock detach/reattach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 12:24:48 +02:00
parent 307ae71857
commit 0660f52e2b
19 changed files with 879 additions and 150 deletions

View File

@ -112,7 +112,7 @@ interface LeafViewProps {
projectId: string;
}
function LeafView({ id, agent, cwd, vm, parentSplit, projectId }: LeafViewProps) {
function LeafView({ id, session, agent, cwd, vm, parentSplit, projectId }: LeafViewProps) {
const canMerge = parentSplit !== null && parentSplit.siblings > 1;
const { agent: agentGateway } = useGateways();
@ -133,6 +133,12 @@ function LeafView({ id, agent, cwd, vm, parentSplit, projectId }: LeafViewProps)
? (opts: Parameters<typeof agentGateway.launchAgent>[2], onData: (bytes: Uint8Array) => void) =>
agentGateway.launchAgent(projectId, agentId, opts, onData)
: undefined;
// Agent cells re-attach through the agent gateway; plain cells fall back to
// the terminal gateway's reattach (handled by TerminalView's default).
const reattachOpener = agentGateway && agentId
? (sessionId: string, onData: (bytes: Uint8Array) => void) =>
agentGateway.reattach(sessionId, onData)
: undefined;
return (
<div
@ -215,8 +221,17 @@ function LeafView({ id, agent, cwd, vm, parentSplit, projectId }: LeafViewProps)
</button>
)}
</div>
{/* Re-key terminal when the agent changes so xterm re-mounts with the right opener. */}
<TerminalView key={`${id}-${agentId ?? "plain"}`} cwd={cwd} open={terminalOpener} />
{/* Re-key terminal when the agent changes so xterm re-mounts with the right opener.
The cell's persisted session id drives reattach-vs-open so navigating
(layout/tab switch) never kills the PTY. */}
<TerminalView
key={`${id}-${agentId ?? "plain"}`}
cwd={cwd}
open={terminalOpener}
reattach={reattachOpener}
sessionId={session}
onSessionId={(sid) => void vm.setSession(id, sid)}
/>
</div>
);
}