From a139a6c2adf6572b79c372a763fb62186896e2d1 Mon Sep 17 00:00:00 2001 From: codebanditssss Date: Tue, 30 Jun 2026 19:30:22 +0530 Subject: [PATCH] fix: reconnect restored terminals - Reopen terminal mux after restored sessions become live - Cover restore with unchanged terminal handles --- .../hooks/useTerminalSession.test.tsx | 37 +++++++++++++++++++ .../src/renderer/hooks/useTerminalSession.ts | 14 +++++++ 2 files changed, 51 insertions(+) diff --git a/frontend/src/renderer/hooks/useTerminalSession.test.tsx b/frontend/src/renderer/hooks/useTerminalSession.test.tsx index dae10728df..99e2dcba77 100644 --- a/frontend/src/renderer/hooks/useTerminalSession.test.tsx +++ b/frontend/src/renderer/hooks/useTerminalSession.test.tsx @@ -263,6 +263,43 @@ describe("useTerminalSession", () => { expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: workspaceQueryKey }); }); + it("reconnects when a restored session becomes live with the same terminal handle", () => { + const muxes: FakeMux[] = []; + const createMux = () => { + const fake = createFakeMux(); + muxes.push(fake); + return fake.mux; + }; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const view = renderHook( + ({ attachedSession }) => useTerminalSession(attachedSession, { daemonReady: true, createMux }), + { initialProps: { attachedSession: session }, wrapper }, + ); + const terminal = createFakeTerminal(); + act(() => { + view.result.current.attach(terminal); + }); + act(() => muxes[0].emitOpened("handle-1")); + act(() => muxes[0].emitExit("handle-1")); + expect(view.result.current.state).toBe("exited"); + + view.rerender({ attachedSession: { ...session, status: "terminated", updatedAt: "terminated" } }); + expect(muxes).toHaveLength(1); + + view.rerender({ attachedSession: { ...session, status: "idle", updatedAt: "restored" } }); + expect(view.result.current.state).toBe("connecting"); + expect(muxes).toHaveLength(2); + expect(muxes[0].disposed).toBe(true); + expect(muxes[1].opens).toEqual([["handle-1", 80, 24]]); + act(() => muxes[1].emitOpened("handle-1")); + expect(view.result.current.state).toBe("attached"); + terminal.typeKeys("echo ok\r"); + expect(muxes[1].inputs).toEqual([["handle-1", "echo ok\r"]]); + }); + it("surfaces pane errors and refetches, with no automatic retry", () => { const { view, muxes, invalidateSpy } = setup(); act(() => muxes[0].emitError("handle-1", "no such pane")); diff --git a/frontend/src/renderer/hooks/useTerminalSession.ts b/frontend/src/renderer/hooks/useTerminalSession.ts index ae36c03d35..c6ea31326a 100644 --- a/frontend/src/renderer/hooks/useTerminalSession.ts +++ b/frontend/src/renderer/hooks/useTerminalSession.ts @@ -323,6 +323,20 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option connect(); }, [daemonReady, connect]); + useEffect(() => { + const r = runtime.current; + const handle = session?.terminalHandleId ?? null; + if (!handle || session?.status === "terminated" || r.detached || !r.terminal) return; + if (r.handle !== handle) return; + if (stateRef.current !== "exited" && stateRef.current !== "error") return; + if (optionsRef.current.daemonReady) { + transition("connecting"); + connect(); + } else { + transition("reattaching"); + } + }, [connect, session?.status, session?.terminalHandleId, session?.updatedAt, transition]); + // Belt-and-braces: never leak a socket past unmount, even if the owner // forgot to call detach. useEffect(