diff --git a/frontend/src/renderer/hooks/useTerminalSession.test.tsx b/frontend/src/renderer/hooks/useTerminalSession.test.tsx
index dae10728d..aa4238762 100644
--- a/frontend/src/renderer/hooks/useTerminalSession.test.tsx
+++ b/frontend/src/renderer/hooks/useTerminalSession.test.tsx
@@ -263,6 +263,72 @@ 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("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 ae36c03d3..70365e350 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);
@@ -323,6 +324,24 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option
connect();
}, [daemonReady, connect]);
+ useEffect(() => {
+ const r = runtime.current;
+ const handle = session?.terminalHandleId ?? null;
+ 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) {
+ transition("connecting");
+ connect();
+ } else {
+ transition("reattaching");
+ }
+ }, [connect, session?.status, session?.terminalHandleId, transition]);
+
// Belt-and-braces: never leak a socket past unmount, even if the owner
// forgot to call detach.
useEffect(