diff --git a/frontend/src/renderer/hooks/useTerminalSession.test.tsx b/frontend/src/renderer/hooks/useTerminalSession.test.tsx index 99e2dcba7..aa4238762 100644 --- a/frontend/src/renderer/hooks/useTerminalSession.test.tsx +++ b/frontend/src/renderer/hooks/useTerminalSession.test.tsx @@ -300,6 +300,35 @@ describe("useTerminalSession", () => { expect(muxes[1].inputs).toEqual([["handle-1", "echo ok\r"]]); }); + it("does not reconnect a broken live pane on ordinary session updates", () => { + 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].emitError("handle-1", "no such pane")); + expect(view.result.current.state).toBe("error"); + + view.rerender({ attachedSession: { ...session, status: "idle", updatedAt: "tick-1" } }); + view.rerender({ attachedSession: { ...session, status: "working", updatedAt: "tick-2" } }); + + expect(view.result.current.state).toBe("error"); + expect(muxes).toHaveLength(1); + }); + 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 c6ea31326..fbfdd7826 100644 --- a/frontend/src/renderer/hooks/useTerminalSession.ts +++ b/frontend/src/renderer/hooks/useTerminalSession.ts @@ -80,6 +80,7 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option const sessionRef = useRef(session); sessionRef.current = session; + const previousSessionStatusRef = useRef(session?.status); const optionsRef = useRef(options); optionsRef.current = options; const stateRef = useRef(state); @@ -326,7 +327,17 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option useEffect(() => { const r = runtime.current; const handle = session?.terminalHandleId ?? null; - if (!handle || session?.status === "terminated" || r.detached || !r.terminal) return; + const previousStatus = previousSessionStatusRef.current; + previousSessionStatusRef.current = session?.status; + if ( + !handle || + previousStatus !== "terminated" || + 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) { @@ -335,7 +346,7 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option } else { transition("reattaching"); } - }, [connect, session?.status, session?.terminalHandleId, session?.updatedAt, transition]); + }, [connect, session?.status, session?.terminalHandleId, transition]); // Belt-and-braces: never leak a socket past unmount, even if the owner // forgot to call detach.