diff --git a/.changeset/FN-7824-terminal-first-launch-autoreconnect.md b/.changeset/FN-7824-terminal-first-launch-autoreconnect.md new file mode 100644 index 0000000000..58bd689cd9 --- /dev/null +++ b/.changeset/FN-7824-terminal-first-launch-autoreconnect.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index f4a9cfc7b5..a327b3994e 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. + +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. + 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. diff --git a/packages/dashboard/app/hooks/__tests__/useTerminal.test.ts b/packages/dashboard/app/hooks/__tests__/useTerminal.test.ts index 5411fc1657..bf8c7365b9 100644 --- a/packages/dashboard/app/hooks/__tests__/useTerminal.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTerminal.test.ts @@ -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), diff --git a/packages/dashboard/app/hooks/useTerminal.ts b/packages/dashboard/app/hooks/useTerminal.ts index 78e88a8bbb..208f081856 100644 --- a/packages/dashboard/app/hooks/useTerminal.ts +++ b/packages/dashboard/app/hooks/useTerminal.ts @@ -94,8 +94,12 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe // Track previous values to detect context changes const previousSessionIdRef = useRef(sessionId); const previousProjectIdRef = useRef(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(null); @@ -111,6 +116,7 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe const reconnectTimeoutRef = useRef(null); const heartbeatIntervalRef = useRef(null); const isManualCloseRef = useRef(false); + const hasEverConnectedRef = useRef(false); // Callback refs to avoid re-subscriptions const onDataCallbacksRef = useRef 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,