@@ -16,6 +16,7 @@ import soy.iko.opencode.data.model.TextPart
1616import soy.iko.opencode.data.model.defaultOption
1717import soy.iko.opencode.data.model.toOptions
1818import soy.iko.opencode.data.network.EventStreamClient
19+import soy.iko.opencode.data.network.NetworkConfig
1920import soy.iko.opencode.data.repo.SessionRepository
2021import soy.iko.opencode.di.AppContainer
2122import soy.iko.opencode.R
@@ -62,7 +63,7 @@ class ChatViewModel(
6263 }
6364 .stateIn(
6465 scope = viewModelScope,
65− started = SharingStarted.WhileSubscribed(5_000),
66+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
6667 initialValue = emptyList(),
6768 )
6869
@@ -89,7 +90,7 @@ class ChatViewModel(
8990 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
9091 .stateIn(
9192 scope = viewModelScope,
92− started = SharingStarted.WhileSubscribed(5_000),
93+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
9394 initialValue = EventStreamClient.ConnectionState.Disconnected,
9495 )
9596
@@ -153,44 +154,92 @@ class ChatViewModel(
153154
154155 fun reloadModels() {
155156 val conn = connection ?: return
156− viewModelScope.launch {
157− conn.api.invalidateCache()
158− _modelsLoading.value = true
159− _modelsError.value = false
160− runCatchingCancellable { conn.api.providers() }
161− .onFailure { Log.w("ChatViewModel", "Failed to reload model catalog", it); _modelsError.value = true }
162− .getOrNull()?.let { resp ->
163− val options = resp.toOptions()
164− _models.value = options
165− _selectedModel.value = resp.defaultOption(options)
166− }
167− _modelsLoading.value = false
168− }
157+ reloadCatalog(
158+ conn = conn,
159+ tag = "model catalog",
160+ loading = _modelsLoading,
161+ error = _modelsError,
162+ fetch = { it.providers() },
163+ onSuccess = { resp ->
164+ val options = resp.toOptions()
165+ _models.value = options
166+ _selectedModel.value = resp.defaultOption(options)
167+ },
168+ )
169169 }
170170
171171 fun reloadAgents() {
172172 val conn = connection ?: return
173− viewModelScope.launch {
174− conn.api.invalidateCache()
175− _agentsLoading.value = true
176− _agentsError.value = false
177− runCatchingCancellable { conn.api.agents() }
178− .onFailure { Log.w("ChatViewModel", "Failed to reload agent catalog", it); _agentsError.value = true }
179− .getOrNull()?.let { _agents.value = it }
180− _agentsLoading.value = false
181− }
173+ reloadCatalog(
174+ conn = conn,
175+ tag = "agent catalog",
176+ loading = _agentsLoading,
177+ error = _agentsError,
178+ fetch = { it.agents() },
179+ onSuccess = { _agents.value = it },
180+ )
182181 }
183182
184183 fun reloadCommands() {
185184 val conn = connection ?: return
185+ reloadCatalog(
186+ conn = conn,
187+ tag = "command catalog",
188+ loading = _commandsLoading,
189+ error = _commandsError,
190+ fetch = { it.commands() },
191+ onSuccess = { _commands.value = it },
192+ )
193+ }
194+
195+ /**
196+ * Shared helper for reloadXxx(): invalidates the API cache, sets loading/error
197+ * state, and applies [onSuccess] to the fetched result. Reduces triplicated
198+ * boilerplate that was easy to get out of sync.
199+ */
200+ private fun <T> reloadCatalog(
201+ conn: soy.iko.opencode.di.OpencodeConnection,
202+ tag: String,
203+ loading: MutableStateFlow<Boolean>,
204+ error: MutableStateFlow<Boolean>,
205+ fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
206+ onSuccess: (T) -> Unit,
207+ ) {
186208 viewModelScope.launch {
187209 conn.api.invalidateCache()
188− _commandsLoading.value = true
189− _commandsError.value = false
190− runCatchingCancellable { conn.api.commands() }
191− .onFailure { Log.w("ChatViewModel", "Failed to reload command catalog", it); _commandsError.value = true }
192− .getOrNull()?.let { _commands.value = it }
193− _commandsLoading.value = false
210+ loading.value = true
211+ error.value = false
212+ runCatchingCancellable { fetch(conn.api) }
213+ .onFailure { Log.w("ChatViewModel", "Failed to reload $tag", it); error.value = true }
214+ .getOrNull()?.let(onSuccess)
215+ loading.value = false
216+ }
217+ }
218+
219+ /**
220+ * Shared helper for init-block catalog observers: re-fetches whenever the active
221+ * connection changes, invoking [onNull] to clear state on null so stale data from
222+ * the old server doesn't persist. Failure is non-fatal — the error flag is surfaced
223+ * to the UI.
224+ */
225+ private fun <T> observeCatalog(
226+ tag: String,
227+ loading: MutableStateFlow<Boolean>,
228+ error: MutableStateFlow<Boolean>,
229+ fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
230+ onSuccess: (T) -> Unit,
231+ onNull: () -> Unit,
232+ ) {
233+ viewModelScope.launch {
234+ container.activeConnection.collectLatest { conn ->
235+ if (conn == null) { onNull(); loading.value = false; error.value = false; return@collectLatest }
236+ loading.value = true
237+ error.value = false
238+ runCatchingCancellable { fetch(conn.api) }
239+ .onFailure { Log.w("ChatViewModel", "Failed to load $tag", it); error.value = true }
240+ .getOrNull()?.let(onSuccess)
241+ loading.value = false
242+ }
194243 }
195244 }
196245
@@ -201,7 +250,7 @@ class ChatViewModel(
201250 init {
202251 // Debounce draft persistence so we don't write to disk on every keystroke.
203252 viewModelScope.launch {
204− _draft.drop(1).debounce(500).collect { text ->
253+ _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
205254 runCatchingCancellable { container.draftStore.set(sessionId, text) }
206255 .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
207256 }
@@ -241,45 +290,36 @@ class ChatViewModel(
241290 }
242291 // Load the model catalog; preselect the server default. Failure is non-fatal —
243292 // sending with no model just uses the server's default agent/model.
244− viewModelScope.launch {
245− container.activeConnection.collectLatest { conn ->
246− if (conn == null) { _models.value = emptyList(); _selectedModel.value = null; _modelsLoading.value = false; _modelsError.value = false; return@collectLatest }
247− _modelsLoading.value = true
248− _modelsError.value = false
249− runCatchingCancellable { conn.api.providers() }
250− .onFailure { Log.w("ChatViewModel", "Failed to load model catalog", it); _modelsError.value = true }
251− .getOrNull()?.let { resp ->
252− val options = resp.toOptions()
253− _models.value = options
254− _selectedModel.value = resp.defaultOption(options)
255− }
256− _modelsLoading.value = false
257− }
258− }
293+ observeCatalog(
294+ tag = "model catalog",
295+ loading = _modelsLoading,
296+ error = _modelsError,
297+ fetch = { it.providers() },
298+ onSuccess = { resp ->
299+ val options = resp.toOptions()
300+ _models.value = options
301+ _selectedModel.value = resp.defaultOption(options)
302+ },
303+ onNull = { _models.value = emptyList(); _selectedModel.value = null },
304+ )
259305 // Load the agent catalog (non-fatal).
260− viewModelScope.launch {
261− container.activeConnection.collectLatest { conn ->
262− if (conn == null) { _agents.value = emptyList(); _agentsLoading.value = false; _agentsError.value = false; return@collectLatest }
263− _agentsLoading.value = true
264− _agentsError.value = false
265− runCatchingCancellable { conn.api.agents() }
266− .onFailure { Log.w("ChatViewModel", "Failed to load agent catalog", it); _agentsError.value = true }
267− .getOrNull()?.let { _agents.value = it }
268− _agentsLoading.value = false
269− }
270− }
306+ observeCatalog(
307+ tag = "agent catalog",
308+ loading = _agentsLoading,
309+ error = _agentsError,
310+ fetch = { it.agents() },
311+ onSuccess = { _agents.value = it },
312+ onNull = { _agents.value = emptyList() },
313+ )
271314 // Load the command catalog (non-fatal).
272− viewModelScope.launch {
273− container.activeConnection.collectLatest { conn ->
274− if (conn == null) { _commands.value = emptyList(); _commandsLoading.value = false; _commandsError.value = false; return@collectLatest }
275− _commandsLoading.value = true
276− _commandsError.value = false
277− runCatchingCancellable { conn.api.commands() }
278− .onFailure { Log.w("ChatViewModel", "Failed to load command catalog", it); _commandsError.value = true }
279− .getOrNull()?.let { _commands.value = it }
280− _commandsLoading.value = false
281− }
282− }
315+ observeCatalog(
316+ tag = "command catalog",
317+ loading = _commandsLoading,
318+ error = _commandsError,
319+ fetch = { it.commands() },
320+ onSuccess = { _commands.value = it },
321+ onNull = { _commands.value = emptyList() },
322+ )
283323 // Resolve the human-readable session title for the app bar (non-fatal).
284324 viewModelScope.launch {
285325 container.activeConnection.collectLatest { conn ->