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.FilePromptPart
8import soy.iko.opencode.data.model.MessageWithParts
9import soy.iko.opencode.data.model.ModelOption
10import soy.iko.opencode.data.model.Permission
11import soy.iko.opencode.data.model.PermissionReplied
12import soy.iko.opencode.data.model.PermissionResponse
13import soy.iko.opencode.data.model.PermissionUpdated
14import soy.iko.opencode.data.model.SessionDeleted
15import soy.iko.opencode.data.model.SessionUpdated
16import soy.iko.opencode.data.model.TextPart
17import soy.iko.opencode.data.model.defaultOption
18import soy.iko.opencode.data.model.toOptions
19import soy.iko.opencode.data.network.EventStreamClient
20import soy.iko.opencode.data.network.NetworkConfig
21import soy.iko.opencode.data.repo.SessionRepository
22import soy.iko.opencode.di.AppContainer
23import soy.iko.opencode.R
24import soy.iko.opencode.util.runCatchingCancellable
25import soy.iko.opencode.util.safeExceptionSummary
26import android.util.Log
27import kotlinx.coroutines.ExperimentalCoroutinesApi
28import kotlinx.coroutines.channels.BufferOverflow
29import kotlinx.coroutines.channels.Channel
30import kotlinx.coroutines.flow.Flow
31import kotlinx.coroutines.flow.MutableStateFlow
32import kotlinx.coroutines.flow.SharingStarted
33import kotlinx.coroutines.flow.StateFlow
34import kotlinx.coroutines.flow.asStateFlow
35import kotlinx.coroutines.flow.receiveAsFlow
36import kotlinx.coroutines.CancellationException
37import kotlinx.coroutines.flow.collectLatest
38import kotlinx.coroutines.flow.debounce
39import kotlinx.coroutines.flow.drop
40import kotlinx.coroutines.flow.first
41import kotlinx.coroutines.flow.filterIsInstance
42import kotlinx.coroutines.flow.flatMapLatest
43import kotlinx.coroutines.flow.flowOf
44import kotlinx.coroutines.flow.map
45import kotlinx.coroutines.flow.merge
46import kotlinx.coroutines.flow.onEach
47import kotlinx.coroutines.flow.retryWhen
48import kotlinx.coroutines.flow.stateIn
49import kotlinx.coroutines.flow.update
50import kotlinx.coroutines.delay
51import kotlinx.coroutines.launch
54 * A user-facing error to surface as a snackbar. [retryable] is true only for a failed
55 * message *send* (whose draft is held in [ChatViewModel.failedDraft]); the snackbar attaches
56 * a Retry action solely for those so an unrelated error (e.g. a message-load failure) can't
57 * inherit a Retry that silently re-submits the last prompt.
59data class ChatError(val message: String, val retryable: Boolean = false)
62 * An attachment staged to send with the next prompt. [previewModel] is a Coil-loadable model
63 * for the thumbnail (the source content Uri, as a string) for images, or null for non-image
64 * files (rendered with a generic icon). [part] is the wire form (a base64 data URL) sent to
67@androidx.compose.runtime.Immutable
68data class PendingAttachment(
72 val previewModel: Any?,
73 val part: FilePromptPart,
75 val isImage: Boolean get() = mime.startsWith("image/")
78@OptIn(ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
80 private val container: AppContainer,
81 private val sessionId: String,
84 private val connection get() = container.activeConnection.value
86 val connected: Boolean get() = connection != null
88 private val _loading = MutableStateFlow(true)
89 val loading: StateFlow<Boolean> = _loading.asStateFlow()
91 /** Set when the messages flow failed to load (and the list is empty), so the UI
92 * can render a distinct error state instead of masquerading as an empty
93 * conversation. Cleared on a successful non-empty emission. */
94 private val _loadError = MutableStateFlow(false)
95 val loadError: StateFlow<Boolean> = _loadError.asStateFlow()
97 /** Tracks whether the messages flow has ever emitted a non-empty list, so
98 * [messages]' retryWhen can decide whether a re-fetch failure should set
99 * [loadError] (nothing shown yet → error screen) or just emit a snackbar
100 * (already showing messages → don't replace the conversation with an error). */
101 private var hasShownMessages = false
103 /** True once we've surfaced a snackbar for the current message-load failure streak.
104 * Reset on any successful emission so a persistent failure doesn't spam a fresh
105 * snackbar every retry cycle (every few seconds). */
106 private var loadErrorSnackbarShown = false
108 /** Separate from [loading]: tracks the manual reconnect() flow so its spinner
109 * doesn't conflict with the messages flow's loading state. */
110 private val _reconnecting = MutableStateFlow(false)
111 val reconnecting: StateFlow<Boolean> = _reconnecting.asStateFlow()
113 /** When true, the debounced draft collector skips persisting an empty draft so
114 * [send]'s deliberate non-persistence of the cleared draft isn't undone by the
115 * debounce timer firing before the send completes. */
116 private val suppressDraftPersist = java.util.concurrent.atomic.AtomicBoolean(false)
118 /** The draft value this VM last persisted, so the drafts observer can tell its own
119 * (debounced, possibly stale) echo apart from a genuine external write. Without this
120 * a debounced persist of an older value could echo back and overwrite newer text the
121 * user has since typed. */
122 @Volatile private var lastPersistedDraft: String? = null
124 /** Per-catalog reload triggers: incrementing one causes [observeCatalog]'s
125 * collectLatest to cancel any in-flight fetch and start a fresh one, so a
126 * manual reload supersedes a stale observeCatalog fetch. */
127 private val _modelsReload = MutableStateFlow(0)
128 private val _agentsReload = MutableStateFlow(0)
129 private val _commandsReload = MutableStateFlow(0)
131 val messages: StateFlow<List<MessageWithParts>> =
132 container.activeConnection
133 .flatMapLatest { conn ->
134 conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
137 _loading.value = false
138 // A value flowed through: the stream recovered, so allow the next distinct
139 // failure to surface a fresh snackbar again.
140 loadErrorSnackbarShown = false
141 if (it.isNotEmpty()) {
142 hasShownMessages = true
143 _loadError.value = false
146 .retryWhen { cause, _ ->
147 _loading.value = false
148 // Only surface the persistent error state when there's nothing to
149 // show — if we already have messages, a transient re-fetch failure
150 // is better surfaced as a snackbar (via errorEvents) than by
151 // replacing the visible conversation with an error screen.
152 if (!hasShownMessages) _loadError.value = true
153 // Surface the snackbar only once per failure streak — retryWhen loops
154 // every retryInitialDelayMs, so emitting here unconditionally would spam a
155 // new snackbar every few seconds on a persistent load failure.
156 if (!loadErrorSnackbarShown) {
157 _errorEvents.trySend(ChatError(container.friendlyError(cause)))
158 loadErrorSnackbarShown = true
160 // Delay before retrying to avoid a tight loop on persistent errors.
161 delay(NetworkConfig.retryInitialDelayMs)
165 scope = viewModelScope,
166 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
167 initialValue = emptyList(),
170 /** Whether the conversation has any messages. Derived separately so the top bar
171 * (share button enabled state) can observe this cheap boolean instead of the
172 * full messages list, avoiding per-token recomposition of the app bar during
174 val hasMessages: StateFlow<Boolean> = messages
175 .map { it.isNotEmpty() }
177 scope = viewModelScope,
178 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
179 initialValue = false,
182 private val _running = MutableStateFlow(false)
183 val running: StateFlow<Boolean> = _running.asStateFlow()
185 /** True while an abort REST call is in flight, so the Stop button can show a
186 * spinner and prevent double-taps from firing a second abort. */
187 private val _aborting = MutableStateFlow(false)
188 val aborting: StateFlow<Boolean> = _aborting.asStateFlow()
190 /** A follow-up the user typed while a run was active. send() queues it here and
191 * auto-sends once the run completes (SessionIdle), so the user isn't blocked with
192 * no way to send and no indication why the Send button is gone. */
193 private val _queuedFollowUp = MutableStateFlow<String?>(null)
194 val queuedFollowUp: StateFlow<String?> = _queuedFollowUp.asStateFlow()
196 /** One-shot error events surfaced as snackbars. A Channel (not SharedFlow) so an event
197 * emitted before the UI subscribes is buffered and still delivered — a SharedFlow with
198 * replay=0 would drop it (e.g. a VM-init catalog fetch failing before first
199 * composition). Each event is delivered exactly once and not replayed to a
200 * re-subscribing collector, so rotation doesn't re-show a stale snackbar. */
201 private val _errorEvents = Channel<ChatError>(
202 capacity = NetworkConfig.snackbarEventBufferCapacity,
203 onBufferOverflow = BufferOverflow.DROP_OLDEST,
205 val errorEvents: Flow<ChatError> = _errorEvents.receiveAsFlow()
207 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
208 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
210 private val _modelsLoading = MutableStateFlow(true)
211 val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
213 private val _modelsError = MutableStateFlow(false)
214 val modelsError: StateFlow<Boolean> = _modelsError.asStateFlow()
216 private val _selectedModel = MutableStateFlow<ModelOption?>(null)
217 val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
219 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
220 container.activeConnection
221 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
223 scope = viewModelScope,
224 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
225 initialValue = EventStreamClient.ConnectionState.Disconnected,
228 private val _pendingPermission = MutableStateFlow<Permission?>(null)
229 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
231 private val _agents = MutableStateFlow<List<Agent>>(emptyList())
232 val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
234 private val _agentsLoading = MutableStateFlow(true)
235 val agentsLoading: StateFlow<Boolean> = _agentsLoading.asStateFlow()
237 private val _agentsError = MutableStateFlow(false)
238 val agentsError: StateFlow<Boolean> = _agentsError.asStateFlow()
240 private val _selectedAgent = MutableStateFlow<String?>(null)
241 val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
243 private val _commands = MutableStateFlow<List<Command>>(emptyList())
244 val commands: StateFlow<List<Command>> = _commands.asStateFlow()
246 private val _commandsLoading = MutableStateFlow(true)
247 val commandsLoading: StateFlow<Boolean> = _commandsLoading.asStateFlow()
249 private val _commandsError = MutableStateFlow(false)
250 val commandsError: StateFlow<Boolean> = _commandsError.asStateFlow()
252 private val _sessionTitle = MutableStateFlow<String?>(null)
253 val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
255 /** Set when the current session is deleted via SSE, so the UI can navigate away. */
256 private val _sessionDeleted = MutableStateFlow(false)
257 val sessionDeleted: StateFlow<Boolean> = _sessionDeleted.asStateFlow()
259 /** The text of the last send that failed, surfaced so the UI can offer a retry. */
260 private val _failedDraft = MutableStateFlow<String?>(null)
261 val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
263 /** Per-session draft, persisted so it survives navigation/process death. */
264 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
265 val draft: StateFlow<String> = _draft.asStateFlow()
267 /** Attachments staged for the next prompt (images/files). In-memory only — a huge base64
268 * payload isn't worth persisting across process death, and the source Uris wouldn't
269 * survive anyway. Cleared on a successful send. */
270 private val _attachments = MutableStateFlow<List<PendingAttachment>>(emptyList())
271 val attachments: StateFlow<List<PendingAttachment>> = _attachments.asStateFlow()
273 /** True while a revert checkpoint is active for this session (messages after it are hidden
274 * server-side). Drives the "reverted" banner with its Undo. */
275 private val _reverted = MutableStateFlow(false)
276 val reverted: StateFlow<Boolean> = _reverted.asStateFlow()
278 /** The active public share URL for this session, or null when not shared. */
279 private val _shareUrl = MutableStateFlow<String?>(null)
280 val shareUrl: StateFlow<String?> = _shareUrl.asStateFlow()
282 /** One-shot events carrying a freshly-created share URL so the UI can copy/share it. */
283 private val _shareLinkEvents = Channel<String>(
284 capacity = NetworkConfig.snackbarEventBufferCapacity,
285 onBufferOverflow = BufferOverflow.DROP_OLDEST,
287 val shareLinkEvents: Flow<String> = _shareLinkEvents.receiveAsFlow()
290 // DraftStore loads SharedPreferences asynchronously; on a cold start the
291 // synchronous get() above returns "" until the background load completes.
292 // Observe the ready signal and re-seed the draft so it appears in the UI.
293 viewModelScope.launch {
294 container.draftStore.ready.collect { ready ->
295 if (!ready) return@collect
296 if (_draft.value.isEmpty()) {
297 _draft.value = container.draftStore.get(sessionId)
299 // Recover a follow-up queued before process death (its in-memory StateFlow
300 // is gone). If the input is now free, drop it back there for review/send and
301 // consume the stored copy; otherwise re-queue it so it auto-sends when the
302 // current run next idles. Guarded on == null so it never clobbers a
303 // follow-up the user queued after opening this session.
304 val savedFollowUp = container.draftStore.getFollowUp(sessionId)
305 if (savedFollowUp.isNotEmpty() && _queuedFollowUp.value == null) {
306 if (_draft.value.isEmpty()) {
307 _draft.value = savedFollowUp
308 container.draftStore.flushFollowUp(sessionId, "")
310 _queuedFollowUp.value = savedFollowUp
315 // Observe the drafts store for external mutations to this session's draft
316 // (e.g. a share injected via draftStore.set in two-pane mode, or setImmediate
317 // before navigation in single-pane mode). Without this, a share injected into
318 // an already-open session never appears in the input field — the init-time
319 // read and the ready-only re-seed miss it when the draft is non-empty.
320 // The VM's own debounced persist writes the same value _draft already holds,
321 // so those updates are no-ops here (storeValue == _draft.value). The
322 // suppressDraftPersist guard prevents a stale store value from clobbering
323 // the deliberate draft clear during a send (the store still holds the
324 // pre-send draft until the send resolves).
325 viewModelScope.launch {
326 container.draftStore.drafts.collect { drafts ->
327 // Ignore emissions until the initial disk load completes: the load itself
328 // emits the persisted snapshot, and applying it here would clobber text the
329 // user typed during the async load (the `ready` observer above seeds an
330 // empty draft). After ready, emissions are external writes.
331 if (!container.draftStore.ready.value) return@collect
332 val storeValue = drafts[sessionId].orEmpty()
333 // Ignore this VM's own (debounced, possibly stale) persistence echo — only a
334 // genuine external write (e.g. a two-pane share injection) should overwrite
336 if (storeValue == lastPersistedDraft) return@collect
337 if (storeValue != _draft.value && !suppressDraftPersist.get()) {
338 _draft.value = storeValue
344 fun selectModel(option: ModelOption) { _selectedModel.value = option }
346 fun selectAgent(name: String?) { _selectedAgent.value = name }
349 val conn = connection ?: return
350 viewModelScope.launch {
351 conn.api.invalidateProvidersCache()
352 _modelsReload.value++
357 val conn = connection ?: return
358 viewModelScope.launch {
359 conn.api.invalidateAgentsCache()
360 _agentsReload.value++
364 fun reloadCommands() {
365 val conn = connection ?: return
366 viewModelScope.launch {
367 conn.api.invalidateCommandsCache()
368 _commandsReload.value++
373 * Shared helper for init-block catalog observers: re-fetches whenever the active
374 * connection or the catalog's reload trigger changes, invoking [onNull] to clear
375 * state on null so stale data from the old server doesn't persist. The
376 * [reloadTrigger] is merged with [activeConnection] so a manual reload (via
377 * reloadModels/reloadAgents/reloadCommands) cancels any in-flight observe fetch
378 * via collectLatest, preventing a stale observe result from overwriting a fresh
379 * reload result. Failure is non-fatal — the error flag is surfaced to the UI.
381 private fun <T> observeCatalog(
383 loading: MutableStateFlow<Boolean>,
384 error: MutableStateFlow<Boolean>,
385 fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
386 onSuccess: (T) -> Unit,
388 reloadTrigger: StateFlow<Int>,
390 viewModelScope.launch {
391 merge(container.activeConnection, reloadTrigger).collectLatest { _ ->
392 val conn = container.activeConnection.value
393 if (conn == null) { onNull(); loading.value = false; error.value = false; return@collectLatest }
396 runCatchingCancellable { fetch(conn.api) }
397 // fetch() does HTTP via withRetry, so the failure can be a
398 // ClientRequestException whose message embeds the full request URL
399 // (may contain auth/paths). Log only a scrubbed summary.
400 .onFailure { Log.w("ChatViewModel", "Failed to load $tag: ${safeExceptionSummary(it)}"); error.value = true }
401 .getOrNull()?.let(onSuccess)
402 loading.value = false
407 fun updateDraft(text: String) {
412 // Debounce draft persistence so we don't write to disk on every keystroke.
413 // Skip persisting empty drafts that were set by send() (suppressed via
414 // suppressDraftPersist) — send() clears the in-memory draft immediately for
415 // UI feedback but deliberately doesn't persist the clear until the send
416 // succeeds, so a failed send can restore the draft.
417 viewModelScope.launch {
418 _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
419 if (text.isBlank() && suppressDraftPersist.get()) return@collect
420 lastPersistedDraft = text
421 runCatchingCancellable { container.draftStore.set(sessionId, text) }
422 .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
425 // Reset per-connection state when the active server changes so stale spinners,
426 // permission dialogs, errors, and agent selections from the old server don't
427 // persist into the new one.
428 viewModelScope.launch {
429 container.activeConnection.collectLatest { conn ->
431 // The connection dropped (e.g. disconnect() mid-run). No SSE stream will
432 // arrive to deliver SessionIdle/SessionError, so reset the run state here —
433 // otherwise the working spinner sticks on, ChatScreen keeps keepScreenOn +
434 // the RunForegroundService alive, and the Stop button no-ops (abort() early-
435 // returns with no connection) until a later reconnect happens to clear it.
436 _running.value = false
437 _aborting.value = false
438 _pendingPermission.value = null
441 _running.value = false
442 _pendingPermission.value = null
443 _failedDraft.value = null
444 // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
445 // user intent that is now persisted; wiping it on every (re)connect — which
446 // this collector does, including transient SSE drops and the cold-start
447 // restore path — would drop a legitimately queued/recovered follow-up.
448 _selectedAgent.value = null
449 _sessionTitle.value = null
451 conn.events.events.collect { event ->
452 if (SessionRepository.isIdle(event, sessionId)) {
453 _running.value = false
454 // Auto-send a queued follow-up once the previous run finishes,
455 // so the user's drafted-while-running message isn't lost.
456 val queued = _queuedFollowUp.value
457 if (queued != null) {
458 setQueuedFollowUp(null)
459 send(queued, includeAttachments = false)
462 if (SessionRepository.isError(event, sessionId)) {
463 _running.value = false
464 // The run errored, so the queued follow-up won't auto-send.
465 // Restore it to the input (if free) so the user's typed text
466 // isn't silently lost; otherwise just clear the queue.
467 val queued = _queuedFollowUp.value
468 if (!queued.isNullOrEmpty() && _draft.value.isEmpty()) {
469 _draft.value = queued
471 setQueuedFollowUp(null)
472 _errorEvents.trySend(ChatError(container.string(R.string.error_agent_reported)))
475 is PermissionUpdated ->
476 if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
477 is PermissionReplied ->
478 if (event.properties.sessionID == sessionId && event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
480 if (event.properties.info.id == sessionId) {
481 val info = event.properties.info
482 _sessionTitle.value = info.displayTitle
483 _reverted.value = info.isReverted
484 _shareUrl.value = info.share?.url?.takeIf { it.isNotBlank() }
487 if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
488 _sessionDeleted.value = true
493 } catch (e: CancellationException) {
495 } catch (e: Exception) {
496 Log.w("ChatViewModel", "SSE event collector error", e)
500 // Load the model catalog; preselect the server default. Failure is non-fatal —
501 // sending with no model just uses the server's default agent/model.
503 tag = "model catalog",
504 loading = _modelsLoading,
505 error = _modelsError,
506 fetch = { it.providers() },
507 onSuccess = { resp ->
508 val options = resp.toOptions()
509 _models.value = options
510 // Only (re)apply the server default when there's no valid current
511 // selection. On re-fetch/reconnect/Retry the user's chosen model must
512 // survive — keep it as long as it's still present in the refreshed list.
513 val current = _selectedModel.value
514 if (current == null || options.none { it.ref == current.ref }) {
515 _selectedModel.value = resp.defaultOption(options)
518 onNull = { _models.value = emptyList(); _selectedModel.value = null },
519 reloadTrigger = _modelsReload,
521 // Load the agent catalog (non-fatal).
523 tag = "agent catalog",
524 loading = _agentsLoading,
525 error = _agentsError,
526 fetch = { it.agents() },
527 onSuccess = { _agents.value = it },
528 onNull = { _agents.value = emptyList() },
529 reloadTrigger = _agentsReload,
531 // Load the command catalog (non-fatal).
533 tag = "command catalog",
534 loading = _commandsLoading,
535 error = _commandsError,
536 fetch = { it.commands() },
537 onSuccess = { _commands.value = it },
538 onNull = { _commands.value = emptyList() },
539 reloadTrigger = _commandsReload,
541 // Resolve the human-readable session title for the app bar (non-fatal).
542 // Skips the REST call when the SSE event collector already delivered the title
543 // (SessionUpdated fires shortly after connect), avoiding a redundant full
544 // session-list download.
545 viewModelScope.launch {
546 container.activeConnection.collectLatest { conn ->
547 if (conn == null) return@collectLatest
548 if (_sessionTitle.value != null) return@collectLatest
549 runCatchingCancellable { conn.repository.listSessions() }
551 ?.firstOrNull { it.id == sessionId }
553 _sessionTitle.value = session.displayTitle
554 _reverted.value = session.isReverted
555 _shareUrl.value = session.share?.url?.takeIf { it.isNotBlank() }
559 // If the SSE stream drops mid-run, the run indicator would spin forever;
560 // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
561 viewModelScope.launch {
562 container.activeConnection.collectLatest { conn ->
563 if (conn == null) return@collectLatest
565 conn.events.state.collect { state ->
566 if ((state == EventStreamClient.ConnectionState.Disconnected ||
567 state == EventStreamClient.ConnectionState.Failed) && _running.value) {
568 _running.value = false
571 } catch (e: CancellationException) {
573 } catch (e: Exception) {
574 Log.w("ChatViewModel", "SSE state collector error", e)
580 /** Sends [text] together with any staged attachments; returns true on success so the
581 * caller can clear the draft only then. */
582 fun send(text: String): Boolean = send(text, includeAttachments = true)
585 * Core send. [includeAttachments] is false for the internal auto-send paths (a queued
586 * follow-up, a retry) so staged attachments aren't silently re-sent with unrelated text.
588 private fun send(text: String, includeAttachments: Boolean): Boolean {
589 val conn = connection ?: return false
590 val trimmed = text.trim()
591 val attachments = if (includeAttachments) _attachments.value else emptyList()
592 // An image-only prompt (attachments, no text) is valid; a blank prompt with nothing
594 if ((trimmed.isEmpty() && attachments.isEmpty()) || !_running.compareAndSet(false, true)) return false
595 _failedDraft.value = null
596 // Clear the staged attachments optimistically so the composer empties immediately;
597 // restore them if the send fails (mirrors the draft handling below).
598 if (attachments.isNotEmpty()) _attachments.value = emptyList()
599 // Clear the in-memory draft for the UI immediately, but don't persist the clear
600 // yet — if the send fails and the process dies before we restore, the draft
601 // would be lost forever. The persisted draft is cleared only on success.
602 // suppressDraftPersist prevents the debounced collector from persisting the
603 // empty draft before the send resolves.
605 // Only clear when the text being sent IS the current draft. send() is also
606 // invoked for auto-sent queued follow-ups (SessionIdle) and retryFailed(), where
607 // the user may have typed a NEW draft since — wiping it would lose that text.
608 if (_draft.value.trim() == trimmed) {
609 suppressDraftPersist.set(true)
612 viewModelScope.launch {
613 runCatchingCancellable {
614 conn.repository.sendPrompt(
617 attachments = attachments.map { it.part },
618 model = _selectedModel.value?.ref,
619 agent = _selectedAgent.value,
622 suppressDraftPersist.set(false)
623 _failedDraft.value = trimmed
624 // Only restore the draft if the user hasn't typed anything new since.
625 if (_draft.value.isBlank()) updateDraft(trimmed)
626 // Restore the staged attachments too, so a failed send doesn't lose them —
627 // but only if the user hasn't staged new ones in the meantime.
628 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
629 // Retryable: this is the failed send whose prompt Retry re-submits.
630 _errorEvents.trySend(ChatError(container.friendlyError(it), retryable = true))
631 _running.value = false
633 suppressDraftPersist.set(false)
634 // Send succeeded — persist the cleared draft, but only if the user hasn't
635 // typed a new one while the send was in flight. Otherwise clearing the
636 // store echoes back through the draft observer and wipes the in-progress
637 // text (data loss). Mirrors the guard on the failure path above.
638 if (_draft.value.isBlank()) {
639 lastPersistedDraft = ""
640 container.draftStore.set(sessionId, "")
642 // Don't reset _running here: the agent continues streaming via SSE.
643 // _running is cleared on SessionIdle/SessionError (see event collector)
644 // or when the SSE stream drops (see connection state watcher below).
650 /** Re-send the last draft whose send failed, if any. */
652 val draft = _failedDraft.value ?: return
653 // Don't clear _failedDraft until send() accepts the text — if _running is
654 // already true, send() returns false and the draft would be lost forever.
655 // Attachments aren't re-sent on retry (they were optimistically restored to the
656 // composer on the original failure, so they'll ride the next manual send).
657 if (send(draft, includeAttachments = false)) {
658 _failedDraft.value = null
663 * Queue [text] to be sent automatically when the current run finishes, or clear any
664 * queued follow-up when [text] is blank. Used when the user taps Send while a run
665 * is already active — the Send button is replaced by Stop during a run, but the
666 * input field stays enabled, so a follow-up typed mid-run would otherwise have
669 fun queueFollowUp(text: String) {
670 val trimmed = text.trim()
671 setQueuedFollowUp(trimmed.takeIf { it.isNotEmpty() })
672 // The text now lives in the queued chip, so clear the input field just as a
673 // normal send would. No-op for the cancel case (blank text), which must leave
674 // whatever the user has since typed untouched.
675 if (trimmed.isNotEmpty()) _draft.value = ""
678 /** Set or clear the queued follow-up and mirror it to persistence, so a follow-up
679 * queued mid-run survives process death (recovered on the next open of this session).
680 * Passing null / blank clears both the in-memory value and the persisted copy. */
681 private fun setQueuedFollowUp(text: String?) {
682 _queuedFollowUp.value = text
683 container.draftStore.flushFollowUp(sessionId, text.orEmpty())
686 /** Transient flag set by [refreshMessages] so the top-bar refresh icon can show
687 * a brief spinner as immediate tap feedback. The SSE reconnect triggered by
688 * refreshMessages may not visibly change [connectionState] (it's already
689 * Connected), so without this the tap appears to do nothing. Clears after a
690 * short delay or when the connection state next becomes Connected. */
691 private val _refreshing = MutableStateFlow(false)
692 val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
694 /** Manually re-fetch messages by forcing the SSE stream to reconnect, which
695 * triggers the repository's re-seed-from-REST logic. Gives the user a recovery
696 * path when the SSE stream silently drops and the auto-reconnect re-seed is slow
697 * or fails — a pull-to-refresh or top-bar refresh forces a fresh fetch under the
698 * existing generation-guarded merge. Also clears [loadError] optimistically and
699 * sets [refreshing] for immediate tap feedback. */
700 fun refreshMessages() {
701 val conn = connection ?: return
702 _loadError.value = false
703 _refreshing.value = true
704 conn.events.triggerReconnect()
705 viewModelScope.launch {
706 delay(NetworkConfig.refreshFeedbackMs)
707 _refreshing.value = false
711 /** Invoke a slash-command by name via the server's /command endpoint. */
712 fun runCommand(command: Command) {
713 val conn = connection ?: return
714 // A run is already in flight — surface feedback instead of silently dropping the
715 // command (the picker stays openable during a run and dismisses on select).
716 if (!_running.compareAndSet(false, true)) {
717 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
720 viewModelScope.launch {
721 runCatchingCancellable {
722 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
724 _errorEvents.trySend(ChatError(container.friendlyError(it)))
725 _running.value = false
731 val conn = connection ?: return
732 // Drop any queued follow-up so the SessionIdle that follows the abort doesn't
733 // auto-send it — the user tapped Stop to halt work, not to trigger the next turn.
734 setQueuedFollowUp(null)
735 if (!_aborting.compareAndSet(false, true)) return
736 viewModelScope.launch {
738 runCatchingCancellable { conn.repository.abort(sessionId) }
739 .onSuccess { _running.value = false }
740 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
742 _aborting.value = false
747 fun addAttachment(attachment: PendingAttachment) {
748 _attachments.update { it + attachment }
751 fun removeAttachment(id: String) {
752 _attachments.update { list -> list.filterNot { it.id == id } }
755 fun clearAttachments() {
756 _attachments.value = emptyList()
759 /** Revert the conversation to just before [messageId], hiding everything after it. The
760 * authoritative state also arrives via SessionUpdated; we flag it here for immediate
762 fun revertTo(messageId: String) {
763 val conn = connection ?: return
764 viewModelScope.launch {
765 runCatchingCancellable { conn.api.revert(sessionId, messageId) }
766 .onSuccess { _reverted.value = it.isReverted }
767 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
771 /** Undo the active revert checkpoint, restoring the hidden messages. */
773 val conn = connection ?: return
774 viewModelScope.launch {
775 runCatchingCancellable { conn.api.unrevert(sessionId) }
776 .onSuccess { _reverted.value = it.isReverted }
777 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
781 /** Create (or fetch the existing) public share link. The URL is exposed via [shareUrl]
782 * and also emitted once via [shareLinkEvents] so the UI can copy it to the clipboard. */
784 val conn = connection ?: return
785 viewModelScope.launch {
786 runCatchingCancellable { conn.api.shareSession(sessionId) }
787 .onSuccess { session ->
788 val url = session.share?.url?.takeIf { it.isNotBlank() }
789 _shareUrl.value = url
790 if (url != null) _shareLinkEvents.trySend(url)
792 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
796 /** Revoke the session's public share link. */
797 fun unshareSession() {
798 val conn = connection ?: return
799 viewModelScope.launch {
800 runCatchingCancellable { conn.api.unshareSession(sessionId) }
801 .onSuccess { _shareUrl.value = it.share?.url?.takeIf { it.isNotBlank() } }
802 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
806 /** Compact the conversation via the summarize endpoint; the summary streams back via SSE.
807 * Uses the currently-selected model (required by the endpoint). */
809 val conn = connection ?: return
810 val model = _selectedModel.value?.ref ?: run {
811 _errorEvents.trySend(ChatError(container.string(R.string.needs_model)))
814 if (!_running.compareAndSet(false, true)) {
815 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
818 viewModelScope.launch {
819 runCatchingCancellable { conn.api.summarize(sessionId, model) }
821 _errorEvents.trySend(ChatError(container.friendlyError(it)))
822 _running.value = false
827 /** Analyze the project and (re)generate its AGENTS.md; the run streams back via SSE. */
829 val conn = connection ?: return
830 if (!_running.compareAndSet(false, true)) {
831 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
834 viewModelScope.launch {
835 runCatchingCancellable { conn.api.initSession(sessionId) }
837 _errorEvents.trySend(ChatError(container.friendlyError(it)))
838 _running.value = false
843 /** Run a one-off shell [command] in the session's worktree; output streams back via SSE.
844 * The server requires an agent to scope the run — use the selected one, else the primary. */
845 fun runShell(command: String) {
846 val conn = connection ?: return
847 val cmd = command.trim()
848 if (cmd.isEmpty()) return
849 val agent = _selectedAgent.value
850 ?: _agents.value.firstOrNull { it.isPrimary }?.name
851 ?: _agents.value.firstOrNull()?.name
852 ?: DEFAULT_SHELL_AGENT
853 if (!_running.compareAndSet(false, true)) {
854 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
857 viewModelScope.launch {
858 runCatchingCancellable { conn.api.shell(sessionId, cmd, agent, _selectedModel.value?.ref) }
860 _errorEvents.trySend(ChatError(container.friendlyError(it)))
861 _running.value = false
866 /** Rename the current session via PATCH /session/:id. On success updates [sessionTitle]
867 * so the top bar reflects the new name immediately. A failure surfaces as a snackbar;
868 * the caller keeps the dialog open so the user can retry without retyping. */
869 fun renameSession(newTitle: String) {
870 val conn = connection ?: return
871 val title = newTitle.trim()
872 if (title.isEmpty() || title == _sessionTitle.value) return
873 viewModelScope.launch {
874 runCatchingCancellable { conn.api.updateSession(sessionId, title) }
875 .onSuccess { _sessionTitle.value = it.displayTitle }
876 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
880 /** Delete the current session via DELETE /session/:id. Surfaces a toast (via the UI's
881 * sessionDeleted flow) and navigates away on success; a failure surfaces as a snackbar.
882 * Unlike [SessionListViewModel.deleteSession] there's no undo window — the user is
883 * already viewing the session, so a confirmation dialog guards the action instead. */
884 fun deleteSession() {
885 val conn = connection ?: return
886 viewModelScope.launch {
887 runCatchingCancellable { conn.repository.deleteSession(sessionId) }
889 container.draftStore.remove(sessionId)
890 _sessionDeleted.value = true
892 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
896 /** Reconnect to the most recently used server profile (used when the connection is gone). */
898 if (container.activeConnection.value != null) return
899 // Guard against concurrent reconnect calls: the user can tap the button
900 // multiple times while _reconnecting is true, which would launch parallel
901 // connect() coroutines that race to set the active connection, leaking the
902 // intermediate connections.
903 if (!_reconnecting.compareAndSet(false, true)) return
904 viewModelScope.launch {
906 val recent = runCatchingCancellable { container.profileStore.profiles.first() }
907 .getOrDefault(emptyList())
909 if (recent == null) {
910 _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
913 runCatchingCancellable {
914 val conn = container.connect(recent)
917 container.disconnect()
918 _errorEvents.trySend(ChatError(container.friendlyError(it)))
921 _reconnecting.value = false
926 fun respondPermission(permission: Permission, response: PermissionResponse) {
927 val conn = connection ?: return
928 // Clear optimistically; permission.replied will confirm. If the call fails we
929 // restore the pending permission so the user can retry instead of being stuck
930 // with a dismissed dialog and a paused tool run.
931 _pendingPermission.value = null
932 viewModelScope.launch {
933 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
935 _errorEvents.trySend(ChatError(container.friendlyError(it)))
936 // Only restore if no new permission has arrived in the meantime.
937 if (_pendingPermission.value == null) _pendingPermission.value = permission
942 private companion object {
943 /** Fallback agent for the shell endpoint when none is selected and no agent catalog
944 * loaded. opencode's default primary agent is "build". */
945 const val DEFAULT_SHELL_AGENT = "build"
948 override fun onCleared() {
950 // Flush any pending debounced draft so it survives navigation. Uses an
951 // asynchronous apply() (not a synchronous commit) so the main thread isn't
952 // blocked on disk I/O — Android's SharedPreferences framework guarantees
953 // pending apply() writes are flushed before the process exits.
954 // Always flush — including an empty draft. Persistence is otherwise debounced, so if
955 // the user cleared the input and navigated back within the debounce window, the
956 // debounce coroutine was cancelled with viewModelScope and the prefs still hold the
957 // previous non-empty draft. flushDraft() removes the key when the text is blank, so an
958 // unconditional flush commits the clear instead of resurrecting the deleted text.
959 container.draftStore.flushDraft(sessionId, _draft.value)
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