@@ -21,11 +21,16 @@ import soy.iko.opencode.data.repo.SessionRepository
2121import soy.iko.opencode.di.AppContainer
2222import soy.iko.opencode.R
2323import soy.iko.opencode.util.runCatchingCancellable
24+import soy.iko.opencode.util.safeExceptionSummary
2425import android.util.Log
2526import kotlinx.coroutines.ExperimentalCoroutinesApi
27+import kotlinx.coroutines.channels.BufferOverflow
28+import kotlinx.coroutines.flow.MutableSharedFlow
2629import kotlinx.coroutines.flow.MutableStateFlow
30+import kotlinx.coroutines.flow.SharedFlow
2731import kotlinx.coroutines.flow.SharingStarted
2832import kotlinx.coroutines.flow.StateFlow
33+import kotlinx.coroutines.flow.asSharedFlow
2934import kotlinx.coroutines.flow.asStateFlow
3035import kotlinx.coroutines.CancellationException
3136import kotlinx.coroutines.flow.collectLatest
@@ -78,14 +83,10 @@ class ChatViewModel(
7883 }
7984 .onEach {
8085 _loading.value = false
81− // Clear a stale error from a previous failed attempt now that
82− // messages are flowing again. Without this, the error snackbar
83− // persists after a transient failure that was retried successfully.
84− if (_error.value != null) _error.value = null
8586 }
8687 .retryWhen { cause, _ ->
8788 _loading.value = false
88− _error.value = container.friendlyError(cause)
89+ _errorEvents.tryEmit(container.friendlyError(cause))
8990 // Delay before retrying to avoid a tight loop on persistent errors.
9091 delay(NetworkConfig.retryInitialDelayMs)
9192 true
@@ -99,8 +100,14 @@ class ChatViewModel(
99100 private val _running = MutableStateFlow(false)
100101 val running: StateFlow<Boolean> = _running.asStateFlow()
101102
102− private val _error = MutableStateFlow<String?>(null)
103− val error: StateFlow<String?> = _error.asStateFlow()
103+ /** One-shot error events surfaced as snackbars. A SharedFlow (not StateFlow) so each
104+ * emission is delivered independently — two rapid failures both get a snackbar
105+ * instead of the second silently overwriting the first. */
106+ private val _errorEvents = MutableSharedFlow<String>(
107+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
108+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
109+ )
110+ val errorEvents: SharedFlow<String> = _errorEvents.asSharedFlow()
104111
105112 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
106113 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
@@ -175,8 +182,6 @@ class ChatViewModel(
175182 }
176183 }
177184
178− fun clearError() { _error.value = null }
179−
180185 fun selectModel(option: ModelOption) { _selectedModel.value = option }
181186
182187 fun selectAgent(name: String?) { _selectedAgent.value = name }
@@ -230,7 +235,10 @@ class ChatViewModel(
230235 loading.value = true
231236 error.value = false
232237 runCatchingCancellable { fetch(conn.api) }
233− .onFailure { Log.w("ChatViewModel", "Failed to load $tag", it); error.value = true }
238+ // fetch() does HTTP via withRetry, so the failure can be a
239+ // ClientRequestException whose message embeds the full request URL
240+ // (may contain auth/paths). Log only a scrubbed summary.
241+ .onFailure { Log.w("ChatViewModel", "Failed to load $tag: ${safeExceptionSummary(it)}"); error.value = true }
234242 .getOrNull()?.let(onSuccess)
235243 loading.value = false
236244 }
@@ -262,7 +270,6 @@ class ChatViewModel(
262270 if (conn == null) return@collectLatest
263271 _running.value = false
264272 _pendingPermission.value = null
265− _error.value = null
266273 _failedDraft.value = null
267274 _selectedAgent.value = null
268275 _sessionTitle.value = null
@@ -271,7 +278,7 @@ class ChatViewModel(
271278 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
272279 if (SessionRepository.isError(event, sessionId)) {
273280 _running.value = false
274− _error.value = container.string(R.string.error_agent_reported)
281+ _errorEvents.tryEmit(container.string(R.string.error_agent_reported))
275282 }
276283 when (event) {
277284 is PermissionUpdated ->
@@ -365,7 +372,6 @@ class ChatViewModel(
365372 val conn = connection ?: return false
366373 val trimmed = text.trim()
367374 if (trimmed.isEmpty() || !_running.compareAndSet(false, true)) return false
368− _error.value = null
369375 _failedDraft.value = null
370376 // Clear the in-memory draft for the UI immediately, but don't persist the clear
371377 // yet — if the send fails and the process dies before we restore, the draft
@@ -387,7 +393,7 @@ class ChatViewModel(
387393 _failedDraft.value = trimmed
388394 // Only restore the draft if the user hasn't typed anything new since.
389395 if (_draft.value.isBlank()) updateDraft(trimmed)
390− _error.value = container.friendlyError(it)
396+ _errorEvents.tryEmit(container.friendlyError(it))
391397 _running.value = false
392398 }.onSuccess {
393399 suppressDraftPersist.set(false)
@@ -415,12 +421,11 @@ class ChatViewModel(
415421 fun runCommand(command: Command) {
416422 val conn = connection ?: return
417423 if (!_running.compareAndSet(false, true)) return
418− _error.value = null
419424 viewModelScope.launch {
420425 runCatchingCancellable {
421426 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
422427 }.onFailure {
423− _error.value = container.friendlyError(it)
428+ _errorEvents.tryEmit(container.friendlyError(it))
424429 _running.value = false
425430 }
426431 }
@@ -431,7 +436,7 @@ class ChatViewModel(
431436 viewModelScope.launch {
432437 runCatchingCancellable { conn.repository.abort(sessionId) }
433438 .onSuccess { _running.value = false }
434− .onFailure { _error.value = container.friendlyError(it) }
439+ .onFailure { _errorEvents.tryEmit(container.friendlyError(it)) }
435440 }
436441 }
437442
@@ -449,7 +454,7 @@ class ChatViewModel(
449454 .getOrDefault(emptyList())
450455 .firstOrNull()
451456 if (recent == null) {
452− _error.value = container.string(R.string.no_servers_to_reconnect)
457+ _errorEvents.tryEmit(container.string(R.string.no_servers_to_reconnect))
453458 return@launch
454459 }
455460 runCatchingCancellable {
@@ -457,7 +462,7 @@ class ChatViewModel(
457462 conn.api.ping()
458463 }.onFailure {
459464 container.disconnect()
460− _error.value = container.friendlyError(it)
465+ _errorEvents.tryEmit(container.friendlyError(it))
461466 }
462467 } finally {
463468 _reconnecting.value = false
@@ -474,7 +479,7 @@ class ChatViewModel(
474479 viewModelScope.launch {
475480 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
476481 .onFailure {
477− _error.value = container.friendlyError(it)
482+ _errorEvents.tryEmit(container.friendlyError(it))
478483 // Only restore if no new permission has arrived in the meantime.
479484 if (_pendingPermission.value == null) _pendingPermission.value = permission
480485 }