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.
|
||||
|
||||
<!-- 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. -->
|
||||
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 { useTerminal } from "../useTerminal";
|
||||
|
||||
const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 16000, 16000, 16000];
|
||||
|
||||
class MockWebSocket {
|
||||
static CONNECTING = 0;
|
||||
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", () => {
|
||||
const originalWebSocket = globalThis.WebSocket;
|
||||
|
||||
@@ -237,6 +253,147 @@ describe("useTerminal", () => {
|
||||
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", () => {
|
||||
it("fires onSessionInvalid callbacks when WebSocket closes with code 4004", () => {
|
||||
const { result } = renderHook(() => useTerminal("test-session-123"));
|
||||
@@ -870,18 +1027,14 @@ describe("useTerminal", () => {
|
||||
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);
|
||||
|
||||
// The final ws should be for project 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", () => {
|
||||
@@ -946,6 +1099,8 @@ describe("useTerminal", () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
|
||||
expect(MockWebSocket.instances).toHaveLength(3);
|
||||
|
||||
// Final ws should be for project B
|
||||
const finalWs = MockWebSocket.instances[MockWebSocket.instances.length - 1];
|
||||
expect(finalWs.url).toContain("projectId=proj-B");
|
||||
@@ -959,6 +1114,39 @@ describe("useTerminal", () => {
|
||||
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", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ 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
|
||||
const previousSessionIdRef = useRef<string | null>(sessionId);
|
||||
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 =
|
||||
previousSessionIdRef.current !== sessionId ||
|
||||
previousProjectIdRef.current !== projectId;
|
||||
@@ -104,6 +108,7 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
|
||||
previousSessionIdRef.current = sessionId;
|
||||
previousProjectIdRef.current = projectId;
|
||||
contextVersionRef.current++;
|
||||
contextChangedSinceLastEffectRef.current = true;
|
||||
}
|
||||
|
||||
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 heartbeatIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isManualCloseRef = useRef(false);
|
||||
const hasEverConnectedRef = useRef(false);
|
||||
|
||||
// Callback refs to avoid re-subscriptions
|
||||
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
|
||||
initialBufferRef.current = createEmptyBuffer();
|
||||
hasEverConnectedRef.current = false;
|
||||
}, []);
|
||||
|
||||
// Cleanup function (used for unmount and manual reconnect)
|
||||
@@ -256,7 +263,7 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
|
||||
}, []);
|
||||
|
||||
// Connect function
|
||||
const connect = useCallback(() => {
|
||||
const connect = useCallback((nextStatus: ConnectionStatus = "connecting") => {
|
||||
if (!sessionId) {
|
||||
setConnectionStatus("disconnected");
|
||||
return;
|
||||
@@ -281,7 +288,7 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
|
||||
}
|
||||
|
||||
isManualCloseRef.current = false;
|
||||
setConnectionStatus("connecting");
|
||||
setConnectionStatus(nextStatus);
|
||||
|
||||
// Capture the context version at connection start. Stale callbacks from
|
||||
// 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
|
||||
// the new session's scrollback/data is captured in a fresh buffer.
|
||||
initialBufferRef.current = createEmptyBuffer();
|
||||
hasEverConnectedRef.current = true;
|
||||
setConnectionStatus("connected");
|
||||
reconnectAttemptsRef.current = 0;
|
||||
|
||||
@@ -425,10 +433,16 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
|
||||
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
|
||||
reconnectAttemptsRef.current++;
|
||||
|
||||
if (reconnectAttemptsRef.current > MAX_RECONNECT_ATTEMPTS) {
|
||||
if (!isInitialConnect && reconnectAttemptsRef.current > MAX_RECONNECT_ATTEMPTS) {
|
||||
setConnectionStatus("disconnected");
|
||||
return;
|
||||
}
|
||||
@@ -446,7 +460,7 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
|
||||
return;
|
||||
}
|
||||
if (!isManualCloseRef.current) {
|
||||
connect();
|
||||
connect("reconnecting");
|
||||
}
|
||||
}, Math.min(delay, 16000));
|
||||
};
|
||||
@@ -459,6 +473,7 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
|
||||
// Manual reconnect
|
||||
const reconnect = useCallback(() => {
|
||||
reconnectAttemptsRef.current = 0;
|
||||
hasEverConnectedRef.current = false;
|
||||
cleanup();
|
||||
connect();
|
||||
}, [cleanup, connect]);
|
||||
@@ -466,25 +481,28 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
|
||||
// Connect when sessionId or projectId changes
|
||||
// Handle context change: close existing WebSocket, cancel timers, reset state
|
||||
useEffect(() => {
|
||||
// If context changed, perform cleanup before connecting to new context
|
||||
if (contextChanged) {
|
||||
// If context changed, perform cleanup before connecting to new context.
|
||||
if (contextChangedSinceLastEffectRef.current) {
|
||||
contextChangedSinceLastEffectRef.current = false;
|
||||
// Use internal cleanup that doesn't mark as manual close,
|
||||
// allowing proper context transition without stale onclose interference
|
||||
closeWebSocketForContextChange();
|
||||
|
||||
// Reset transient state
|
||||
reconnectAttemptsRef.current = 0;
|
||||
hasEverConnectedRef.current = false;
|
||||
setConnectionStatus("disconnected");
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
connect();
|
||||
} else {
|
||||
hasEverConnectedRef.current = false;
|
||||
setConnectionStatus("disconnected");
|
||||
}
|
||||
|
||||
return cleanup;
|
||||
}, [sessionId, projectId, contextChanged, connect, cleanup, closeWebSocketForContextChange]);
|
||||
}, [sessionId, projectId, connect, cleanup, closeWebSocketForContextChange]);
|
||||
|
||||
return {
|
||||
connectionStatus,
|
||||
|
||||
Reference in New Issue
Block a user