authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-27 17:55:47-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-27 18:08:18-08:00
log80498f1709e05caa744716b740fdcaa884bef8e0
tree02989fd14c96f6f59b45bee0bc4c2c095f1ac611
parente6c5a4a4bb962c8276887c26e889fe97a8668f44

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

3 files changed, 36 insertions(+), 0 deletions(-)

apps/server/src/terminal/Layers/Manager.test.ts+15
......@@ -394,6 +394,21 @@ describe("TerminalManager", () => {
394394 manager.dispose();
395395 });
396396
397 it("ignores trailing writes after terminal exit", async () => {
398 const { manager, ptyAdapter } = makeManager();
399 await manager.open(openInput());
400 const process = ptyAdapter.processes[0];
401 expect(process).toBeDefined();
402 if (!process) return;
403
404 process.emitExit({ exitCode: 0, signal: 0 });
405
406 await expect(manager.write({ threadId: "thread-1", data: "\r" })).resolves.toBeUndefined();
407 expect(process.writes).toEqual([]);
408
409 manager.dispose();
410 });
411
397412 it("emits subprocess activity events when child-process state changes", async () => {
398413 let hasRunningSubprocess = false;
399414 const { manager } = makeManager(5, {
apps/server/src/terminal/Layers/Manager.ts+3
......@@ -438,6 +438,9 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
438438 const input = decodeTerminalWriteInput(raw);
439439 const session = this.requireSession(input.threadId, input.terminalId);
440440 if (!session.process || session.status !== "running") {
441 if (session.status === "exited") {
442 return;
443 }
441444 throw new Error(
442445 `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`,
443446 );
apps/web/src/components/ThreadTerminalDrawer.tsx+18
......@@ -112,6 +112,7 @@ interface TerminalViewportProps {
112112 terminalId: string;
113113 cwd: string;
114114 runtimeEnv?: Record<string, string>;
115 onSessionExited: () => void;
115116 focusRequestId: number;
116117 autoFocus: boolean;
117118 resizeEpoch: number;
......@@ -123,6 +124,7 @@ function TerminalViewport({
123124 terminalId,
124125 cwd,
125126 runtimeEnv,
127 onSessionExited,
126128 focusRequestId,
127129 autoFocus,
128130 resizeEpoch,
......@@ -131,6 +133,12 @@ function TerminalViewport({
131133 const containerRef = useRef<HTMLDivElement>(null);
132134 const terminalRef = useRef<Terminal | null>(null);
133135 const fitAddonRef = useRef<FitAddon | null>(null);
136 const onSessionExitedRef = useRef(onSessionExited);
137 const hasHandledExitRef = useRef(false);
138
139 useEffect(() => {
140 onSessionExitedRef.current = onSessionExited;
141 }, [onSessionExited]);
134142
135143 useEffect(() => {
136144 const mount = containerRef.current;
......@@ -306,6 +314,7 @@ function TerminalViewport({
306314 }
307315
308316 if (event.type === "started" || event.type === "restarted") {
317 hasHandledExitRef.current = false;
309318 activeTerminal.write("\u001bc");
310319 if (event.snapshot.history.length > 0) {
311320 activeTerminal.write(event.snapshot.history);
......@@ -335,6 +344,13 @@ function TerminalViewport({
335344 activeTerminal,
336345 details.length > 0 ? `Process exited (${details})` : "Process exited",
337346 );
347 if (hasHandledExitRef.current) {
348 return;
349 }
350 hasHandledExitRef.current = true;
351 window.setTimeout(() => {
352 onSessionExitedRef.current();
353 }, 0);
338354 }
339355 });
340356
......@@ -783,6 +799,7 @@ export default function ThreadTerminalDrawer({
783799 terminalId={terminalId}
784800 cwd={cwd}
785801 {...(runtimeEnv ? { runtimeEnv } : {})}
802 onSessionExited={() => onCloseTerminal(terminalId)}
786803 focusRequestId={focusRequestId}
787804 autoFocus={terminalId === resolvedActiveTerminalId}
788805 resizeEpoch={resizeEpoch}
......@@ -800,6 +817,7 @@ export default function ThreadTerminalDrawer({
800817 terminalId={resolvedActiveTerminalId}
801818 cwd={cwd}
802819 {...(runtimeEnv ? { runtimeEnv } : {})}
820 onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)}
803821 focusRequestId={focusRequestId}
804822 autoFocus
805823 resizeEpoch={resizeEpoch}