@@ -27,7 +27,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
27import kotlinx.coroutines.flow.SharingStarted
27import kotlinx.coroutines.flow.SharingStarted
28import kotlinx.coroutines.flow.StateFlow
28import kotlinx.coroutines.flow.StateFlow
29import kotlinx.coroutines.flow.asStateFlow
29import kotlinx.coroutines.flow.asStateFlow
30−import kotlinx.coroutines.flow.catch
31import kotlinx.coroutines.CancellationException
30import kotlinx.coroutines.CancellationException
32import kotlinx.coroutines.flow.collectLatest
31import kotlinx.coroutines.flow.collectLatest
33import kotlinx.coroutines.flow.debounce
32import kotlinx.coroutines.flow.debounce
@@ -35,11 +34,14 @@ import kotlinx.coroutines.flow.drop
35import kotlinx.coroutines.flow.first
34import kotlinx.coroutines.flow.first
36import kotlinx.coroutines.flow.flatMapLatest
35import kotlinx.coroutines.flow.flatMapLatest
37import kotlinx.coroutines.flow.flowOf
36import kotlinx.coroutines.flow.flowOf
37+import kotlinx.coroutines.flow.merge
38import kotlinx.coroutines.flow.onEach
38import kotlinx.coroutines.flow.onEach
39+import kotlinx.coroutines.flow.retryWhen
39import kotlinx.coroutines.flow.stateIn
40import kotlinx.coroutines.flow.stateIn
41+import kotlinx.coroutines.delay
40import kotlinx.coroutines.launch
42import kotlinx.coroutines.launch
42−@OptIn(ExperimentalCoroutinesApi::class)
44+@OptIn(ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
43class ChatViewModel(
45class ChatViewModel(
44 private val container: AppContainer,
46 private val container: AppContainer,
45 private val sessionId: String,
47 private val sessionId: String,
@@ -52,15 +54,35 @@ class ChatViewModel(
52 private val _loading = MutableStateFlow(true)
54 private val _loading = MutableStateFlow(true)
53 val loading: StateFlow<Boolean> = _loading.asStateFlow()
55 val loading: StateFlow<Boolean> = _loading.asStateFlow()
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()
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)
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)
55 val messages: StateFlow<List<MessageWithParts>> =
74 val messages: StateFlow<List<MessageWithParts>> =
56 container.activeConnection
75 container.activeConnection
57 .flatMapLatest { conn ->
76 .flatMapLatest { conn ->
58 conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
77 conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
60 .onEach { _loading.value = false }
79 .onEach { _loading.value = false }
61− .catch {
80+ .retryWhen { cause, _ ->
62 _loading.value = false
81 _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)
66 scope = viewModelScope,
88 scope = viewModelScope,
@@ -155,73 +177,36 @@ class ChatViewModel(
156 fun reloadModels() {
178 fun reloadModels() {
157 val conn = connection ?: return
179 val conn = connection ?: return
158− reloadCatalog(
180+ viewModelScope.launch {
159− conn = conn,
181+ conn.api.invalidateCache()
160− tag = "model catalog",
182+ _modelsReload.value++
161− loading = _modelsLoading,
183+ }
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)
172 fun reloadAgents() {
186 fun reloadAgents() {
173 val conn = connection ?: return
187 val conn = connection ?: return
174− reloadCatalog(
188+ viewModelScope.launch {
175− conn = conn,
189+ conn.api.invalidateCache()
176− tag = "agent catalog",
190+ _agentsReload.value++
177− loading = _agentsLoading,
191+ }
178− error = _agentsError,
179− fetch = { it.agents() },
180− onSuccess = { _agents.value = it },
184 fun reloadCommands() {
194 fun reloadCommands() {
185 val conn = connection ?: return
195 val conn = connection ?: return
188− tag = "command catalog",
189− loading = _commandsLoading,
190− error = _commandsError,
191− fetch = { it.commands() },
192− onSuccess = { _commands.value = it },
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.
201− private fun <T> reloadCatalog(
202− conn: soy.iko.opencode.di.OpencodeConnection,
204− loading: MutableStateFlow<Boolean>,
205− error: MutableStateFlow<Boolean>,
206− fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
207− onSuccess: (T) -> Unit,
209 viewModelScope.launch {
196 viewModelScope.launch {
210 conn.api.invalidateCache()
197 conn.api.invalidateCache()
211− loading.value = true
198+ _commandsReload.value++
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
221 * Shared helper for init-block catalog observers: re-fetches whenever the active
203 * 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
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
223− * the old server doesn't persist. Failure is non-fatal — the error flag is surfaced
209+ * reload result. Failure is non-fatal — the error flag is surfaced to the UI.
226 private fun <T> observeCatalog(
211 private fun <T> observeCatalog(
227 tag: String,
212 tag: String,
@@ -230,9 +215,11 @@ class ChatViewModel(
230 fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
215 fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
231 onSuccess: (T) -> Unit,
216 onSuccess: (T) -> Unit,
232 onNull: () -> Unit,
217 onNull: () -> Unit,
218+ reloadTrigger: StateFlow<Int>,
234 viewModelScope.launch {
220 viewModelScope.launch {
235− container.activeConnection.collectLatest { conn ->
221+ merge(container.activeConnection, reloadTrigger).collectLatest { _ ->
222+ val conn = container.activeConnection.value
236 if (conn == null) { onNull(); loading.value = false; error.value = false; return@collectLatest }
223 if (conn == null) { onNull(); loading.value = false; error.value = false; return@collectLatest }
237 loading.value = true
224 loading.value = true
238 error.value = false
225 error.value = false
@@ -250,8 +237,13 @@ class ChatViewModel(
252 // Debounce draft persistence so we don't write to disk on every keystroke.
239 // 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.
253 viewModelScope.launch {
244 viewModelScope.launch {
254 _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
245 _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
246+ if (text.isBlank() && suppressDraftPersist.get()) return@collect
255 runCatchingCancellable { container.draftStore.set(sessionId, text) }
247 runCatchingCancellable { container.draftStore.set(sessionId, text) }
256 .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
248 .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
@@ -308,6 +300,7 @@ class ChatViewModel(
308 _selectedModel.value = resp.defaultOption(options)
300 _selectedModel.value = resp.defaultOption(options)
310 onNull = { _models.value = emptyList(); _selectedModel.value = null },
302 onNull = { _models.value = emptyList(); _selectedModel.value = null },
303+ reloadTrigger = _modelsReload,
312 // Load the agent catalog (non-fatal).
305 // Load the agent catalog (non-fatal).
313 observeCatalog(
306 observeCatalog(
@@ -317,6 +310,7 @@ class ChatViewModel(
317 fetch = { it.agents() },
310 fetch = { it.agents() },
318 onSuccess = { _agents.value = it },
311 onSuccess = { _agents.value = it },
319 onNull = { _agents.value = emptyList() },
312 onNull = { _agents.value = emptyList() },
313+ reloadTrigger = _agentsReload,
321 // Load the command catalog (non-fatal).
315 // Load the command catalog (non-fatal).
322 observeCatalog(
316 observeCatalog(
@@ -326,6 +320,7 @@ class ChatViewModel(
326 fetch = { it.commands() },
320 fetch = { it.commands() },
327 onSuccess = { _commands.value = it },
321 onSuccess = { _commands.value = it },
328 onNull = { _commands.value = emptyList() },
322 onNull = { _commands.value = emptyList() },
323+ reloadTrigger = _commandsReload,
330 // Resolve the human-readable session title for the app bar (non-fatal).
325 // Resolve the human-readable session title for the app bar (non-fatal).
331 viewModelScope.launch {
326 viewModelScope.launch {
@@ -368,6 +363,9 @@ class ChatViewModel(
368 // Clear the in-memory draft for the UI immediately, but don't persist the clear
363 // Clear the in-memory draft for the UI immediately, but don't persist the clear
369 // yet — if the send fails and the process dies before we restore, the draft
364 // yet — if the send fails and the process dies before we restore, the draft
370 // would be lost forever. The persisted draft is cleared only on success.
365 // 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)
371 _draft.value = ""
369 _draft.value = ""
372 viewModelScope.launch {
370 viewModelScope.launch {
373 runCatchingCancellable {
371 runCatchingCancellable {
@@ -378,12 +376,14 @@ class ChatViewModel(
378 agent = _selectedAgent.value,
376 agent = _selectedAgent.value,
380 }.onFailure {
378 }.onFailure {
379+ suppressDraftPersist.set(false)
381 _failedDraft.value = trimmed
380 _failedDraft.value = trimmed
382 // Only restore the draft if the user hasn't typed anything new since.
381 // Only restore the draft if the user hasn't typed anything new since.
383 if (_draft.value.isBlank()) updateDraft(trimmed)
382 if (_draft.value.isBlank()) updateDraft(trimmed)
384 _error.value = container.friendlyError(it)
383 _error.value = container.friendlyError(it)
385 _running.value = false
384 _running.value = false
386 }.onSuccess {
385 }.onSuccess {
386+ suppressDraftPersist.set(false)
387 // Send succeeded — now it's safe to persist the empty draft.
387 // Send succeeded — now it's safe to persist the empty draft.
388 container.draftStore.set(sessionId, "")
388 container.draftStore.set(sessionId, "")
389 // Don't reset _running here: the agent continues streaming via SSE.
389 // Don't reset _running here: the agent continues streaming via SSE.
@@ -397,8 +397,11 @@ class ChatViewModel(
397 /** Re-send the last draft whose send failed, if any. */
397 /** Re-send the last draft whose send failed, if any. */
398 fun retryFailed() {
398 fun retryFailed() {
399 val draft = _failedDraft.value ?: return
399 val draft = _failedDraft.value ?: return
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.
400− _failedDraft.value = null
403+ _failedDraft.value = null
404 /** Invoke a slash-command by name via the server's /command endpoint. */
407 /** Invoke a slash-command by name via the server's /command endpoint. */
@@ -429,19 +432,19 @@ class ChatViewModel(
429 fun reconnect() {
432 fun reconnect() {
430 if (container.activeConnection.value != null) return
433 if (container.activeConnection.value != null) return
431 viewModelScope.launch {
434 viewModelScope.launch {
432− _loading.value = true
435+ _reconnecting.value = true
433 val recent = runCatchingCancellable { container.profileStore.profiles.first() }
436 val recent = runCatchingCancellable { container.profileStore.profiles.first() }
434 .getOrDefault(emptyList())
437 .getOrDefault(emptyList())
435− .firstOrNull() ?: run { _loading.value = false; return@launch }
438+ .firstOrNull() ?: run { _reconnecting.value = false; return@launch }
436 runCatchingCancellable {
439 runCatchingCancellable {
437 val conn = container.connect(recent)
440 val conn = container.connect(recent)
438 conn.api.ping()
441 conn.api.ping()
439 }.onSuccess {
442 }.onSuccess {
440− _loading.value = false
443+ _reconnecting.value = false
441 }.onFailure {
444 }.onFailure {
442 container.disconnect()
445 container.disconnect()
443 _error.value = container.friendlyError(it)
446 _error.value = container.friendlyError(it)
444− _loading.value = false
447+ _reconnecting.value = false