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:
@ -12,6 +12,17 @@
|
||||
*
|
||||
* An optional `open` prop can override the default `terminal.openTerminal` call,
|
||||
* enabling the agent terminal to reuse this component with `agent.launchAgent`.
|
||||
*
|
||||
* **PTY lifecycle is decoupled from the view lifecycle.** Navigating (switching
|
||||
* layout or project tab) tears this view down but must NEVER kill the backend
|
||||
* PTY — otherwise running AIs would be cut off. So:
|
||||
* - On unmount the cleanup only `detach`es (drops the local output subscription)
|
||||
* and disposes xterm; it never calls `handle.close()`. Killing a PTY is an
|
||||
* explicit user action handled elsewhere (the terminal's close button).
|
||||
* - On mount, if a `sessionId` already exists for this cell (persisted by the
|
||||
* caller via `onSessionId`), the view **re-attaches** to the still-running PTY
|
||||
* — repainting its scrollback and resuming its output — instead of opening a
|
||||
* fresh one. If the session is gone (was explicitly closed), it opens fresh.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
@ -21,7 +32,11 @@ import { FitAddon } from "@xterm/addon-fit";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
import { useGateways } from "@/app/di";
|
||||
import type { OpenTerminalOptions, TerminalHandle } from "@/ports";
|
||||
import type {
|
||||
OpenTerminalOptions,
|
||||
ReattachResult,
|
||||
TerminalHandle,
|
||||
} from "@/ports";
|
||||
|
||||
interface TerminalViewProps {
|
||||
/** Working directory the shell opens in (typically the project root). */
|
||||
@ -36,9 +51,35 @@ interface TerminalViewProps {
|
||||
options: OpenTerminalOptions,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
) => Promise<TerminalHandle>;
|
||||
/**
|
||||
* Optional re-attach opener. When provided together with a {@link sessionId},
|
||||
* the view re-binds to the existing live PTY instead of opening a new one.
|
||||
* When absent, falls back to the terminal gateway's `reattach`.
|
||||
*/
|
||||
reattach?: (
|
||||
sessionId: string,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
) => Promise<ReattachResult>;
|
||||
/**
|
||||
* Persisted session id for this cell, if a PTY is already running for it.
|
||||
* Drives the reattach-vs-open decision at mount.
|
||||
*/
|
||||
sessionId?: string | null;
|
||||
/**
|
||||
* Called once a session is established (opened) so the caller can persist its
|
||||
* id for this cell and re-attach to it on the next mount. Not called on
|
||||
* reattach (the id is already known).
|
||||
*/
|
||||
onSessionId?: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export function TerminalView({ cwd, open }: TerminalViewProps) {
|
||||
export function TerminalView({
|
||||
cwd,
|
||||
open,
|
||||
reattach,
|
||||
sessionId,
|
||||
onSessionId,
|
||||
}: TerminalViewProps) {
|
||||
const { terminal } = useGateways();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@ -51,6 +92,12 @@ export function TerminalView({ cwd, open }: TerminalViewProps) {
|
||||
// so the correct opener is always captured at mount.
|
||||
const openRef = useRef(open);
|
||||
openRef.current = open;
|
||||
const reattachRef = useRef(reattach);
|
||||
reattachRef.current = reattach;
|
||||
const sessionIdRef = useRef(sessionId);
|
||||
sessionIdRef.current = sessionId;
|
||||
const onSessionIdRef = useRef(onSessionId);
|
||||
onSessionIdRef.current = onSessionId;
|
||||
const terminalRef = useRef(terminal);
|
||||
terminalRef.current = terminal;
|
||||
|
||||
@ -58,6 +105,7 @@ export function TerminalView({ cwd, open }: TerminalViewProps) {
|
||||
const container = containerRef.current;
|
||||
const tgw = terminalRef.current;
|
||||
const opener = openRef.current ?? tgw?.openTerminal.bind(tgw);
|
||||
const reattacher = reattachRef.current ?? tgw?.reattach.bind(tgw);
|
||||
if (!container || !opener) return;
|
||||
|
||||
const term = new Terminal({
|
||||
@ -94,30 +142,65 @@ export function TerminalView({ cwd, open }: TerminalViewProps) {
|
||||
else pending += data;
|
||||
});
|
||||
|
||||
opener(
|
||||
{ cwd, rows: term.rows, cols: term.cols },
|
||||
(bytes) => {
|
||||
if (!disposed) term.write(bytes);
|
||||
},
|
||||
)
|
||||
.then((h) => {
|
||||
if (disposed) {
|
||||
void h.close();
|
||||
return;
|
||||
}
|
||||
handle = h;
|
||||
if (pending) {
|
||||
void h.write(encoder.encode(pending));
|
||||
pending = "";
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!disposed) {
|
||||
term.write(
|
||||
`\r\n\x1b[31mfailed to open terminal: ${describe(e)}\x1b[0m\r\n`,
|
||||
);
|
||||
}
|
||||
});
|
||||
const onData = (bytes: Uint8Array) => {
|
||||
if (!disposed) term.write(bytes);
|
||||
};
|
||||
|
||||
// Adopt a freshly-established handle: flush buffered keystrokes. If the view
|
||||
// was disposed before the promise resolved, just detach (NEVER close — the
|
||||
// PTY must survive a transient mount/unmount).
|
||||
const adopt = (h: TerminalHandle) => {
|
||||
if (disposed) {
|
||||
h.detach();
|
||||
return;
|
||||
}
|
||||
handle = h;
|
||||
if (pending) {
|
||||
void h.write(encoder.encode(pending));
|
||||
pending = "";
|
||||
}
|
||||
};
|
||||
|
||||
const onOpenError = (e: unknown) => {
|
||||
if (!disposed) {
|
||||
term.write(
|
||||
`\r\n\x1b[31mfailed to open terminal: ${describe(e)}\x1b[0m\r\n`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Re-attach to an existing live PTY when this cell already has a session;
|
||||
// otherwise open a fresh one and persist its id for the next mount.
|
||||
const existingSession = sessionIdRef.current;
|
||||
if (existingSession && reattacher) {
|
||||
reattacher(existingSession, onData)
|
||||
.then(({ handle: h, scrollback }) => {
|
||||
if (disposed) {
|
||||
h.detach();
|
||||
return;
|
||||
}
|
||||
if (scrollback.length > 0) term.write(scrollback);
|
||||
adopt(h);
|
||||
})
|
||||
.catch(() => {
|
||||
// The session is gone (explicitly closed / exited): fall back to a
|
||||
// fresh terminal so the cell still works.
|
||||
if (disposed) return;
|
||||
opener({ cwd, rows: term.rows, cols: term.cols }, onData)
|
||||
.then((h) => {
|
||||
onSessionIdRef.current?.(h.sessionId);
|
||||
adopt(h);
|
||||
})
|
||||
.catch(onOpenError);
|
||||
});
|
||||
} else {
|
||||
opener({ cwd, rows: term.rows, cols: term.cols }, onData)
|
||||
.then((h) => {
|
||||
onSessionIdRef.current?.(h.sessionId);
|
||||
adopt(h);
|
||||
})
|
||||
.catch(onOpenError);
|
||||
}
|
||||
|
||||
// Refit + propagate size to the PTY on container resize.
|
||||
const ro = new ResizeObserver(() => {
|
||||
@ -134,7 +217,10 @@ export function TerminalView({ cwd, open }: TerminalViewProps) {
|
||||
disposed = true;
|
||||
ro.disconnect();
|
||||
onKey.dispose();
|
||||
if (handle) void handle.close();
|
||||
// DETACH, never close: tearing the view down (navigation / layout change)
|
||||
// must leave the backend PTY running so the AI isn't cut off. Killing the
|
||||
// PTY is an explicit user action handled elsewhere.
|
||||
if (handle) handle.detach();
|
||||
term.dispose();
|
||||
};
|
||||
// Only re-open on cwd change (or mount). The opener is read from a ref, and
|
||||
|
||||
Reference in New Issue
Block a user