@@ -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