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)
46 private val container: AppContainer,
47 private val sessionId: String,
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())
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
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)
94 scope = viewModelScope,
95 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
96 initialValue = emptyList(),
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) }
121 scope = viewModelScope,
122 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
123 initialValue = EventStreamClient.ConnectionState.Disconnected,
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()
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)
178 fun clearError() { _error.value = null }
180 fun selectModel(option: ModelOption) { _selectedModel.value = option }
182 fun selectAgent(name: String?) { _selectedAgent.value = name }
185 val conn = connection ?: return
186 viewModelScope.launch {
187 conn.api.invalidateCache()
188 _modelsReload.value++
193 val conn = connection ?: return
194 viewModelScope.launch {
195 conn.api.invalidateCache()
196 _agentsReload.value++
200 fun reloadCommands() {
201 val conn = connection ?: return
202 viewModelScope.launch {
203 conn.api.invalidateCache()
204 _commandsReload.value++
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.
217 private fun <T> observeCatalog(
219 loading: MutableStateFlow<Boolean>,
220 error: MutableStateFlow<Boolean>,
221 fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
222 onSuccess: (T) -> Unit,
224 reloadTrigger: StateFlow<Int>,
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 }
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
240 fun updateDraft(text: String) {
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) }
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
266 _failedDraft.value = null
267 _selectedAgent.value = null
268 _sessionTitle.value = null
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)
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
282 if (event.properties.info.id == sessionId) _sessionTitle.value = event.properties.info.displayTitle
284 if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
285 _sessionDeleted.value = true
290 } catch (e: CancellationException) {
292 } catch (e: Exception) {
293 Log.w("ChatViewModel", "SSE event collector error", e)
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.
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)
309 onNull = { _models.value = emptyList(); _selectedModel.value = null },
310 reloadTrigger = _modelsReload,
312 // Load the agent catalog (non-fatal).
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,
322 // Load the command catalog (non-fatal).
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,
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() }
338 ?.firstOrNull { it.id == sessionId }
339 ?.let { _sessionTitle.value = it.displayTitle }
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
348 conn.events.state.collect { state ->
349 if ((state == EventStreamClient.ConnectionState.Disconnected ||
350 state == EventStreamClient.ConnectionState.Failed) && _running.value) {
351 _running.value = false
354 } catch (e: CancellationException) {
356 } catch (e: Exception) {
357 Log.w("ChatViewModel", "SSE state collector error", e)
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
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)
377 viewModelScope.launch {
378 runCatchingCancellable {
379 conn.repository.sendPrompt(
382 model = _selectedModel.value?.ref,
383 agent = _selectedAgent.value,
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
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).
404 /** Re-send the last draft whose send failed, if any. */
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.
410 _failedDraft.value = null
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
419 viewModelScope.launch {
420 runCatchingCancellable {
421 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
423 _error.value = container.friendlyError(it)
424 _running.value = false
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) }
438 /** Reconnect to the most recently used server profile (used when the connection is gone). */
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 {
448 val recent = runCatchingCancellable { container.profileStore.profiles.first() }
449 .getOrDefault(emptyList())
451 if (recent == null) {
452 _error.value = container.string(R.string.no_servers_to_reconnect)
455 runCatchingCancellable {
456 val conn = container.connect(recent)
459 container.disconnect()
460 _error.value = container.friendlyError(it)
463 _reconnecting.value = false
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) }
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
484 override fun 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)
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list