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", () => {...@@ -394,6 +394,21 @@ describe("TerminalManager", () => {
394 manager.dispose();394 manager.dispose();
395 });395 });
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
397 it("emits subprocess activity events when child-process state changes", async () => {412 it("emits subprocess activity events when child-process state changes", async () => {
398 let hasRunningSubprocess = false;413 let hasRunningSubprocess = false;
399 const { manager } = makeManager(5, {414 const { manager } = makeManager(5, {
apps/server/src/terminal/Layers/Manager.ts+3
...@@ -438,6 +438,9 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>...@@ -438,6 +438,9 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
438 const input = decodeTerminalWriteInput(raw);438 const input = decodeTerminalWriteInput(raw);
439 const session = this.requireSession(input.threadId, input.terminalId);439 const session = this.requireSession(input.threadId, input.terminalId);
440 if (!session.process || session.status !== "running") {440 if (!session.process || session.status !== "running") {
441 if (session.status === "exited") {
442 return;
443 }
441 throw new Error(444 throw new Error(
442 `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`,445 `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`,
443 );446 );
apps/web/src/components/ThreadTerminalDrawer.tsx+18
...@@ -112,6 +112,7 @@ interface TerminalViewportProps {...@@ -112,6 +112,7 @@ interface TerminalViewportProps {
112 terminalId: string;112 terminalId: string;
113 cwd: string;113 cwd: string;
114 runtimeEnv?: Record<string, string>;114 runtimeEnv?: Record<string, string>;
115 onSessionExited: () => void;
115 focusRequestId: number;116 focusRequestId: number;
116 autoFocus: boolean;117 autoFocus: boolean;
117 resizeEpoch: number;118 resizeEpoch: number;
...@@ -123,6 +124,7 @@ function TerminalViewport({...@@ -123,6 +124,7 @@ function TerminalViewport({
123 terminalId,124 terminalId,
124 cwd,125 cwd,
125 runtimeEnv,126 runtimeEnv,
127 onSessionExited,
126 focusRequestId,128 focusRequestId,
127 autoFocus,129 autoFocus,
128 resizeEpoch,130 resizeEpoch,
...@@ -131,6 +133,12 @@ function TerminalViewport({...@@ -131,6 +133,12 @@ function TerminalViewport({
131 const containerRef = useRef<HTMLDivElement>(null);133 const containerRef = useRef<HTMLDivElement>(null);
132 const terminalRef = useRef<Terminal | null>(null);134 const terminalRef = useRef<Terminal | null>(null);
133 const fitAddonRef = useRef<FitAddon | null>(null);135 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
135 useEffect(() => {143 useEffect(() => {
136 const mount = containerRef.current;144 const mount = containerRef.current;
...@@ -306,6 +314,7 @@ function TerminalViewport({...@@ -306,6 +314,7 @@ function TerminalViewport({
306 }314 }
307315
308 if (event.type === "started" || event.type === "restarted") {316 if (event.type === "started" || event.type === "restarted") {
317 hasHandledExitRef.current = false;
309 activeTerminal.write("\u001bc");318 activeTerminal.write("\u001bc");
310 if (event.snapshot.history.length > 0) {319 if (event.snapshot.history.length > 0) {
311 activeTerminal.write(event.snapshot.history);320 activeTerminal.write(event.snapshot.history);
...@@ -335,6 +344,13 @@ function TerminalViewport({...@@ -335,6 +344,13 @@ function TerminalViewport({
335 activeTerminal,344 activeTerminal,
336 details.length > 0 ? `Process exited (${details})` : "Process exited",345 details.length > 0 ? `Process exited (${details})` : "Process exited",
337 );346 );
347 if (hasHandledExitRef.current) {
348 return;
349 }
350 hasHandledExitRef.current = true;
351 window.setTimeout(() => {
352 onSessionExitedRef.current();
353 }, 0);
338 }354 }
339 });355 });
340356
...@@ -783,6 +799,7 @@ export default function ThreadTerminalDrawer({...@@ -783,6 +799,7 @@ export default function ThreadTerminalDrawer({
783 terminalId={terminalId}799 terminalId={terminalId}
784 cwd={cwd}800 cwd={cwd}
785 {...(runtimeEnv ? { runtimeEnv } : {})}801 {...(runtimeEnv ? { runtimeEnv } : {})}
802 onSessionExited={() => onCloseTerminal(terminalId)}
786 focusRequestId={focusRequestId}803 focusRequestId={focusRequestId}
787 autoFocus={terminalId === resolvedActiveTerminalId}804 autoFocus={terminalId === resolvedActiveTerminalId}
788 resizeEpoch={resizeEpoch}805 resizeEpoch={resizeEpoch}
...@@ -800,6 +817,7 @@ export default function ThreadTerminalDrawer({...@@ -800,6 +817,7 @@ export default function ThreadTerminalDrawer({
800 terminalId={resolvedActiveTerminalId}817 terminalId={resolvedActiveTerminalId}
801 cwd={cwd}818 cwd={cwd}
802 {...(runtimeEnv ? { runtimeEnv } : {})}819 {...(runtimeEnv ? { runtimeEnv } : {})}
820 onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)}
803 focusRequestId={focusRequestId}821 focusRequestId={focusRequestId}
804 autoFocus822 autoFocus
805 resizeEpoch={resizeEpoch}823 resizeEpoch={resizeEpoch}