git.iko.soy/opencode-android
on 325e359 help sign in
495 lines 22 kB history raw
1package soy.iko.opencode.ui.chat
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
5import soy.iko.opencode.data.model.Agent
6import soy.iko.opencode.data.model.Command
7import soy.iko.opencode.data.model.MessageWithParts
8import soy.iko.opencode.data.model.ModelOption
9import soy.iko.opencode.data.model.Permission
10import soy.iko.opencode.data.model.PermissionReplied
11import soy.iko.opencode.data.model.PermissionResponse
12import soy.iko.opencode.data.model.PermissionUpdated
13import soy.iko.opencode.data.model.SessionDeleted
14import soy.iko.opencode.data.model.SessionUpdated
15import soy.iko.opencode.data.model.TextPart
16import soy.iko.opencode.data.model.defaultOption
17import soy.iko.opencode.data.model.toOptions
18import soy.iko.opencode.data.network.EventStreamClient
19import soy.iko.opencode.data.network.NetworkConfig
20import soy.iko.opencode.data.repo.SessionRepository
21import soy.iko.opencode.di.AppContainer
22import soy.iko.opencode.R
23import soy.iko.opencode.util.runCatchingCancellable
24import android.util.Log
25import kotlinx.coroutines.ExperimentalCoroutinesApi
26import kotlinx.coroutines.flow.MutableStateFlow
27import kotlinx.coroutines.flow.SharingStarted
28import kotlinx.coroutines.flow.StateFlow
29import kotlinx.coroutines.flow.asStateFlow
30import kotlinx.coroutines.CancellationException
31import kotlinx.coroutines.flow.collectLatest
32import kotlinx.coroutines.flow.debounce
33import kotlinx.coroutines.flow.drop
34import kotlinx.coroutines.flow.first
35import kotlinx.coroutines.flow.flatMapLatest
36import kotlinx.coroutines.flow.flowOf
37import kotlinx.coroutines.flow.merge
38import kotlinx.coroutines.flow.onEach
39import kotlinx.coroutines.flow.retryWhen
40import kotlinx.coroutines.flow.stateIn
41import kotlinx.coroutines.delay
42import kotlinx.coroutines.launch
44@OptIn(ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
45class ChatViewModel(
46 private val container: AppContainer,
47 private val sessionId: String,
48) : ViewModel() {
50 private val connection get() = container.activeConnection.value
52 val connected: Boolean get() = connection != null
54 private val _loading = MutableStateFlow(true)
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)
74 val messages: StateFlow<List<MessageWithParts>> =
75 container.activeConnection
76 .flatMapLatest { conn ->
77 conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
78 }
79 .onEach {
80 _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
85 }
86 .retryWhen { cause, _ ->
87 _loading.value = false
88 _error.value = container.friendlyError(cause)
89 // Delay before retrying to avoid a tight loop on persistent errors.
90 delay(NetworkConfig.retryInitialDelayMs)
91 true
92 }
93 .stateIn(
94 scope = viewModelScope,
95 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
96 initialValue = emptyList(),
97 )
99 private val _running = MutableStateFlow(false)
100 val running: StateFlow<Boolean> = _running.asStateFlow()
102 private val _error = MutableStateFlow<String?>(null)
103 val error: StateFlow<String?> = _error.asStateFlow()
105 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
106 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
108 private val _modelsLoading = MutableStateFlow(true)
109 val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
111 private val _modelsError = MutableStateFlow(false)
112 val modelsError: StateFlow<Boolean> = _modelsError.asStateFlow()
114 private val _selectedModel = MutableStateFlow<ModelOption?>(null)
115 val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
117 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
118 container.activeConnection
119 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
120 .stateIn(
121 scope = viewModelScope,
122 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
123 initialValue = EventStreamClient.ConnectionState.Disconnected,
124 )
126 private val _pendingPermission = MutableStateFlow<Permission?>(null)
127 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
129 private val _agents = MutableStateFlow<List<Agent>>(emptyList())
130 val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
132 private val _agentsLoading = MutableStateFlow(true)
133 val agentsLoading: StateFlow<Boolean> = _agentsLoading.asStateFlow()
135 private val _agentsError = MutableStateFlow(false)
136 val agentsError: StateFlow<Boolean> = _agentsError.asStateFlow()
138 private val _selectedAgent = MutableStateFlow<String?>(null)
139 val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
141 private val _commands = MutableStateFlow<List<Command>>(emptyList())
142 val commands: StateFlow<List<Command>> = _commands.asStateFlow()
144 private val _commandsLoading = MutableStateFlow(true)
145 val commandsLoading: StateFlow<Boolean> = _commandsLoading.asStateFlow()
147 private val _commandsError = MutableStateFlow(false)
148 val commandsError: StateFlow<Boolean> = _commandsError.asStateFlow()
150 private val _sessionTitle = MutableStateFlow<String?>(null)
151 val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
153 /** Set when the current session is deleted via SSE, so the UI can navigate away. */
154 private val _sessionDeleted = MutableStateFlow(false)
155 val sessionDeleted: StateFlow<Boolean> = _sessionDeleted.asStateFlow()
157 /** The text of the last send that failed, surfaced so the UI can offer a retry. */
158 private val _failedDraft = MutableStateFlow<String?>(null)
159 val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
161 /** Per-session draft, persisted so it survives navigation/process death. */
162 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
163 val draft: StateFlow<String> = _draft.asStateFlow()
165 init {
166 // DraftStore loads SharedPreferences asynchronously; on a cold start the
167 // synchronous get() above returns "" until the background load completes.
168 // Observe the ready signal and re-seed the draft so it appears in the UI.
169 viewModelScope.launch {
170 container.draftStore.ready.collect { ready ->
171 if (ready && _draft.value.isEmpty()) {
172 _draft.value = container.draftStore.get(sessionId)
173 }
174 }
175 }
176 }
178 fun clearError() { _error.value = null }
180 fun selectModel(option: ModelOption) { _selectedModel.value = option }
182 fun selectAgent(name: String?) { _selectedAgent.value = name }
184 fun reloadModels() {
185 val conn = connection ?: return
186 viewModelScope.launch {
187 conn.api.invalidateCache()
188 _modelsReload.value++
189 }
190 }
192 fun reloadAgents() {
193 val conn = connection ?: return
194 viewModelScope.launch {
195 conn.api.invalidateCache()
196 _agentsReload.value++
197 }
198 }
200 fun reloadCommands() {
201 val conn = connection ?: return
202 viewModelScope.launch {
203 conn.api.invalidateCache()
204 _commandsReload.value++
205 }
206 }
208 /**
209 * Shared helper for init-block catalog observers: re-fetches whenever the active
210 * connection or the catalog's reload trigger changes, invoking [onNull] to clear
211 * state on null so stale data from the old server doesn't persist. The
212 * [reloadTrigger] is merged with [activeConnection] so a manual reload (via
213 * reloadModels/reloadAgents/reloadCommands) cancels any in-flight observe fetch
214 * via collectLatest, preventing a stale observe result from overwriting a fresh
215 * reload result. Failure is non-fatal — the error flag is surfaced to the UI.
216 */
217 private fun <T> observeCatalog(
218 tag: String,
219 loading: MutableStateFlow<Boolean>,
220 error: MutableStateFlow<Boolean>,
221 fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
222 onSuccess: (T) -> Unit,
223 onNull: () -> Unit,
224 reloadTrigger: StateFlow<Int>,
225 ) {
226 viewModelScope.launch {
227 merge(container.activeConnection, reloadTrigger).collectLatest { _ ->
228 val conn = container.activeConnection.value
229 if (conn == null) { onNull(); loading.value = false; error.value = false; return@collectLatest }
230 loading.value = true
231 error.value = false
232 runCatchingCancellable { fetch(conn.api) }
233 .onFailure { Log.w("ChatViewModel", "Failed to load $tag", it); error.value = true }
234 .getOrNull()?.let(onSuccess)
235 loading.value = false
236 }
237 }
238 }
240 fun updateDraft(text: String) {
241 _draft.value = text
242 }
244 init {
245 // Debounce draft persistence so we don't write to disk on every keystroke.
246 // Skip persisting empty drafts that were set by send() (suppressed via
247 // suppressDraftPersist) — send() clears the in-memory draft immediately for
248 // UI feedback but deliberately doesn't persist the clear until the send
249 // succeeds, so a failed send can restore the draft.
250 viewModelScope.launch {
251 _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
252 if (text.isBlank() && suppressDraftPersist.get()) return@collect
253 runCatchingCancellable { container.draftStore.set(sessionId, text) }
254 .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
255 }
256 }
257 // Reset per-connection state when the active server changes so stale spinners,
258 // permission dialogs, errors, and agent selections from the old server don't
259 // persist into the new one.
260 viewModelScope.launch {
261 container.activeConnection.collectLatest { conn ->
262 if (conn == null) return@collectLatest
263 _running.value = false
264 _pendingPermission.value = null
265 _error.value = null
266 _failedDraft.value = null
267 _selectedAgent.value = null
268 _sessionTitle.value = null
269 try {
270 conn.events.events.collect { event ->
271 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
272 if (SessionRepository.isError(event, sessionId)) {
273 _running.value = false
274 _error.value = container.string(R.string.error_agent_reported)
275 }
276 when (event) {
277 is PermissionUpdated ->
278 if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
279 is PermissionReplied ->
280 if (event.properties.sessionID == sessionId && event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
281 is SessionUpdated ->
282 if (event.properties.info.id == sessionId) _sessionTitle.value = event.properties.info.displayTitle
283 is SessionDeleted ->
284 if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
285 _sessionDeleted.value = true
286 }
287 else -> {}
288 }
289 }
290 } catch (e: CancellationException) {
291 throw e
292 } catch (e: Exception) {
293 Log.w("ChatViewModel", "SSE event collector error", e)
294 }
295 }
296 }
297 // Load the model catalog; preselect the server default. Failure is non-fatal —
298 // sending with no model just uses the server's default agent/model.
299 observeCatalog(
300 tag = "model catalog",
301 loading = _modelsLoading,
302 error = _modelsError,
303 fetch = { it.providers() },
304 onSuccess = { resp ->
305 val options = resp.toOptions()
306 _models.value = options
307 _selectedModel.value = resp.defaultOption(options)
308 },
309 onNull = { _models.value = emptyList(); _selectedModel.value = null },
310 reloadTrigger = _modelsReload,
311 )
312 // Load the agent catalog (non-fatal).
313 observeCatalog(
314 tag = "agent catalog",
315 loading = _agentsLoading,
316 error = _agentsError,
317 fetch = { it.agents() },
318 onSuccess = { _agents.value = it },
319 onNull = { _agents.value = emptyList() },
320 reloadTrigger = _agentsReload,
321 )
322 // Load the command catalog (non-fatal).
323 observeCatalog(
324 tag = "command catalog",
325 loading = _commandsLoading,
326 error = _commandsError,
327 fetch = { it.commands() },
328 onSuccess = { _commands.value = it },
329 onNull = { _commands.value = emptyList() },
330 reloadTrigger = _commandsReload,
331 )
332 // Resolve the human-readable session title for the app bar (non-fatal).
333 viewModelScope.launch {
334 container.activeConnection.collectLatest { conn ->
335 if (conn == null) return@collectLatest
336 runCatchingCancellable { conn.repository.listSessions() }
337 .getOrNull()
338 ?.firstOrNull { it.id == sessionId }
339 ?.let { _sessionTitle.value = it.displayTitle }
340 }
341 }
342 // If the SSE stream drops mid-run, the run indicator would spin forever;
343 // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
344 viewModelScope.launch {
345 container.activeConnection.collectLatest { conn ->
346 if (conn == null) return@collectLatest
347 try {
348 conn.events.state.collect { state ->
349 if ((state == EventStreamClient.ConnectionState.Disconnected ||
350 state == EventStreamClient.ConnectionState.Failed) && _running.value) {
351 _running.value = false
352 }
353 }
354 } catch (e: CancellationException) {
355 throw e
356 } catch (e: Exception) {
357 Log.w("ChatViewModel", "SSE state collector error", e)
358 }
359 }
360 }
361 }
363 /** Sends [text]; returns true on success so the caller can clear the draft only then. */
364 fun send(text: String): Boolean {
365 val conn = connection ?: return false
366 val trimmed = text.trim()
367 if (trimmed.isEmpty() || !_running.compareAndSet(false, true)) return false
368 _error.value = null
369 _failedDraft.value = null
370 // 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
372 // would be lost forever. The persisted draft is cleared only on success.
373 // suppressDraftPersist prevents the debounced collector from persisting the
374 // empty draft before the send resolves.
375 suppressDraftPersist.set(true)
376 _draft.value = ""
377 viewModelScope.launch {
378 runCatchingCancellable {
379 conn.repository.sendPrompt(
380 sessionId,
381 trimmed,
382 model = _selectedModel.value?.ref,
383 agent = _selectedAgent.value,
384 )
385 }.onFailure {
386 suppressDraftPersist.set(false)
387 _failedDraft.value = trimmed
388 // Only restore the draft if the user hasn't typed anything new since.
389 if (_draft.value.isBlank()) updateDraft(trimmed)
390 _error.value = container.friendlyError(it)
391 _running.value = false
392 }.onSuccess {
393 suppressDraftPersist.set(false)
394 // Send succeeded — now it's safe to persist the empty draft.
395 container.draftStore.set(sessionId, "")
396 // Don't reset _running here: the agent continues streaming via SSE.
397 // _running is cleared on SessionIdle/SessionError (see event collector)
398 // or when the SSE stream drops (see connection state watcher below).
399 }
400 }
401 return true
402 }
404 /** Re-send the last draft whose send failed, if any. */
405 fun retryFailed() {
406 val draft = _failedDraft.value ?: return
407 // Don't clear _failedDraft until send() accepts the text — if _running is
408 // already true, send() returns false and the draft would be lost forever.
409 if (send(draft)) {
410 _failedDraft.value = null
411 }
412 }
414 /** Invoke a slash-command by name via the server's /command endpoint. */
415 fun runCommand(command: Command) {
416 val conn = connection ?: return
417 if (!_running.compareAndSet(false, true)) return
418 _error.value = null
419 viewModelScope.launch {
420 runCatchingCancellable {
421 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
422 }.onFailure {
423 _error.value = container.friendlyError(it)
424 _running.value = false
425 }
426 }
427 }
429 fun abort() {
430 val conn = connection ?: return
431 viewModelScope.launch {
432 runCatchingCancellable { conn.repository.abort(sessionId) }
433 .onSuccess { _running.value = false }
434 .onFailure { _error.value = container.friendlyError(it) }
435 }
436 }
438 /** Reconnect to the most recently used server profile (used when the connection is gone). */
439 fun reconnect() {
440 if (container.activeConnection.value != null) return
441 // Guard against concurrent reconnect calls: the user can tap the button
442 // multiple times while _reconnecting is true, which would launch parallel
443 // connect() coroutines that race to set the active connection, leaking the
444 // intermediate connections.
445 if (!_reconnecting.compareAndSet(false, true)) return
446 viewModelScope.launch {
447 try {
448 val recent = runCatchingCancellable { container.profileStore.profiles.first() }
449 .getOrDefault(emptyList())
450 .firstOrNull()
451 if (recent == null) {
452 _error.value = container.string(R.string.no_servers_to_reconnect)
453 return@launch
454 }
455 runCatchingCancellable {
456 val conn = container.connect(recent)
457 conn.api.ping()
458 }.onFailure {
459 container.disconnect()
460 _error.value = container.friendlyError(it)
461 }
462 } finally {
463 _reconnecting.value = false
464 }
465 }
466 }
468 fun respondPermission(permission: Permission, response: PermissionResponse) {
469 val conn = connection ?: return
470 // Clear optimistically; permission.replied will confirm. If the call fails we
471 // restore the pending permission so the user can retry instead of being stuck
472 // with a dismissed dialog and a paused tool run.
473 _pendingPermission.value = null
474 viewModelScope.launch {
475 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
476 .onFailure {
477 _error.value = container.friendlyError(it)
478 // Only restore if no new permission has arrived in the meantime.
479 if (_pendingPermission.value == null) _pendingPermission.value = permission
480 }
481 }
482 }
484 override fun onCleared() {
485 super.onCleared()
486 // Flush any pending debounced draft so it survives navigation. Uses an
487 // asynchronous apply() (not a synchronous commit) so the main thread isn't
488 // blocked on disk I/O — Android's SharedPreferences framework guarantees
489 // pending apply() writes are flushed before the process exits.
490 val pending = _draft.value
491 if (pending.isNotEmpty()) {
492 container.draftStore.flushDraft(sessionId, pending)
493 }
494 }