@@ -27,7 +27,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
2727import kotlinx.coroutines.flow.SharingStarted
2828import kotlinx.coroutines.flow.StateFlow
2929import kotlinx.coroutines.flow.asStateFlow
30−import kotlinx.coroutines.flow.catch
3130import kotlinx.coroutines.CancellationException
3231import kotlinx.coroutines.flow.collectLatest
3332import kotlinx.coroutines.flow.debounce
@@ -35,11 +34,14 @@ import kotlinx.coroutines.flow.drop
3534import kotlinx.coroutines.flow.first
3635import kotlinx.coroutines.flow.flatMapLatest
3736import kotlinx.coroutines.flow.flowOf
37+import kotlinx.coroutines.flow.merge
3838import kotlinx.coroutines.flow.onEach
39+import kotlinx.coroutines.flow.retryWhen
3940import kotlinx.coroutines.flow.stateIn
41+import kotlinx.coroutines.delay
4042import kotlinx.coroutines.launch
4143
42−@OptIn(ExperimentalCoroutinesApi::class)
44+@OptIn(ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
4345class ChatViewModel(
4446 private val container: AppContainer,
4547 private val sessionId: String,
@@ -52,15 +54,35 @@ class ChatViewModel(
5254 private val _loading = MutableStateFlow(true)
5355 val loading: StateFlow<Boolean> = _loading.asStateFlow()
5456
57+ /** Separate from [loading]: tracks the manual reconnect() flow so its spinner
58+ * doesn't conflict with the messages flow's loading state. */
59+ private val _reconnecting = MutableStateFlow(false)
60+ val reconnecting: StateFlow<Boolean> = _reconnecting.asStateFlow()
61+
62+ /** When true, the debounced draft collector skips persisting an empty draft so
63+ * [send]'s deliberate non-persistence of the cleared draft isn't undone by the
64+ * debounce timer firing before the send completes. */
65+ private val suppressDraftPersist = java.util.concurrent.atomic.AtomicBoolean(false)
66+
67+ /** Per-catalog reload triggers: incrementing one causes [observeCatalog]'s
68+ * collectLatest to cancel any in-flight fetch and start a fresh one, so a
69+ * manual reload supersedes a stale observeCatalog fetch. */
70+ private val _modelsReload = MutableStateFlow(0)
71+ private val _agentsReload = MutableStateFlow(0)
72+ private val _commandsReload = MutableStateFlow(0)
73+
5574 val messages: StateFlow<List<MessageWithParts>> =
5675 container.activeConnection
5776 .flatMapLatest { conn ->
5877 conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
5978 }
6079 .onEach { _loading.value = false }
61− .catch {
80+ .retryWhen { cause, _ ->
6281 _loading.value = false
63− _error.value = container.friendlyError(it)
82+ _error.value = container.friendlyError(cause)
83+ // Delay before retrying to avoid a tight loop on persistent errors.
84+ delay(NetworkConfig.retryInitialDelayMs)
85+ true
6486 }
6587 .stateIn(
6688 scope = viewModelScope,
@@ -155,73 +177,36 @@ class ChatViewModel(
155177
156178 fun reloadModels() {
157179 val conn = connection ?: return
158− reloadCatalog(
159− conn = conn,
160− tag = "model catalog",
161− loading = _modelsLoading,
162− error = _modelsError,
163− fetch = { it.providers() },
164− onSuccess = { resp ->
165− val options = resp.toOptions()
166− _models.value = options
167− _selectedModel.value = resp.defaultOption(options)
168− },
169− )
180+ viewModelScope.launch {
181+ conn.api.invalidateCache()
182+ _modelsReload.value++
183+ }
170184 }
171185
172186 fun reloadAgents() {
173187 val conn = connection ?: return
174− reloadCatalog(
175− conn = conn,
176− tag = "agent catalog",
177− loading = _agentsLoading,
178− error = _agentsError,
179− fetch = { it.agents() },
180− onSuccess = { _agents.value = it },
181− )
188+ viewModelScope.launch {
189+ conn.api.invalidateCache()
190+ _agentsReload.value++
191+ }
182192 }
183193
184194 fun reloadCommands() {
185195 val conn = connection ?: return
186− reloadCatalog(
187− conn = conn,
188− tag = "command catalog",
189− loading = _commandsLoading,
190− error = _commandsError,
191− fetch = { it.commands() },
192− onSuccess = { _commands.value = it },
193− )
194− }
195−
196− /**
197− * Shared helper for reloadXxx(): invalidates the API cache, sets loading/error
198− * state, and applies [onSuccess] to the fetched result. Reduces triplicated
199− * boilerplate that was easy to get out of sync.
200− */
201− private fun <T> reloadCatalog(
202− conn: soy.iko.opencode.di.OpencodeConnection,
203− tag: String,
204− loading: MutableStateFlow<Boolean>,
205− error: MutableStateFlow<Boolean>,
206− fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
207− onSuccess: (T) -> Unit,
208− ) {
209196 viewModelScope.launch {
210197 conn.api.invalidateCache()
211− loading.value = true
212− error.value = false
213− runCatchingCancellable { fetch(conn.api) }
214− .onFailure { Log.w("ChatViewModel", "Failed to reload $tag", it); error.value = true }
215− .getOrNull()?.let(onSuccess)
216− loading.value = false
198+ _commandsReload.value++
217199 }
218200 }
219201
220202 /**
221203 * Shared helper for init-block catalog observers: re-fetches whenever the active
222− * connection changes, invoking [onNull] to clear state on null so stale data from
223− * the old server doesn't persist. Failure is non-fatal — the error flag is surfaced
224− * to the UI.
204+ * connection or the catalog's reload trigger changes, invoking [onNull] to clear
205+ * state on null so stale data from the old server doesn't persist. The
206+ * [reloadTrigger] is merged with [activeConnection] so a manual reload (via
207+ * reloadModels/reloadAgents/reloadCommands) cancels any in-flight observe fetch
208+ * via collectLatest, preventing a stale observe result from overwriting a fresh
209+ * reload result. Failure is non-fatal — the error flag is surfaced to the UI.
225210 */
226211 private fun <T> observeCatalog(
227212 tag: String,
@@ -230,9 +215,11 @@ class ChatViewModel(
230215 fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
231216 onSuccess: (T) -> Unit,
232217 onNull: () -> Unit,
218+ reloadTrigger: StateFlow<Int>,
233219 ) {
234220 viewModelScope.launch {
235− container.activeConnection.collectLatest { conn ->
221+ merge(container.activeConnection, reloadTrigger).collectLatest { _ ->
222+ val conn = container.activeConnection.value
236223 if (conn == null) { onNull(); loading.value = false; error.value = false; return@collectLatest }
237224 loading.value = true
238225 error.value = false
@@ -250,8 +237,13 @@ class ChatViewModel(
250237
251238 init {
252239 // Debounce draft persistence so we don't write to disk on every keystroke.
240+ // Skip persisting empty drafts that were set by send() (suppressed via
241+ // suppressDraftPersist) — send() clears the in-memory draft immediately for
242+ // UI feedback but deliberately doesn't persist the clear until the send
243+ // succeeds, so a failed send can restore the draft.
253244 viewModelScope.launch {
254245 _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
246+ if (text.isBlank() && suppressDraftPersist.get()) return@collect
255247 runCatchingCancellable { container.draftStore.set(sessionId, text) }
256248 .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
257249 }
@@ -308,6 +300,7 @@ class ChatViewModel(
308300 _selectedModel.value = resp.defaultOption(options)
309301 },
310302 onNull = { _models.value = emptyList(); _selectedModel.value = null },
303+ reloadTrigger = _modelsReload,
311304 )
312305 // Load the agent catalog (non-fatal).
313306 observeCatalog(
@@ -317,6 +310,7 @@ class ChatViewModel(
317310 fetch = { it.agents() },
318311 onSuccess = { _agents.value = it },
319312 onNull = { _agents.value = emptyList() },
313+ reloadTrigger = _agentsReload,
320314 )
321315 // Load the command catalog (non-fatal).
322316 observeCatalog(
@@ -326,6 +320,7 @@ class ChatViewModel(
326320 fetch = { it.commands() },
327321 onSuccess = { _commands.value = it },
328322 onNull = { _commands.value = emptyList() },
323+ reloadTrigger = _commandsReload,
329324 )
330325 // Resolve the human-readable session title for the app bar (non-fatal).
331326 viewModelScope.launch {
@@ -368,6 +363,9 @@ class ChatViewModel(
368363 // Clear the in-memory draft for the UI immediately, but don't persist the clear
369364 // yet — if the send fails and the process dies before we restore, the draft
370365 // would be lost forever. The persisted draft is cleared only on success.
366+ // suppressDraftPersist prevents the debounced collector from persisting the
367+ // empty draft before the send resolves.
368+ suppressDraftPersist.set(true)
371369 _draft.value = ""
372370 viewModelScope.launch {
373371 runCatchingCancellable {
@@ -378,12 +376,14 @@ class ChatViewModel(
378376 agent = _selectedAgent.value,
379377 )
380378 }.onFailure {
379+ suppressDraftPersist.set(false)
381380 _failedDraft.value = trimmed
382381 // Only restore the draft if the user hasn't typed anything new since.
383382 if (_draft.value.isBlank()) updateDraft(trimmed)
384383 _error.value = container.friendlyError(it)
385384 _running.value = false
386385 }.onSuccess {
386+ suppressDraftPersist.set(false)
387387 // Send succeeded — now it's safe to persist the empty draft.
388388 container.draftStore.set(sessionId, "")
389389 // Don't reset _running here: the agent continues streaming via SSE.
@@ -397,8 +397,11 @@ class ChatViewModel(
397397 /** Re-send the last draft whose send failed, if any. */
398398 fun retryFailed() {
399399 val draft = _failedDraft.value ?: return
400− _failedDraft.value = null
401− send(draft)
400+ // Don't clear _failedDraft until send() accepts the text — if _running is
401+ // already true, send() returns false and the draft would be lost forever.
402+ if (send(draft)) {
403+ _failedDraft.value = null
404+ }
402405 }
403406
404407 /** Invoke a slash-command by name via the server's /command endpoint. */
@@ -429,19 +432,19 @@ class ChatViewModel(
429432 fun reconnect() {
430433 if (container.activeConnection.value != null) return
431434 viewModelScope.launch {
432− _loading.value = true
435+ _reconnecting.value = true
433436 val recent = runCatchingCancellable { container.profileStore.profiles.first() }
434437 .getOrDefault(emptyList())
435− .firstOrNull() ?: run { _loading.value = false; return@launch }
438+ .firstOrNull() ?: run { _reconnecting.value = false; return@launch }
436439 runCatchingCancellable {
437440 val conn = container.connect(recent)
438441 conn.api.ping()
439442 }.onSuccess {
440− _loading.value = false
443+ _reconnecting.value = false
441444 }.onFailure {
442445 container.disconnect()
443446 _error.value = container.friendlyError(it)
444− _loading.value = false
447+ _reconnecting.value = false
445448 }
446449 }
447450 }