Fix message ordering when sending multiple messages rapidly (#22)

* Fix message ordering when sending multiple messages rapidly

The issue: When a user sends multiple messages quickly, messages were added
to the UI in the order API responses returned, not the order they were sent.
If network caused a later request to complete first, messages displayed
out of order.

Fix for web and iOS:
- Add messages optimistically BEFORE the API call with a temporary ID
- Update the temp ID to server ID when the response arrives
- Handle the case where SSE arrives before API response by matching
  the FIRST pending temp message (position-based, not content-based)
- Remove optimistic message on error

This ensures messages always appear in the order the user sent them,
regardless of network response timing.

* Fix bugbot review issues: preserve timestamps and add content verification

- iOS: Preserve original timestamp when replacing temp ID with server ID
- Both: Add content verification when matching SSE messages to temp messages
  to avoid mismatching with messages from other sessions/devices

* Fix @MainActor isolation in sendMessage Task block

* Add display selector dropdown for desktop stream

- Changed default display from :99 to :101 (more commonly available)
- Added dropdown to select display ID (:99, :100, :101, :102)
- Display selector shows next to Desktop button
This commit is contained in:
Thomas Marchand
2026-01-03 16:52:57 +00:00
committed by GitHub
parent 5c361e02a6
commit 7f00b9b25c
2 changed files with 137 additions and 44 deletions

View File

@@ -651,7 +651,8 @@ export default function ControlClient() {
// Desktop stream state
const [showDesktopStream, setShowDesktopStream] = useState(false);
const [desktopDisplayId] = useState(":99");
const [desktopDisplayId, setDesktopDisplayId] = useState(":101");
const [showDisplaySelector, setShowDisplaySelector] = useState(false);
// Check if the mission we're viewing is actually running (not just any mission)
const viewingMissionIsRunning = useMemo(() => {
@@ -1282,15 +1283,34 @@ export default function ControlClient() {
if (event.type === "user_message" && isRecord(data)) {
const msgId = String(data["id"] ?? Date.now());
const msgContent = String(data["content"] ?? "");
setItems((prev) => {
// Skip if already added (optimistic update race condition)
// Skip if already added with this ID
if (prev.some((item) => item.id === msgId)) return prev;
// Check if there's a pending temp message with matching content (SSE arrived before API response)
// We verify content to avoid mismatching with messages from other sessions/devices
const tempIndex = prev.findIndex(
(item) =>
item.kind === "user" &&
item.id.startsWith("temp-") &&
item.content === msgContent
);
if (tempIndex !== -1) {
// Replace temp ID with server ID, keeping the original content/timestamp
const updated = [...prev];
updated[tempIndex] = { ...updated[tempIndex], id: msgId };
return updated;
}
// No matching temp message found, add new (message came from another client/session)
return [
...prev,
{
kind: "user",
id: msgId,
content: String(data["content"] ?? ""),
content: msgContent,
timestamp: Date.now(),
},
];
@@ -1481,24 +1501,35 @@ export default function ControlClient() {
setInput("");
setDraftInput("");
// Generate temp ID and add message optimistically BEFORE the API call
// This ensures messages appear in send order, not response order
const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const timestamp = Date.now();
setItems((prev) => [
...prev,
{
kind: "user" as const,
id: tempId,
content,
timestamp,
},
]);
try {
const { id } = await postControlMessage(content);
// Optimistically add user message if not already present (race condition guard)
setItems((prev) => {
if (prev.some((item) => item.id === id)) return prev;
return [
...prev,
{
kind: "user" as const,
id,
content,
timestamp: Date.now(),
},
];
});
// Replace temp ID with server-assigned ID
// This allows SSE handler to correctly deduplicate
setItems((prev) =>
prev.map((item) =>
item.id === tempId ? { ...item, id } : item
)
);
} catch (err) {
console.error(err);
// Remove the optimistic message on error
setItems((prev) => prev.filter((item) => item.id !== tempId));
toast.error("Failed to send message");
}
};
@@ -1728,25 +1759,66 @@ export default function ControlClient() {
</button>
)}
{/* Desktop stream toggle */}
<button
onClick={() => setShowDesktopStream(!showDesktopStream)}
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors",
showDesktopStream
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
: "border-white/[0.06] bg-white/[0.02] text-white/70 hover:bg-white/[0.04]"
)}
title={showDesktopStream ? "Hide desktop stream" : "Show desktop stream"}
>
<Monitor className="h-4 w-4" />
<span className="hidden sm:inline">Desktop</span>
{showDesktopStream ? (
<PanelRightClose className="h-4 w-4" />
) : (
<PanelRight className="h-4 w-4" />
)}
</button>
{/* Desktop stream toggle with display selector */}
<div className="relative flex items-center">
<button
onClick={() => setShowDesktopStream(!showDesktopStream)}
className={cn(
"flex items-center gap-2 rounded-l-lg border px-3 py-2 text-sm transition-colors",
showDesktopStream
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
: "border-white/[0.06] bg-white/[0.02] text-white/70 hover:bg-white/[0.04]"
)}
title={showDesktopStream ? "Hide desktop stream" : "Show desktop stream"}
>
<Monitor className="h-4 w-4" />
<span className="hidden sm:inline">Desktop</span>
{showDesktopStream ? (
<PanelRightClose className="h-4 w-4" />
) : (
<PanelRight className="h-4 w-4" />
)}
</button>
<div className="relative">
<button
onClick={() => setShowDisplaySelector(!showDisplaySelector)}
className={cn(
"flex items-center gap-1 rounded-r-lg border-y border-r px-2 py-2 text-sm transition-colors",
showDesktopStream
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
: "border-white/[0.06] bg-white/[0.02] text-white/70 hover:bg-white/[0.04]"
)}
title="Select display"
>
<span className="text-xs font-mono">{desktopDisplayId}</span>
<ChevronDown className="h-3 w-3" />
</button>
{showDisplaySelector && (
<div className="absolute right-0 top-full mt-1 z-50 min-w-[120px] rounded-lg border border-white/[0.06] bg-[#121214] shadow-xl">
{[":99", ":100", ":101", ":102"].map((display) => (
<button
key={display}
onClick={() => {
setDesktopDisplayId(display);
setShowDisplaySelector(false);
}}
className={cn(
"flex w-full items-center px-3 py-2 text-sm font-mono transition-colors hover:bg-white/[0.04]",
desktopDisplayId === display
? "text-emerald-400"
: "text-white/70"
)}
>
{display}
{desktopDisplayId === display && (
<CheckCircle className="ml-auto h-3.5 w-3.5" />
)}
</button>
))}
</div>
)}
</div>
</div>
{/* Status panel */}
<div className="flex items-center gap-2 rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2">

View File

@@ -786,15 +786,22 @@ struct ControlView: View {
inputText = ""
HapticService.lightTap()
Task {
// Generate temp ID and add message optimistically BEFORE the API call
// This ensures messages appear in send order, not response order
let tempId = "temp-\(UUID().uuidString)"
let tempMessage = ChatMessage(id: tempId, type: .user, content: content)
messages.append(tempMessage)
shouldScrollToBottom = true
Task { @MainActor in
do {
let (messageId, _) = try await api.sendMessage(content: content)
// Add user message to UI if not already added by SSE (race condition guard)
if !messages.contains(where: { $0.id == messageId }) {
let userMessage = ChatMessage(id: messageId, type: .user, content: content)
messages.append(userMessage)
shouldScrollToBottom = true
// Replace temp ID with server-assigned ID, preserving timestamp
// This allows SSE handler to correctly deduplicate
if let index = messages.firstIndex(where: { $0.id == tempId }) {
let originalTimestamp = messages[index].timestamp
messages[index] = ChatMessage(id: messageId, type: .user, content: content, timestamp: originalTimestamp)
}
// If we don't have a current mission, the backend may have just created one
@@ -804,6 +811,8 @@ struct ControlView: View {
}
} catch {
print("Failed to send message: \(error)")
// Remove the optimistic message on error
messages.removeAll { $0.id == tempId }
HapticService.error()
}
}
@@ -1079,10 +1088,22 @@ struct ControlView: View {
case "user_message":
if let content = data["content"] as? String,
let id = data["id"] as? String {
// Skip if we already have this message (added optimistically)
// Skip if we already have this message with this ID
guard !messages.contains(where: { $0.id == id }) else { break }
let message = ChatMessage(id: id, type: .user, content: content)
messages.append(message)
// Check if there's a pending temp message with matching content (SSE arrived before API response)
// We verify content to avoid mismatching with messages from other sessions/devices
if let tempIndex = messages.firstIndex(where: {
$0.type == .user && $0.id.hasPrefix("temp-") && $0.content == content
}) {
// Replace temp ID with server ID, preserving original timestamp
let originalTimestamp = messages[tempIndex].timestamp
messages[tempIndex] = ChatMessage(id: id, type: .user, content: content, timestamp: originalTimestamp)
} else {
// No matching temp message found, add new (message came from another client/session)
let message = ChatMessage(id: id, type: .user, content: content)
messages.append(message)
}
}
case "assistant_message":