@@ -234,9 +234,16 @@ class ChatViewModel(
234 // Exponential backoff capped at retryMaxDelayMs so a persistently failing
234 // Exponential backoff capped at retryMaxDelayMs so a persistently failing
235 // server is retried with growing delays (retryInitialDelayMs, then doubling)
235 // server is retried with growing delays (retryInitialDelayMs, then doubling)
236 // instead of hammered at a fixed interval forever. Clamp the shift so a long
236 // instead of hammered at a fixed interval forever. Clamp the shift so a long
237− // failure streak can't overflow the Long delay.
237+ // failure streak can't overflow the Long delay. Apply symmetric jitter
238+ // (matching withRetry's jitteredBackoff) so concurrent retrying flows (e.g.
239+ // messages + catalogs after a server blip) don't retry in lockstep and
240+ // thundering-herd the recovering server.
238− val backoffMs = (NetworkConfig.retryInitialDelayMs shl attempt.coerceAtMost(16L).toInt())
241+ val shift = attempt.coerceAtMost(16L).toInt()
242+ val baseDelay = (NetworkConfig.retryInitialDelayMs shl shift)
239 .coerceAtMost(NetworkConfig.retryMaxDelayMs)
243 .coerceAtMost(NetworkConfig.retryMaxDelayMs)
244+ val jitter = ((baseDelay * NetworkConfig.retryJitterFactor) *
245+ (kotlin.random.Random.nextDouble() * 2 - 1)).toLong()
246+ val backoffMs = (baseDelay + jitter).coerceAtLeast(0)
240 delay(backoffMs)
247 delay(backoffMs)
@@ -275,7 +282,11 @@ class ChatViewModel(
275 /** Merge SSE-driven messages with optimistic entries. An optimistic entry whose trimmed
282 /** Merge SSE-driven messages with optimistic entries. An optimistic entry whose trimmed
276 * text matches a real [UserMessage] in [sse] is dropped (the server echoed it back).
283 * text matches a real [UserMessage] in [sse] is dropped (the server echoed it back).
277 * Remaining entries are appended as synthetic [UserMessage]s at the end. Failed entries
284 * Remaining entries are appended as synthetic [UserMessage]s at the end. Failed entries
278− * are kept so the user sees the failed indicator until they retry or dismiss. */
285+ * are kept so the user sees the failed indicator until they retry or dismiss.
287+ * Only the OLDEST matching entry per real text is dropped, so duplicate prompts (the same
288+ * text sent twice) each match their own server-echoed UserMessage instead of both
289+ * vanishing on the first echo. */
279 private fun mergeOptimistic(
290 private fun mergeOptimistic(
280 sse: List<MessageWithParts>,
291 sse: List<MessageWithParts>,
281 optimistic: List<OptimisticEntry>,
292 optimistic: List<OptimisticEntry>,
@@ -290,13 +301,16 @@ class ChatViewModel(
291 if (text.isNotEmpty()) realUserTexts.add(text)
302 if (text.isNotEmpty()) realUserTexts.add(text)
293− val surviving = optimistic.filter { it.text.trim() !in realUserTexts }
304+ val surviving = dropOldestMatches(optimistic, realUserTexts)
294 if (surviving.isEmpty()) return sse
305 if (surviving.isEmpty()) return sse
295 return sse + surviving.map { it.toMessageWithParts() }
306 return sse + surviving.map { it.toMessageWithParts() }
298 /** Remove optimistic entries whose text now matches a real user message delivered by SSE.
309 /** Remove optimistic entries whose text now matches a real user message delivered by SSE.
299− * Called from the SSE flow's onEach so stale optimistic entries are cleaned up promptly. */
310+ * Called from the SSE flow's onEach so stale optimistic entries are cleaned up promptly.
311+ * Only the OLDEST matching entry per real text is removed, so duplicate prompts (the same
312+ * text sent twice) each match their own server-echoed UserMessage instead of both
313+ * vanishing on the first echo. */
300 private fun reconcileOptimistic(sse: List<MessageWithParts>) {
314 private fun reconcileOptimistic(sse: List<MessageWithParts>) {
301 if (_optimisticMessages.value.isEmpty()) return
315 if (_optimisticMessages.value.isEmpty()) return
302 val realUserTexts = mutableSetOf<String>()
316 val realUserTexts = mutableSetOf<String>()
@@ -308,9 +322,28 @@ class ChatViewModel(
308 if (text.isNotEmpty()) realUserTexts.add(text)
322 if (text.isNotEmpty()) realUserTexts.add(text)
310 if (realUserTexts.isEmpty()) return
324 if (realUserTexts.isEmpty()) return
311− _optimisticMessages.update { entries ->
325+ _optimisticMessages.update { entries -> dropOldestMatches(entries, realUserTexts) }
312− entries.filterNot { it.text.trim() in realUserTexts }
326+ }
328+ /** Drop the first (oldest) entry whose trimmed text matches each real user text, once per
329+ * text. A second optimistic entry with the same text survives until a second matching
330+ * UserMessage arrives, so duplicate prompts aren't both cleared by the first echo. */
331+ private fun dropOldestMatches(
332+ entries: List<OptimisticEntry>,
333+ realUserTexts: Set<String>,
334+ ): List<OptimisticEntry> {
335+ if (entries.isEmpty() || realUserTexts.isEmpty()) return entries
336+ val remaining = realUserTexts.toMutableSet()
337+ val result = ArrayList<OptimisticEntry>(entries.size)
338+ for (entry in entries) {
339+ val text = entry.text.trim()
340+ if (text in remaining) {
341+ remaining.remove(text)
316 /** Convert an optimistic entry to a [MessageWithParts] for display. */
349 /** Convert an optimistic entry to a [MessageWithParts] for display. */