@@ -16,6 +16,7 @@ import soy.iko.opencode.data.model.TextPart
16import soy.iko.opencode.data.model.defaultOption
16import soy.iko.opencode.data.model.defaultOption
17import soy.iko.opencode.data.model.toOptions
17import soy.iko.opencode.data.model.toOptions
18import soy.iko.opencode.data.network.EventStreamClient
18import soy.iko.opencode.data.network.EventStreamClient
19+import soy.iko.opencode.data.network.NetworkConfig
19import soy.iko.opencode.data.repo.SessionRepository
20import soy.iko.opencode.data.repo.SessionRepository
20import soy.iko.opencode.di.AppContainer
21import soy.iko.opencode.di.AppContainer
21import soy.iko.opencode.R
22import soy.iko.opencode.R
@@ -62,7 +63,7 @@ class ChatViewModel(
64 scope = viewModelScope,
65 scope = viewModelScope,
65− started = SharingStarted.WhileSubscribed(5_000),
66+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
66 initialValue = emptyList(),
67 initialValue = emptyList(),
@@ -89,7 +90,7 @@ class ChatViewModel(
89 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
90 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
91 scope = viewModelScope,
92 scope = viewModelScope,
92− started = SharingStarted.WhileSubscribed(5_000),
93+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
93 initialValue = EventStreamClient.ConnectionState.Disconnected,
94 initialValue = EventStreamClient.ConnectionState.Disconnected,
@@ -153,44 +154,92 @@ class ChatViewModel(
154 fun reloadModels() {
155 fun reloadModels() {
155 val conn = connection ?: return
156 val conn = connection ?: return
156− viewModelScope.launch {
157+ reloadCatalog(
157− conn.api.invalidateCache()
158+ conn = conn,
158− _modelsLoading.value = true
159+ tag = "model catalog",
159− _modelsError.value = false
160+ loading = _modelsLoading,
160− runCatchingCancellable { conn.api.providers() }
161+ error = _modelsError,
161− .onFailure { Log.w("ChatViewModel", "Failed to reload model catalog", it); _modelsError.value = true }
162+ fetch = { it.providers() },
162− .getOrNull()?.let { resp ->
163+ onSuccess = { resp ->
163− val options = resp.toOptions()
164+ val options = resp.toOptions()
164− _models.value = options
165+ _models.value = options
165− _selectedModel.value = resp.defaultOption(options)
166+ _selectedModel.value = resp.defaultOption(options)
167− _modelsLoading.value = false
168+ )
171 fun reloadAgents() {
171 fun reloadAgents() {
172 val conn = connection ?: return
172 val conn = connection ?: return
173− viewModelScope.launch {
173+ reloadCatalog(
174− conn.api.invalidateCache()
174+ conn = conn,
175− _agentsLoading.value = true
175+ tag = "agent catalog",
176− _agentsError.value = false
176+ loading = _agentsLoading,
177− runCatchingCancellable { conn.api.agents() }
177+ error = _agentsError,
178− .onFailure { Log.w("ChatViewModel", "Failed to reload agent catalog", it); _agentsError.value = true }
178+ fetch = { it.agents() },
179− .getOrNull()?.let { _agents.value = it }
179+ onSuccess = { _agents.value = it },
180− _agentsLoading.value = false
180+ )
184 fun reloadCommands() {
183 fun reloadCommands() {
185 val conn = connection ?: return
184 val conn = connection ?: return
187+ tag = "command catalog",
188+ loading = _commandsLoading,
189+ error = _commandsError,
190+ fetch = { it.commands() },
191+ onSuccess = { _commands.value = it },
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.
200+ private fun <T> reloadCatalog(
201+ conn: soy.iko.opencode.di.OpencodeConnection,
203+ loading: MutableStateFlow<Boolean>,
204+ error: MutableStateFlow<Boolean>,
205+ fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
206+ onSuccess: (T) -> Unit,
186 viewModelScope.launch {
208 viewModelScope.launch {
187 conn.api.invalidateCache()
209 conn.api.invalidateCache()
188− _commandsLoading.value = true
210+ loading.value = true
189− _commandsError.value = false
211+ error.value = false
190− runCatchingCancellable { conn.api.commands() }
212+ runCatchingCancellable { fetch(conn.api) }
191− .onFailure { Log.w("ChatViewModel", "Failed to reload command catalog", it); _commandsError.value = true }
213+ .onFailure { Log.w("ChatViewModel", "Failed to reload $tag", it); error.value = true }
192− .getOrNull()?.let { _commands.value = it }
214+ .getOrNull()?.let(onSuccess)
215+ loading.value = false
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
225+ private fun <T> observeCatalog(
227+ loading: MutableStateFlow<Boolean>,
228+ error: MutableStateFlow<Boolean>,
229+ fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
230+ onSuccess: (T) -> Unit,
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
238+ runCatchingCancellable { fetch(conn.api) }
239+ .onFailure { Log.w("ChatViewModel", "Failed to load $tag", it); error.value = true }
240+ .getOrNull()?.let(onSuccess)
193− _commandsLoading.value = false
241+ loading.value = false
@@ -201,7 +250,7 @@ class ChatViewModel(
202 // Debounce draft persistence so we don't write to disk on every keystroke.
251 // Debounce draft persistence so we don't write to disk on every keystroke.
203 viewModelScope.launch {
252 viewModelScope.launch {
204− _draft.drop(1).debounce(500).collect { text ->
253+ _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
205 runCatchingCancellable { container.draftStore.set(sessionId, text) }
254 runCatchingCancellable { container.draftStore.set(sessionId, text) }
206 .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
255 .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
@@ -241,45 +290,36 @@ class ChatViewModel(
242 // Load the model catalog; preselect the server default. Failure is non-fatal —
291 // Load the model catalog; preselect the server default. Failure is non-fatal —
243 // sending with no model just uses the server's default agent/model.
292 // sending with no model just uses the server's default agent/model.
244− viewModelScope.launch {
293+ observeCatalog(
245− container.activeConnection.collectLatest { conn ->
294+ tag = "model catalog",
246− if (conn == null) { _models.value = emptyList(); _selectedModel.value = null; _modelsLoading.value = false; _modelsError.value = false; return@collectLatest }
295+ loading = _modelsLoading,
247− _modelsLoading.value = true
296+ error = _modelsError,
248− _modelsError.value = false
297+ fetch = { it.providers() },
249− runCatchingCancellable { conn.api.providers() }
298+ onSuccess = { resp ->
250− .onFailure { Log.w("ChatViewModel", "Failed to load model catalog", it); _modelsError.value = true }
251− .getOrNull()?.let { resp ->
252− val options = resp.toOptions()
299+ val options = resp.toOptions()
253− _models.value = options
300+ _models.value = options
254− _selectedModel.value = resp.defaultOption(options)
301+ _selectedModel.value = resp.defaultOption(options)
256− _modelsLoading.value = false
303+ onNull = { _models.value = emptyList(); _selectedModel.value = null },
259 // Load the agent catalog (non-fatal).
305 // Load the agent catalog (non-fatal).
260− viewModelScope.launch {
306+ observeCatalog(
261− container.activeConnection.collectLatest { conn ->
307+ tag = "agent catalog",
262− if (conn == null) { _agents.value = emptyList(); _agentsLoading.value = false; _agentsError.value = false; return@collectLatest }
308+ loading = _agentsLoading,
263− _agentsLoading.value = true
309+ error = _agentsError,
264− _agentsError.value = false
310+ fetch = { it.agents() },
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 }
311+ onSuccess = { _agents.value = it },
268− _agentsLoading.value = false
312+ onNull = { _agents.value = emptyList() },
271 // Load the command catalog (non-fatal).
314 // Load the command catalog (non-fatal).
272− viewModelScope.launch {
315+ observeCatalog(
273− container.activeConnection.collectLatest { conn ->
316+ tag = "command catalog",
274− if (conn == null) { _commands.value = emptyList(); _commandsLoading.value = false; _commandsError.value = false; return@collectLatest }
317+ loading = _commandsLoading,
275− _commandsLoading.value = true
318+ error = _commandsError,
276− _commandsError.value = false
319+ fetch = { it.commands() },
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 }
320+ onSuccess = { _commands.value = it },
280− _commandsLoading.value = false
321+ onNull = { _commands.value = emptyList() },
283 // Resolve the human-readable session title for the app bar (non-fatal).
323 // Resolve the human-readable session title for the app bar (non-fatal).
284 viewModelScope.launch {
324 viewModelScope.launch {
285 container.activeConnection.collectLatest { conn ->
325 container.activeConnection.collectLatest { conn ->