feat(FN-1021): stabilize terminal first-open bootstrap and WebSocket recovery

- Harden TerminalModal bootstrap to handle invalid/expired sessions on first open
- Add WebSocket reconnection logic for terminal sessions that become invalid mid-stream
- Create useTerminal hook with robust session lifecycle management
- Create useTerminalSessions hook for multi-session terminal coordination
- Add comprehensive tests for TerminalModal, useTerminal, and useTerminalSessions
- Document terminal first-open reliability behavior in dashboard README
This commit is contained in:
gsxdsm
2026-04-07 00:20:12 -07:00
parent ab064ebc26
commit 4097d9d250
8 changed files with 477 additions and 1 deletions

View File

@@ -181,6 +181,16 @@ Saved scripts (managed via the Scripts modal or QuickScripts dropdown in the hea
- On successful recovery, the terminal initializes normally; the error state clears automatically
- Existing sessions (tabs) that are already connected are not affected by bootstrap errors on new tabs
**First-Open Reliability**:
- The terminal is usable on first modal open without requiring a page reload
- Stale sessions from a previous browser session are detected during bootstrap via server-side validation (`listTerminalSessions`) and automatically filtered out; a fresh session is created when all stored sessions are stale
- When the WebSocket reports that the current session is invalid (close code 4004 — session-not-found), the terminal auto-recovers without user intervention:
1. xterm is disposed and state is cleared
2. A new server session is created for the active tab via `replaceActiveTabSession`
3. The WebSocket reconnects to the new session automatically (triggered by `sessionId` change)
- If session creation fails during recovery, the bootstrap error UI is shown with a retry button
- This recovery path also handles server restarts, session garbage collection, and any scenario where the backend no longer recognizes the client's stored session ID
### Git Manager
The Git Manager provides comprehensive repository visualization and management directly from the web UI. Access it via the Git Branch icon button in the header (desktop: inline with other utility buttons, mobile: in the overflow menu).
- **Safety Validation**: Dangerous commands (rm -rf /, etc.) are automatically blocked

View File

@@ -203,10 +203,11 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
updateTabTitle,
restartActiveTab,
retryBootstrap,
replaceActiveTabSession,
} = useTerminalSessions();
// Get the WebSocket connection for the active session
const { connectionStatus, sendInput, resize, onData, onConnect, onExit, onScrollback, reconnect } =
const { connectionStatus, sendInput, resize, onData, onConnect, onExit, onScrollback, reconnect, onSessionInvalid } =
useTerminal(activeTab?.sessionId ?? null);
// Keep a ref to resize so the viewport-change effect can call it
@@ -504,6 +505,39 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
}
}, [connectionStatus]);
/**
* Auto-recover when the server reports the session is invalid (code 4004).
*
* Without this handler the user sees "Disconnected" with a reconnect button
* that retries the same stale session forever — the only fix was a full page
* reload. Now we silently create a fresh session on the active tab and let
* the normal connect effect (useTerminal's sessionId dep) open a new
* WebSocket to the replacement session.
*/
useEffect(() => {
const unsub = onSessionInvalid(() => {
// Clear terminal display for the fresh session
xtermRef.current?.clear();
setExitCode(null);
hasInitialCommandRun.current = false;
// Dispose current xterm so the init effect re-runs with the new session
if (xtermRef.current) {
xtermRef.current.dispose();
xtermRef.current = null;
}
fitAddonRef.current = null;
xtermInitializedRef.current = false;
setXtermReady(false);
setXtermInitError(null);
replaceActiveTabSession().catch((err) => {
console.error("Failed to replace invalid terminal session:", err);
});
});
return unsub;
}, [onSessionInvalid, replaceActiveTabSession]);
// Handle overlay click to close
const handleOverlayClick = useCallback(
(e: React.MouseEvent) => {

View File

@@ -102,6 +102,7 @@ vi.mock("../../hooks/useTerminal", () => ({
onConnect: vi.fn(() => vi.fn()),
onScrollback: vi.fn(() => vi.fn()),
reconnect: vi.fn(),
onSessionInvalid: vi.fn(() => vi.fn()),
}),
}));

View File

@@ -83,6 +83,7 @@ const defaultSessionState = {
updateTabTitle: vi.fn(),
restartActiveTab: vi.fn(),
retryBootstrap: vi.fn(),
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
};
describe("TerminalModal", () => {
@@ -100,6 +101,7 @@ describe("TerminalModal", () => {
onConnect: vi.fn(() => vi.fn()),
onScrollback: vi.fn(() => vi.fn()),
reconnect: mockReconnect,
onSessionInvalid: vi.fn(() => vi.fn()),
...overrides,
});
@@ -555,6 +557,7 @@ describe("TerminalModal", () => {
onConnect: vi.fn(() => vi.fn()),
onScrollback: vi.fn(() => vi.fn()),
reconnect: mockReconnect,
onSessionInvalid: vi.fn(() => vi.fn()),
});
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
@@ -839,6 +842,149 @@ describe("TerminalModal", () => {
});
});
});
// --- Invalid session auto-recovery ---
describe("invalid session auto-recovery (FN-1021)", () => {
it("calls replaceActiveTabSession when WebSocket reports session invalid (code 4004)", async () => {
const mockReplaceActiveTabSession = vi.fn().mockResolvedValue(undefined);
let capturedSessionInvalidCb: (() => void) | null = null;
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
replaceActiveTabSession: mockReplaceActiveTabSession,
});
mockUseTerminal.mockReturnValue(
createMockTerminalState({
connectionStatus: "disconnected",
onSessionInvalid: vi.fn((cb: () => void) => {
capturedSessionInvalidCb = cb;
return vi.fn();
}),
})
);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(capturedSessionInvalidCb).not.toBeNull();
});
// Simulate the WebSocket reporting session invalid
act(() => {
capturedSessionInvalidCb!();
});
expect(mockReplaceActiveTabSession).toHaveBeenCalledTimes(1);
});
it("clears xterm state when session is invalid", async () => {
const mockReplaceActiveTabSession = vi.fn().mockResolvedValue(undefined);
let capturedSessionInvalidCb: (() => void) | null = null;
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
replaceActiveTabSession: mockReplaceActiveTabSession,
});
mockUseTerminal.mockReturnValue(
createMockTerminalState({
connectionStatus: "connected",
onSessionInvalid: vi.fn((cb: () => void) => {
capturedSessionInvalidCb = cb;
return vi.fn();
}),
})
);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
// Wait for xterm to initialize
await waitFor(() => {
expect(mockTerminalInstance.open).toHaveBeenCalled();
});
// Simulate session invalidation
act(() => {
capturedSessionInvalidCb!();
});
// xterm should be disposed and cleared for fresh init
expect(mockTerminalInstance.dispose).toHaveBeenCalled();
expect(mockTerminalInstance.clear).toHaveBeenCalled();
});
it("terminal is usable after session recovery without page reload", async () => {
const mockReplaceActiveTabSession = vi.fn().mockResolvedValue(undefined);
let capturedSessionInvalidCb: (() => void) | null = null;
// Start with a stale session that will be invalidated
const staleTab = {
id: "tab-stale",
sessionId: "stale-session-999",
title: "bash",
isActive: true,
createdAt: Date.now(),
};
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
tabs: [staleTab],
activeTab: staleTab,
replaceActiveTabSession: mockReplaceActiveTabSession,
});
mockUseTerminal.mockReturnValue(
createMockTerminalState({
connectionStatus: "disconnected",
onSessionInvalid: vi.fn((cb: () => void) => {
capturedSessionInvalidCb = cb;
return vi.fn();
}),
})
);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
});
// Trigger session invalidation
act(() => {
capturedSessionInvalidCb!();
});
// replaceActiveTabSession should be called — this creates a new session
await waitFor(() => {
expect(mockReplaceActiveTabSession).toHaveBeenCalledTimes(1);
});
// Simulate the session hook returning a new session after replacement
const freshTab = {
id: "tab-stale",
sessionId: "fresh-session-001",
title: "bash",
isActive: true,
createdAt: Date.now(),
};
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
tabs: [freshTab],
activeTab: freshTab,
replaceActiveTabSession: mockReplaceActiveTabSession,
});
// After replacement, useTerminal should be called with the new session ID
// This happens automatically because activeTab.sessionId changed
// The modal should still be open and usable
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
// No bootstrap error should be shown (we recovered)
expect(screen.queryByTestId("terminal-bootstrap-error")).toBeNull();
});
});
});
// --- Mobile layout regression tests ---
@@ -867,6 +1013,7 @@ describe("TerminalModal — mobile layout contract", () => {
onConnect: vi.fn(() => vi.fn()),
onScrollback: vi.fn(() => vi.fn()),
reconnect: mockReconnect,
onSessionInvalid: vi.fn(() => vi.fn()),
...overrides,
});
@@ -1132,6 +1279,7 @@ describe("TerminalModal — new tab while modal open", () => {
onConnect: vi.fn(() => vi.fn()),
onScrollback: vi.fn(() => vi.fn()),
reconnect: mockReconnect,
onSessionInvalid: vi.fn(() => vi.fn()),
...overrides,
});
@@ -1446,6 +1594,7 @@ describe("TerminalModal — virtual keyboard overlap handling", () => {
onConnect: vi.fn(() => vi.fn()),
onScrollback: vi.fn(() => vi.fn()),
reconnect: mockReconnect,
onSessionInvalid: vi.fn(() => vi.fn()),
...overrides,
});
@@ -1466,6 +1615,7 @@ describe("TerminalModal — virtual keyboard overlap handling", () => {
setActiveTab: vi.fn(),
updateTabTitle: vi.fn(),
restartActiveTab: vi.fn(),
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
};
let savedVisualViewport: typeof window.visualViewport;
@@ -1964,6 +2114,7 @@ describe("TerminalModal — close and reopen scrollback replay", () => {
onConnect: vi.fn(() => vi.fn()),
onScrollback: vi.fn(() => vi.fn()),
reconnect: mockReconnect,
onSessionInvalid: vi.fn(() => vi.fn()),
...overrides,
});
@@ -1986,6 +2137,7 @@ describe("TerminalModal — close and reopen scrollback replay", () => {
updateTabTitle: vi.fn(),
restartActiveTab: vi.fn(),
retryBootstrap: vi.fn(),
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
};
beforeEach(() => {
@@ -2169,6 +2321,7 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", ()
onConnect: vi.fn(() => vi.fn()),
onScrollback: vi.fn(() => vi.fn()),
reconnect: mockReconnect,
onSessionInvalid: vi.fn(() => vi.fn()),
...overrides,
});
@@ -2191,6 +2344,7 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", ()
updateTabTitle: vi.fn(),
restartActiveTab: vi.fn(),
retryBootstrap: vi.fn(),
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
};
let savedVisualViewport: typeof window.visualViewport;

View File

@@ -427,6 +427,123 @@ describe("useTerminalSessions", () => {
});
});
describe("replacing active tab session (invalid session recovery)", () => {
it("swaps sessionId on active tab without killing old session", async () => {
localStorageMock.getItem.mockReturnValue(null);
mockListTerminalSessions.mockResolvedValue([]);
mockCreateTerminalSession
.mockResolvedValueOnce({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" })
.mockResolvedValueOnce({ sessionId: "session-replacement", shell: "/bin/bash", cwd: "/project" });
const { result } = renderHook(() => useTerminalSessions());
await waitFor(() => {
expect(result.current.tabs.length).toBe(1);
expect(result.current.activeTab?.sessionId).toBe("session-1");
});
const tabId = result.current.activeTab!.id;
await act(async () => {
await result.current.replaceActiveTabSession();
});
// Should NOT kill the old session (it's already gone from server)
expect(mockKillPtyTerminalSession).not.toHaveBeenCalled();
// Tab should still exist with same ID but new sessionId
expect(result.current.tabs.length).toBe(1);
expect(result.current.activeTab?.id).toBe(tabId);
expect(result.current.activeTab?.sessionId).toBe("session-replacement");
});
it("sets bootstrapError when replacement session creation fails", async () => {
localStorageMock.getItem.mockReturnValue(null);
mockListTerminalSessions.mockResolvedValue([]);
mockCreateTerminalSession
.mockResolvedValueOnce({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" })
.mockRejectedValueOnce(new Error("Server unreachable"));
const { result } = renderHook(() => useTerminalSessions());
await waitFor(() => {
expect(result.current.tabs.length).toBe(1);
});
await act(async () => {
await result.current.replaceActiveTabSession();
});
// Error should be set so UI can show retry
await waitFor(() => {
expect(result.current.bootstrapError).toBe("Server unreachable");
});
// Tab should still exist with the old sessionId
expect(result.current.activeTab?.sessionId).toBe("session-1");
});
it("does nothing when no active tab exists", async () => {
// Set up a scenario where tabs are empty and isReady is true but no auto-create happened yet
localStorageMock.getItem.mockReturnValue(null);
mockListTerminalSessions.mockResolvedValue([]);
// Make auto-create hang so no tab is created
mockCreateTerminalSession.mockReturnValue(new Promise(() => {}));
const { result } = renderHook(() => useTerminalSessions());
// Wait for isReady to be true (list completes) but tabs are empty (create pending)
await waitFor(() => {
expect(result.current.isReady).toBe(true);
});
// replaceActiveTabSession should be a no-op with no active tab
await act(async () => {
await result.current.replaceActiveTabSession();
});
// No additional createTerminalSession calls beyond the pending auto-create
expect(mockCreateTerminalSession).toHaveBeenCalledTimes(1);
});
it("clears bootstrapError on successful replacement after failure", async () => {
localStorageMock.getItem.mockReturnValue(null);
mockListTerminalSessions.mockResolvedValue([]);
mockCreateTerminalSession
.mockResolvedValueOnce({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" })
.mockRejectedValueOnce(new Error("Temporary failure"))
.mockResolvedValueOnce({ sessionId: "session-recovered", shell: "/bin/bash", cwd: "/project" });
const { result } = renderHook(() => useTerminalSessions());
await waitFor(() => {
expect(result.current.tabs.length).toBe(1);
});
// First replacement fails
await act(async () => {
await result.current.replaceActiveTabSession();
});
await waitFor(() => {
expect(result.current.bootstrapError).toBe("Temporary failure");
});
// Second replacement succeeds
await act(async () => {
await result.current.replaceActiveTabSession();
});
await waitFor(() => {
expect(result.current.bootstrapError).toBeNull();
});
expect(result.current.activeTab?.sessionId).toBe("session-recovered");
});
});
describe("localStorage persistence", () => {
it("persists tabs to localStorage", async () => {
localStorageMock.getItem.mockReturnValue(null);

View File

@@ -186,6 +186,90 @@ describe("useTerminal", () => {
expect(result.current.connectionStatus).toBe("disconnected");
});
describe("onSessionInvalid callback", () => {
it("fires onSessionInvalid callbacks when WebSocket closes with code 4004", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
const onSessionInvalid = vi.fn();
act(() => {
result.current.onSessionInvalid(onSessionInvalid);
});
act(() => {
MockWebSocket.instances[0].emitClose(4004);
});
expect(onSessionInvalid).toHaveBeenCalledTimes(1);
});
it("does NOT fire onSessionInvalid for close code 4000", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
const onSessionInvalid = vi.fn();
act(() => {
result.current.onSessionInvalid(onSessionInvalid);
});
act(() => {
MockWebSocket.instances[0].emitClose(4000);
});
expect(onSessionInvalid).not.toHaveBeenCalled();
});
it("does NOT fire onSessionInvalid for normal close codes", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
const onSessionInvalid = vi.fn();
act(() => {
result.current.onSessionInvalid(onSessionInvalid);
});
act(() => {
MockWebSocket.instances[0].emitClose(1000);
});
expect(onSessionInvalid).not.toHaveBeenCalled();
});
it("unsubscribes correctly", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
const onSessionInvalid = vi.fn();
const unsub = result.current.onSessionInvalid(onSessionInvalid);
// Unsubscribe
act(() => {
unsub();
});
act(() => {
MockWebSocket.instances[0].emitClose(4004);
});
// Should NOT have been called after unsubscribe
expect(onSessionInvalid).not.toHaveBeenCalled();
});
it("fires for multiple subscribers", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
const cb1 = vi.fn();
const cb2 = vi.fn();
act(() => {
result.current.onSessionInvalid(cb1);
result.current.onSessionInvalid(cb2);
});
act(() => {
MockWebSocket.instances[0].emitClose(4004);
});
expect(cb1).toHaveBeenCalledTimes(1);
expect(cb2).toHaveBeenCalledTimes(1);
});
});
describe("early message buffering", () => {
it("replays buffered scrollback to late subscribers", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));

View File

@@ -19,6 +19,13 @@ export interface UseTerminalReturn {
onScrollback: (callback: (data: string) => void) => () => void;
/** Manually reconnect */
reconnect: () => void;
/**
* Register a callback for session-invalid events.
* Fires when the WebSocket closes with code 4004 (session-not-found),
* meaning the server no longer recognizes the session. The caller should
* create a new session rather than attempting reconnect.
*/
onSessionInvalid: (callback: () => void) => () => void;
}
interface WebSocketMessage {
@@ -86,6 +93,7 @@ export function useTerminal(sessionId: string | null): UseTerminalReturn {
const onExitCallbacksRef = useRef<Set<(exitCode: number) => void>>(new Set());
const onConnectCallbacksRef = useRef<Set<(info: { shell: string; cwd: string }) => void>>(new Set());
const onScrollbackCallbacksRef = useRef<Set<(data: string) => void>>(new Set());
const onSessionInvalidCallbacksRef = useRef<Set<() => void>>(new Set());
// Buffer for initial messages received before subscribers are registered.
// This ensures scrollback, connected info, and early shell output are
@@ -135,6 +143,17 @@ export function useTerminal(sessionId: string | null): UseTerminalReturn {
return () => onScrollbackCallbacksRef.current.delete(callback);
}, []);
/**
* Register a callback for session-invalid events.
* Fires when the server closes the WebSocket with code 4004, indicating
* the session no longer exists. Unlike transient disconnects, this is a
* permanent condition that requires creating a new session to recover.
*/
const onSessionInvalid = useCallback((callback: () => void) => {
onSessionInvalidCallbacksRef.current.add(callback);
return () => onSessionInvalidCallbacksRef.current.delete(callback);
}, []);
// Send input to terminal
const sendInput = useCallback((data: string) => {
const ws = wsRef.current;
@@ -288,6 +307,13 @@ export function useTerminal(sessionId: string | null): UseTerminalReturn {
// Don't reconnect for certain close codes
if (event.code === 4000 || event.code === 4004) {
setConnectionStatus("disconnected");
// Code 4004 means the server doesn't recognize the session — it's
// permanently invalid. Notify subscribers so they can create a new
// session rather than retrying the stale one.
if (event.code === 4004) {
onSessionInvalidCallbacksRef.current.forEach((cb) => cb());
}
return;
}
@@ -342,5 +368,6 @@ export function useTerminal(sessionId: string | null): UseTerminalReturn {
onConnect,
onScrollback,
reconnect,
onSessionInvalid,
};
}

View File

@@ -50,6 +50,14 @@ interface UseTerminalSessionsReturn {
restartActiveTab: () => Promise<void>;
/** Retry bootstrap after a creation failure. Clears error and re-attempts auto-create. */
retryBootstrap: () => void;
/**
* Replace the active tab's session with a fresh server session.
* Called when the WebSocket reports the current session is invalid (code 4004).
* Unlike restartActiveTab, this does NOT kill the old session (it's already
* gone from the server) and does NOT reset xterm state — it only swaps the
* sessionId so the next WebSocket connect targets the new session.
*/
replaceActiveTabSession: () => Promise<void>;
}
/**
@@ -393,6 +401,46 @@ export function useTerminalSessions(): UseTerminalSessionsReturn {
);
}, [tabs]);
/**
* Replace the active tab's session with a fresh server session.
* Called when the WebSocket reports the current session is invalid (code 4004).
*
* Unlike restartActiveTab:
* - Does NOT kill the old session (it's already gone from the server).
* - Does NOT clear xterm or reset exit state — TerminalModal handles that.
* - Only swaps the sessionId so useTerminal reconnects to the new session.
*
* If session creation fails, the bootstrap error is set so the user can
* retry via the error UI.
*/
const replaceActiveTabSession = useCallback(async (): Promise<void> => {
// Read the active tab directly from the derived value.
// Use a local snapshot since the async createTerminalSession may
// cause re-renders that change tabs state.
const currentActiveTab = tabs.find((t) => t.isActive);
if (!currentActiveTab) return;
try {
const session = await createTerminalSession();
setTabs((currentTabs) =>
currentTabs.map((tab) =>
tab.id === currentActiveTab.id
? { ...tab, sessionId: session.sessionId }
: tab
)
);
setBootstrapError(null);
} catch (err) {
if (!isRelativeUrlFetchError(err)) {
console.error(err);
}
const message =
err instanceof Error ? err.message : typeof err === "string" ? err : "Failed to create terminal session";
setBootstrapError(message);
}
}, [tabs]);
// Derive active tab
const activeTab = tabs.find((tab) => tab.isActive) ?? null;
@@ -420,5 +468,6 @@ export function useTerminalSessions(): UseTerminalSessionsReturn {
updateTabTitle,
restartActiveTab,
retryBootstrap,
replaceActiveTabSession,
};
}