FN-7824: auto-reconnect terminal on first launch instead of parking disconnected
Terminal WebSocket sessions now retry with capped backoff through cold-start failures instead of giving up and requiring a manual Reconnect click. - useTerminal tracks whether a socket has ever successfully opened via hasEverConnectedRef - a never-connected initial connect ignores MAX_RECONNECT_ATTEMPTS and keeps retrying at capped backoff, staying in the reconnecting affordance until it opens - mid-session drops (sockets that opened at least once) keep the existing bounded give-up behavior, and permanent 4000/4004 closes remain terminal - context-change invalidation now uses a ref flag (contextChangedSinceLastEffectRef) consumed inside the effect instead of a transient boolean dependency, avoiding cleanup re-runs that tore down the replacement socket during context-switch/reconnect races - manual reconnect() and context/session changes reset hasEverConnectedRef so cold-start behavior reapplies per session - added a patch changeset and expanded useTerminal test coverage for first-launch reconnect vs. mid-session disconnect behavior - documented the first-launch reconnect behavior in docs/dashboard-guide.md Files changed: .changeset/FN-7824-terminal-first-launch-autoreconnect.md | 7 + docs/dashboard-guide.md | 3 + packages/dashboard/app/hooks/__tests__/useTerminal.test.ts | 208 ++++++++++++++++++++- packages/dashboard/app/hooks/useTerminal.ts | 34 +++- 4 files changed, 234 insertions(+), 18 deletions(-) Fusion-Task-Id: FN-7824 Fusion-Task-Lineage: 7ed696d0-449e-4dc0-9be0-48b429b8c844 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Terminal now auto-reconnects on first launch instead of getting stuck on "Disconnected".
|
||||||
|
category: fix
|
||||||
|
dev: useTerminal tracks whether the socket has ever opened; a never-connected initial connect keeps retrying at capped backoff (staying "reconnecting") until it opens, while mid-session drops and 4000/4004 permanent closes are unchanged.
|
||||||
@@ -628,6 +628,9 @@ Mailbox view shows inbox/outbox communication threads and unread state.
|
|||||||
|
|
||||||
Fusion embeds a terminal using xterm.js. Desktop and tablet use the footer status bar as the terminal launcher; mobile keeps the full-screen terminal path.
|
Fusion embeds a terminal using xterm.js. Desktop and tablet use the footer status bar as the terminal launcher; mobile keeps the full-screen terminal path.
|
||||||
|
|
||||||
|
<!-- FNXC:Terminal 2026-07-11-18:20: FN-7824 first-launch terminal sockets auto-retry with capped backoff until the first successful open, so the manual Reconnect affordance is reserved for terminal sessions that already connected and then exhaust their mid-session reconnect budget. -->
|
||||||
|
On first launch or first open, the terminal keeps reconnecting automatically until its initial WebSocket opens; it should show **Reconnecting...** during that cold-start recovery rather than requiring a manual **Reconnect** click. If an already-connected terminal drops and exhausts its bounded reconnect budget, Fusion then parks it as **Disconnected** and surfaces the manual **Reconnect** control.
|
||||||
|
|
||||||
<!-- FNXC:TaskDetailTerminal 2026-07-11-13:20: FN-7826 makes the Task Detail interactive Terminal tab always available while preserving the existing CLI-agent Session tab label. The first shell uses task.worktree when present and otherwise falls back to the project base directory, including for multi-repo workspace tasks with no single worktree, while task-scoped terminal tabs remain separate from the footer/global project terminal. -->
|
<!-- FNXC:TaskDetailTerminal 2026-07-11-13:20: FN-7826 makes the Task Detail interactive Terminal tab always available while preserving the existing CLI-agent Session tab label. The first shell uses task.worktree when present and otherwise falls back to the project base directory, including for multi-repo workspace tasks with no single worktree, while task-scoped terminal tabs remain separate from the footer/global project terminal. -->
|
||||||
Task Detail has two terminal-adjacent tabs when both are applicable: **Session** shows the pre-existing CLI agent session transcript/control surface, while **Terminal** embeds the interactive multi-tab terminal inside the task detail body. The interactive **Terminal** tab is always available in Task Detail; its first shell starts in the task worktree when one is recorded, otherwise it starts in the project base directory (project root), including for multi-repo workspace tasks that have no single task worktree. Its terminal tabs are stored separately from the footer/global project terminal tabs.
|
Task Detail has two terminal-adjacent tabs when both are applicable: **Session** shows the pre-existing CLI agent session transcript/control surface, while **Terminal** embeds the interactive multi-tab terminal inside the task detail body. The interactive **Terminal** tab is always available in Task Detail; its first shell starts in the task worktree when one is recorded, otherwise it starts in the project base directory (project root), including for multi-repo workspace tasks that have no single task worktree. Its terminal tabs are stored separately from the footer/global project terminal tabs.
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { act, renderHook } from "@testing-library/react";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
import { useTerminal } from "../useTerminal";
|
import { useTerminal } from "../useTerminal";
|
||||||
|
|
||||||
|
const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 16000, 16000, 16000];
|
||||||
|
|
||||||
class MockWebSocket {
|
class MockWebSocket {
|
||||||
static CONNECTING = 0;
|
static CONNECTING = 0;
|
||||||
static OPEN = 1;
|
static OPEN = 1;
|
||||||
@@ -46,6 +48,20 @@ class MockWebSocket {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeLatestSocketAndAdvance(code: number, cycleIndex: number): MockWebSocket {
|
||||||
|
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1];
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socket.emitClose(code);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(RECONNECT_DELAYS_MS[cycleIndex] ?? 16000);
|
||||||
|
});
|
||||||
|
|
||||||
|
return MockWebSocket.instances[MockWebSocket.instances.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
describe("useTerminal", () => {
|
describe("useTerminal", () => {
|
||||||
const originalWebSocket = globalThis.WebSocket;
|
const originalWebSocket = globalThis.WebSocket;
|
||||||
|
|
||||||
@@ -237,6 +253,147 @@ describe("useTerminal", () => {
|
|||||||
expect(result.current.connectionStatus).toBe("disconnected");
|
expect(result.current.connectionStatus).toBe("disconnected");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("first-launch reconnect surface enumeration", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ label: "without projectId", projectId: undefined },
|
||||||
|
{ label: "with projectId", projectId: "proj-456" },
|
||||||
|
])(
|
||||||
|
"keeps a never-opened initial socket retrying with capped backoff $label",
|
||||||
|
({ projectId }) => {
|
||||||
|
const { result } = renderHook(() => useTerminal("test-session-123", projectId));
|
||||||
|
|
||||||
|
expect(result.current.connectionStatus).toBe("connecting");
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(1);
|
||||||
|
|
||||||
|
for (let cycle = 0; cycle < 7; cycle++) {
|
||||||
|
const beforeCloseCount = MockWebSocket.instances.length;
|
||||||
|
const closingSocket = MockWebSocket.instances[beforeCloseCount - 1];
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
closingSocket.emitClose(1006);
|
||||||
|
});
|
||||||
|
expect(result.current.connectionStatus).toBe("reconnecting");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(RECONNECT_DELAYS_MS[cycle]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(beforeCloseCount + 1);
|
||||||
|
expect(result.current.connectionStatus).toBe("reconnecting");
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalSocket = MockWebSocket.instances[MockWebSocket.instances.length - 1];
|
||||||
|
act(() => {
|
||||||
|
finalSocket.emitOpen();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.connectionStatus).toBe("connected");
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(8);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("keeps 4004 terminal and fires onSessionInvalid instead of retrying", () => {
|
||||||
|
const { result } = renderHook(() => useTerminal("test-session-123"));
|
||||||
|
const onSessionInvalid = vi.fn();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.onSessionInvalid(onSessionInvalid);
|
||||||
|
MockWebSocket.instances[0].emitClose(4004);
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(16000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.connectionStatus).toBe("disconnected");
|
||||||
|
expect(onSessionInvalid).toHaveBeenCalledTimes(1);
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps 4000 terminal without retrying", () => {
|
||||||
|
const { result } = renderHook(() => useTerminal("test-session-123"));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
MockWebSocket.instances[0].emitClose(4000);
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(16000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.connectionStatus).toBe("disconnected");
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves bounded give-up behavior after a socket opened once", () => {
|
||||||
|
const { result } = renderHook(() => useTerminal("test-session-123"));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
MockWebSocket.instances[0].emitOpen();
|
||||||
|
});
|
||||||
|
expect(result.current.connectionStatus).toBe("connected");
|
||||||
|
|
||||||
|
for (let cycle = 0; cycle < 5; cycle++) {
|
||||||
|
closeLatestSocketAndAdvance(1006, cycle);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(6);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
MockWebSocket.instances[MockWebSocket.instances.length - 1].emitClose(1006);
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(16000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.connectionStatus).toBe("disconnected");
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not create a socket or retry when sessionId is null", () => {
|
||||||
|
const { result } = renderHook(() => useTerminal(null));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(16000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.connectionStatus).toBe("disconnected");
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets never-connected retry state on context change without leaking stale timers", () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ sessionId, projectId }: { sessionId: string; projectId?: string }) =>
|
||||||
|
useTerminal(sessionId, projectId),
|
||||||
|
{ initialProps: { sessionId: "test-session-123", projectId: "proj-A" } },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
MockWebSocket.instances[0].emitClose(1006);
|
||||||
|
});
|
||||||
|
expect(result.current.connectionStatus).toBe("reconnecting");
|
||||||
|
|
||||||
|
rerender({ sessionId: "test-session-456", projectId: "proj-B" });
|
||||||
|
const countAfterContextChange = MockWebSocket.instances.length;
|
||||||
|
const activeContextSocket = MockWebSocket.instances[countAfterContextChange - 1];
|
||||||
|
expect(activeContextSocket.url).toContain("sessionId=test-session-456");
|
||||||
|
expect(activeContextSocket.url).toContain("projectId=proj-B");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(countAfterContextChange);
|
||||||
|
expect(result.current.connectionStatus).toBe("connecting");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("onSessionInvalid callback", () => {
|
describe("onSessionInvalid callback", () => {
|
||||||
it("fires onSessionInvalid callbacks when WebSocket closes with code 4004", () => {
|
it("fires onSessionInvalid callbacks when WebSocket closes with code 4004", () => {
|
||||||
const { result } = renderHook(() => useTerminal("test-session-123"));
|
const { result } = renderHook(() => useTerminal("test-session-123"));
|
||||||
@@ -870,18 +1027,14 @@ describe("useTerminal", () => {
|
|||||||
vi.advanceTimersByTime(2000);
|
vi.advanceTimersByTime(2000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// At this point:
|
|
||||||
// - ws1 was created (original)
|
|
||||||
// - ws1 close triggered reconnect
|
|
||||||
// - reconnect timeout fired and created ws2 (for stale context A)
|
|
||||||
// - context changed to B
|
|
||||||
// - closeWebSocketForContextChange closed ws1 and ws2
|
|
||||||
// - connect() created ws3 (for new context B)
|
|
||||||
// The stale ws2 reconnect should NOT have created another instance
|
|
||||||
expect(MockWebSocket.instances).toHaveLength(3);
|
expect(MockWebSocket.instances).toHaveLength(3);
|
||||||
|
|
||||||
// The final ws should be for project B
|
|
||||||
expect(MockWebSocket.instances[2].url).toContain("projectId=proj-B");
|
expect(MockWebSocket.instances[2].url).toContain("projectId=proj-B");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resets connection status on context change", () => {
|
it("resets connection status on context change", () => {
|
||||||
@@ -946,6 +1099,8 @@ describe("useTerminal", () => {
|
|||||||
vi.advanceTimersByTime(0);
|
vi.advanceTimersByTime(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(3);
|
||||||
|
|
||||||
// Final ws should be for project B
|
// Final ws should be for project B
|
||||||
const finalWs = MockWebSocket.instances[MockWebSocket.instances.length - 1];
|
const finalWs = MockWebSocket.instances[MockWebSocket.instances.length - 1];
|
||||||
expect(finalWs.url).toContain("projectId=proj-B");
|
expect(finalWs.url).toContain("projectId=proj-B");
|
||||||
@@ -959,6 +1114,39 @@ describe("useTerminal", () => {
|
|||||||
expect(result.current.connectionStatus).toBe("connected");
|
expect(result.current.connectionStatus).toBe("connected");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("connects after context switch races an in-flight automatic reconnect", () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ sessionId, projectId }: { sessionId: string | null; projectId?: string }) =>
|
||||||
|
useTerminal(sessionId, projectId),
|
||||||
|
{ initialProps: { sessionId: "test-session-123", projectId: "proj-A" } },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
MockWebSocket.instances[0].emitOpen();
|
||||||
|
MockWebSocket.instances[0].emitClose(1006);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(2);
|
||||||
|
expect(MockWebSocket.instances[1].url).toContain("projectId=proj-A");
|
||||||
|
|
||||||
|
rerender({ sessionId: "test-session-123", projectId: "proj-B" });
|
||||||
|
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(3);
|
||||||
|
const projectBSocket = MockWebSocket.instances[2];
|
||||||
|
expect(projectBSocket.url).toContain("projectId=proj-B");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(0);
|
||||||
|
projectBSocket.emitOpen();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.connectionStatus).toBe("connected");
|
||||||
|
});
|
||||||
|
|
||||||
it("clears buffer on context change", () => {
|
it("clears buffer on context change", () => {
|
||||||
const { result, rerender } = renderHook(
|
const { result, rerender } = renderHook(
|
||||||
({ projectId }: { projectId?: string }) => useTerminal("test-session-123", projectId),
|
({ projectId }: { projectId?: string }) => useTerminal("test-session-123", projectId),
|
||||||
|
|||||||
@@ -94,8 +94,12 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
|
|||||||
// Track previous values to detect context changes
|
// Track previous values to detect context changes
|
||||||
const previousSessionIdRef = useRef<string | null>(sessionId);
|
const previousSessionIdRef = useRef<string | null>(sessionId);
|
||||||
const previousProjectIdRef = useRef<string | undefined>(projectId);
|
const previousProjectIdRef = useRef<string | undefined>(projectId);
|
||||||
|
const contextChangedSinceLastEffectRef = useRef(false);
|
||||||
|
|
||||||
// Detect context change: either projectId or sessionId changed
|
/*
|
||||||
|
* FNXC:Terminal 2026-07-11-18:42:
|
||||||
|
* Context changes must invalidate stale WebSocket callbacks during render, before effects run, but the effect consumes a ref flag instead of depending on the transient boolean. Depending on that boolean makes React run cleanup again when it flips back to false after a status update, tearing down the replacement socket during context-switch/reconnect races.
|
||||||
|
*/
|
||||||
const contextChanged =
|
const contextChanged =
|
||||||
previousSessionIdRef.current !== sessionId ||
|
previousSessionIdRef.current !== sessionId ||
|
||||||
previousProjectIdRef.current !== projectId;
|
previousProjectIdRef.current !== projectId;
|
||||||
@@ -104,6 +108,7 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
|
|||||||
previousSessionIdRef.current = sessionId;
|
previousSessionIdRef.current = sessionId;
|
||||||
previousProjectIdRef.current = projectId;
|
previousProjectIdRef.current = projectId;
|
||||||
contextVersionRef.current++;
|
contextVersionRef.current++;
|
||||||
|
contextChangedSinceLastEffectRef.current = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
@@ -111,6 +116,7 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
|
|||||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const heartbeatIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const heartbeatIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const isManualCloseRef = useRef(false);
|
const isManualCloseRef = useRef(false);
|
||||||
|
const hasEverConnectedRef = useRef(false);
|
||||||
|
|
||||||
// Callback refs to avoid re-subscriptions
|
// Callback refs to avoid re-subscriptions
|
||||||
const onDataCallbacksRef = useRef<Set<(data: string) => void>>(new Set());
|
const onDataCallbacksRef = useRef<Set<(data: string) => void>>(new Set());
|
||||||
@@ -221,6 +227,7 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
|
|||||||
|
|
||||||
// Clear buffers on context change to prevent stale replay
|
// Clear buffers on context change to prevent stale replay
|
||||||
initialBufferRef.current = createEmptyBuffer();
|
initialBufferRef.current = createEmptyBuffer();
|
||||||
|
hasEverConnectedRef.current = false;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Cleanup function (used for unmount and manual reconnect)
|
// Cleanup function (used for unmount and manual reconnect)
|
||||||
@@ -256,7 +263,7 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Connect function
|
// Connect function
|
||||||
const connect = useCallback(() => {
|
const connect = useCallback((nextStatus: ConnectionStatus = "connecting") => {
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
setConnectionStatus("disconnected");
|
setConnectionStatus("disconnected");
|
||||||
return;
|
return;
|
||||||
@@ -281,7 +288,7 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
|
|||||||
}
|
}
|
||||||
|
|
||||||
isManualCloseRef.current = false;
|
isManualCloseRef.current = false;
|
||||||
setConnectionStatus("connecting");
|
setConnectionStatus(nextStatus);
|
||||||
|
|
||||||
// Capture the context version at connection start. Stale callbacks from
|
// Capture the context version at connection start. Stale callbacks from
|
||||||
// previous project/session contexts will be rejected by comparing against
|
// previous project/session contexts will be rejected by comparing against
|
||||||
@@ -312,6 +319,7 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
|
|||||||
// late-arriving messages from a previous session are discarded and
|
// late-arriving messages from a previous session are discarded and
|
||||||
// the new session's scrollback/data is captured in a fresh buffer.
|
// the new session's scrollback/data is captured in a fresh buffer.
|
||||||
initialBufferRef.current = createEmptyBuffer();
|
initialBufferRef.current = createEmptyBuffer();
|
||||||
|
hasEverConnectedRef.current = true;
|
||||||
setConnectionStatus("connected");
|
setConnectionStatus("connected");
|
||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
|
|
||||||
@@ -425,10 +433,16 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:Terminal 2026-07-11-18:20:
|
||||||
|
* FN-7824: A cold first launch can close the WebSocket before the backend is ready often enough to exhaust the normal reconnect budget. Never-opened sockets must keep retrying with capped backoff and stay in the reconnecting affordance so operators do not need a manual Reconnect click. Sockets that have opened at least once are real mid-session drops and keep the bounded give-up behavior, while permanent 4000/4004 closes remain terminal above.
|
||||||
|
*/
|
||||||
|
const isInitialConnect = !hasEverConnectedRef.current;
|
||||||
|
|
||||||
// Attempt reconnect with exponential backoff
|
// Attempt reconnect with exponential backoff
|
||||||
reconnectAttemptsRef.current++;
|
reconnectAttemptsRef.current++;
|
||||||
|
|
||||||
if (reconnectAttemptsRef.current > MAX_RECONNECT_ATTEMPTS) {
|
if (!isInitialConnect && reconnectAttemptsRef.current > MAX_RECONNECT_ATTEMPTS) {
|
||||||
setConnectionStatus("disconnected");
|
setConnectionStatus("disconnected");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -446,7 +460,7 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isManualCloseRef.current) {
|
if (!isManualCloseRef.current) {
|
||||||
connect();
|
connect("reconnecting");
|
||||||
}
|
}
|
||||||
}, Math.min(delay, 16000));
|
}, Math.min(delay, 16000));
|
||||||
};
|
};
|
||||||
@@ -459,6 +473,7 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
|
|||||||
// Manual reconnect
|
// Manual reconnect
|
||||||
const reconnect = useCallback(() => {
|
const reconnect = useCallback(() => {
|
||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
|
hasEverConnectedRef.current = false;
|
||||||
cleanup();
|
cleanup();
|
||||||
connect();
|
connect();
|
||||||
}, [cleanup, connect]);
|
}, [cleanup, connect]);
|
||||||
@@ -466,25 +481,28 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
|
|||||||
// Connect when sessionId or projectId changes
|
// Connect when sessionId or projectId changes
|
||||||
// Handle context change: close existing WebSocket, cancel timers, reset state
|
// Handle context change: close existing WebSocket, cancel timers, reset state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If context changed, perform cleanup before connecting to new context
|
// If context changed, perform cleanup before connecting to new context.
|
||||||
if (contextChanged) {
|
if (contextChangedSinceLastEffectRef.current) {
|
||||||
|
contextChangedSinceLastEffectRef.current = false;
|
||||||
// Use internal cleanup that doesn't mark as manual close,
|
// Use internal cleanup that doesn't mark as manual close,
|
||||||
// allowing proper context transition without stale onclose interference
|
// allowing proper context transition without stale onclose interference
|
||||||
closeWebSocketForContextChange();
|
closeWebSocketForContextChange();
|
||||||
|
|
||||||
// Reset transient state
|
// Reset transient state
|
||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
|
hasEverConnectedRef.current = false;
|
||||||
setConnectionStatus("disconnected");
|
setConnectionStatus("disconnected");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
connect();
|
connect();
|
||||||
} else {
|
} else {
|
||||||
|
hasEverConnectedRef.current = false;
|
||||||
setConnectionStatus("disconnected");
|
setConnectionStatus("disconnected");
|
||||||
}
|
}
|
||||||
|
|
||||||
return cleanup;
|
return cleanup;
|
||||||
}, [sessionId, projectId, contextChanged, connect, cleanup, closeWebSocketForContextChange]);
|
}, [sessionId, projectId, connect, cleanup, closeWebSocketForContextChange]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connectionStatus,
|
connectionStatus,
|
||||||
|
|||||||
Reference in New Issue
Block a user