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..c2653a90dada 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,16 @@ function TerminalViewport({ activeTerminal, details.length > 0 ? `Process exited (${details})` : "Process exited", ); + if (hasHandledExitRef.current) { + return; + } + hasHandledExitRef.current = true; + window.setTimeout(() => { + if (!hasHandledExitRef.current) { + return; + } + onSessionExitedRef.current(); + }, 0); } }); @@ -783,6 +802,7 @@ export default function ThreadTerminalDrawer({ terminalId={terminalId} cwd={cwd} {...(runtimeEnv ? { runtimeEnv } : {})} + onSessionExited={() => onCloseTerminal(terminalId)} focusRequestId={focusRequestId} autoFocus={terminalId === resolvedActiveTerminalId} resizeEpoch={resizeEpoch} @@ -800,6 +820,7 @@ export default function ThreadTerminalDrawer({ terminalId={resolvedActiveTerminalId} cwd={cwd} {...(runtimeEnv ? { runtimeEnv } : {})} + onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)} focusRequestId={focusRequestId} autoFocus resizeEpoch={resizeEpoch}