diff --git a/desktop/src-tauri/src/terminal_runtime.rs b/desktop/src-tauri/src/terminal_runtime.rs index 0e0123aa9..87f969592 100644 --- a/desktop/src-tauri/src/terminal_runtime.rs +++ b/desktop/src-tauri/src/terminal_runtime.rs @@ -277,12 +277,11 @@ impl Session { } fn shutdown(mut self) { - if let Ok(mut channel) = self.channel.lock() { - *channel = None; - } // Publication is detached before the reader enters close mode; the // lifecycle helper then abandons parser work and keeps raw-draining - // through child termination and reap. + // through child termination and reap. Keep the renderer channel alive + // until the reader has reported Exit; close() removes the session from + // the runtime before shutdown begins, so this is its final message. if let Ok(mut publisher) = self.publisher.lock() { publisher.close(); } @@ -300,6 +299,9 @@ impl Session { reader.join(); } } + if let Ok(mut channel) = self.channel.lock() { + *channel = None; + } } } diff --git a/desktop/src/features/terminal/TerminalBootstrap.test.mjs b/desktop/src/features/terminal/TerminalBootstrap.test.mjs index df73cd89e..7eb5ffcb9 100644 --- a/desktop/src/features/terminal/TerminalBootstrap.test.mjs +++ b/desktop/src/features/terminal/TerminalBootstrap.test.mjs @@ -319,6 +319,43 @@ test("opening a tab keeps terminal ownership while its attachment is pending", a view.unmount(); }); +test("a successful close removes the tab even if the exit event is lost", async () => { + const { createElement } = await import("react"); + const { fireEvent, render, waitFor } = await import("@testing-library/react"); + const { ThemeProvider } = await import("@/shared/theme/ThemeProvider"); + const { TerminalBootstrap } = await import("./TerminalBootstrap.tsx"); + + const view = render( + createElement( + ThemeProvider, + null, + createElement("div", { + className: "buzz-huddle-app-surface", + tabIndex: -1, + }), + createElement(TerminalBootstrap, { + channelId: "channel-1", + channelName: "general", + npub: "npub1owner", + relayUrl: "wss://relay.example", + threadId: null, + }), + ), + ); + await waitFor(() => + assert.ok(calls.some(({ command }) => command === "terminal_attach")), + ); + await waitFor(() => assert.ok(view.queryByRole("tab", { name: /SHELL/ }))); + + fireEvent.click(view.getByLabelText("Close SHELL")); + + await waitFor(() => + assert.ok(calls.some(({ command }) => command === "terminal_close")), + ); + await waitFor(() => assert.equal(view.queryByRole("tab"), null)); + view.unmount(); +}); + // The wheel-to-IPC path end to end. `TerminalSubstrate` already proves it // accumulates pixels into whole cells and `buzz-terminal` already proves which // way the engine goes; the seam between them is this file's business, and the diff --git a/desktop/src/features/terminal/TerminalBootstrap.tsx b/desktop/src/features/terminal/TerminalBootstrap.tsx index acd822dcf..20905e9fb 100644 --- a/desktop/src/features/terminal/TerminalBootstrap.tsx +++ b/desktop/src/features/terminal/TerminalBootstrap.tsx @@ -228,7 +228,10 @@ export function TerminalBootstrap({ removeSession(key); return; } - void connection.close().catch(fail); + void connection + .close() + .then(() => removeSession(key)) + .catch(fail); }} onFrameConsumed={(frame) => { const delivery = sessionsRef.current.find(