git.iko.soy/opencode-android
on 06fd41b help sign in
1596 lines 85 kB history raw
1package soy.iko.opencode.ui.chat
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
5import soy.iko.opencode.data.model.Agent
6import soy.iko.opencode.data.model.Command
7import soy.iko.opencode.data.model.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
69/**
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.
74 */
75data class ChatError(val message: String, val retryable: Boolean = false)
77/**
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.
81 */
82@androidx.compose.runtime.Immutable
83data class PermissionProgress(val position: Int, val total: Int)
85/**
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
89 * the server.
90 */
91@androidx.compose.runtime.Immutable
92data class PendingAttachment(
93 val id: String,
94 val name: String,
95 val mime: String,
96 val previewModel: Any?,
97 val part: FilePromptPart,
98) {
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(
109 id = id,
110 name = name,
111 mime = mime,
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)
122class ChatViewModel(
123 private val container: AppContainer,
124 private val sessionId: String,
125) : ViewModel() {
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())
186 }
187 .onEach {
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
200 }
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)
205 }
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
219 }
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)
226 delay(backoffMs)
227 true
228 }
230 val messages: StateFlow<List<MessageWithParts>> =
231 combine(sseMessages, _optimisticMessages) { sse, optimistic ->
232 mergeOptimistic(sse, optimistic)
233 }.stateIn(
234 scope = viewModelScope,
235 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
236 initialValue = emptyList(),
237 )
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
242 * streaming. */
243 val hasMessages: StateFlow<Boolean> = messages
244 .map { it.isNotEmpty() }
245 .stateIn(
246 scope = viewModelScope,
247 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
248 initialValue = false,
249 )
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 } }
255 .stateIn(
256 scope = viewModelScope,
257 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
258 initialValue = emptyMap(),
259 )
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>()
272 for (msg in sse) {
273 if (msg.info !is UserMessage) continue
274 val text = msg.parts.filterIsInstance<TextPart>()
275 .joinToString("\n\n") { it.text }
276 .trim()
277 if (text.isNotEmpty()) realUserTexts.add(text)
278 }
279 val surviving = optimistic.filter { it.text.trim() !in realUserTexts }
280 if (surviving.isEmpty()) return sse
281 return sse + surviving.map { it.toMessageWithParts() }
282 }
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>()
289 for (msg in sse) {
290 if (msg.info !is UserMessage) continue
291 val text = msg.parts.filterIsInstance<TextPart>()
292 .joinToString("\n\n") { it.text }
293 .trim()
294 if (text.isNotEmpty()) realUserTexts.add(text)
295 }
296 if (realUserTexts.isEmpty()) return
297 _optimisticMessages.update { entries ->
298 entries.filterNot { it.text.trim() in realUserTexts }
299 }
300 }
302 /** Convert an optimistic entry to a [MessageWithParts] for display. */
303 private fun OptimisticEntry.toMessageWithParts(): MessageWithParts = MessageWithParts(
304 info = UserMessage(
305 id = tempId,
306 sessionID = sessionId,
307 time = soy.iko.opencode.data.model.TimeInfo(created = timestamp),
308 ),
309 parts = listOf(TextPart(id = "$tempId-text", text = text)),
310 )
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()
323 .stateIn(
324 scope = viewModelScope,
325 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
326 initialValue = emptyList(),
327 )
329 private val _running = MutableStateFlow(false)
330 val running: StateFlow<Boolean> = _running.asStateFlow()
332 /** True once the current run ended via SessionIdle/SessionError, until the next run starts
333 * (a send) or an SSE reconnect. Gates the run-indicator re-light below: isRunActivity
334 * returns true for ANY message.part.updated, so without this a trailing/replayed part
335 * arriving after a legitimate SessionIdle would re-light the "working" indicator with no
336 * further idle to clear it — a stuck spinner + foreground service. Cleared on reconnect so
337 * genuine reconnect-recovery re-lighting (its actual purpose) still works. */
338 @Volatile private var runEndedByIdle = false
340 /** The in-flight REST title resolution for the current connection. Launched from — and
341 * cancelled by — the per-connection reset block so it can't observe or restore a previous
342 * server's stale title. */
343 private var sessionTitleJob: Job? = null
345 /** True while an abort REST call is in flight, so the Stop button can show a
346 * spinner and prevent double-taps from firing a second abort. */
347 private val _aborting = MutableStateFlow(false)
348 val aborting: StateFlow<Boolean> = _aborting.asStateFlow()
350 /** A follow-up the user typed while a run was active. send() queues it here and
351 * auto-sends once the run completes (SessionIdle), so the user isn't blocked with
352 * no way to send and no indication why the Send button is gone. */
353 private val _queuedFollowUp = MutableStateFlow<String?>(null)
354 val queuedFollowUp: StateFlow<String?> = _queuedFollowUp.asStateFlow()
356 /** One-shot error events surfaced as snackbars. A Channel (not SharedFlow) so an event
357 * emitted before the UI subscribes is buffered and still delivered — a SharedFlow with
358 * replay=0 would drop it (e.g. a VM-init catalog fetch failing before first
359 * composition). Each event is delivered exactly once and not replayed to a
360 * re-subscribing collector, so rotation doesn't re-show a stale snackbar. */
361 private val _errorEvents = Channel<ChatError>(
362 capacity = NetworkConfig.snackbarEventBufferCapacity,
363 onBufferOverflow = BufferOverflow.DROP_OLDEST,
364 )
365 val errorEvents: Flow<ChatError> = _errorEvents.receiveAsFlow()
367 /** One-shot events signaling a user-initiated delete was scheduled and is undoable in-place.
368 * The ChatScreen collects these to show an Undo snackbar over the conversation the user is
369 * already viewing, instead of navigating away first and surfacing Undo on the session list. */
370 private val _deleteUndoEvents = MutableSharedFlow<Unit>(
371 extraBufferCapacity = 1,
372 onBufferOverflow = BufferOverflow.DROP_OLDEST,
373 )
374 val deleteUndoEvents: SharedFlow<Unit> = _deleteUndoEvents.asSharedFlow()
376 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
377 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
379 private val _modelsLoading = MutableStateFlow(true)
380 val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
382 private val _modelsError = MutableStateFlow(false)
383 val modelsError: StateFlow<Boolean> = _modelsError.asStateFlow()
385 private val _selectedModel = MutableStateFlow<ModelOption?>(null)
386 val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
388 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
389 container.activeConnection
390 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
391 .stateIn(
392 scope = viewModelScope,
393 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
394 initialValue = EventStreamClient.ConnectionState.Disconnected,
395 )
397 private val _pendingPermission = MutableStateFlow<Permission?>(null)
398 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
400 /** Backlog indicator for the permission dialog (see [PermissionProgress]). */
401 private val _permissionProgress = MutableStateFlow(PermissionProgress(0, 0))
402 val permissionProgress: StateFlow<PermissionProgress> = _permissionProgress.asStateFlow()
404 /** Max number of permissions simultaneously pending since the queue was last empty. Reset
405 * to 0 when the queue drains, so a fresh burst starts its count over. */
406 private var maxPendingSeen = 0
408 // Unanswered permission requests keyed by id, insertion-ordered. The dialog shows the most
409 // recent; when one is answered/replied — or a response fails — we fall back to the next so a
410 // request that arrived while another was in flight isn't silently dropped (its tool run would
411 // otherwise stay paused server-side with no UI affordance). Only touched on the VM's main
412 // dispatcher (event collector + viewModelScope launches), so no extra synchronization is needed.
413 private val pendingPermissions = LinkedHashMap<String, Permission>()
415 // Session-scoped permission grants (Allow-for-this-session). Each entry is a (type, patternText)
416 // pair the user approved with PermissionResponse.SESSION. Subsequent matching requests are
417 // auto-responded ONCE without showing the dialog, so e.g. a user granting "read" for one path
418 // isn't re-prompted for every other file read in the same conversation. Cleared on connection
419 // change (see the activeConnection collector) so it never leaks across servers. The server only
420 // knows once/always/reject; SESSION is a client-side scope that maps to ONCE on the wire.
421 private val sessionAllowed = mutableSetOf<Pair<String, String>>()
423 private fun enqueuePermission(permission: Permission) {
424 // Auto-respond to a permission whose (type, pattern) the user previously granted for this
425 // session, instead of showing the dialog again. Resolved immediately (not via the queue) so
426 // the tool run isn't paused waiting for a dialog the user will never see.
427 val type = permission.type.orEmpty()
428 val pattern = permission.patternText.orEmpty()
429 if (type.isNotEmpty() && sessionAllowed.contains(type to pattern)) {
430 respondPermission(permission, PermissionResponse.ONCE)
431 return
432 }
433 pendingPermissions[permission.id] = permission
434 _pendingPermission.value = pendingPermissions.values.lastOrNull()
435 updatePermissionProgress()
436 }
438 private fun resolvePermission(id: String) {
439 pendingPermissions.remove(id)
440 _pendingPermission.value = pendingPermissions.values.lastOrNull()
441 updatePermissionProgress()
442 }
444 private fun clearPermissions() {
445 pendingPermissions.clear()
446 _pendingPermission.value = null
447 updatePermissionProgress()
448 }
450 /** Recompute the "N of M" backlog indicator. [maxPendingSeen] grows with the queue and
451 * resets only when it drains, so position = total - remaining + 1 advances as requests are
452 * answered. */
453 private fun updatePermissionProgress() {
454 val remaining = pendingPermissions.size
455 if (remaining == 0) {
456 maxPendingSeen = 0
457 _permissionProgress.value = PermissionProgress(0, 0)
458 return
459 }
460 if (remaining > maxPendingSeen) maxPendingSeen = remaining
461 _permissionProgress.value = PermissionProgress(
462 position = maxPendingSeen - remaining + 1,
463 total = maxPendingSeen,
464 )
465 }
467 private val _agents = MutableStateFlow<List<Agent>>(emptyList())
468 val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
470 private val _agentsLoading = MutableStateFlow(true)
471 val agentsLoading: StateFlow<Boolean> = _agentsLoading.asStateFlow()
473 private val _agentsError = MutableStateFlow(false)
474 val agentsError: StateFlow<Boolean> = _agentsError.asStateFlow()
476 private val _selectedAgent = MutableStateFlow<String?>(null)
477 val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
479 private val _commands = MutableStateFlow<List<Command>>(emptyList())
480 val commands: StateFlow<List<Command>> = _commands.asStateFlow()
482 private val _commandsLoading = MutableStateFlow(true)
483 val commandsLoading: StateFlow<Boolean> = _commandsLoading.asStateFlow()
485 private val _commandsError = MutableStateFlow(false)
486 val commandsError: StateFlow<Boolean> = _commandsError.asStateFlow()
488 private val _sessionTitle = MutableStateFlow<String?>(null)
489 val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
491 /** Set when the current session is deleted via SSE, so the UI can navigate away. */
492 private val _sessionDeleted = MutableStateFlow(false)
493 val sessionDeleted: StateFlow<Boolean> = _sessionDeleted.asStateFlow()
495 /** The text of the last send that failed, surfaced so the UI can offer a retry. */
496 private val _failedDraft = MutableStateFlow<String?>(null)
497 val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
499 /** The idempotency key of the last failed online send, stashed alongside [_failedDraft] so
500 * [retryFailed] re-submits with the SAME key. A retry after a lost response is then
501 * deduplicated server-side instead of starting a duplicate agent run. */
502 @Volatile private var failedIdempotencyKey: String? = null
504 /** Per-session draft, persisted so it survives navigation/process death. */
505 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
506 val draft: StateFlow<String> = _draft.asStateFlow()
508 /** Messages composed for this session while offline/disconnected, queued in the outbox and
509 * awaiting an automatic flush on reconnect. Surfaced so the composer can show a "queued"
510 * chip with a discard/send-now affordance. */
511 val outbox: StateFlow<List<OutboxMessage>> =
512 container.outboxStore.messages
513 .map { list -> list.filter { it.sessionId == sessionId } }
514 .stateIn(
515 scope = viewModelScope,
516 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
517 initialValue = emptyList(),
518 )
520 /** Attachments staged for the next prompt (images/files). Persisted per-session (as their
521 * self-contained base64 data URLs) via [AttachmentDraftStore] so an interrupted compose
522 * survives process death — the source content Uris wouldn't survive, but the data URLs
523 * do. Cleared on a successful send. */
524 private val _attachments = MutableStateFlow<List<PendingAttachment>>(emptyList())
525 val attachments: StateFlow<List<PendingAttachment>> = _attachments.asStateFlow()
527 /** True while a revert checkpoint is active for this session (messages after it are hidden
528 * server-side). Drives the "reverted" banner with its Undo. */
529 private val _reverted = MutableStateFlow(false)
530 val reverted: StateFlow<Boolean> = _reverted.asStateFlow()
532 /** The diff of the active revert checkpoint, when the server includes one. Shown in the
533 * revert banner so the user can see what changed before deciding to undo. Null when the
534 * session isn't reverted or the server didn't send a diff. */
535 private val _revertDiff = MutableStateFlow<String?>(null)
536 val revertDiff: StateFlow<String?> = _revertDiff.asStateFlow()
538 /** The active public share URL for this session, or null when not shared. */
539 private val _shareUrl = MutableStateFlow<String?>(null)
540 val shareUrl: StateFlow<String?> = _shareUrl.asStateFlow()
542 /** One-shot events carrying a freshly-created share URL so the UI can copy/share it. */
543 private val _shareLinkEvents = Channel<String>(
544 capacity = NetworkConfig.snackbarEventBufferCapacity,
545 onBufferOverflow = BufferOverflow.DROP_OLDEST,
546 )
547 val shareLinkEvents: Flow<String> = _shareLinkEvents.receiveAsFlow()
549 /** One-shot events carrying the id of a session freshly branched from a message, so the
550 * UI can navigate to it. */
551 private val _branchEvents = Channel<String>(
552 capacity = NetworkConfig.snackbarEventBufferCapacity,
553 onBufferOverflow = BufferOverflow.DROP_OLDEST,
554 )
555 val branchEvents: Flow<String> = _branchEvents.receiveAsFlow()
557 init {
558 // DraftStore loads SharedPreferences asynchronously; on a cold start the
559 // synchronous get() above returns "" until the background load completes.
560 // Observe the ready signal and re-seed the draft so it appears in the UI.
561 viewModelScope.launch {
562 container.draftStore.ready.collect { ready ->
563 if (!ready) return@collect
564 if (_draft.value.isEmpty()) {
565 _draft.value = container.draftStore.get(sessionId)
566 }
567 // Recover a follow-up queued before process death (its in-memory StateFlow
568 // is gone). If the input is now free, drop it back there for review/send and
569 // consume the stored copy; otherwise re-queue it so it auto-sends when the
570 // current run next idles. Guarded on == null so it never clobbers a
571 // follow-up the user queued after opening this session.
572 val savedFollowUp = container.draftStore.getFollowUp(sessionId)
573 if (savedFollowUp.isNotEmpty() && _queuedFollowUp.value == null) {
574 if (_draft.value.isEmpty()) {
575 _draft.value = savedFollowUp
576 container.draftStore.flushFollowUp(sessionId, "")
577 } else {
578 _queuedFollowUp.value = savedFollowUp
579 }
580 }
581 }
582 }
583 // Observe the drafts store for external mutations to this session's draft
584 // (e.g. a share injected via draftStore.set in two-pane mode, or setImmediate
585 // before navigation in single-pane mode). Without this, a share injected into
586 // an already-open session never appears in the input field — the init-time
587 // read and the ready-only re-seed miss it when the draft is non-empty.
588 // The VM's own debounced persist writes the same value _draft already holds,
589 // so those updates are no-ops here (storeValue == _draft.value). The
590 // suppressDraftPersist guard prevents a stale store value from clobbering
591 // the deliberate draft clear during a send (the store still holds the
592 // pre-send draft until the send resolves).
593 viewModelScope.launch {
594 container.draftStore.drafts.collect { drafts ->
595 // Ignore emissions until the initial disk load completes: the load itself
596 // emits the persisted snapshot, and applying it here would clobber text the
597 // user typed during the async load (the `ready` observer above seeds an
598 // empty draft). After ready, emissions are external writes.
599 if (!container.draftStore.ready.value) return@collect
600 val storeValue = drafts[sessionId].orEmpty()
601 // Ignore this VM's own (debounced, possibly stale) persistence echo — only a
602 // genuine external write (e.g. a two-pane share injection) should overwrite
603 // the live draft.
604 if (storeValue == lastPersistedDraft) return@collect
605 if (storeValue != _draft.value && !suppressDraftPersist.get()) {
606 _draft.value = storeValue
607 }
608 }
609 }
610 }
612 init {
613 // Restore attachments staged before process death. They were persisted as
614 // self-contained data URLs, so they re-send and (for images) re-preview intact.
615 // Guarded on empty so it never clobbers attachments the user staged after opening.
616 viewModelScope.launch {
617 val restored = runCatchingCancellable { container.attachmentDraftStore.load(sessionId) }
618 .getOrDefault(emptyList())
619 if (restored.isNotEmpty() && _attachments.value.isEmpty()) {
620 _attachments.value = restored.map { it.toPending() }
621 }
622 }
623 }
625 fun selectModel(option: ModelOption) { _selectedModel.value = option }
627 fun selectAgent(name: String?) { _selectedAgent.value = name }
629 fun reloadModels() {
630 val conn = connection ?: return
631 viewModelScope.launch {
632 conn.api.invalidateProvidersCache()
633 _modelsReload.value++
634 }
635 }
637 fun reloadAgents() {
638 val conn = connection ?: return
639 viewModelScope.launch {
640 conn.api.invalidateAgentsCache()
641 _agentsReload.value++
642 }
643 }
645 fun reloadCommands() {
646 val conn = connection ?: return
647 viewModelScope.launch {
648 conn.api.invalidateCommandsCache()
649 _commandsReload.value++
650 }
651 }
653 /**
654 * Shared helper for init-block catalog observers: re-fetches whenever the active
655 * connection or the catalog's reload trigger changes, invoking [onNull] to clear
656 * state on null so stale data from the old server doesn't persist. The
657 * [reloadTrigger] is merged with [activeConnection] so a manual reload (via
658 * reloadModels/reloadAgents/reloadCommands) cancels any in-flight observe fetch
659 * via collectLatest, preventing a stale observe result from overwriting a fresh
660 * reload result. Failure is non-fatal — the error flag is surfaced to the UI.
661 */
662 private fun <T> observeCatalog(
663 tag: String,
664 loading: MutableStateFlow<Boolean>,
665 error: MutableStateFlow<Boolean>,
666 fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
667 onSuccess: (T) -> Unit,
668 onNull: () -> Unit,
669 reloadTrigger: StateFlow<Int>,
670 ) {
671 viewModelScope.launch {
672 merge(container.activeConnection, reloadTrigger).collectLatest { _ ->
673 val conn = container.activeConnection.value
674 if (conn == null) { onNull(); loading.value = false; error.value = false; return@collectLatest }
675 loading.value = true
676 error.value = false
677 runCatchingCancellable { fetch(conn.api) }
678 // fetch() does HTTP via withRetry, so the failure can be a
679 // ClientRequestException whose message embeds the full request URL
680 // (may contain auth/paths). Log only a scrubbed summary.
681 .onFailure { Log.w("ChatViewModel", "Failed to load $tag: ${safeExceptionSummary(it)}"); error.value = true }
682 .getOrNull()?.let(onSuccess)
683 loading.value = false
684 }
685 }
686 }
688 fun updateDraft(text: String) {
689 _draft.value = text
690 }
692 init {
693 // Debounce draft persistence so we don't write to disk on every keystroke.
694 // Skip persisting empty drafts that were set by send() (suppressed via
695 // suppressDraftPersist) — send() clears the in-memory draft immediately for
696 // UI feedback but deliberately doesn't persist the clear until the send
697 // succeeds, so a failed send can restore the draft.
698 viewModelScope.launch {
699 _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
700 if (text.isBlank() && suppressDraftPersist.get()) return@collect
701 lastPersistedDraft = text
702 runCatchingCancellable { container.draftStore.set(sessionId, text) }
703 .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
704 }
705 }
706 // Reset per-connection state when the active server changes so stale spinners,
707 // permission dialogs, errors, and agent selections from the old server don't
708 // persist into the new one.
709 viewModelScope.launch {
710 container.activeConnection.collectLatest { conn ->
711 if (conn == null) {
712 // The connection dropped (e.g. disconnect() mid-run). No SSE stream will
713 // arrive to deliver SessionIdle/SessionError, so reset the run state here —
714 // otherwise the working spinner sticks on, ChatScreen keeps keepScreenOn +
715 // the RunForegroundService alive, and the Stop button no-ops (abort() early-
716 // returns with no connection) until a later reconnect happens to clear it.
717 _running.value = false
718 _aborting.value = false
719 clearPermissions()
720 sessionAllowed.clear()
721 sessionTitleJob?.cancel()
722 _revertDiff.value = null
723 return@collectLatest
724 }
725 _running.value = false
726 // Allow the re-light below to recover a still-running run after this (re)connect;
727 // a run that actually finished during the outage emits no live parts, so this
728 // won't spuriously re-light.
729 runEndedByIdle = false
730 clearPermissions()
731 sessionAllowed.clear()
732 _failedDraft.value = null
733 _revertDiff.value = null
734 // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
735 // user intent that is now persisted; wiping it on every (re)connect — which
736 // this collector does, including transient SSE drops and the cold-start
737 // restore path — would drop a legitimately queued/recovered follow-up.
738 _selectedAgent.value = null
739 _sessionTitle.value = null
740 // Resolve the app-bar title via REST for THIS connection. Launched from the same
741 // block that just nulled _sessionTitle (and cancelled the previous fetch) so it can
742 // never observe or restore the previous server's stale title — the old separate
743 // collector could, racing that reset. The assignment is guarded on a still-null
744 // title so a fresher SSE SessionUpdated (from the event collector below) wins.
745 sessionTitleJob?.cancel()
746 sessionTitleJob = viewModelScope.launch {
747 runCatchingCancellable { conn.repository.listSessions() }
748 .getOrNull()
749 ?.firstOrNull { it.id == sessionId }
750 ?.let { session ->
751 if (_sessionTitle.value == null) {
752 _sessionTitle.value = session.displayTitle
753 _reverted.value = session.isReverted
754 _revertDiff.value = session.revert?.diff?.takeIf { it.isNotBlank() }
755 _shareUrl.value = session.share?.url?.takeIf { it.isNotBlank() }
756 }
757 }
758 }
759 try {
760 // Loop the inner collect so a transient non-cancellation exception (a
761 // malformed SSE event decoding error, an unexpected channel close)
762 // doesn't terminate the event collector for this connection. Without
763 // the loop, the catch returns the collectLatest lambda normally, so
764 // collectLatest considers this connection's block complete and never
765 // re-runs it — the chat would go permanently silent (no streaming,
766 // no permission prompts, no title updates) until a server switch.
767 while (currentCoroutineContext().isActive) {
768 try {
769 conn.events.events.collect { event ->
770 if (SessionRepository.isIdle(event, sessionId)) {
771 _running.value = false
772 runEndedByIdle = true
773 // Auto-send a queued follow-up once the previous run finishes,
774 // so the user's drafted-while-running message isn't lost.
775 val queued = _queuedFollowUp.value
776 if (queued != null) {
777 setQueuedFollowUp(null)
778 send(queued, includeAttachments = false)
779 }
780 }
781 if (SessionRepository.isError(event, sessionId)) {
782 _running.value = false
783 runEndedByIdle = true
784 // The run errored, so the queued follow-up won't auto-send.
785 // Restore it to the input (if free) so the user's typed text
786 // isn't silently lost; otherwise just clear the queue.
787 val queued = _queuedFollowUp.value
788 if (!queued.isNullOrEmpty() && _draft.value.isEmpty()) {
789 _draft.value = queued
790 }
791 setQueuedFollowUp(null)
792 _errorEvents.trySend(ChatError(container.string(R.string.error_agent_reported)))
793 }
794 // Restore the run indicator if a live streaming event arrives while it's
795 // cleared — e.g. an SSE reconnect mid-run reset _running to false, but the
796 // agent is still working. Don't revive it once the user has hit Stop.
797 val relightRunIndicator = SessionRepository.isRunActivity(event, sessionId) &&
798 !_aborting.value && !_running.value && !runEndedByIdle
799 if (relightRunIndicator) {
800 _running.value = true
801 }
802 when (event) {
803 is PermissionUpdated ->
804 if (event.properties.sessionID == sessionId) enqueuePermission(event.properties)
805 is PermissionReplied ->
806 if (event.properties.sessionID == sessionId) event.properties.permissionID?.let { resolvePermission(it) }
807 is SessionUpdated ->
808 if (event.properties.info.id == sessionId) {
809 val info = event.properties.info
810 _sessionTitle.value = info.displayTitle
811 _reverted.value = info.isReverted
812 _revertDiff.value = info.revert?.diff?.takeIf { it.isNotBlank() }
813 _shareUrl.value = info.share?.url?.takeIf { it.isNotBlank() }
814 }
815 is SessionDeleted ->
816 if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
817 _sessionDeleted.value = true
818 }
819 else -> {}
820 }
821 }
822 } catch (e: CancellationException) {
823 throw e
824 } catch (e: Exception) {
825 Log.w("ChatViewModel", "SSE event collector error, restarting", e)
826 }
827 }
828 } catch (e: CancellationException) {
829 throw e
830 } catch (e: Exception) {
831 Log.w("ChatViewModel", "SSE event collector loop error", e)
832 }
833 }
834 }
835 // Load the model catalog; preselect the server default. Failure is non-fatal —
836 // sending with no model just uses the server's default agent/model.
837 observeCatalog(
838 tag = "model catalog",
839 loading = _modelsLoading,
840 error = _modelsError,
841 fetch = { it.providers() },
842 onSuccess = { resp ->
843 val options = resp.toOptions()
844 _models.value = options
845 // Only (re)apply the server default when there's no valid current
846 // selection. On re-fetch/reconnect/Retry the user's chosen model must
847 // survive — keep it as long as it's still present in the refreshed list.
848 val current = _selectedModel.value
849 if (current == null || options.none { it.ref == current.ref }) {
850 _selectedModel.value = resp.defaultOption(options)
851 }
852 },
853 onNull = { _models.value = emptyList(); _selectedModel.value = null },
854 reloadTrigger = _modelsReload,
855 )
856 // Load the agent catalog (non-fatal).
857 observeCatalog(
858 tag = "agent catalog",
859 loading = _agentsLoading,
860 error = _agentsError,
861 fetch = { it.agents() },
862 onSuccess = { _agents.value = it },
863 onNull = { _agents.value = emptyList() },
864 reloadTrigger = _agentsReload,
865 )
866 // Load the command catalog (non-fatal).
867 observeCatalog(
868 tag = "command catalog",
869 loading = _commandsLoading,
870 error = _commandsError,
871 fetch = { it.commands() },
872 onSuccess = { _commands.value = it },
873 onNull = { _commands.value = emptyList() },
874 reloadTrigger = _commandsReload,
875 )
876 // If the SSE stream drops mid-run, the run indicator would spin forever;
877 // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
878 viewModelScope.launch {
879 container.activeConnection.collectLatest { conn ->
880 if (conn == null) return@collectLatest
881 try {
882 // Loop so a transient non-cancellation exception doesn't terminate the
883 // state watcher for this connection. Without the loop, a single thrown
884 // error returns the collectLatest lambda and the _running reset on
885 // Disconnected/Failed never fires again for this connection — a dropped
886 // stream leaves the working spinner (and the RunForegroundService) stuck.
887 while (currentCoroutineContext().isActive) {
888 try {
889 conn.events.state.collect { state ->
890 val isDown = state == EventStreamClient.ConnectionState.Disconnected ||
891 state == EventStreamClient.ConnectionState.Failed ||
892 state == EventStreamClient.ConnectionState.AuthFailed
893 if (isDown && _running.value) {
894 _running.value = false
895 }
896 }
897 } catch (e: CancellationException) {
898 throw e
899 } catch (e: Exception) {
900 Log.w("ChatViewModel", "SSE state collector error, restarting", e)
901 }
902 }
903 } catch (e: CancellationException) {
904 throw e
905 } catch (e: Exception) {
906 Log.w("ChatViewModel", "SSE state collector loop error", e)
907 }
908 }
909 }
910 }
912 /** Sends [text] together with any staged attachments; returns true on success so the
913 * caller can clear the draft only then. */
914 fun send(text: String): Boolean = send(text, includeAttachments = true)
916 /**
917 * Core send. [includeAttachments] is false for the internal auto-send paths (a queued
918 * follow-up, a retry) so staged attachments aren't silently re-sent with unrelated text.
919 */
920 private fun send(text: String, includeAttachments: Boolean, idempotencyKey: String? = null): Boolean {
921 val trimmed = text.trim()
922 val attachments = if (includeAttachments) _attachments.value else emptyList()
923 // An image-only prompt (attachments, no text) is valid; a blank prompt with nothing
924 // attached is not.
925 if (trimmed.isEmpty() && attachments.isEmpty()) return false
926 // Offline, or no active connection: queue the prompt to the outbox (flushed on
927 // reconnect) instead of failing the send. Device-offline is caught even when a stale
928 // connection object lingers, so a phone that lost signal mid-conversation still
929 // captures the message rather than bouncing it off a dead socket.
930 val conn = connection
931 if (conn == null || !container.isOnline.value) {
932 return enqueueOffline(trimmed, attachments)
933 }
934 if (!_running.compareAndSet(false, true)) return false
935 // A new run is starting: reopen the re-light gate so this run's streamed parts keep the
936 // indicator lit (and a later SessionIdle re-closes it).
937 runEndedByIdle = false
938 // A plain manual Send of the auto-restored failed draft must reuse the stashed key too
939 // (retryFailed passes it explicitly; the composer's Send does not), so a lost-response
940 // re-send stays deduplicated server-side instead of starting a duplicate agent run.
941 // Capture the match BEFORE clearing _failedDraft just below.
942 val reuseKey = if (idempotencyKey == null && trimmed == _failedDraft.value?.trim()) {
943 failedIdempotencyKey
944 } else {
945 null
946 }
947 _failedDraft.value = null
948 // Stable idempotency key for THIS online attempt: a brand-new message gets a fresh key,
949 // while retryFailed() (or a manual re-send of the restored failed draft) re-submits with the
950 // key it stashed on the original failure — so a retry after a lost response is deduplicated
951 // server-side (no duplicate agent run).
952 val key = idempotencyKey ?: reuseKey ?: java.util.UUID.randomUUID().toString()
953 // Clear the staged attachments optimistically so the composer empties immediately;
954 // restore them if the send fails (mirrors the draft handling below).
955 if (attachments.isNotEmpty()) _attachments.value = emptyList()
956 // Clear the in-memory draft for the UI immediately, but don't persist the clear
957 // yet — if the send fails and the process dies before we restore, the draft
958 // would be lost forever. The persisted draft is cleared only on success.
959 // suppressDraftPersist prevents the debounced collector from persisting the
960 // empty draft before the send resolves.
961 //
962 // Only clear when the text being sent IS the current draft. send() is also
963 // invoked for auto-sent queued follow-ups (SessionIdle) and retryFailed(), where
964 // the user may have typed a NEW draft since — wiping it would lose that text.
965 if (_draft.value.trim() == trimmed) {
966 suppressDraftPersist.set(true)
967 _draft.value = ""
968 }
969 // Inject an optimistic user message so the outgoing prompt is visible immediately,
970 // before the server echoes it back via SSE. Removed by reconcileOptimistic once the
971 // real UserMessage arrives. Only for text-bearing prompts (an image-only prompt has
972 // no text to show).
973 val optimisticId = if (trimmed.isNotEmpty()) "__optimistic_${System.nanoTime()}" else null
974 if (optimisticId != null) {
975 // Clear any prior failed optimistic entry with the same text (a retry re-sends).
976 _optimisticMessages.update { entries -> entries.filterNot { it.failed && it.text.trim() == trimmed } }
977 _optimisticMessages.update { it + OptimisticEntry(optimisticId, trimmed, System.currentTimeMillis(), failed = false) }
978 }
979 viewModelScope.launch {
980 runCatchingCancellable {
981 conn.repository.sendPrompt(
982 sessionId,
983 trimmed,
984 attachments = attachments.map { it.part },
985 model = _selectedModel.value?.ref,
986 agent = _selectedAgent.value,
987 idempotencyKey = key,
988 )
989 }.onFailure {
990 // Mark the optimistic message as failed so the user sees a failure indicator
991 // instead of a perpetual "sending" state.
992 if (optimisticId != null) {
993 _optimisticMessages.update { entries ->
994 entries.map { if (it.tempId == optimisticId) it.copy(failed = true) else it }
995 }
996 }
997 suppressDraftPersist.set(false)
998 _failedDraft.value = trimmed
999 failedIdempotencyKey = key
1000 // Only restore the draft if the user hasn't typed anything new since.
1001 if (_draft.value.isBlank()) updateDraft(trimmed)
1002 // Restore the staged attachments too, so a failed send doesn't lose them —
1003 // but only if the user hasn't staged new ones in the meantime.
1004 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
1005 // Re-persist so the restored attachments survive process death after a failure.
1006 persistAttachments()
1007 // Retryable: this is the failed send whose prompt Retry re-submits.
1008 _errorEvents.trySend(ChatError(container.friendlyError(it), retryable = true))
1009 _running.value = false
1010 }.onSuccess {
1011 suppressDraftPersist.set(false)
1012 // This attempt reached the server, so the stashed retry key is spent — drop it so a
1013 // later unrelated send can't reuse it (retryFailed clears it on success too).
1014 failedIdempotencyKey = null
1015 // Send succeeded — the attachments rode along, so clear their persisted copy
1016 // (or keep any the user staged after the optimistic clear).
1017 persistAttachments()
1018 // Send succeeded — persist the cleared draft, but only if the user hasn't
1019 // typed a new one while the send was in flight. Otherwise clearing the
1020 // store echoes back through the draft observer and wipes the in-progress
1021 // text (data loss). Mirrors the guard on the failure path above.
1022 if (_draft.value.isBlank()) {
1023 lastPersistedDraft = ""
1024 container.draftStore.set(sessionId, "")
1026 // Don't reset _running here: the agent continues streaming via SSE.
1027 // _running is cleared on SessionIdle/SessionError (see event collector)
1028 // or when the SSE stream drops (see connection state watcher below).
1031 return true
1034 /**
1035 * Queue [trimmed] (+ [attachments]) to the offline outbox, clearing the composer
1036 * optimistically like a real send. AppContainer flushes the outbox automatically on
1037 * reconnect. The prompt is attributed to the active (or most-recent) server profile so a
1038 * reconnect to a different server doesn't misfire it. On any failure the draft/attachments
1039 * are restored so nothing is lost.
1040 */
1041 private fun enqueueOffline(trimmed: String, attachments: List<PendingAttachment>): Boolean {
1042 _failedDraft.value = null
1043 if (attachments.isNotEmpty()) _attachments.value = emptyList()
1044 val clearDraft = _draft.value.trim() == trimmed
1045 if (clearDraft) {
1046 suppressDraftPersist.set(true)
1047 _draft.value = ""
1049 viewModelScope.launch {
1050 val profileId = connection?.profile?.id
1051 ?: runCatchingCancellable { container.profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
1052 if (profileId == null) {
1053 // No server to attribute the message to — restore the composer and report it
1054 // rather than silently dropping the prompt.
1055 suppressDraftPersist.set(false)
1056 if (_draft.value.isBlank()) updateDraft(trimmed)
1057 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
1058 persistAttachments()
1059 _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
1060 return@launch
1062 runCatchingCancellable {
1063 container.outboxStore.enqueue(
1064 OutboxMessage(
1065 id = java.util.UUID.randomUUID().toString(),
1066 profileId = profileId,
1067 sessionId = sessionId,
1068 text = trimmed,
1069 attachments = attachments.map { it.toPersisted() },
1070 model = _selectedModel.value?.ref,
1071 agent = _selectedAgent.value,
1072 createdAt = System.currentTimeMillis(),
1073 ),
1075 }.onSuccess {
1076 suppressDraftPersist.set(false)
1077 // The staged attachments now live in the outbox item; clear their draft copy.
1078 persistAttachments()
1079 if (_draft.value.isBlank()) {
1080 lastPersistedDraft = ""
1081 container.draftStore.set(sessionId, "")
1083 // Nudge a flush in case we're actually online with a live connection.
1084 container.flushOutbox()
1085 _errorEvents.trySend(ChatError(container.string(R.string.outbox_queued)))
1086 }.onFailure {
1087 suppressDraftPersist.set(false)
1088 if (_draft.value.isBlank()) updateDraft(trimmed)
1089 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
1090 persistAttachments()
1091 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1094 return true
1097 /** Discard a single queued (offline) message. */
1098 fun discardQueued(id: String) {
1099 viewModelScope.launch { runCatchingCancellable { container.outboxStore.remove(id) } }
1102 /** Discard every queued (offline) message for this session. */
1103 fun discardAllQueued() {
1104 viewModelScope.launch { runCatchingCancellable { container.outboxStore.removeForSession(sessionId) } }
1107 /** Attempt to flush the outbox now (e.g. the user tapped "Send now"). */
1108 fun flushQueued() { container.flushOutbox() }
1110 /** Re-send the last draft whose send failed, if any. */
1111 fun retryFailed() {
1112 val draft = _failedDraft.value ?: return
1113 // Reuse the failed send's idempotency key so a retry after a lost response is
1114 // deduplicated server-side rather than starting a duplicate run.
1115 val key = failedIdempotencyKey
1116 // Don't clear _failedDraft until send() accepts the text — if _running is
1117 // already true, send() returns false and the draft would be lost forever.
1118 // Attachments aren't re-sent on retry (they were optimistically restored to the
1119 // composer on the original failure, so they'll ride the next manual send).
1120 if (send(draft, includeAttachments = false, idempotencyKey = key)) {
1121 _failedDraft.value = null
1122 failedIdempotencyKey = null
1126 /** Dismiss a single failed optimistic message by its tempId so it no longer lingers in the
1127 * list. Without this there's no recovery path for a failed message the user chose to abandon
1128 * (its text won't ever match a real server message, so reconcileOptimistic never clears it). */
1129 fun dismissOptimistic(tempId: String) {
1130 _optimisticMessages.update { entries -> entries.filterNot { it.tempId == tempId } }
1133 /** Re-send a specific failed optimistic message by its tempId. Loads its text into the
1134 * composer and fires a send, mirroring the snackbar's Retry path. Returns false (and is a
1135 * no-op) if the entry can't be found or a run is already active. */
1136 fun retryOptimisticMessage(tempId: String): Boolean {
1137 val entry = _optimisticMessages.value.firstOrNull { it.tempId == tempId && it.failed } ?: return false
1138 return send(entry.text, includeAttachments = false)
1141 /**
1142 * Queue [text] to be sent automatically when the current run finishes, or clear any
1143 * queued follow-up when [text] is blank. Used when the user taps Send while a run
1144 * is already active — the Send button is replaced by Stop during a run, but the
1145 * input field stays enabled, so a follow-up typed mid-run would otherwise have
1146 * nowhere to go.
1147 */
1148 fun queueFollowUp(text: String) {
1149 val trimmed = text.trim()
1150 setQueuedFollowUp(trimmed.takeIf { it.isNotEmpty() })
1151 // The text now lives in the queued chip, so clear the input field just as a
1152 // normal send would. No-op for the cancel case (blank text), which must leave
1153 // whatever the user has since typed untouched.
1154 if (trimmed.isNotEmpty()) {
1155 _draft.value = ""
1156 // Persist the cleared draft IMMEDIATELY, not just in memory: the follow-up was already
1157 // persisted synchronously above, so if the process is killed before the debounced
1158 // draft-persist fires, the same text would remain under BOTH the draft and follow-up
1159 // keys — and cold-start recovery would restore it to the composer AND re-queue it (a
1160 // duplicate). Mirror send()'s lastPersistedDraft update so the drafts observer treats
1161 // this store change as our own echo and doesn't write the cleared value back.
1162 lastPersistedDraft = ""
1163 container.draftStore.flushDraft(sessionId, "")
1167 /** Set or clear the queued follow-up and mirror it to persistence, so a follow-up
1168 * queued mid-run survives process death (recovered on the next open of this session).
1169 * Passing null / blank clears both the in-memory value and the persisted copy. */
1170 private fun setQueuedFollowUp(text: String?) {
1171 _queuedFollowUp.value = text
1172 container.draftStore.flushFollowUp(sessionId, text.orEmpty())
1175 /** Transient flag set by [refreshMessages] so the top-bar refresh icon can show
1176 * a brief spinner as immediate tap feedback. The SSE reconnect triggered by
1177 * refreshMessages may not visibly change [connectionState] (it's already
1178 * Connected), so without this the tap appears to do nothing. Clears after a
1179 * short delay or when the connection state next becomes Connected. */
1180 private val _refreshing = MutableStateFlow(false)
1181 val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
1183 /** Manually re-fetch messages by forcing the SSE stream to reconnect, which
1184 * triggers the repository's re-seed-from-REST logic. Gives the user a recovery
1185 * path when the SSE stream silently drops and the auto-reconnect re-seed is slow
1186 * or fails — a pull-to-refresh or top-bar refresh forces a fresh fetch under the
1187 * existing generation-guarded merge. Also clears [loadError] optimistically and
1188 * sets [refreshing] for immediate tap feedback. */
1189 fun refreshMessages() {
1190 val conn = connection ?: return
1191 _loadError.value = false
1192 _refreshing.value = true
1193 conn.events.triggerReconnect()
1194 viewModelScope.launch {
1195 delay(NetworkConfig.refreshFeedbackMs)
1196 _refreshing.value = false
1200 /** Invoke a slash-command by name via the server's /command endpoint. */
1201 fun runCommand(command: Command) {
1202 val conn = connection ?: return
1203 // A run is already in flight — surface feedback instead of silently dropping the
1204 // command (the picker stays openable during a run and dismisses on select).
1205 if (!_running.compareAndSet(false, true)) {
1206 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1207 return
1209 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1210 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1211 runEndedByIdle = false
1212 viewModelScope.launch {
1213 runCatchingCancellable {
1214 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
1215 }.onFailure {
1216 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1217 _running.value = false
1222 fun abort() {
1223 val conn = connection ?: return
1224 // Drop any queued follow-up so the SessionIdle that follows the abort doesn't
1225 // auto-send it — the user tapped Stop to halt work, not to trigger the next turn.
1226 setQueuedFollowUp(null)
1227 if (!_aborting.compareAndSet(false, true)) return
1228 viewModelScope.launch {
1229 try {
1230 // Run the abort POST under NonCancellable so it survives this scope being
1231 // cancelled. The "Stop and exit" path calls abort() then navigates back
1232 // immediately, which pops this ViewModel's back-stack entry → onCleared() →
1233 // viewModelScope.cancel(); without this the in-flight abort is cancelled (often
1234 // before the request even reaches the server) and the run keeps burning tokens
1235 // server-side despite the user explicitly stopping it.
1236 runCatchingCancellable { withContext(NonCancellable) { conn.repository.abort(sessionId) } }
1237 .onSuccess { _running.value = false }
1238 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1239 } finally {
1240 _aborting.value = false
1245 fun addAttachment(attachment: PendingAttachment) {
1246 // Enforce the cumulative size cap across the whole staged set (the per-file size and the
1247 // count caps are checked upstream). The base64 data URLs are held in memory and
1248 // re-serialized on every add/remove and again when the request body is built, so an
1249 // unbounded set OOMs. Reject rather than add, mirroring the other attachment limits'
1250 // snackbar via errorEvents. Size is the raw (pre-base64) byte count, matching the
1251 // per-file check, and includes attachments already staged (incl. restored ones).
1252 val staged = _attachments.value.sumOf { base64DataUrlByteSize(it.part.url) }
1253 if (staged + base64DataUrlByteSize(attachment.part.url) > NetworkConfig.maxTotalAttachmentBytes) {
1254 _errorEvents.trySend(ChatError(container.string(R.string.attachment_limit)))
1255 return
1257 _attachments.update { it + attachment }
1258 persistAttachments()
1261 fun removeAttachment(id: String) {
1262 _attachments.update { list -> list.filterNot { it.id == id } }
1263 persistAttachments()
1266 fun clearAttachments() {
1267 _attachments.value = emptyList()
1268 persistAttachments()
1271 /** Persist the current staged attachments so an interrupted compose survives process
1272 * death (mirrors draft persistence). Fire-and-forget on [viewModelScope]. */
1273 private fun persistAttachments() {
1274 val snapshot = _attachments.value
1275 viewModelScope.launch {
1276 runCatchingCancellable { container.attachmentDraftStore.save(sessionId, snapshot.map { it.toPersisted() }) }
1277 .onFailure { Log.w("ChatViewModel", "Failed to persist attachments", it) }
1281 /** Revert the conversation to just before [messageId], hiding everything after it. The
1282 * authoritative state also arrives via SessionUpdated; we flag it here for immediate
1283 * feedback. */
1284 fun revertTo(messageId: String) {
1285 val conn = connection ?: return
1286 viewModelScope.launch {
1287 runCatchingCancellable { conn.api.revert(sessionId, messageId) }
1288 .onSuccess {
1289 _reverted.value = it.isReverted
1290 _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1292 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1296 /**
1297 * Regenerate an assistant reply: revert the conversation to that assistant message (removing
1298 * it and everything after), then re-send the user prompt that immediately preceded it. A
1299 * one-tap "try again" mirroring ChatGPT/Claude, instead of the two-step revert + re-Send.
1301 * Captures the preceding user text *before* the revert (the messages list updates
1302 * asynchronously via SSE once the revert lands). No-op if there's no preceding user prompt
1303 * or the assistant message isn't found.
1304 */
1305 fun regenerate(assistantMessageId: String) {
1306 val conn = connection ?: return
1307 val msgs = messages.value
1308 val idx = msgs.indexOfFirst { it.info.id == assistantMessageId }
1309 if (idx < 0) return
1310 val precedingUserText = (idx - 1 downTo 0)
1311 .asSequence()
1312 .map { msgs[it] }
1313 .firstOrNull { it.info is UserMessage }
1314 ?.parts
1315 ?.filterIsInstance<TextPart>()
1316 ?.joinToString("\n\n") { it.text }
1317 ?.takeIf { it.isNotBlank() }
1318 ?: return
1319 viewModelScope.launch {
1320 runCatchingCancellable { conn.api.revert(sessionId, assistantMessageId) }
1321 .onSuccess { send(precedingUserText, includeAttachments = false) }
1322 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1326 /** Load a previously-sent user prompt back into the composer to edit and re-send.
1327 * Reverts the conversation to just before [messageId] (hiding it and everything after)
1328 * and prefills the draft with [text]. The next Send continues from that checkpoint,
1329 * effectively replacing the edited message; the reverted banner's Undo restores it.
1330 * Overwrites the current draft — this is an explicit edit action on that message. */
1331 fun editMessage(messageId: String, text: String) {
1332 revertTo(messageId)
1333 _draft.value = text.take(NetworkConfig.maxDraftLengthChars)
1336 /** Prefill the composer with [text] as a Markdown blockquote so the user can respond to a
1337 * specific message inline. Appends to any existing draft (with a blank line) rather than
1338 * overwriting, so quoting doesn't discard text already being typed. */
1339 fun quoteReply(text: String) {
1340 val quoted = text.trim().lineSequence().joinToString("\n") { "> $it" }
1341 if (quoted.isBlank()) return
1342 val current = _draft.value
1343 val combined = if (current.isBlank()) "$quoted\n\n" else "$current\n\n$quoted\n\n"
1344 // Cap like the composer's typed/pasted/dictated input so quoting onto a near-full draft
1345 // can't push it past the limit (negative remaining counter + an over-cap prompt sent).
1346 _draft.value = combined.take(NetworkConfig.maxDraftLengthChars)
1349 /** Fork the conversation by creating a brand-new session seeded with [text] as its first
1350 * prompt (using the currently-selected model/agent). Emits the new session id via
1351 * [branchEvents] so the UI can navigate to it. There's no server-side "branch" endpoint,
1352 * so this starts a fresh session from the chosen prompt rather than copying history. */
1353 fun branchFrom(text: String) {
1354 val conn = connection ?: return
1355 val prompt = text.trim()
1356 if (prompt.isEmpty()) return
1357 viewModelScope.launch {
1358 runCatchingCancellable {
1359 val session = conn.repository.createSession(title = null)
1360 conn.repository.sendPrompt(
1361 session.id,
1362 prompt,
1363 model = _selectedModel.value?.ref,
1364 agent = _selectedAgent.value,
1366 session.id
1368 .onSuccess { newId -> _branchEvents.trySend(newId) }
1369 .onFailure { _errorEvents.trySend(ChatError(container.string(R.string.branch_failed))) }
1373 /** Undo the active revert checkpoint, restoring the hidden messages. */
1374 fun unrevert() {
1375 val conn = connection ?: return
1376 viewModelScope.launch {
1377 runCatchingCancellable { conn.api.unrevert(sessionId) }
1378 .onSuccess {
1379 _reverted.value = it.isReverted
1380 _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1382 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1386 /** Create (or fetch the existing) public share link. The URL is exposed via [shareUrl]
1387 * and also emitted once via [shareLinkEvents] so the UI can copy it to the clipboard. */
1388 fun shareSession() {
1389 val conn = connection ?: return
1390 viewModelScope.launch {
1391 runCatchingCancellable { conn.api.shareSession(sessionId) }
1392 .onSuccess { session ->
1393 val url = session.share?.url?.takeIf { it.isNotBlank() }
1394 _shareUrl.value = url
1395 if (url != null) _shareLinkEvents.trySend(url)
1397 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1401 /** Revoke the session's public share link. */
1402 fun unshareSession() {
1403 val conn = connection ?: return
1404 viewModelScope.launch {
1405 runCatchingCancellable { conn.api.unshareSession(sessionId) }
1406 .onSuccess { _shareUrl.value = it.share?.url?.takeIf { it.isNotBlank() } }
1407 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1411 /** Compact the conversation via the summarize endpoint; the summary streams back via SSE.
1412 * Uses the currently-selected model (required by the endpoint). */
1413 fun summarize() {
1414 val conn = connection ?: return
1415 val model = _selectedModel.value?.ref ?: run {
1416 _errorEvents.trySend(ChatError(container.string(R.string.needs_model)))
1417 return
1419 if (!_running.compareAndSet(false, true)) {
1420 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1421 return
1423 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1424 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1425 runEndedByIdle = false
1426 viewModelScope.launch {
1427 runCatchingCancellable { conn.api.summarize(sessionId, model) }
1428 .onFailure {
1429 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1430 _running.value = false
1435 /** Analyze the project and (re)generate its AGENTS.md; the run streams back via SSE. */
1436 fun initProject() {
1437 val conn = connection ?: return
1438 if (!_running.compareAndSet(false, true)) {
1439 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1440 return
1442 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1443 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1444 runEndedByIdle = false
1445 viewModelScope.launch {
1446 runCatchingCancellable { conn.api.initSession(sessionId) }
1447 .onFailure {
1448 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1449 _running.value = false
1454 /** Run a one-off shell [command] in the session's worktree; output streams back via SSE.
1455 * The server requires an agent to scope the run — use the selected one, else the primary. */
1456 fun runShell(command: String) {
1457 val conn = connection ?: return
1458 val cmd = command.trim()
1459 if (cmd.isEmpty()) return
1460 val agent = _selectedAgent.value
1461 ?: _agents.value.firstOrNull { it.isPrimary }?.name
1462 ?: _agents.value.firstOrNull()?.name
1463 ?: DEFAULT_SHELL_AGENT
1464 if (!_running.compareAndSet(false, true)) {
1465 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1466 return
1468 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1469 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1470 runEndedByIdle = false
1471 viewModelScope.launch {
1472 runCatchingCancellable { conn.api.shell(sessionId, cmd, agent, _selectedModel.value?.ref) }
1473 .onFailure {
1474 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1475 _running.value = false
1480 /** Rename the current session via PATCH /session/:id. On success updates [sessionTitle]
1481 * so the top bar reflects the new name immediately. A failure surfaces as a snackbar;
1482 * the caller keeps the dialog open so the user can retry without retyping. */
1483 fun renameSession(newTitle: String) {
1484 val conn = connection ?: return
1485 val title = newTitle.trim()
1486 if (title.isEmpty() || title == _sessionTitle.value) return
1487 viewModelScope.launch {
1488 runCatchingCancellable { conn.api.updateSession(sessionId, title) }
1489 .onSuccess { _sessionTitle.value = it.displayTitle }
1490 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1494 /** Delete the current session via DELETE /session/:id. Schedules a deferred delete with an
1495 * undo window and signals [deleteUndoEvents] so the ChatScreen shows an Undo snackbar
1496 * *over the conversation the user is viewing* — they can cancel without first being
1497 * navigated back to the session list. The actual REST delete runs after
1498 * [NetworkConfig.undoDeleteDelayMs]; cancel via [cancelSessionDelete]. If the window
1499 * expires, the scheduled delete's onDeleted sets [sessionDeleted] so the UI navigates away.
1500 * A failure surfaces as a snackbar. */
1501 fun deleteSession() {
1502 val conn = connection ?: return
1503 container.scheduleSessionDelete(
1504 id = sessionId,
1505 delayMs = NetworkConfig.undoDeleteDelayMs,
1506 // Navigate away only once the deferred delete actually commits — so an in-place Undo
1507 // before the window expires keeps the user on this conversation.
1508 onDeleted = { _sessionDeleted.value = true },
1509 onError = { _errorEvents.trySend(ChatError(container.friendlyError(it))) },
1511 _deleteUndoEvents.tryEmit(Unit)
1514 /** Cancel a delete scheduled by [deleteSession] (the in-chat Undo action). Returns true if the
1515 * delete was still pending (Undo succeeded). */
1516 fun cancelSessionDelete(): Boolean = container.cancelSessionDelete(sessionId)
1518 /** Reconnect to the most recently used server profile (used when the connection is gone). */
1519 fun reconnect() {
1520 if (container.activeConnection.value != null) return
1521 // Guard against concurrent reconnect calls: the user can tap the button
1522 // multiple times while _reconnecting is true, which would launch parallel
1523 // connect() coroutines that race to set the active connection, leaking the
1524 // intermediate connections.
1525 if (!_reconnecting.compareAndSet(false, true)) return
1526 viewModelScope.launch {
1527 try {
1528 val recent = runCatchingCancellable { container.profileStore.profiles.first() }
1529 .getOrDefault(emptyList())
1530 .firstOrNull()
1531 if (recent == null) {
1532 _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
1533 return@launch
1535 runCatchingCancellable {
1536 val conn = container.connect(recent)
1537 conn.api.ping()
1538 }.onFailure {
1539 container.disconnect()
1540 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1542 } finally {
1543 _reconnecting.value = false
1548 fun respondPermission(permission: Permission, response: PermissionResponse) {
1549 val conn = connection ?: return
1550 // Record a session-scoped grant so subsequent matching requests are auto-answered.
1551 // SESSION maps to ONCE on the wire (the server has no session-scope concept); the
1552 // client-side set is what gives it the "for this session" semantics.
1553 if (response == PermissionResponse.SESSION) {
1554 val type = permission.type.orEmpty()
1555 val pattern = permission.patternText.orEmpty()
1556 if (type.isNotEmpty()) sessionAllowed.add(type to pattern)
1558 // Dismiss optimistically (revealing any queued request); permission.replied will confirm.
1559 // If the call fails we re-surface this request so the user can retry instead of being stuck
1560 // with a dismissed dialog and a tool run that's still paused server-side.
1561 resolvePermission(permission.id)
1562 viewModelScope.launch {
1563 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
1564 .onFailure {
1565 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1566 enqueuePermission(permission)
1571 private companion object {
1572 /** Fallback agent for the shell endpoint when none is selected and no agent catalog
1573 * loaded. opencode's default primary agent is "build". */
1574 const val DEFAULT_SHELL_AGENT = "build"
1577 override fun onCleared() {
1578 super.onCleared()
1579 // Flush any pending debounced draft so it survives navigation. Uses an
1580 // asynchronous apply() (not a synchronous commit) so the main thread isn't
1581 // blocked on disk I/O — Android's SharedPreferences framework guarantees
1582 // pending apply() writes are flushed before the process exits.
1583 // Flush the pending draft — including an empty one. Persistence is otherwise debounced,
1584 // so if the user cleared the input and navigated back within the debounce window, the
1585 // debounce coroutine was cancelled with viewModelScope and the prefs still hold the
1586 // previous non-empty draft. flushDraft() removes the key when the text is blank, so
1587 // flushing commits the clear instead of resurrecting the deleted text.
1588 //
1589 // BUT respect the send-in-flight guard: send() optimistically clears _draft to "" WITHOUT
1590 // persisting the clear so a failed send can restore the text. If the user taps Send then
1591 // immediately navigates back (cancelling the in-flight send), flushing the blank _draft here
1592 // would remove the persisted key and erase the pre-send draft with no recovery. Skip the
1593 // flush while a send is in flight so the pre-send draft stays persisted (recoverable).
1594 if (!suppressDraftPersist.get()) container.draftStore.flushDraft(sessionId, _draft.value)