diff --git a/packages/react-web-cli/src/AblyCliTerminal.test.tsx b/packages/react-web-cli/src/AblyCliTerminal.test.tsx
index 9e462b0b..373ae918 100644
--- a/packages/react-web-cli/src/AblyCliTerminal.test.tsx
+++ b/packages/react-web-cli/src/AblyCliTerminal.test.tsx
@@ -2542,3 +2542,76 @@ describe("AblyCliTerminal - Initial Command Execution", () => {
expect(hasTestCmd).toBe(true);
}, 15_000);
});
+
+describe("AblyCliTerminal - Unmount cleanup", () => {
+ test("closes socket normally on unmount (no special code for resume support)", async () => {
+ mockClose.mockClear();
+
+ const { unmount } = render(
+ ,
+ );
+
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ });
+
+ expect(mockSocketInstance).toBeTruthy();
+ expect(mockSocketInstance.readyState).toBe(WebSocket.OPEN);
+
+ unmount();
+
+ // Should close without special code (allows grace period for resume)
+ expect(mockClose).toHaveBeenCalledWith();
+ });
+
+ test("does not call close if socket already closing", async () => {
+ mockClose.mockClear();
+
+ const { unmount } = render(
+ ,
+ );
+
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ });
+
+ mockSocketInstance.readyState = WebSocket.CLOSING;
+
+ unmount();
+
+ expect(mockClose).not.toHaveBeenCalled();
+ });
+
+ test("terminateSession() sends close code 4001 for immediate cleanup", async () => {
+ mockClose.mockClear();
+ const terminalRef = React.createRef();
+
+ render(
+ ,
+ );
+
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ });
+
+ expect(mockSocketInstance).toBeTruthy();
+ expect(mockSocketInstance.readyState).toBe(WebSocket.OPEN);
+
+ // Call terminateSession explicitly
+ act(() => {
+ terminalRef.current?.terminateSession();
+ });
+
+ expect(mockClose).toHaveBeenCalledWith(4001, "user-closed-panel");
+ });
+});
diff --git a/packages/react-web-cli/src/AblyCliTerminal.tsx b/packages/react-web-cli/src/AblyCliTerminal.tsx
index 3edc5777..f8256035 100644
--- a/packages/react-web-cli/src/AblyCliTerminal.tsx
+++ b/packages/react-web-cli/src/AblyCliTerminal.tsx
@@ -142,6 +142,8 @@ export interface AblyCliTerminalHandle {
setSplitPosition: (percent: number) => void;
/** Read current split state. */
getSplitState: () => { isSplit: boolean; splitPosition: number };
+ /** Terminate the session immediately. Call this before unmounting when user explicitly closes the panel. */
+ terminateSession: () => void;
}
// Use shared debug logging
@@ -340,6 +342,23 @@ const AblyCliTerminalInner = (
setSplitPosition(clamped);
},
getSplitState: () => ({ isSplit, splitPosition }),
+ terminateSession: () => {
+ debugLog(
+ "[AblyCLITerminal] terminateSession called - closing with code 4001",
+ );
+ if (
+ socketReference.current &&
+ socketReference.current.readyState < WebSocket.CLOSING
+ ) {
+ socketReference.current.close(4001, "user-closed-panel");
+ }
+ if (
+ secondarySocketReference.current &&
+ secondarySocketReference.current.readyState < WebSocket.CLOSING
+ ) {
+ secondarySocketReference.current.close(4001, "user-closed-panel");
+ }
+ },
}),
[
enableSplitScreen,
@@ -2382,7 +2401,7 @@ const AblyCliTerminalInner = (
socketReference.current &&
socketReference.current.readyState < WebSocket.CLOSING
) {
- // close websocket
+ // Normal close (no 4001) so server grace period allows resume
debugLog("[AblyCLITerminal] Closing WebSocket on unmount.");
socketReference.current.close();
}