@@ -234,9 +234,16 @@ class ChatViewModel(
234234 // Exponential backoff capped at retryMaxDelayMs so a persistently failing
235235 // server is retried with growing delays (retryInitialDelayMs, then doubling)
236236 // instead of hammered at a fixed interval forever. Clamp the shift so a long
237− // failure streak can't overflow the Long delay.
238− val backoffMs = (NetworkConfig.retryInitialDelayMs shl attempt.coerceAtMost(16L).toInt())
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.
241+ val shift = attempt.coerceAtMost(16L).toInt()
242+ val baseDelay = (NetworkConfig.retryInitialDelayMs shl shift)
239243 .coerceAtMost(NetworkConfig.retryMaxDelayMs)
244+ val jitter = ((baseDelay * NetworkConfig.retryJitterFactor) *
245+ (kotlin.random.Random.nextDouble() * 2 - 1)).toLong()
246+ val backoffMs = (baseDelay + jitter).coerceAtLeast(0)
240247 delay(backoffMs)
241248 true
242249 }
@@ -275,7 +282,11 @@ class ChatViewModel(
275282 /** Merge SSE-driven messages with optimistic entries. An optimistic entry whose trimmed
276283 * text matches a real [UserMessage] in [sse] is dropped (the server echoed it back).
277284 * 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.
286+ *
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. */
279290 private fun mergeOptimistic(
280291 sse: List<MessageWithParts>,
281292 optimistic: List<OptimisticEntry>,
@@ -290,13 +301,16 @@ class ChatViewModel(
290301 .trim()
291302 if (text.isNotEmpty()) realUserTexts.add(text)
292303 }
293− val surviving = optimistic.filter { it.text.trim() !in realUserTexts }
304+ val surviving = dropOldestMatches(optimistic, realUserTexts)
294305 if (surviving.isEmpty()) return sse
295306 return sse + surviving.map { it.toMessageWithParts() }
296307 }
297308
298309 /** 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. */
300314 private fun reconcileOptimistic(sse: List<MessageWithParts>) {
301315 if (_optimisticMessages.value.isEmpty()) return
302316 val realUserTexts = mutableSetOf<String>()
@@ -308,9 +322,28 @@ class ChatViewModel(
308322 if (text.isNotEmpty()) realUserTexts.add(text)
309323 }
310324 if (realUserTexts.isEmpty()) return
311− _optimisticMessages.update { entries ->
312− entries.filterNot { it.text.trim() in realUserTexts }
325+ _optimisticMessages.update { entries -> dropOldestMatches(entries, realUserTexts) }
326+ }
327+
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)
342+ } else {
343+ result.add(entry)
344+ }
313345 }
346+ return result
314347 }
315348
316349 /** Convert an optimistic entry to a [MessageWithParts] for display. */