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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user