From dd5b985d26ba7b287a3c632adc66979c799baec1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Feb 2026 17:55:47 -0800 Subject: [PATCH 1/2] Handle terminal exit writes and auto-close exited terminal view - Ignore late write calls after a terminal session has already exited - Add regression test for trailing writes after exit - Close terminal drawer session once an exit event is handled --- .../server/src/terminal/Layers/Manager.test.ts | 15 +++++++++++++++ apps/server/src/terminal/Layers/Manager.ts | 3 +++ .../src/components/ThreadTerminalDrawer.tsx | 18 ++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 304b49d42b79..0d45bfcb294b 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -394,6 +394,21 @@ describe("TerminalManager", () => { manager.dispose(); }); + it("ignores trailing writes after terminal exit", async () => { + const { manager, ptyAdapter } = makeManager(); + await manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitExit({ exitCode: 0, signal: 0 }); + + await expect(manager.write({ threadId: "thread-1", data: "\r" })).resolves.toBeUndefined(); + expect(process.writes).toEqual([]); + + manager.dispose(); + }); + it("emits subprocess activity events when child-process state changes", async () => { let hasRunningSubprocess = false; const { manager } = makeManager(5, { diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index fa766da570f1..a0e4ad705ccf 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -438,6 +438,9 @@ export class TerminalManagerRuntime extends EventEmitter const input = decodeTerminalWriteInput(raw); const session = this.requireSession(input.threadId, input.terminalId); if (!session.process || session.status !== "running") { + if (session.status === "exited") { + return; + } throw new Error( `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`, ); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index ab86780487a8..494adf36b4d0 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -112,6 +112,7 @@ interface TerminalViewportProps { terminalId: string; cwd: string; runtimeEnv?: Record; + onSessionExited: () => void; focusRequestId: number; autoFocus: boolean; resizeEpoch: number; @@ -123,6 +124,7 @@ function TerminalViewport({ terminalId, cwd, runtimeEnv, + onSessionExited, focusRequestId, autoFocus, resizeEpoch, @@ -131,6 +133,12 @@ function TerminalViewport({ const containerRef = useRef(null); const terminalRef = useRef(null); const fitAddonRef = useRef(null); + const onSessionExitedRef = useRef(onSessionExited); + const hasHandledExitRef = useRef(false); + + useEffect(() => { + onSessionExitedRef.current = onSessionExited; + }, [onSessionExited]); useEffect(() => { const mount = containerRef.current; @@ -306,6 +314,7 @@ function TerminalViewport({ } if (event.type === "started" || event.type === "restarted") { + hasHandledExitRef.current = false; activeTerminal.write("\u001bc"); if (event.snapshot.history.length > 0) { activeTerminal.write(event.snapshot.history); @@ -335,6 +344,13 @@ function TerminalViewport({ activeTerminal, details.length > 0 ? `Process exited (${details})` : "Process exited", ); + if (hasHandledExitRef.current) { + return; + } + hasHandledExitRef.current = true; + window.setTimeout(() => { + onSessionExitedRef.current(); + }, 0); } }); @@ -783,6 +799,7 @@ export default function ThreadTerminalDrawer({ terminalId={terminalId} cwd={cwd} {...(runtimeEnv ? { runtimeEnv } : {})} + onSessionExited={() => onCloseTerminal(terminalId)} focusRequestId={focusRequestId} autoFocus={terminalId === resolvedActiveTerminalId} resizeEpoch={resizeEpoch} @@ -800,6 +817,7 @@ export default function ThreadTerminalDrawer({ terminalId={resolvedActiveTerminalId} cwd={cwd} {...(runtimeEnv ? { runtimeEnv } : {})} + onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)} focusRequestId={focusRequestId} autoFocus resizeEpoch={resizeEpoch} From e6fd90df22c6d28bda7a918dd379e9ecfdc81883 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Feb 2026 18:02:09 -0800 Subject: [PATCH 2/2] Guard delayed terminal exit callback - Skip `onSessionExited` if exit handling was reset before timeout runs - Prevent stale async exit callbacks from firing in terminal drawer --- apps/web/src/components/ThreadTerminalDrawer.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 494adf36b4d0..c2653a90dada 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -349,6 +349,9 @@ function TerminalViewport({ } hasHandledExitRef.current = true; window.setTimeout(() => { + if (!hasHandledExitRef.current) { + return; + } onSessionExitedRef.current(); }, 0); }