diff --git a/dashboard/src/app/control/control-client.tsx b/dashboard/src/app/control/control-client.tsx index a6658d6..9556d5e 100644 --- a/dashboard/src/app/control/control-client.tsx +++ b/dashboard/src/app/control/control-client.tsx @@ -68,6 +68,8 @@ import { Monitor, PanelRightClose, PanelRight, + Wifi, + WifiOff, } from "lucide-react"; import { OptionList, @@ -588,6 +590,12 @@ export default function ControlClient() { const [runState, setRunState] = useState("idle"); const [queueLen, setQueueLen] = useState(0); + // Connection state for SSE stream - starts as disconnected until first event received + const [connectionState, setConnectionState] = useState< + "connected" | "disconnected" | "reconnecting" + >("disconnected"); + const [reconnectAttempt, setReconnectAttempt] = useState(0); + // Progress state (for "Subtask X of Y" indicator) const [progress, setProgress] = useState<{ total: number; @@ -1207,7 +1215,25 @@ export default function ControlClient() { } if (event.type === "status" && isRecord(data)) { + const wasReconnecting = reconnectAttempts > 0; reconnectAttempts = 0; + + // Update connection state to connected + setConnectionState("connected"); + setReconnectAttempt(0); + + // If we just reconnected, refresh the viewed mission's history to catch missed events + if (wasReconnecting && viewingId) { + getMission(viewingId) + .then((mission) => { + if (!mounted) return; + const historyItems = missionHistoryToItems(mission); + setItems(historyItems); + setMissionItems((prev) => ({ ...prev, [viewingId]: historyItems })); + }) + .catch(() => {}); // Ignore errors - we'll get updates via stream + } + const st = data["state"]; const newState = typeof st === "string" ? (st as ControlRunState) : "idle"; @@ -1420,6 +1446,9 @@ export default function ControlClient() { maxReconnectDelay ); reconnectAttempts++; + // Update connection state to show reconnecting indicator + setConnectionState("reconnecting"); + setReconnectAttempt(reconnectAttempts); reconnectTimeout = setTimeout(() => { if (mounted) connect(); }, delay); @@ -1721,6 +1750,31 @@ export default function ControlClient() { {/* Status panel */}
+ {/* Connection status indicator - only show when not connected */} + {connectionState !== "connected" && ( + <> +
+ {connectionState === "reconnecting" ? ( + <> + + + Reconnecting{reconnectAttempt > 1 ? ` (${reconnectAttempt})` : "..."} + + + ) : ( + <> + + Disconnected + + )} +
+
+ + )} + {/* Run state indicator */}
0 if !self.connectionState.isConnected { self.connectionState = .connected self.reconnectAttempt = 0 + + // If we just reconnected, refresh the viewed mission's history to catch missed events + if wasReconnecting, let viewingId = self.viewingMissionId { + Task { + do { + let mission = try await self.api.getMission(id: viewingId) + await MainActor.run { + self.applyViewingMission(mission) + } + } catch { + // Ignore errors - we'll get updates via stream + } + } + } } self.handleStreamEvent(type: eventType, data: data) }