fix(FN-868): add terminal bootstrap error handling with retry and improve workflow step manager

- Add bootstrap error state and retry logic to useTerminalSessions hook to prevent indefinite 'Starting terminal' hang
- Add error/retry UI states in TerminalModal component with CSS styling
- Add tests for useTerminalSessions bootstrap error and retry behavior
- Refactor WorkflowStepManager with template support and improved UX
- Extract CSS from inline styles to stylesheet for terminal and workflow components
- Document terminal startup error handling and non-hanging guarantee in dashboard README
This commit is contained in:
gsxdsm
2026-04-04 07:11:40 -07:00
parent f5430f6ed2
commit 6c3f540e0e
6 changed files with 352 additions and 10 deletions

View File

@@ -97,11 +97,13 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
tabs,
activeTab,
isReady,
bootstrapError,
createTab,
closeTab,
setActiveTab,
updateTabTitle,
restartActiveTab
restartActiveTab,
retryBootstrap,
} = useTerminalSessions();
// Get the WebSocket connection for the active session
@@ -415,8 +417,9 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
}
};
// Determine loading state
const isLoading = !isReady || !activeTab || !xtermReady;
// Determine loading state — when bootstrapError is set, we are NOT loading
// (we have a definitive error to show instead of an indefinite spinner).
const isLoading = !isReady || (!activeTab && !bootstrapError) || !xtermReady;
return (
<div
@@ -530,12 +533,27 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
{/* Terminal container */}
<div className="terminal-container" data-testid="terminal-container">
{isLoading && (
{isLoading && !bootstrapError && (
<div className="terminal-loading" data-testid="terminal-loading">
<div className="terminal-spinner" />
<span>Starting terminal...</span>
</div>
)}
{bootstrapError && !activeTab && (
<div className="terminal-loading" data-testid="terminal-bootstrap-error">
<div className="terminal-error-content">
<span>Failed to start terminal: {bootstrapError}</span>
<button
className="terminal-retry-btn"
onClick={retryBootstrap}
data-testid="terminal-retry-btn"
>
<RefreshCw size={14} />
Retry
</button>
</div>
</div>
)}
{/*
Always render the xterm container (no display:none) so that
terminal.open() can measure its dimensions even during a tab switch.

View File

@@ -76,11 +76,13 @@ const defaultSessionState = {
tabs: [defaultTab],
activeTab: defaultTab,
isReady: true,
bootstrapError: null,
createTab: vi.fn(),
closeTab: vi.fn(),
setActiveTab: vi.fn(),
updateTabTitle: vi.fn(),
restartActiveTab: vi.fn(),
retryBootstrap: vi.fn(),
};
describe("TerminalModal", () => {
@@ -143,6 +145,104 @@ describe("TerminalModal", () => {
});
});
it("shows error with retry button when bootstrap fails instead of stuck loading", async () => {
const mockRetryBootstrap = vi.fn();
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
tabs: [],
activeTab: null,
bootstrapError: "Server unreachable",
retryBootstrap: mockRetryBootstrap,
});
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
// Should NOT show the loading spinner
await waitFor(() => {
expect(screen.queryByTestId("terminal-loading")).toBeNull();
});
// Should show the bootstrap error state with retry button
expect(screen.getByTestId("terminal-bootstrap-error")).toBeTruthy();
expect(screen.getByText(/Failed to start terminal: Server unreachable/)).toBeTruthy();
const retryBtn = screen.getByTestId("terminal-retry-btn");
expect(retryBtn).toBeTruthy();
expect(retryBtn.textContent).toContain("Retry");
});
it("retry button calls retryBootstrap from the hook", async () => {
const mockRetryBootstrap = vi.fn();
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
tabs: [],
activeTab: null,
bootstrapError: "Connection refused",
retryBootstrap: mockRetryBootstrap,
});
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const retryBtn = screen.getByTestId("terminal-retry-btn");
fireEvent.click(retryBtn);
expect(mockRetryBootstrap).toHaveBeenCalled();
});
it("clears error state and shows terminal after successful retry", async () => {
const mockRetryBootstrap = vi.fn();
// Start with error state
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
tabs: [],
activeTab: null,
bootstrapError: "Server unreachable",
retryBootstrap: mockRetryBootstrap,
});
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
// Error state should be shown
expect(screen.getByTestId("terminal-bootstrap-error")).toBeTruthy();
// Simulate successful retry — hook updates state
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
tabs: [defaultTab],
activeTab: defaultTab,
bootstrapError: null,
retryBootstrap: mockRetryBootstrap,
});
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
// Error state should be gone
await waitFor(() => {
expect(screen.queryByTestId("terminal-bootstrap-error")).toBeNull();
});
// Loading spinner should also be gone (xterm will init)
// The loading overlay will disappear after xterm initializes
expect(screen.queryByTestId("terminal-loading")).toBeNull();
});
it("does not show bootstrap error when activeTab exists (recovered state)", async () => {
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
bootstrapError: "Previous error",
tabs: [defaultTab],
activeTab: defaultTab,
});
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
// Bootstrap error should NOT show because activeTab exists
await waitFor(() => {
expect(screen.queryByTestId("terminal-bootstrap-error")).toBeNull();
});
});
it("shows tabs when multiple sessions exist", async () => {
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,