@@ -21,11 +21,16 @@ import soy.iko.opencode.data.repo.SessionRepository
21import soy.iko.opencode.di.AppContainer
21import soy.iko.opencode.di.AppContainer
22import soy.iko.opencode.R
22import soy.iko.opencode.R
23import soy.iko.opencode.util.runCatchingCancellable
23import soy.iko.opencode.util.runCatchingCancellable
24+import soy.iko.opencode.util.safeExceptionSummary
24import android.util.Log
25import android.util.Log
25import kotlinx.coroutines.ExperimentalCoroutinesApi
26import kotlinx.coroutines.ExperimentalCoroutinesApi
27+import kotlinx.coroutines.channels.BufferOverflow
28+import kotlinx.coroutines.flow.MutableSharedFlow
26import kotlinx.coroutines.flow.MutableStateFlow
29import kotlinx.coroutines.flow.MutableStateFlow
30+import kotlinx.coroutines.flow.SharedFlow
27import kotlinx.coroutines.flow.SharingStarted
31import kotlinx.coroutines.flow.SharingStarted
28import kotlinx.coroutines.flow.StateFlow
32import kotlinx.coroutines.flow.StateFlow
33+import kotlinx.coroutines.flow.asSharedFlow
29import kotlinx.coroutines.flow.asStateFlow
34import kotlinx.coroutines.flow.asStateFlow
30import kotlinx.coroutines.CancellationException
35import kotlinx.coroutines.CancellationException
31import kotlinx.coroutines.flow.collectLatest
36import kotlinx.coroutines.flow.collectLatest
@@ -78,14 +83,10 @@ class ChatViewModel(
80 _loading.value = false
85 _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
86 .retryWhen { cause, _ ->
87 .retryWhen { cause, _ ->
87 _loading.value = false
88 _loading.value = false
88− _error.value = container.friendlyError(cause)
89+ _errorEvents.tryEmit(container.friendlyError(cause))
89 // Delay before retrying to avoid a tight loop on persistent errors.
90 // Delay before retrying to avoid a tight loop on persistent errors.
90 delay(NetworkConfig.retryInitialDelayMs)
91 delay(NetworkConfig.retryInitialDelayMs)
@@ -99,8 +100,14 @@ class ChatViewModel(
99 private val _running = MutableStateFlow(false)
100 private val _running = MutableStateFlow(false)
100 val running: StateFlow<Boolean> = _running.asStateFlow()
101 val running: StateFlow<Boolean> = _running.asStateFlow()
102− private val _error = MutableStateFlow<String?>(null)
103+ /** One-shot error events surfaced as snackbars. A SharedFlow (not StateFlow) so each
103− val error: StateFlow<String?> = _error.asStateFlow()
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,
110+ val errorEvents: SharedFlow<String> = _errorEvents.asSharedFlow()
105 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
112 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
106 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
113 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
@@ -175,8 +182,6 @@ class ChatViewModel(
178− fun clearError() { _error.value = null }
180 fun selectModel(option: ModelOption) { _selectedModel.value = option }
185 fun selectModel(option: ModelOption) { _selectedModel.value = option }
182 fun selectAgent(name: String?) { _selectedAgent.value = name }
187 fun selectAgent(name: String?) { _selectedAgent.value = name }
@@ -230,7 +235,10 @@ class ChatViewModel(
230 loading.value = true
235 loading.value = true
231 error.value = false
236 error.value = false
232 runCatchingCancellable { fetch(conn.api) }
237 runCatchingCancellable { fetch(conn.api) }
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.
233− .onFailure { Log.w("ChatViewModel", "Failed to load $tag", it); error.value = true }
241+ .onFailure { Log.w("ChatViewModel", "Failed to load $tag: ${safeExceptionSummary(it)}"); error.value = true }
234 .getOrNull()?.let(onSuccess)
242 .getOrNull()?.let(onSuccess)
235 loading.value = false
243 loading.value = false
@@ -262,7 +270,6 @@ class ChatViewModel(
262 if (conn == null) return@collectLatest
270 if (conn == null) return@collectLatest
263 _running.value = false
271 _running.value = false
264 _pendingPermission.value = null
272 _pendingPermission.value = null
266 _failedDraft.value = null
273 _failedDraft.value = null
267 _selectedAgent.value = null
274 _selectedAgent.value = null
268 _sessionTitle.value = null
275 _sessionTitle.value = null
@@ -271,7 +278,7 @@ class ChatViewModel(
271 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
278 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
272 if (SessionRepository.isError(event, sessionId)) {
279 if (SessionRepository.isError(event, sessionId)) {
273 _running.value = false
280 _running.value = false
274− _error.value = container.string(R.string.error_agent_reported)
281+ _errorEvents.tryEmit(container.string(R.string.error_agent_reported))
276 when (event) {
283 when (event) {
277 is PermissionUpdated ->
284 is PermissionUpdated ->
@@ -365,7 +372,6 @@ class ChatViewModel(
365 val conn = connection ?: return false
372 val conn = connection ?: return false
366 val trimmed = text.trim()
373 val trimmed = text.trim()
367 if (trimmed.isEmpty() || !_running.compareAndSet(false, true)) return false
374 if (trimmed.isEmpty() || !_running.compareAndSet(false, true)) return false
369 _failedDraft.value = null
375 _failedDraft.value = null
370 // Clear the in-memory draft for the UI immediately, but don't persist the clear
376 // Clear the in-memory draft for the UI immediately, but don't persist the clear
371 // yet — if the send fails and the process dies before we restore, the draft
377 // yet — if the send fails and the process dies before we restore, the draft
@@ -387,7 +393,7 @@ class ChatViewModel(
387 _failedDraft.value = trimmed
393 _failedDraft.value = trimmed
388 // Only restore the draft if the user hasn't typed anything new since.
394 // Only restore the draft if the user hasn't typed anything new since.
389 if (_draft.value.isBlank()) updateDraft(trimmed)
395 if (_draft.value.isBlank()) updateDraft(trimmed)
390− _error.value = container.friendlyError(it)
396+ _errorEvents.tryEmit(container.friendlyError(it))
391 _running.value = false
397 _running.value = false
392 }.onSuccess {
398 }.onSuccess {
393 suppressDraftPersist.set(false)
399 suppressDraftPersist.set(false)
@@ -415,12 +421,11 @@ class ChatViewModel(
415 fun runCommand(command: Command) {
421 fun runCommand(command: Command) {
416 val conn = connection ?: return
422 val conn = connection ?: return
417 if (!_running.compareAndSet(false, true)) return
423 if (!_running.compareAndSet(false, true)) return
419 viewModelScope.launch {
424 viewModelScope.launch {
420 runCatchingCancellable {
425 runCatchingCancellable {
421 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
426 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
422 }.onFailure {
427 }.onFailure {
423− _error.value = container.friendlyError(it)
428+ _errorEvents.tryEmit(container.friendlyError(it))
424 _running.value = false
429 _running.value = false
@@ -431,7 +436,7 @@ class ChatViewModel(
431 viewModelScope.launch {
436 viewModelScope.launch {
432 runCatchingCancellable { conn.repository.abort(sessionId) }
437 runCatchingCancellable { conn.repository.abort(sessionId) }
433 .onSuccess { _running.value = false }
438 .onSuccess { _running.value = false }
434− .onFailure { _error.value = container.friendlyError(it) }
439+ .onFailure { _errorEvents.tryEmit(container.friendlyError(it)) }
@@ -449,7 +454,7 @@ class ChatViewModel(
449 .getOrDefault(emptyList())
454 .getOrDefault(emptyList())
450 .firstOrNull()
455 .firstOrNull()
451 if (recent == null) {
456 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))
453 return@launch
458 return@launch
455 runCatchingCancellable {
460 runCatchingCancellable {
@@ -457,7 +462,7 @@ class ChatViewModel(
457 conn.api.ping()
462 conn.api.ping()
458 }.onFailure {
463 }.onFailure {
459 container.disconnect()
464 container.disconnect()
460− _error.value = container.friendlyError(it)
465+ _errorEvents.tryEmit(container.friendlyError(it))
462 } finally {
467 } finally {
463 _reconnecting.value = false
468 _reconnecting.value = false
@@ -474,7 +479,7 @@ class ChatViewModel(
474 viewModelScope.launch {
479 viewModelScope.launch {
475 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
480 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
476 .onFailure {
481 .onFailure {
477− _error.value = container.friendlyError(it)
482+ _errorEvents.tryEmit(container.friendlyError(it))
478 // Only restore if no new permission has arrived in the meantime.
483 // Only restore if no new permission has arrived in the meantime.
479 if (_pendingPermission.value == null) _pendingPermission.value = permission
484 if (_pendingPermission.value == null) _pendingPermission.value = permission