Improve connection state tracking and sync reliability for web and iOS

Changes:
- Web: Add connection state indicator showing disconnected/reconnecting status
- Web: Refresh mission history on successful reconnection to catch missed events
- iOS: Fix 'Agent is working' indicator to check viewed mission state, not global state
- iOS: Add history refresh on reconnection to catch missed events during disconnect

This ensures both web and iOS dashboards show accurate status during connection
issues and automatically sync conversation history when reconnecting.
This commit is contained in:
Thomas Marchand
2026-01-03 15:47:56 +00:00
parent 07378f3fe8
commit 5c361e02a6
2 changed files with 86 additions and 4 deletions

View File

@@ -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<ControlRunState>("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 */}
<div className="flex items-center gap-2 rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2">
{/* Connection status indicator - only show when not connected */}
{connectionState !== "connected" && (
<>
<div className={cn(
"flex items-center gap-2",
connectionState === "reconnecting" ? "text-amber-400" : "text-red-400"
)}>
{connectionState === "reconnecting" ? (
<>
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
<span className="text-sm font-medium">
Reconnecting{reconnectAttempt > 1 ? ` (${reconnectAttempt})` : "..."}
</span>
</>
) : (
<>
<WifiOff className="h-3.5 w-3.5" />
<span className="text-sm font-medium">Disconnected</span>
</>
)}
</div>
<div className="h-4 w-px bg-white/[0.08]" />
</>
)}
{/* Run state indicator */}
<div className={cn("flex items-center gap-2", status.className)}>
<StatusIcon

View File

@@ -306,8 +306,8 @@ struct ControlView: View {
ScrollView {
LazyVStack(spacing: 16) {
if messages.isEmpty && !isLoading {
// Show working indicator when agent is running but no messages yet
if runState == .running {
// Show working indicator when this specific mission is running but no messages yet
if viewingMissionIsRunning {
agentWorkingIndicator
} else {
emptyStateView
@@ -325,8 +325,8 @@ struct ControlView: View {
.id(message.id)
}
// Show working indicator after messages when running but no active streaming item
if runState == .running && !hasActiveStreamingItem {
// Show working indicator after messages when this mission is running but no active streaming item
if viewingMissionIsRunning && !hasActiveStreamingItem {
agentWorkingIndicator
}
}
@@ -400,6 +400,19 @@ struct ControlView: View {
(msg.isThinking && !msg.thinkingDone) || msg.isPhase
}
}
/// Check if the currently viewed mission is running (not just any mission)
private var viewingMissionIsRunning: Bool {
guard let viewingId = viewingMissionId else {
// No specific mission being viewed - fall back to global state
return runState != .idle
}
// Check if this specific mission is in the running missions list
guard let missionInfo = runningMissions.first(where: { $0.missionId == viewingId }) else {
return false
}
return missionInfo.state == "running" || missionInfo.state == "waiting_for_tool"
}
private var agentWorkingIndicator: some View {
HStack(spacing: 12) {
@@ -833,9 +846,24 @@ struct ControlView: View {
}
Task { @MainActor in
// Successfully received an event - we're connected
let wasReconnecting = !self.connectionState.isConnected && self.reconnectAttempt > 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)
}