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.UserMessage
11import soy.iko.opencode.data.model.Permission
12import soy.iko.opencode.data.model.PermissionReplied
13import soy.iko.opencode.data.model.PermissionResponse
14import soy.iko.opencode.data.model.PermissionUpdated
15import soy.iko.opencode.data.model.SessionDeleted
16import soy.iko.opencode.data.model.SessionUpdated
17import soy.iko.opencode.data.model.TextPart
18import soy.iko.opencode.data.model.TodoItem
19import soy.iko.opencode.data.model.currentTodoPlan
20import soy.iko.opencode.data.model.defaultOption
21import soy.iko.opencode.data.model.toOptions
22import soy.iko.opencode.data.network.EventStreamClient
23import soy.iko.opencode.data.network.NetworkConfig
24import soy.iko.opencode.data.repo.OutboxMessage
25import soy.iko.opencode.data.repo.SessionRepository
26import soy.iko.opencode.di.AppContainer
27import soy.iko.opencode.R
28import soy.iko.opencode.util.runCatchingCancellable
29import soy.iko.opencode.util.safeExceptionSummary
30import android.util.Log
31import kotlinx.coroutines.ExperimentalCoroutinesApi
32import kotlinx.coroutines.channels.BufferOverflow
33import kotlinx.coroutines.channels.Channel
34import kotlinx.coroutines.flow.Flow
35import kotlinx.coroutines.flow.MutableSharedFlow
36import kotlinx.coroutines.flow.MutableStateFlow
37import kotlinx.coroutines.flow.SharedFlow
38import kotlinx.coroutines.flow.SharingStarted
39import kotlinx.coroutines.flow.StateFlow
40import kotlinx.coroutines.flow.asSharedFlow
41import kotlinx.coroutines.flow.asStateFlow
42import kotlinx.coroutines.flow.receiveAsFlow
43import kotlinx.coroutines.CancellationException
44import kotlinx.coroutines.flow.collectLatest
45import kotlinx.coroutines.flow.combine
46import kotlinx.coroutines.flow.debounce
47import kotlinx.coroutines.flow.distinctUntilChanged
48import kotlinx.coroutines.flow.drop
49import kotlinx.coroutines.flow.first
50import kotlinx.coroutines.flow.filterIsInstance
51import kotlinx.coroutines.flow.flatMapLatest
52import kotlinx.coroutines.flow.flowOf
53import kotlinx.coroutines.flow.flowOn
54import kotlinx.coroutines.flow.map
55import kotlinx.coroutines.flow.merge
56import kotlinx.coroutines.flow.onEach
57import kotlinx.coroutines.flow.retryWhen
58import kotlinx.coroutines.flow.stateIn
59import kotlinx.coroutines.flow.update
60import kotlinx.coroutines.Dispatchers
61import kotlinx.coroutines.Job
62import kotlinx.coroutines.NonCancellable
63import kotlinx.coroutines.delay
64import kotlinx.coroutines.isActive
65import kotlinx.coroutines.currentCoroutineContext
66import kotlinx.coroutines.launch
67import kotlinx.coroutines.withContext
70 * A user-facing error to surface as a snackbar. [retryable] is true only for a failed
71 * message *send* (whose draft is held in [ChatViewModel.failedDraft]); the snackbar attaches
72 * a Retry action solely for those so an unrelated error (e.g. a message-load failure) can't
73 * inherit a Retry that silently re-submits the last prompt.
75data class ChatError(val message: String, val retryable: Boolean = false)
78 * Progress of the current permission within a burst of requests. [total] is the max number
79 * simultaneously pending since the queue was last empty; [position] advances as the user clears
80 * the backlog. A [total] of 0/1 means there's no meaningful backlog to surface.
82@androidx.compose.runtime.Immutable
83data class PermissionProgress(val position: Int, val total: Int)
86 * An attachment staged to send with the next prompt. [previewModel] is a Coil-loadable model
87 * for the thumbnail (the source content Uri, as a string) for images, or null for non-image
88 * files (rendered with a generic icon). [part] is the wire form (a base64 data URL) sent to
91@androidx.compose.runtime.Immutable
92data class PendingAttachment(
96 val previewModel: Any?,
97 val part: FilePromptPart,
99 val isImage: Boolean get() = mime.startsWith("image/")
102/** Reduce a staged attachment to its persistable form (the self-contained data URL). */
103private fun PendingAttachment.toPersisted() =
104 soy.iko.opencode.data.repo.PersistedAttachment(id, name, mime, part.url, part.filename)
106/** Rebuild a staged attachment from persistence. The base64 data URL doubles as the Coil
107 * preview model for images (the original content Uri didn't survive the process restart). */
108private fun soy.iko.opencode.data.repo.PersistedAttachment.toPending() = PendingAttachment(
112 previewModel = if (mime.startsWith("image/")) url else null,
113 part = FilePromptPart(mime = mime, url = url, filename = filename),
116// Large by design: this VM owns the whole chat surface (streaming, drafts, attachments,
117// catalogs, permissions, revert/edit, sharing, summarize/init/shell, TTS wiring). It's
118// already grandfathered for TooManyFunctions in the detekt baseline; the class-size rule is
119// suppressed for the same reason. A future split into sub-controllers is tracked separately.
120@Suppress("LargeClass")
121@OptIn(ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
123 private val container: AppContainer,
124 private val sessionId: String,
127 private val connection get() = container.activeConnection.value
129 val connected: Boolean get() = connection != null
131 private val _loading = MutableStateFlow(true)
132 val loading: StateFlow<Boolean> = _loading.asStateFlow()
134 /** Set when the messages flow failed to load (and the list is empty), so the UI
135 * can render a distinct error state instead of masquerading as an empty
136 * conversation. Cleared on a successful non-empty emission. */
137 private val _loadError = MutableStateFlow(false)
138 val loadError: StateFlow<Boolean> = _loadError.asStateFlow()
140 /** Tracks whether the messages flow has ever emitted a non-empty list, so
141 * [messages]' retryWhen can decide whether a re-fetch failure should set
142 * [loadError] (nothing shown yet → error screen) or just emit a snackbar
143 * (already showing messages → don't replace the conversation with an error). */
144 private var hasShownMessages = false
146 /** True once we've surfaced a snackbar for the current message-load failure streak.
147 * Reset on any successful emission so a persistent failure doesn't spam a fresh
148 * snackbar every retry cycle (every few seconds). */
149 private var loadErrorSnackbarShown = false
151 /** Separate from [loading]: tracks the manual reconnect() flow so its spinner
152 * doesn't conflict with the messages flow's loading state. */
153 private val _reconnecting = MutableStateFlow(false)
154 val reconnecting: StateFlow<Boolean> = _reconnecting.asStateFlow()
156 /** When true, the debounced draft collector skips persisting an empty draft so
157 * [send]'s deliberate non-persistence of the cleared draft isn't undone by the
158 * debounce timer firing before the send completes. */
159 private val suppressDraftPersist = java.util.concurrent.atomic.AtomicBoolean(false)
161 /** The draft value this VM last persisted, so the drafts observer can tell its own
162 * (debounced, possibly stale) echo apart from a genuine external write. Without this
163 * a debounced persist of an older value could echo back and overwrite newer text the
164 * user has since typed. */
165 @Volatile private var lastPersistedDraft: String? = null
167 /** Per-catalog reload triggers: incrementing one causes [observeCatalog]'s
168 * collectLatest to cancel any in-flight fetch and start a fresh one, so a
169 * manual reload supersedes a stale observeCatalog fetch. */
170 private val _modelsReload = MutableStateFlow(0)
171 private val _agentsReload = MutableStateFlow(0)
172 private val _commandsReload = MutableStateFlow(0)
174 /** An optimistically-injected user message shown immediately on send, before the server
175 * echoes the real message back via SSE. Removed once a matching UserMessage arrives. */
176 data class OptimisticEntry(val tempId: String, val text: String, val timestamp: Long, val failed: Boolean)
178 private val _optimisticMessages = MutableStateFlow<List<OptimisticEntry>>(emptyList())
180 /** SSE-driven message stream with retry/backoff and loading/error side effects. Combined
181 * with [_optimisticMessages] by [messages] so the user sees their outgoing prompt instantly. */
182 private val sseMessages: Flow<List<MessageWithParts>> =
183 container.activeConnection
184 .flatMapLatest { conn ->
185 conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
188 _loading.value = false
189 // A value flowed through: the stream recovered, so allow the next distinct
190 // failure to surface a fresh snackbar again.
191 loadErrorSnackbarShown = false
192 // Clear the error on ANY successful emission, including an empty list. An empty
193 // session that hit one transient error would otherwise stay latched on the
194 // "Failed to load / Retry" screen forever, because the later successful EMPTY
195 // emission couldn't clear the flag. hasShownMessages stays gated on non-empty
196 // (an empty session has genuinely shown nothing yet).
197 _loadError.value = false
198 if (it.isNotEmpty()) {
199 hasShownMessages = true
201 // Reconcile optimistic messages: drop any whose text now matches a real SSE
202 // UserMessage (the server echoed the prompt back). Matching by trimmed text,
203 // removing the oldest match first so duplicate texts are handled in order.
204 reconcileOptimistic(it)
206 .retryWhen { cause, attempt ->
207 _loading.value = false
208 // Only surface the persistent error state when there's nothing to
209 // show — if we already have messages, a transient re-fetch failure
210 // is better surfaced as a snackbar (via errorEvents) than by
211 // replacing the visible conversation with an error screen.
212 if (!hasShownMessages) _loadError.value = true
213 // Surface the snackbar only once per failure streak — retryWhen loops on a
214 // backoff, so emitting here unconditionally would spam a new snackbar every
215 // retry on a persistent load failure.
216 if (!loadErrorSnackbarShown) {
217 _errorEvents.trySend(ChatError(container.friendlyError(cause)))
218 loadErrorSnackbarShown = true
220 // Exponential backoff capped at retryMaxDelayMs so a persistently failing
221 // server is retried with growing delays (retryInitialDelayMs, then doubling)
222 // instead of hammered at a fixed interval forever. Clamp the shift so a long
223 // failure streak can't overflow the Long delay.
224 val backoffMs = (NetworkConfig.retryInitialDelayMs shl attempt.coerceAtMost(16L).toInt())
225 .coerceAtMost(NetworkConfig.retryMaxDelayMs)
230 val messages: StateFlow<List<MessageWithParts>> =
231 combine(sseMessages, _optimisticMessages) { sse, optimistic ->
232 mergeOptimistic(sse, optimistic)
234 scope = viewModelScope,
235 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
236 initialValue = emptyList(),
239 /** Whether the conversation has any messages. Derived separately so the top bar
240 * (share button enabled state) can observe this cheap boolean instead of the
241 * full messages list, avoiding per-token recomposition of the app bar during
243 val hasMessages: StateFlow<Boolean> = messages
244 .map { it.isNotEmpty() }
246 scope = viewModelScope,
247 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
248 initialValue = false,
251 /** Map of optimistic message tempId → failed flag, so the UI can render a "Sending…"
252 * or "Failed to send" indicator on the corresponding message bubble. */
253 val optimisticStatuses: StateFlow<Map<String, Boolean>> = _optimisticMessages
254 .map { entries -> entries.associate { it.tempId to it.failed } }
256 scope = viewModelScope,
257 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
258 initialValue = emptyMap(),
261 /** Merge SSE-driven messages with optimistic entries. An optimistic entry whose trimmed
262 * text matches a real [UserMessage] in [sse] is dropped (the server echoed it back).
263 * Remaining entries are appended as synthetic [UserMessage]s at the end. Failed entries
264 * are kept so the user sees the failed indicator until they retry or dismiss. */
265 private fun mergeOptimistic(
266 sse: List<MessageWithParts>,
267 optimistic: List<OptimisticEntry>,
268 ): List<MessageWithParts> {
269 if (optimistic.isEmpty()) return sse
270 // Collect the text of real user messages so optimistic entries can be matched.
271 val realUserTexts = mutableSetOf<String>()
273 if (msg.info !is UserMessage) continue
274 val text = msg.parts.filterIsInstance<TextPart>()
275 .joinToString("\n\n") { it.text }
277 if (text.isNotEmpty()) realUserTexts.add(text)
279 val surviving = optimistic.filter { it.text.trim() !in realUserTexts }
280 if (surviving.isEmpty()) return sse
281 return sse + surviving.map { it.toMessageWithParts() }
284 /** Remove optimistic entries whose text now matches a real user message delivered by SSE.
285 * Called from the SSE flow's onEach so stale optimistic entries are cleaned up promptly. */
286 private fun reconcileOptimistic(sse: List<MessageWithParts>) {
287 if (_optimisticMessages.value.isEmpty()) return
288 val realUserTexts = mutableSetOf<String>()
290 if (msg.info !is UserMessage) continue
291 val text = msg.parts.filterIsInstance<TextPart>()
292 .joinToString("\n\n") { it.text }
294 if (text.isNotEmpty()) realUserTexts.add(text)
296 if (realUserTexts.isEmpty()) return
297 _optimisticMessages.update { entries ->
298 entries.filterNot { it.text.trim() in realUserTexts }
302 /** Convert an optimistic entry to a [MessageWithParts] for display. */
303 private fun OptimisticEntry.toMessageWithParts(): MessageWithParts = MessageWithParts(
306 sessionID = sessionId,
307 time = soy.iko.opencode.data.model.TimeInfo(created = timestamp),
309 parts = listOf(TextPart(id = "$tempId-text", text = text)),
312 /** The agent's current task plan (the latest `todowrite`), surfaced so the chat can pin a
313 * live progress checklist above the composer. distinctUntilChanged so a per-token messages
314 * emission that doesn't change the plan doesn't recompose the bar. */
315 val todoPlan: StateFlow<List<TodoItem>> = messages
316 .map { currentTodoPlan(it) }
317 // Run currentTodoPlan off the main thread: it's an O(messages×parts) scan (worst case
318 // when the conversation has no todowrite) and runs on every conflated messages emission.
319 // distinctUntilChanged is downstream so it dedupes the result but doesn't prevent the
320 // scan — without flowOn this scans on Main.immediate per streamed token → jank.
321 .flowOn(Dispatchers.Default)
322 .distinctUntilChanged()
324 scope = viewModelScope,
325 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
326 initialValue = emptyList(),
329 private val _running = MutableStateFlow(false)
330 val running: StateFlow<Boolean> = _running.asStateFlow()
333 * Wall-clock millis at which the current run started (0L when no run is active). Drives
334 * the typing row's elapsed timer so it survives LazyColumn recycling — a local `remember`
335 * in the row resets to 0:00 when the row is disposed and scrolled back into view. Stamped
336 * only on a real false→[beginRun] transition (send/run/init), not on SSE-reconnect relight
337 * (which continues an existing run and should keep its original elapsed time).
339 private val _runStartMs = MutableStateFlow(0L)
340 val runStartMs: StateFlow<Long> = _runStartMs.asStateFlow()
343 * Atomically transition `_running` false→true and stamp the run-start timestamp. Returns
344 * false (with no state change) when a run is already in flight, so callers can surface a
345 * "busy" message instead of silently dropping the action. Centralizes the transition so
346 * every run-start path captures [runStartMs] consistently.
348 private fun beginRun(): Boolean {
349 if (!_running.compareAndSet(false, true)) return false
350 _runStartMs.value = System.currentTimeMillis()
354 /** True once the current run ended via SessionIdle/SessionError, until the next run starts
355 * (a send) or an SSE reconnect. Gates the run-indicator re-light below: isRunActivity
356 * returns true for ANY message.part.updated, so without this a trailing/replayed part
357 * arriving after a legitimate SessionIdle would re-light the "working" indicator with no
358 * further idle to clear it — a stuck spinner + foreground service. Cleared on reconnect so
359 * genuine reconnect-recovery re-lighting (its actual purpose) still works. */
360 @Volatile private var runEndedByIdle = false
362 /** The in-flight REST title resolution for the current connection. Launched from — and
363 * cancelled by — the per-connection reset block so it can't observe or restore a previous
364 * server's stale title. */
365 private var sessionTitleJob: Job? = null
367 /** True while an abort REST call is in flight, so the Stop button can show a
368 * spinner and prevent double-taps from firing a second abort. */
369 private val _aborting = MutableStateFlow(false)
370 val aborting: StateFlow<Boolean> = _aborting.asStateFlow()
372 /** A follow-up the user typed while a run was active. send() queues it here and
373 * auto-sends once the run completes (SessionIdle), so the user isn't blocked with
374 * no way to send and no indication why the Send button is gone. */
375 private val _queuedFollowUp = MutableStateFlow<String?>(null)
376 val queuedFollowUp: StateFlow<String?> = _queuedFollowUp.asStateFlow()
378 /** One-shot error events surfaced as snackbars. A Channel (not SharedFlow) so an event
379 * emitted before the UI subscribes is buffered and still delivered — a SharedFlow with
380 * replay=0 would drop it (e.g. a VM-init catalog fetch failing before first
381 * composition). Each event is delivered exactly once and not replayed to a
382 * re-subscribing collector, so rotation doesn't re-show a stale snackbar. */
383 private val _errorEvents = Channel<ChatError>(
384 capacity = NetworkConfig.snackbarEventBufferCapacity,
385 onBufferOverflow = BufferOverflow.DROP_OLDEST,
387 val errorEvents: Flow<ChatError> = _errorEvents.receiveAsFlow()
389 /** One-shot events signaling a user-initiated delete was scheduled and is undoable in-place.
390 * The ChatScreen collects these to show an Undo snackbar over the conversation the user is
391 * already viewing, instead of navigating away first and surfacing Undo on the session list. */
392 private val _deleteUndoEvents = MutableSharedFlow<Unit>(
393 extraBufferCapacity = 1,
394 onBufferOverflow = BufferOverflow.DROP_OLDEST,
396 val deleteUndoEvents: SharedFlow<Unit> = _deleteUndoEvents.asSharedFlow()
398 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
399 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
401 private val _modelsLoading = MutableStateFlow(true)
402 val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
404 private val _modelsError = MutableStateFlow(false)
405 val modelsError: StateFlow<Boolean> = _modelsError.asStateFlow()
407 private val _selectedModel = MutableStateFlow<ModelOption?>(null)
408 val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
410 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
411 container.activeConnection
412 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
414 scope = viewModelScope,
415 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
416 initialValue = EventStreamClient.ConnectionState.Disconnected,
419 private val _pendingPermission = MutableStateFlow<Permission?>(null)
420 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
422 /** Backlog indicator for the permission dialog (see [PermissionProgress]). */
423 private val _permissionProgress = MutableStateFlow(PermissionProgress(0, 0))
424 val permissionProgress: StateFlow<PermissionProgress> = _permissionProgress.asStateFlow()
426 /** Max number of permissions simultaneously pending since the queue was last empty. Reset
427 * to 0 when the queue drains, so a fresh burst starts its count over. */
428 private var maxPendingSeen = 0
430 // Unanswered permission requests keyed by id, insertion-ordered. The dialog shows the most
431 // recent; when one is answered/replied — or a response fails — we fall back to the next so a
432 // request that arrived while another was in flight isn't silently dropped (its tool run would
433 // otherwise stay paused server-side with no UI affordance). Only touched on the VM's main
434 // dispatcher (event collector + viewModelScope launches), so no extra synchronization is needed.
435 private val pendingPermissions = LinkedHashMap<String, Permission>()
437 // Session-scoped permission grants (Allow-for-this-session). Each entry is a (type, patternText)
438 // pair the user approved with PermissionResponse.SESSION. Subsequent matching requests are
439 // auto-responded ONCE without showing the dialog, so e.g. a user granting "read" for one path
440 // isn't re-prompted for every other file read in the same conversation. Cleared on connection
441 // change (see the activeConnection collector) so it never leaks across servers. The server only
442 // knows once/always/reject; SESSION is a client-side scope that maps to ONCE on the wire.
443 private val sessionAllowed = mutableSetOf<Pair<String, String>>()
445 private fun enqueuePermission(permission: Permission) {
446 // Auto-respond to a permission whose (type, pattern) the user previously granted for this
447 // session, instead of showing the dialog again. Resolved immediately (not via the queue) so
448 // the tool run isn't paused waiting for a dialog the user will never see.
449 val type = permission.type.orEmpty()
450 val pattern = permission.patternText.orEmpty()
451 if (type.isNotEmpty() && sessionAllowed.contains(type to pattern)) {
452 respondPermission(permission, PermissionResponse.ONCE)
455 pendingPermissions[permission.id] = permission
456 _pendingPermission.value = pendingPermissions.values.lastOrNull()
457 updatePermissionProgress()
460 private fun resolvePermission(id: String) {
461 pendingPermissions.remove(id)
462 _pendingPermission.value = pendingPermissions.values.lastOrNull()
463 updatePermissionProgress()
466 private fun clearPermissions() {
467 pendingPermissions.clear()
468 _pendingPermission.value = null
469 updatePermissionProgress()
472 /** Recompute the "N of M" backlog indicator. [maxPendingSeen] grows with the queue and
473 * resets only when it drains, so position = total - remaining + 1 advances as requests are
475 private fun updatePermissionProgress() {
476 val remaining = pendingPermissions.size
477 if (remaining == 0) {
479 _permissionProgress.value = PermissionProgress(0, 0)
482 if (remaining > maxPendingSeen) maxPendingSeen = remaining
483 _permissionProgress.value = PermissionProgress(
484 position = maxPendingSeen - remaining + 1,
485 total = maxPendingSeen,
489 private val _agents = MutableStateFlow<List<Agent>>(emptyList())
490 val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
492 private val _agentsLoading = MutableStateFlow(true)
493 val agentsLoading: StateFlow<Boolean> = _agentsLoading.asStateFlow()
495 private val _agentsError = MutableStateFlow(false)
496 val agentsError: StateFlow<Boolean> = _agentsError.asStateFlow()
498 private val _selectedAgent = MutableStateFlow<String?>(null)
499 val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
501 private val _commands = MutableStateFlow<List<Command>>(emptyList())
502 val commands: StateFlow<List<Command>> = _commands.asStateFlow()
504 private val _commandsLoading = MutableStateFlow(true)
505 val commandsLoading: StateFlow<Boolean> = _commandsLoading.asStateFlow()
507 private val _commandsError = MutableStateFlow(false)
508 val commandsError: StateFlow<Boolean> = _commandsError.asStateFlow()
510 private val _sessionTitle = MutableStateFlow<String?>(null)
511 val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
513 /** Set when the current session is deleted via SSE, so the UI can navigate away. */
514 private val _sessionDeleted = MutableStateFlow(false)
515 val sessionDeleted: StateFlow<Boolean> = _sessionDeleted.asStateFlow()
517 /** The text of the last send that failed, surfaced so the UI can offer a retry. */
518 private val _failedDraft = MutableStateFlow<String?>(null)
519 val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
521 /** The idempotency key of the last failed online send, stashed alongside [_failedDraft] so
522 * [retryFailed] re-submits with the SAME key. A retry after a lost response is then
523 * deduplicated server-side instead of starting a duplicate agent run. */
524 @Volatile private var failedIdempotencyKey: String? = null
526 /** Per-session draft, persisted so it survives navigation/process death. */
527 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
528 val draft: StateFlow<String> = _draft.asStateFlow()
530 /** Messages composed for this session while offline/disconnected, queued in the outbox and
531 * awaiting an automatic flush on reconnect. Surfaced so the composer can show a "queued"
532 * chip with a discard/send-now affordance. */
533 val outbox: StateFlow<List<OutboxMessage>> =
534 container.outboxStore.messages
535 .map { list -> list.filter { it.sessionId == sessionId } }
537 scope = viewModelScope,
538 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
539 initialValue = emptyList(),
542 /** Attachments staged for the next prompt (images/files). Persisted per-session (as their
543 * self-contained base64 data URLs) via [AttachmentDraftStore] so an interrupted compose
544 * survives process death — the source content Uris wouldn't survive, but the data URLs
545 * do. Cleared on a successful send. */
546 private val _attachments = MutableStateFlow<List<PendingAttachment>>(emptyList())
547 val attachments: StateFlow<List<PendingAttachment>> = _attachments.asStateFlow()
549 /** True while a revert checkpoint is active for this session (messages after it are hidden
550 * server-side). Drives the "reverted" banner with its Undo. */
551 private val _reverted = MutableStateFlow(false)
552 val reverted: StateFlow<Boolean> = _reverted.asStateFlow()
554 /** The diff of the active revert checkpoint, when the server includes one. Shown in the
555 * revert banner so the user can see what changed before deciding to undo. Null when the
556 * session isn't reverted or the server didn't send a diff. */
557 private val _revertDiff = MutableStateFlow<String?>(null)
558 val revertDiff: StateFlow<String?> = _revertDiff.asStateFlow()
560 /** The active public share URL for this session, or null when not shared. */
561 private val _shareUrl = MutableStateFlow<String?>(null)
562 val shareUrl: StateFlow<String?> = _shareUrl.asStateFlow()
564 /** One-shot events carrying a freshly-created share URL so the UI can copy/share it. */
565 private val _shareLinkEvents = Channel<String>(
566 capacity = NetworkConfig.snackbarEventBufferCapacity,
567 onBufferOverflow = BufferOverflow.DROP_OLDEST,
569 val shareLinkEvents: Flow<String> = _shareLinkEvents.receiveAsFlow()
571 /** One-shot events carrying the id of a session freshly branched from a message, so the
572 * UI can navigate to it. */
573 private val _branchEvents = Channel<String>(
574 capacity = NetworkConfig.snackbarEventBufferCapacity,
575 onBufferOverflow = BufferOverflow.DROP_OLDEST,
577 val branchEvents: Flow<String> = _branchEvents.receiveAsFlow()
580 // DraftStore loads SharedPreferences asynchronously; on a cold start the
581 // synchronous get() above returns "" until the background load completes.
582 // Observe the ready signal and re-seed the draft so it appears in the UI.
583 viewModelScope.launch {
584 container.draftStore.ready.collect { ready ->
585 if (!ready) return@collect
586 if (_draft.value.isEmpty()) {
587 _draft.value = container.draftStore.get(sessionId)
589 // Recover a follow-up queued before process death (its in-memory StateFlow
590 // is gone). If the input is now free, drop it back there for review/send and
591 // consume the stored copy; otherwise re-queue it so it auto-sends when the
592 // current run next idles. Guarded on == null so it never clobbers a
593 // follow-up the user queued after opening this session.
594 val savedFollowUp = container.draftStore.getFollowUp(sessionId)
595 if (savedFollowUp.isNotEmpty() && _queuedFollowUp.value == null) {
596 if (_draft.value.isEmpty()) {
597 _draft.value = savedFollowUp
598 container.draftStore.flushFollowUp(sessionId, "")
600 _queuedFollowUp.value = savedFollowUp
605 // Observe the drafts store for external mutations to this session's draft
606 // (e.g. a share injected via draftStore.set in two-pane mode, or setImmediate
607 // before navigation in single-pane mode). Without this, a share injected into
608 // an already-open session never appears in the input field — the init-time
609 // read and the ready-only re-seed miss it when the draft is non-empty.
610 // The VM's own debounced persist writes the same value _draft already holds,
611 // so those updates are no-ops here (storeValue == _draft.value). The
612 // suppressDraftPersist guard prevents a stale store value from clobbering
613 // the deliberate draft clear during a send (the store still holds the
614 // pre-send draft until the send resolves).
615 viewModelScope.launch {
616 container.draftStore.drafts.collect { drafts ->
617 // Ignore emissions until the initial disk load completes: the load itself
618 // emits the persisted snapshot, and applying it here would clobber text the
619 // user typed during the async load (the `ready` observer above seeds an
620 // empty draft). After ready, emissions are external writes.
621 if (!container.draftStore.ready.value) return@collect
622 val storeValue = drafts[sessionId].orEmpty()
623 // Ignore this VM's own (debounced, possibly stale) persistence echo — only a
624 // genuine external write (e.g. a two-pane share injection) should overwrite
626 if (storeValue == lastPersistedDraft) return@collect
627 if (storeValue != _draft.value && !suppressDraftPersist.get()) {
628 _draft.value = storeValue
635 // Restore attachments staged before process death. They were persisted as
636 // self-contained data URLs, so they re-send and (for images) re-preview intact.
637 // Guarded on empty so it never clobbers attachments the user staged after opening.
638 viewModelScope.launch {
639 val restored = runCatchingCancellable { container.attachmentDraftStore.load(sessionId) }
640 .getOrDefault(emptyList())
641 if (restored.isNotEmpty() && _attachments.value.isEmpty()) {
642 _attachments.value = restored.map { it.toPending() }
647 fun selectModel(option: ModelOption) { _selectedModel.value = option }
649 fun selectAgent(name: String?) { _selectedAgent.value = name }
652 val conn = connection ?: return
653 viewModelScope.launch {
654 conn.api.invalidateProvidersCache()
655 _modelsReload.value++
660 val conn = connection ?: return
661 viewModelScope.launch {
662 conn.api.invalidateAgentsCache()
663 _agentsReload.value++
667 fun reloadCommands() {
668 val conn = connection ?: return
669 viewModelScope.launch {
670 conn.api.invalidateCommandsCache()
671 _commandsReload.value++
676 * Shared helper for init-block catalog observers: re-fetches whenever the active
677 * connection or the catalog's reload trigger changes, invoking [onNull] to clear
678 * state on null so stale data from the old server doesn't persist. The
679 * [reloadTrigger] is merged with [activeConnection] so a manual reload (via
680 * reloadModels/reloadAgents/reloadCommands) cancels any in-flight observe fetch
681 * via collectLatest, preventing a stale observe result from overwriting a fresh
682 * reload result. Failure is non-fatal — the error flag is surfaced to the UI.
684 private fun <T> observeCatalog(
686 loading: MutableStateFlow<Boolean>,
687 error: MutableStateFlow<Boolean>,
688 fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
689 onSuccess: (T) -> Unit,
691 reloadTrigger: StateFlow<Int>,
693 viewModelScope.launch {
694 merge(container.activeConnection, reloadTrigger).collectLatest { _ ->
695 val conn = container.activeConnection.value
696 if (conn == null) { onNull(); loading.value = false; error.value = false; return@collectLatest }
699 runCatchingCancellable { fetch(conn.api) }
700 // fetch() does HTTP via withRetry, so the failure can be a
701 // ClientRequestException whose message embeds the full request URL
702 // (may contain auth/paths). Log only a scrubbed summary.
703 .onFailure { Log.w("ChatViewModel", "Failed to load $tag: ${safeExceptionSummary(it)}"); error.value = true }
704 .getOrNull()?.let(onSuccess)
705 loading.value = false
710 fun updateDraft(text: String) {
715 // Debounce draft persistence so we don't write to disk on every keystroke.
716 // Skip persisting empty drafts that were set by send() (suppressed via
717 // suppressDraftPersist) — send() clears the in-memory draft immediately for
718 // UI feedback but deliberately doesn't persist the clear until the send
719 // succeeds, so a failed send can restore the draft.
720 viewModelScope.launch {
721 _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
722 if (text.isBlank() && suppressDraftPersist.get()) return@collect
723 lastPersistedDraft = text
724 runCatchingCancellable { container.draftStore.set(sessionId, text) }
725 .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
728 // Reset per-connection state when the active server changes so stale spinners,
729 // permission dialogs, errors, and agent selections from the old server don't
730 // persist into the new one.
731 viewModelScope.launch {
732 container.activeConnection.collectLatest { conn ->
734 // The connection dropped (e.g. disconnect() mid-run). No SSE stream will
735 // arrive to deliver SessionIdle/SessionError, so reset the run state here —
736 // otherwise the working spinner sticks on, ChatScreen keeps keepScreenOn +
737 // the RunForegroundService alive, and the Stop button no-ops (abort() early-
738 // returns with no connection) until a later reconnect happens to clear it.
739 _running.value = false
740 _aborting.value = false
742 sessionAllowed.clear()
743 sessionTitleJob?.cancel()
744 _revertDiff.value = null
747 _running.value = false
748 // Allow the re-light below to recover a still-running run after this (re)connect;
749 // a run that actually finished during the outage emits no live parts, so this
750 // won't spuriously re-light.
751 runEndedByIdle = false
753 sessionAllowed.clear()
754 _failedDraft.value = null
755 _revertDiff.value = null
756 // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
757 // user intent that is now persisted; wiping it on every (re)connect — which
758 // this collector does, including transient SSE drops and the cold-start
759 // restore path — would drop a legitimately queued/recovered follow-up.
760 _selectedAgent.value = null
761 _sessionTitle.value = null
762 // Resolve the app-bar title via REST for THIS connection. Launched from the same
763 // block that just nulled _sessionTitle (and cancelled the previous fetch) so it can
764 // never observe or restore the previous server's stale title — the old separate
765 // collector could, racing that reset. The assignment is guarded on a still-null
766 // title so a fresher SSE SessionUpdated (from the event collector below) wins.
767 sessionTitleJob?.cancel()
768 sessionTitleJob = viewModelScope.launch {
769 runCatchingCancellable { conn.repository.listSessions() }
771 ?.firstOrNull { it.id == sessionId }
773 if (_sessionTitle.value == null) {
774 _sessionTitle.value = session.displayTitle
775 _reverted.value = session.isReverted
776 _revertDiff.value = session.revert?.diff?.takeIf { it.isNotBlank() }
777 _shareUrl.value = session.share?.url?.takeIf { it.isNotBlank() }
782 // Loop the inner collect so a transient non-cancellation exception (a
783 // malformed SSE event decoding error, an unexpected channel close)
784 // doesn't terminate the event collector for this connection. Without
785 // the loop, the catch returns the collectLatest lambda normally, so
786 // collectLatest considers this connection's block complete and never
787 // re-runs it — the chat would go permanently silent (no streaming,
788 // no permission prompts, no title updates) until a server switch.
789 while (currentCoroutineContext().isActive) {
791 conn.events.events.collect { event ->
792 if (SessionRepository.isIdle(event, sessionId)) {
793 _running.value = false
794 runEndedByIdle = true
795 // Auto-send a queued follow-up once the previous run finishes,
796 // so the user's drafted-while-running message isn't lost.
797 val queued = _queuedFollowUp.value
798 if (queued != null) {
799 setQueuedFollowUp(null)
800 send(queued, includeAttachments = false)
803 if (SessionRepository.isError(event, sessionId)) {
804 _running.value = false
805 runEndedByIdle = true
806 // The run errored, so the queued follow-up won't auto-send.
807 // Restore it to the input (if free) so the user's typed text
808 // isn't silently lost; otherwise just clear the queue.
809 val queued = _queuedFollowUp.value
810 if (!queued.isNullOrEmpty() && _draft.value.isEmpty()) {
811 _draft.value = queued
813 setQueuedFollowUp(null)
814 _errorEvents.trySend(ChatError(container.string(R.string.error_agent_reported)))
816 // Restore the run indicator if a live streaming event arrives while it's
817 // cleared — e.g. an SSE reconnect mid-run reset _running to false, but the
818 // agent is still working. Don't revive it once the user has hit Stop.
819 val relightRunIndicator = SessionRepository.isRunActivity(event, sessionId) &&
820 !_aborting.value && !_running.value && !runEndedByIdle
821 if (relightRunIndicator) {
822 _running.value = true
825 is PermissionUpdated ->
826 if (event.properties.sessionID == sessionId) enqueuePermission(event.properties)
827 is PermissionReplied ->
828 if (event.properties.sessionID == sessionId) event.properties.permissionID?.let { resolvePermission(it) }
830 if (event.properties.info.id == sessionId) {
831 val info = event.properties.info
832 _sessionTitle.value = info.displayTitle
833 _reverted.value = info.isReverted
834 _revertDiff.value = info.revert?.diff?.takeIf { it.isNotBlank() }
835 _shareUrl.value = info.share?.url?.takeIf { it.isNotBlank() }
838 if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
839 _sessionDeleted.value = true
844 } catch (e: CancellationException) {
846 } catch (e: Exception) {
847 Log.w("ChatViewModel", "SSE event collector error, restarting", e)
850 } catch (e: CancellationException) {
852 } catch (e: Exception) {
853 Log.w("ChatViewModel", "SSE event collector loop error", e)
857 // Load the model catalog; preselect the server default. Failure is non-fatal —
858 // sending with no model just uses the server's default agent/model.
860 tag = "model catalog",
861 loading = _modelsLoading,
862 error = _modelsError,
863 fetch = { it.providers() },
864 onSuccess = { resp ->
865 val options = resp.toOptions()
866 _models.value = options
867 // Only (re)apply the server default when there's no valid current
868 // selection. On re-fetch/reconnect/Retry the user's chosen model must
869 // survive — keep it as long as it's still present in the refreshed list.
870 val current = _selectedModel.value
871 if (current == null || options.none { it.ref == current.ref }) {
872 _selectedModel.value = resp.defaultOption(options)
875 onNull = { _models.value = emptyList(); _selectedModel.value = null },
876 reloadTrigger = _modelsReload,
878 // Load the agent catalog (non-fatal).
880 tag = "agent catalog",
881 loading = _agentsLoading,
882 error = _agentsError,
883 fetch = { it.agents() },
884 onSuccess = { _agents.value = it },
885 onNull = { _agents.value = emptyList() },
886 reloadTrigger = _agentsReload,
888 // Load the command catalog (non-fatal).
890 tag = "command catalog",
891 loading = _commandsLoading,
892 error = _commandsError,
893 fetch = { it.commands() },
894 onSuccess = { _commands.value = it },
895 onNull = { _commands.value = emptyList() },
896 reloadTrigger = _commandsReload,
898 // If the SSE stream drops mid-run, the run indicator would spin forever;
899 // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
900 viewModelScope.launch {
901 container.activeConnection.collectLatest { conn ->
902 if (conn == null) return@collectLatest
904 // Loop so a transient non-cancellation exception doesn't terminate the
905 // state watcher for this connection. Without the loop, a single thrown
906 // error returns the collectLatest lambda and the _running reset on
907 // Disconnected/Failed never fires again for this connection — a dropped
908 // stream leaves the working spinner (and the RunForegroundService) stuck.
909 while (currentCoroutineContext().isActive) {
911 conn.events.state.collect { state ->
912 val isDown = state == EventStreamClient.ConnectionState.Disconnected ||
913 state == EventStreamClient.ConnectionState.Failed ||
914 state == EventStreamClient.ConnectionState.AuthFailed
915 if (isDown && _running.value) {
916 _running.value = false
919 } catch (e: CancellationException) {
921 } catch (e: Exception) {
922 Log.w("ChatViewModel", "SSE state collector error, restarting", e)
925 } catch (e: CancellationException) {
927 } catch (e: Exception) {
928 Log.w("ChatViewModel", "SSE state collector loop error", e)
934 /** Sends [text] together with any staged attachments; returns true on success so the
935 * caller can clear the draft only then. */
936 fun send(text: String): Boolean = send(text, includeAttachments = true)
939 * Core send. [includeAttachments] is false for the internal auto-send paths (a queued
940 * follow-up, a retry) so staged attachments aren't silently re-sent with unrelated text.
942 private fun send(text: String, includeAttachments: Boolean, idempotencyKey: String? = null): Boolean {
943 val trimmed = text.trim()
944 val attachments = if (includeAttachments) _attachments.value else emptyList()
945 // An image-only prompt (attachments, no text) is valid; a blank prompt with nothing
947 if (trimmed.isEmpty() && attachments.isEmpty()) return false
948 // Offline, or no active connection: queue the prompt to the outbox (flushed on
949 // reconnect) instead of failing the send. Device-offline is caught even when a stale
950 // connection object lingers, so a phone that lost signal mid-conversation still
951 // captures the message rather than bouncing it off a dead socket.
952 val conn = connection
953 if (conn == null || !container.isOnline.value) {
954 return enqueueOffline(trimmed, attachments)
956 if (!beginRun()) return false
957 // A new run is starting: reopen the re-light gate so this run's streamed parts keep the
958 // indicator lit (and a later SessionIdle re-closes it).
959 runEndedByIdle = false
960 // A plain manual Send of the auto-restored failed draft must reuse the stashed key too
961 // (retryFailed passes it explicitly; the composer's Send does not), so a lost-response
962 // re-send stays deduplicated server-side instead of starting a duplicate agent run.
963 // Capture the match BEFORE clearing _failedDraft just below.
964 val reuseKey = if (idempotencyKey == null && trimmed == _failedDraft.value?.trim()) {
969 _failedDraft.value = null
970 // Stable idempotency key for THIS online attempt: a brand-new message gets a fresh key,
971 // while retryFailed() (or a manual re-send of the restored failed draft) re-submits with the
972 // key it stashed on the original failure — so a retry after a lost response is deduplicated
973 // server-side (no duplicate agent run).
974 val key = idempotencyKey ?: reuseKey ?: java.util.UUID.randomUUID().toString()
975 // Clear the staged attachments optimistically so the composer empties immediately;
976 // restore them if the send fails (mirrors the draft handling below).
977 if (attachments.isNotEmpty()) _attachments.value = emptyList()
978 // Clear the in-memory draft for the UI immediately, but don't persist the clear
979 // yet — if the send fails and the process dies before we restore, the draft
980 // would be lost forever. The persisted draft is cleared only on success.
981 // suppressDraftPersist prevents the debounced collector from persisting the
982 // empty draft before the send resolves.
984 // Only clear when the text being sent IS the current draft. send() is also
985 // invoked for auto-sent queued follow-ups (SessionIdle) and retryFailed(), where
986 // the user may have typed a NEW draft since — wiping it would lose that text.
987 if (_draft.value.trim() == trimmed) {
988 suppressDraftPersist.set(true)
991 // Inject an optimistic user message so the outgoing prompt is visible immediately,
992 // before the server echoes it back via SSE. Removed by reconcileOptimistic once the
993 // real UserMessage arrives. Only for text-bearing prompts (an image-only prompt has
995 val optimisticId = if (trimmed.isNotEmpty()) "__optimistic_${System.nanoTime()}" else null
996 if (optimisticId != null) {
997 // Clear any prior failed optimistic entry with the same text (a retry re-sends).
998 _optimisticMessages.update { entries -> entries.filterNot { it.failed && it.text.trim() == trimmed } }
999 _optimisticMessages.update { it + OptimisticEntry(optimisticId, trimmed, System.currentTimeMillis(), failed = false) }
1001 viewModelScope.launch {
1002 runCatchingCancellable {
1003 conn.repository.sendPrompt(
1006 attachments = attachments.map { it.part },
1007 model = _selectedModel.value?.ref,
1008 agent = _selectedAgent.value,
1009 idempotencyKey = key,
1012 // Mark the optimistic message as failed so the user sees a failure indicator
1013 // instead of a perpetual "sending" state.
1014 if (optimisticId != null) {
1015 _optimisticMessages.update { entries ->
1016 entries.map { if (it.tempId == optimisticId) it.copy(failed = true) else it }
1019 suppressDraftPersist.set(false)
1020 _failedDraft.value = trimmed
1021 failedIdempotencyKey = key
1022 // Only restore the draft if the user hasn't typed anything new since.
1023 if (_draft.value.isBlank()) updateDraft(trimmed)
1024 // Restore the staged attachments too, so a failed send doesn't lose them —
1025 // but only if the user hasn't staged new ones in the meantime.
1026 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
1027 // Re-persist so the restored attachments survive process death after a failure.
1028 persistAttachments()
1029 // Retryable: this is the failed send whose prompt Retry re-submits.
1030 _errorEvents.trySend(ChatError(container.friendlyError(it), retryable = true))
1031 _running.value = false
1033 suppressDraftPersist.set(false)
1034 // This attempt reached the server, so the stashed retry key is spent — drop it so a
1035 // later unrelated send can't reuse it (retryFailed clears it on success too).
1036 failedIdempotencyKey = null
1037 // Send succeeded — the attachments rode along, so clear their persisted copy
1038 // (or keep any the user staged after the optimistic clear).
1039 persistAttachments()
1040 // Send succeeded — persist the cleared draft, but only if the user hasn't
1041 // typed a new one while the send was in flight. Otherwise clearing the
1042 // store echoes back through the draft observer and wipes the in-progress
1043 // text (data loss). Mirrors the guard on the failure path above.
1044 if (_draft.value.isBlank()) {
1045 lastPersistedDraft = ""
1046 container.draftStore.set(sessionId, "")
1048 // Don't reset _running here: the agent continues streaming via SSE.
1049 // _running is cleared on SessionIdle/SessionError (see event collector)
1050 // or when the SSE stream drops (see connection state watcher below).
1057 * Queue [trimmed] (+ [attachments]) to the offline outbox, clearing the composer
1058 * optimistically like a real send. AppContainer flushes the outbox automatically on
1059 * reconnect. The prompt is attributed to the active (or most-recent) server profile so a
1060 * reconnect to a different server doesn't misfire it. On any failure the draft/attachments
1061 * are restored so nothing is lost.
1063 private fun enqueueOffline(trimmed: String, attachments: List<PendingAttachment>): Boolean {
1064 _failedDraft.value = null
1065 if (attachments.isNotEmpty()) _attachments.value = emptyList()
1066 val clearDraft = _draft.value.trim() == trimmed
1068 suppressDraftPersist.set(true)
1071 viewModelScope.launch {
1072 val profileId = connection?.profile?.id
1073 ?: runCatchingCancellable { container.profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
1074 if (profileId == null) {
1075 // No server to attribute the message to — restore the composer and report it
1076 // rather than silently dropping the prompt.
1077 suppressDraftPersist.set(false)
1078 if (_draft.value.isBlank()) updateDraft(trimmed)
1079 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
1080 persistAttachments()
1081 _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
1084 runCatchingCancellable {
1085 container.outboxStore.enqueue(
1087 id = java.util.UUID.randomUUID().toString(),
1088 profileId = profileId,
1089 sessionId = sessionId,
1091 attachments = attachments.map { it.toPersisted() },
1092 model = _selectedModel.value?.ref,
1093 agent = _selectedAgent.value,
1094 createdAt = System.currentTimeMillis(),
1098 suppressDraftPersist.set(false)
1099 // The staged attachments now live in the outbox item; clear their draft copy.
1100 persistAttachments()
1101 if (_draft.value.isBlank()) {
1102 lastPersistedDraft = ""
1103 container.draftStore.set(sessionId, "")
1105 // Nudge a flush in case we're actually online with a live connection.
1106 container.flushOutbox()
1107 _errorEvents.trySend(ChatError(container.string(R.string.outbox_queued)))
1109 suppressDraftPersist.set(false)
1110 if (_draft.value.isBlank()) updateDraft(trimmed)
1111 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
1112 persistAttachments()
1113 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1119 /** Discard a single queued (offline) message. */
1120 fun discardQueued(id: String) {
1121 viewModelScope.launch { runCatchingCancellable { container.outboxStore.remove(id) } }
1124 /** Discard every queued (offline) message for this session. */
1125 fun discardAllQueued() {
1126 viewModelScope.launch { runCatchingCancellable { container.outboxStore.removeForSession(sessionId) } }
1129 /** Attempt to flush the outbox now (e.g. the user tapped "Send now"). */
1130 fun flushQueued() { container.flushOutbox() }
1132 /** Re-send the last draft whose send failed, if any. */
1134 val draft = _failedDraft.value ?: return
1135 // Reuse the failed send's idempotency key so a retry after a lost response is
1136 // deduplicated server-side rather than starting a duplicate run.
1137 val key = failedIdempotencyKey
1138 // Don't clear _failedDraft until send() accepts the text — if _running is
1139 // already true, send() returns false and the draft would be lost forever.
1140 // Attachments aren't re-sent on retry (they were optimistically restored to the
1141 // composer on the original failure, so they'll ride the next manual send).
1142 if (send(draft, includeAttachments = false, idempotencyKey = key)) {
1143 _failedDraft.value = null
1144 failedIdempotencyKey = null
1148 /** Dismiss a single failed optimistic message by its tempId so it no longer lingers in the
1149 * list. Without this there's no recovery path for a failed message the user chose to abandon
1150 * (its text won't ever match a real server message, so reconcileOptimistic never clears it). */
1151 fun dismissOptimistic(tempId: String) {
1152 _optimisticMessages.update { entries -> entries.filterNot { it.tempId == tempId } }
1155 /** Re-send a specific failed optimistic message by its tempId. Loads its text into the
1156 * composer and fires a send, mirroring the snackbar's Retry path. Returns false (and is a
1157 * no-op) if the entry can't be found or a run is already active. */
1158 fun retryOptimisticMessage(tempId: String): Boolean {
1159 val entry = _optimisticMessages.value.firstOrNull { it.tempId == tempId && it.failed } ?: return false
1160 return send(entry.text, includeAttachments = false)
1164 * Queue [text] to be sent automatically when the current run finishes, or clear any
1165 * queued follow-up when [text] is blank. Used when the user taps Send while a run
1166 * is already active — the Send button is replaced by Stop during a run, but the
1167 * input field stays enabled, so a follow-up typed mid-run would otherwise have
1170 fun queueFollowUp(text: String) {
1171 val trimmed = text.trim()
1172 setQueuedFollowUp(trimmed.takeIf { it.isNotEmpty() })
1173 // The text now lives in the queued chip, so clear the input field just as a
1174 // normal send would. No-op for the cancel case (blank text), which must leave
1175 // whatever the user has since typed untouched.
1176 if (trimmed.isNotEmpty()) {
1178 // Persist the cleared draft IMMEDIATELY, not just in memory: the follow-up was already
1179 // persisted synchronously above, so if the process is killed before the debounced
1180 // draft-persist fires, the same text would remain under BOTH the draft and follow-up
1181 // keys — and cold-start recovery would restore it to the composer AND re-queue it (a
1182 // duplicate). Mirror send()'s lastPersistedDraft update so the drafts observer treats
1183 // this store change as our own echo and doesn't write the cleared value back.
1184 lastPersistedDraft = ""
1185 container.draftStore.flushDraft(sessionId, "")
1189 /** Set or clear the queued follow-up and mirror it to persistence, so a follow-up
1190 * queued mid-run survives process death (recovered on the next open of this session).
1191 * Passing null / blank clears both the in-memory value and the persisted copy. */
1192 private fun setQueuedFollowUp(text: String?) {
1193 _queuedFollowUp.value = text
1194 container.draftStore.flushFollowUp(sessionId, text.orEmpty())
1197 /** Transient flag set by [refreshMessages] so the top-bar refresh icon can show
1198 * a brief spinner as immediate tap feedback. The SSE reconnect triggered by
1199 * refreshMessages may not visibly change [connectionState] (it's already
1200 * Connected), so without this the tap appears to do nothing. Clears after a
1201 * short delay or when the connection state next becomes Connected. */
1202 private val _refreshing = MutableStateFlow(false)
1203 val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
1205 /** Manually re-fetch messages by forcing the SSE stream to reconnect, which
1206 * triggers the repository's re-seed-from-REST logic. Gives the user a recovery
1207 * path when the SSE stream silently drops and the auto-reconnect re-seed is slow
1208 * or fails — a pull-to-refresh or top-bar refresh forces a fresh fetch under the
1209 * existing generation-guarded merge. Also clears [loadError] optimistically and
1210 * sets [refreshing] for immediate tap feedback. */
1211 fun refreshMessages() {
1212 val conn = connection ?: return
1213 _loadError.value = false
1214 _refreshing.value = true
1215 conn.events.triggerReconnect()
1216 viewModelScope.launch {
1217 delay(NetworkConfig.refreshFeedbackMs)
1218 _refreshing.value = false
1222 /** Invoke a slash-command by name via the server's /command endpoint. */
1223 fun runCommand(command: Command) {
1224 val conn = connection ?: return
1225 // A run is already in flight — surface feedback instead of silently dropping the
1226 // command (the picker stays openable during a run and dismisses on select).
1228 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1231 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1232 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1233 runEndedByIdle = false
1234 viewModelScope.launch {
1235 runCatchingCancellable {
1236 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
1238 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1239 _running.value = false
1245 val conn = connection ?: return
1246 // Drop any queued follow-up so the SessionIdle that follows the abort doesn't
1247 // auto-send it — the user tapped Stop to halt work, not to trigger the next turn.
1248 setQueuedFollowUp(null)
1249 if (!_aborting.compareAndSet(false, true)) return
1250 viewModelScope.launch {
1252 // Run the abort POST under NonCancellable so it survives this scope being
1253 // cancelled. The "Stop and exit" path calls abort() then navigates back
1254 // immediately, which pops this ViewModel's back-stack entry → onCleared() →
1255 // viewModelScope.cancel(); without this the in-flight abort is cancelled (often
1256 // before the request even reaches the server) and the run keeps burning tokens
1257 // server-side despite the user explicitly stopping it.
1258 runCatchingCancellable { withContext(NonCancellable) { conn.repository.abort(sessionId) } }
1259 .onSuccess { _running.value = false }
1260 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1262 _aborting.value = false
1267 fun addAttachment(attachment: PendingAttachment) {
1268 // Enforce the cumulative size cap across the whole staged set (the per-file size and the
1269 // count caps are checked upstream). The base64 data URLs are held in memory and
1270 // re-serialized on every add/remove and again when the request body is built, so an
1271 // unbounded set OOMs. Reject rather than add, mirroring the other attachment limits'
1272 // snackbar via errorEvents. Size is the raw (pre-base64) byte count, matching the
1273 // per-file check, and includes attachments already staged (incl. restored ones).
1274 val staged = _attachments.value.sumOf { base64DataUrlByteSize(it.part.url) }
1275 if (staged + base64DataUrlByteSize(attachment.part.url) > NetworkConfig.maxTotalAttachmentBytes) {
1276 _errorEvents.trySend(ChatError(container.string(R.string.attachment_limit)))
1279 _attachments.update { it + attachment }
1280 persistAttachments()
1283 fun removeAttachment(id: String) {
1284 _attachments.update { list -> list.filterNot { it.id == id } }
1285 persistAttachments()
1288 fun clearAttachments() {
1289 _attachments.value = emptyList()
1290 persistAttachments()
1293 /** Persist the current staged attachments so an interrupted compose survives process
1294 * death (mirrors draft persistence). Fire-and-forget on [viewModelScope]. */
1295 private fun persistAttachments() {
1296 val snapshot = _attachments.value
1297 viewModelScope.launch {
1298 runCatchingCancellable { container.attachmentDraftStore.save(sessionId, snapshot.map { it.toPersisted() }) }
1299 .onFailure { Log.w("ChatViewModel", "Failed to persist attachments", it) }
1303 /** Revert the conversation to just before [messageId], hiding everything after it. The
1304 * authoritative state also arrives via SessionUpdated; we flag it here for immediate
1306 fun revertTo(messageId: String) {
1307 val conn = connection ?: return
1308 viewModelScope.launch {
1309 runCatchingCancellable { conn.api.revert(sessionId, messageId) }
1311 _reverted.value = it.isReverted
1312 _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1314 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1319 * Regenerate an assistant reply: revert the conversation to that assistant message (removing
1320 * it and everything after), then re-send the user prompt that immediately preceded it. A
1321 * one-tap "try again" mirroring ChatGPT/Claude, instead of the two-step revert + re-Send.
1323 * Captures the preceding user text *before* the revert (the messages list updates
1324 * asynchronously via SSE once the revert lands). No-op if there's no preceding user prompt
1325 * or the assistant message isn't found.
1327 fun regenerate(assistantMessageId: String) {
1328 val conn = connection ?: return
1329 val msgs = messages.value
1330 val idx = msgs.indexOfFirst { it.info.id == assistantMessageId }
1332 val precedingUserText = (idx - 1 downTo 0)
1335 .firstOrNull { it.info is UserMessage }
1337 ?.filterIsInstance<TextPart>()
1338 ?.joinToString("\n\n") { it.text }
1339 ?.takeIf { it.isNotBlank() }
1341 viewModelScope.launch {
1342 runCatchingCancellable { conn.api.revert(sessionId, assistantMessageId) }
1343 .onSuccess { send(precedingUserText, includeAttachments = false) }
1344 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1348 /** Load a previously-sent user prompt back into the composer to edit and re-send.
1349 * Reverts the conversation to just before [messageId] (hiding it and everything after)
1350 * and prefills the draft with [text]. The next Send continues from that checkpoint,
1351 * effectively replacing the edited message; the reverted banner's Undo restores it.
1352 * Overwrites the current draft — this is an explicit edit action on that message. */
1353 fun editMessage(messageId: String, text: String) {
1355 _draft.value = text.take(NetworkConfig.maxDraftLengthChars)
1358 /** Prefill the composer with [text] as a Markdown blockquote so the user can respond to a
1359 * specific message inline. Appends to any existing draft (with a blank line) rather than
1360 * overwriting, so quoting doesn't discard text already being typed. */
1361 fun quoteReply(text: String) {
1362 val quoted = text.trim().lineSequence().joinToString("\n") { "> $it" }
1363 if (quoted.isBlank()) return
1364 val current = _draft.value
1365 val combined = if (current.isBlank()) "$quoted\n\n" else "$current\n\n$quoted\n\n"
1366 // Cap like the composer's typed/pasted/dictated input so quoting onto a near-full draft
1367 // can't push it past the limit (negative remaining counter + an over-cap prompt sent).
1368 _draft.value = combined.take(NetworkConfig.maxDraftLengthChars)
1371 /** Fork the conversation by creating a brand-new session seeded with [text] as its first
1372 * prompt (using the currently-selected model/agent). Emits the new session id via
1373 * [branchEvents] so the UI can navigate to it. There's no server-side "branch" endpoint,
1374 * so this starts a fresh session from the chosen prompt rather than copying history. */
1375 fun branchFrom(text: String) {
1376 val conn = connection ?: return
1377 val prompt = text.trim()
1378 if (prompt.isEmpty()) return
1379 viewModelScope.launch {
1380 runCatchingCancellable {
1381 val session = conn.repository.createSession(title = null)
1382 conn.repository.sendPrompt(
1385 model = _selectedModel.value?.ref,
1386 agent = _selectedAgent.value,
1390 .onSuccess { newId -> _branchEvents.trySend(newId) }
1391 .onFailure { _errorEvents.trySend(ChatError(container.string(R.string.branch_failed))) }
1395 /** Undo the active revert checkpoint, restoring the hidden messages. */
1397 val conn = connection ?: return
1398 viewModelScope.launch {
1399 runCatchingCancellable { conn.api.unrevert(sessionId) }
1401 _reverted.value = it.isReverted
1402 _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1404 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1408 /** Create (or fetch the existing) public share link. The URL is exposed via [shareUrl]
1409 * and also emitted once via [shareLinkEvents] so the UI can copy it to the clipboard. */
1410 fun shareSession() {
1411 val conn = connection ?: return
1412 viewModelScope.launch {
1413 runCatchingCancellable { conn.api.shareSession(sessionId) }
1414 .onSuccess { session ->
1415 val url = session.share?.url?.takeIf { it.isNotBlank() }
1416 _shareUrl.value = url
1417 if (url != null) _shareLinkEvents.trySend(url)
1419 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1423 /** Revoke the session's public share link. */
1424 fun unshareSession() {
1425 val conn = connection ?: return
1426 viewModelScope.launch {
1427 runCatchingCancellable { conn.api.unshareSession(sessionId) }
1428 .onSuccess { _shareUrl.value = it.share?.url?.takeIf { it.isNotBlank() } }
1429 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1433 /** Compact the conversation via the summarize endpoint; the summary streams back via SSE.
1434 * Uses the currently-selected model (required by the endpoint). */
1436 val conn = connection ?: return
1437 val model = _selectedModel.value?.ref ?: run {
1438 _errorEvents.trySend(ChatError(container.string(R.string.needs_model)))
1442 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1445 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1446 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1447 runEndedByIdle = false
1448 viewModelScope.launch {
1449 runCatchingCancellable { conn.api.summarize(sessionId, model) }
1451 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1452 _running.value = false
1457 /** Analyze the project and (re)generate its AGENTS.md; the run streams back via SSE. */
1459 val conn = connection ?: return
1461 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1464 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1465 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1466 runEndedByIdle = false
1467 viewModelScope.launch {
1468 runCatchingCancellable { conn.api.initSession(sessionId) }
1470 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1471 _running.value = false
1476 /** Run a one-off shell [command] in the session's worktree; output streams back via SSE.
1477 * The server requires an agent to scope the run — use the selected one, else the primary. */
1478 fun runShell(command: String) {
1479 val conn = connection ?: return
1480 val cmd = command.trim()
1481 if (cmd.isEmpty()) return
1482 val agent = _selectedAgent.value
1483 ?: _agents.value.firstOrNull { it.isPrimary }?.name
1484 ?: _agents.value.firstOrNull()?.name
1485 ?: DEFAULT_SHELL_AGENT
1487 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1490 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1491 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1492 runEndedByIdle = false
1493 viewModelScope.launch {
1494 runCatchingCancellable { conn.api.shell(sessionId, cmd, agent, _selectedModel.value?.ref) }
1496 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1497 _running.value = false
1502 /** Rename the current session via PATCH /session/:id. On success updates [sessionTitle]
1503 * so the top bar reflects the new name immediately. A failure surfaces as a snackbar;
1504 * the caller keeps the dialog open so the user can retry without retyping. */
1505 fun renameSession(newTitle: String) {
1506 val conn = connection ?: return
1507 val title = newTitle.trim()
1508 if (title.isEmpty() || title == _sessionTitle.value) return
1509 viewModelScope.launch {
1510 runCatchingCancellable { conn.api.updateSession(sessionId, title) }
1511 .onSuccess { _sessionTitle.value = it.displayTitle }
1512 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1516 /** Delete the current session via DELETE /session/:id. Schedules a deferred delete with an
1517 * undo window and signals [deleteUndoEvents] so the ChatScreen shows an Undo snackbar
1518 * *over the conversation the user is viewing* — they can cancel without first being
1519 * navigated back to the session list. The actual REST delete runs after
1520 * [NetworkConfig.undoDeleteDelayMs]; cancel via [cancelSessionDelete]. If the window
1521 * expires, the scheduled delete's onDeleted sets [sessionDeleted] so the UI navigates away.
1522 * A failure surfaces as a snackbar. */
1523 fun deleteSession() {
1524 val conn = connection ?: return
1525 container.scheduleSessionDelete(
1527 delayMs = NetworkConfig.undoDeleteDelayMs,
1528 // Navigate away only once the deferred delete actually commits — so an in-place Undo
1529 // before the window expires keeps the user on this conversation.
1530 onDeleted = { _sessionDeleted.value = true },
1531 onError = { _errorEvents.trySend(ChatError(container.friendlyError(it))) },
1533 _deleteUndoEvents.tryEmit(Unit)
1536 /** Cancel a delete scheduled by [deleteSession] (the in-chat Undo action). Returns true if the
1537 * delete was still pending (Undo succeeded). */
1538 fun cancelSessionDelete(): Boolean = container.cancelSessionDelete(sessionId)
1540 /** Reconnect to the most recently used server profile (used when the connection is gone). */
1542 if (container.activeConnection.value != null) return
1543 // Guard against concurrent reconnect calls: the user can tap the button
1544 // multiple times while _reconnecting is true, which would launch parallel
1545 // connect() coroutines that race to set the active connection, leaking the
1546 // intermediate connections.
1547 if (!_reconnecting.compareAndSet(false, true)) return
1548 viewModelScope.launch {
1550 val recent = runCatchingCancellable { container.profileStore.profiles.first() }
1551 .getOrDefault(emptyList())
1553 if (recent == null) {
1554 _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
1557 runCatchingCancellable {
1558 val conn = container.connect(recent)
1561 container.disconnect()
1562 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1565 _reconnecting.value = false
1570 fun respondPermission(permission: Permission, response: PermissionResponse) {
1571 val conn = connection ?: return
1572 // Record a session-scoped grant so subsequent matching requests are auto-answered.
1573 // SESSION maps to ONCE on the wire (the server has no session-scope concept); the
1574 // client-side set is what gives it the "for this session" semantics.
1575 if (response == PermissionResponse.SESSION) {
1576 val type = permission.type.orEmpty()
1577 val pattern = permission.patternText.orEmpty()
1578 if (type.isNotEmpty()) sessionAllowed.add(type to pattern)
1580 // Dismiss optimistically (revealing any queued request); permission.replied will confirm.
1581 // If the call fails we re-surface this request so the user can retry instead of being stuck
1582 // with a dismissed dialog and a tool run that's still paused server-side.
1583 resolvePermission(permission.id)
1584 viewModelScope.launch {
1585 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
1587 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1588 enqueuePermission(permission)
1593 private companion object {
1594 /** Fallback agent for the shell endpoint when none is selected and no agent catalog
1595 * loaded. opencode's default primary agent is "build". */
1596 const val DEFAULT_SHELL_AGENT = "build"
1599 override fun onCleared() {
1601 // Flush any pending debounced draft so it survives navigation. Uses an
1602 // asynchronous apply() (not a synchronous commit) so the main thread isn't
1603 // blocked on disk I/O — Android's SharedPreferences framework guarantees
1604 // pending apply() writes are flushed before the process exits.
1605 // Flush the pending draft — including an empty one. Persistence is otherwise debounced,
1606 // so if the user cleared the input and navigated back within the debounce window, the
1607 // debounce coroutine was cancelled with viewModelScope and the prefs still hold the
1608 // previous non-empty draft. flushDraft() removes the key when the text is blank, so
1609 // flushing commits the clear instead of resurrecting the deleted text.
1611 // BUT respect the send-in-flight guard: send() optimistically clears _draft to "" WITHOUT
1612 // persisting the clear so a failed send can restore the text. If the user taps Send then
1613 // immediately navigates back (cancelling the in-flight send), flushing the blank _draft here
1614 // would remove the persisted key and erase the pre-send draft with no recovery. Skip the
1615 // flush while a send is in flight so the pre-send draft stays persisted (recoverable).
1616 if (!suppressDraftPersist.get()) 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