Skip to content

Commit 9f8aa06

Browse files
fix(android): dedupe user messages by SSE id instead of content
Match optimistic send bubbles using the server user_message id (same as client_message_id) so identical consecutive sends no longer confirm the wrong bubble; map event_id during event-log hydration for stable ids. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent edcd151 commit 9f8aa06

1 file changed

Lines changed: 23 additions & 15 deletions

File tree

android_dashboard/app/src/main/java/sh/sandboxed/dashboard/ui/control/ControlViewModel.kt

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,6 @@ class ControlViewModel(private val container: AppContainer) : ViewModel() {
104104
// Some deployments lack /api/control/parallel/config; remember the 404
105105
// instead of re-probing it on every poll tick.
106106
private var parallelConfigSupported = true
107-
// Contents of messages sent from this client that haven't been echoed back
108-
// by the server yet. Lets the live `user_message` event confirm the local
109-
// bubble instead of appending a duplicate.
110-
private val pendingEchoes = ArrayDeque<String>()
111107

112108
init {
113109
viewModelScope.launch {
@@ -208,18 +204,13 @@ class ControlViewModel(private val container: AppContainer) : ViewModel() {
208204
container.settings.setLastMission(mission.id)
209205
missionId = mission.id
210206
}
211-
synchronized(pendingEchoes) {
212-
pendingEchoes.addLast(text)
213-
while (pendingEchoes.size > 8) pendingEchoes.removeFirst()
214-
}
215207
// mission_id pins the send to the conversation on screen;
216208
// client_message_id lets the server dedupe retries.
217209
container.api.sendMessage(text, missionId = missionId, clientMessageId = messageId)
218210
refreshQueue()
219211
}.onSuccess {
220212
markSendState(messageId, SendState.SENT)
221213
}.onFailure { e ->
222-
synchronized(pendingEchoes) { pendingEchoes.remove(text) }
223214
markSendState(messageId, SendState.FAILED)
224215
_state.update { it.copy(error = e.message) }
225216
}
@@ -232,6 +223,25 @@ class ControlViewModel(private val container: AppContainer) : ViewModel() {
232223
}
233224
}
234225

226+
/// Resolve a `user_message` event by id. The optimistic bubble already
227+
/// uses `client_message_id` as its id, so a live echo confirms it instead
228+
/// of duplicating; a failed bubble whose POST actually reached the server
229+
/// is healed to SENT when the echo arrives.
230+
private fun confirmUserMessage(id: String, content: String) {
231+
_state.update { st ->
232+
val idx = st.messages.indexOfFirst { it.id == id }
233+
if (idx >= 0) {
234+
st.copy(
235+
messages = st.messages.mapIndexed { i, m ->
236+
if (i == idx) m.copy(content = content, sendState = SendState.SENT) else m
237+
},
238+
)
239+
} else {
240+
st.copy(messages = st.messages + ChatMessage(id = id, kind = ChatMessageKind.User, content = content))
241+
}
242+
}
243+
}
244+
235245
/// Send the current draft as a parallel (child) mission instead of a turn
236246
/// in the current conversation. The backend spawns a worker mission that
237247
/// shows up in the running bar and the workers dialog.
@@ -536,12 +546,9 @@ class ControlViewModel(private val container: AppContainer) : ViewModel() {
536546
when (evt.type) {
537547
"user_message" -> {
538548
val content = s("content") ?: return
539-
// A live echo of a message this client just sent confirms the
540-
// local bubble instead of duplicating it.
541-
val isLocalEcho = live && synchronized(pendingEchoes) { pendingEchoes.remove(content) }
542-
if (isLocalEcho) {
543-
_state.value.messages.lastOrNull { it.kind is ChatMessageKind.User && it.content == content }
544-
?.let { markSendState(it.id, SendState.SENT) }
549+
val id = s("id")
550+
if (id != null) {
551+
confirmUserMessage(id, content)
545552
} else {
546553
appendMessage(ChatMessage(kind = ChatMessageKind.User, content = content))
547554
}
@@ -757,6 +764,7 @@ class ControlViewModel(private val container: AppContainer) : ViewModel() {
757764
val data = ev.metadata.toMutableMap()
758765
data["mission_id"] = JsonPrimitive(ev.missionId)
759766
if (ev.content.isNotBlank()) data["content"] = JsonPrimitive(ev.content)
767+
ev.eventId?.let { data["id"] = JsonPrimitive(it) }
760768
ev.toolCallId?.let { data["tool_call_id"] = JsonPrimitive(it) }
761769
ev.toolName?.let { data["name"] = JsonPrimitive(it) }
762770
when (ev.eventType) {

0 commit comments

Comments
 (0)