git.iko.soy/opencode-android
on e66c7ed help sign in
2013 lines 111 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.TodoPlanCache
20import soy.iko.opencode.data.model.currentTodoPlan
21import soy.iko.opencode.data.model.defaultOption
22import soy.iko.opencode.data.model.toOptions
23import soy.iko.opencode.data.network.EventStreamClient
24import soy.iko.opencode.data.network.NetworkConfig
25import soy.iko.opencode.data.repo.OutboxMessage
26import soy.iko.opencode.data.repo.SessionRepository
27import soy.iko.opencode.di.AppContainer
28import soy.iko.opencode.R
29import soy.iko.opencode.util.runCatchingCancellable
30import soy.iko.opencode.util.safeExceptionSummary
31import android.util.Log
32import kotlinx.coroutines.ExperimentalCoroutinesApi
33import kotlinx.coroutines.channels.BufferOverflow
34import kotlinx.coroutines.channels.Channel
35import kotlinx.coroutines.flow.Flow
36import kotlinx.coroutines.flow.MutableSharedFlow
37import kotlinx.coroutines.flow.MutableStateFlow
38import kotlinx.coroutines.flow.SharedFlow
39import kotlinx.coroutines.flow.SharingStarted
40import kotlinx.coroutines.flow.StateFlow
41import kotlinx.coroutines.flow.asSharedFlow
42import kotlinx.coroutines.flow.asStateFlow
43import kotlinx.coroutines.flow.receiveAsFlow
44import kotlinx.coroutines.CancellationException
45import kotlinx.coroutines.flow.collectLatest
46import kotlinx.coroutines.flow.combine
47import kotlinx.coroutines.flow.debounce
48import kotlinx.coroutines.flow.distinctUntilChanged
49import kotlinx.coroutines.flow.drop
50import kotlinx.coroutines.flow.first
51import kotlinx.coroutines.flow.filterIsInstance
52import kotlinx.coroutines.flow.flatMapLatest
53import kotlinx.coroutines.flow.flowOf
54import kotlinx.coroutines.flow.flowOn
55import kotlinx.coroutines.flow.map
56import kotlinx.coroutines.flow.merge
57import kotlinx.coroutines.flow.onEach
58import kotlinx.coroutines.flow.retryWhen
59import kotlinx.coroutines.flow.stateIn
60import kotlinx.coroutines.flow.update
61import kotlinx.coroutines.Dispatchers
62import kotlinx.coroutines.Job
63import kotlinx.coroutines.NonCancellable
64import kotlinx.coroutines.delay
65import kotlinx.coroutines.isActive
66import kotlinx.coroutines.currentCoroutineContext
67import kotlinx.coroutines.launch
68import kotlinx.coroutines.withContext
70/**
71 * A user-facing error to surface as a snackbar. [retryable] is true only for a failed
72 * message *send* (whose draft is held in [ChatViewModel.failedDraft]); the snackbar attaches
73 * a Retry action solely for those so an unrelated error (e.g. a message-load failure) can't
74 * inherit a Retry that silently re-submits the last prompt.
75 */
76data class ChatError(val message: String, val retryable: Boolean = false)
78/**
79 * Progress of the current permission within a burst of requests. [total] is the max number
80 * simultaneously pending since the queue was last empty; [position] advances as the user clears
81 * the backlog. A [total] of 0/1 means there's no meaningful backlog to surface.
82 */
83@androidx.compose.runtime.Immutable
84data class PermissionProgress(val position: Int, val total: Int)
86/**
87 * An attachment staged to send with the next prompt. [previewModel] is a Coil-loadable model
88 * for the thumbnail (the source content Uri, as a string) for images, or null for non-image
89 * files (rendered with a generic icon). [part] is the wire form (a base64 data URL) sent to
90 * the server.
91 */
92@androidx.compose.runtime.Immutable
93data class PendingAttachment(
94 val id: String,
95 val name: String,
96 val mime: String,
97 val previewModel: Any?,
98 val part: FilePromptPart,
99) {
100 val isImage: Boolean get() = mime.startsWith("image/")
103/** Reduce a staged attachment to its persistable form (the self-contained data URL). */
104private fun PendingAttachment.toPersisted() =
105 soy.iko.opencode.data.repo.PersistedAttachment(id, name, mime, part.url, part.filename)
107/** Rebuild a staged attachment from persistence. The base64 data URL doubles as the Coil
108 * preview model for images (the original content Uri didn't survive the process restart). */
109private fun soy.iko.opencode.data.repo.PersistedAttachment.toPending() = PendingAttachment(
110 id = id,
111 name = name,
112 mime = mime,
113 previewModel = if (mime.startsWith("image/")) url else null,
114 part = FilePromptPart(mime = mime, url = url, filename = filename),
117// Large by design: this VM owns the whole chat surface (streaming, drafts, attachments,
118// catalogs, permissions, revert/edit, sharing, summarize/init/shell, TTS wiring). It's
119// already grandfathered for TooManyFunctions in the detekt baseline; the class-size rule is
120// suppressed for the same reason. A future split into sub-controllers is tracked separately.
121@Suppress("LargeClass")
122@OptIn(ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
123class ChatViewModel(
124 private val container: AppContainer,
125 private val sessionId: String,
126) : ViewModel() {
128 private val connection get() = container.activeConnection.value
130 val connected: Boolean get() = connection != null
132 private val _loading = MutableStateFlow(true)
133 val loading: StateFlow<Boolean> = _loading.asStateFlow()
135 /** Set when the messages flow failed to load (and the list is empty), so the UI
136 * can render a distinct error state instead of masquerading as an empty
137 * conversation. Cleared on a successful non-empty emission. */
138 private val _loadError = MutableStateFlow(false)
139 val loadError: StateFlow<Boolean> = _loadError.asStateFlow()
141 /** Set when a re-fetch fails mid-conversation (messages already visible). Distinct
142 * from [loadError] (which replaces the whole content with an error screen): this
143 * surfaces as a persistent inline banner above the message list so a persistent
144 * outage doesn't go silent after the one-shot snackbar. Cleared on the next
145 * successful emission. */
146 private val _loadErrorInline = MutableStateFlow(false)
147 val loadErrorInline: StateFlow<Boolean> = _loadErrorInline.asStateFlow()
149 /** Tracks whether the messages flow has ever emitted a non-empty list, so
150 * [messages]' retryWhen can decide whether a re-fetch failure should set
151 * [loadError] (nothing shown yet → error screen) or just emit a snackbar
152 * (already showing messages → don't replace the conversation with an error). */
153 private var hasShownMessages = false
155 /** True once we've surfaced a snackbar for the current message-load failure streak.
156 * Reset on any successful emission so a persistent failure doesn't spam a fresh
157 * snackbar every retry cycle (every few seconds). */
158 private var loadErrorSnackbarShown = false
160 /** Separate from [loading]: tracks the manual reconnect() flow so its spinner
161 * doesn't conflict with the messages flow's loading state. */
162 private val _reconnecting = MutableStateFlow(false)
163 val reconnecting: StateFlow<Boolean> = _reconnecting.asStateFlow()
165 /** When true, the debounced draft collector skips persisting an empty draft so
166 * [send]'s deliberate non-persistence of the cleared draft isn't undone by the
167 * debounce timer firing before the send completes. */
168 private val suppressDraftPersist = java.util.concurrent.atomic.AtomicBoolean(false)
170 /** The draft value this VM last persisted, so the drafts observer can tell its own
171 * (debounced, possibly stale) echo apart from a genuine external write. Without this
172 * a debounced persist of an older value could echo back and overwrite newer text the
173 * user has since typed. */
174 @Volatile private var lastPersistedDraft: String? = null
176 /** Per-catalog reload triggers: incrementing one causes [observeCatalog]'s
177 * collectLatest to cancel any in-flight fetch and start a fresh one, so a
178 * manual reload supersedes a stale observeCatalog fetch. */
179 private val _modelsReload = MutableStateFlow(0)
180 private val _agentsReload = MutableStateFlow(0)
181 private val _commandsReload = MutableStateFlow(0)
183 /** An optimistically-injected user message shown immediately on send, before the server
184 * echoes the real message back via SSE. Removed once a matching UserMessage arrives. */
185 data class OptimisticEntry(val tempId: String, val text: String, val timestamp: Long, val failed: Boolean)
187 private val _optimisticMessages = MutableStateFlow<List<OptimisticEntry>>(emptyList())
189 /** SSE-driven message stream with retry/backoff and loading/error side effects. Combined
190 * with [_optimisticMessages] by [messages] so the user sees their outgoing prompt instantly. */
191 private val sseMessages: Flow<List<MessageWithParts>> =
192 container.activeConnection
193 .flatMapLatest { conn ->
194 conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
195 }
196 .onEach {
197 _loading.value = false
198 // A value flowed through: the stream recovered, so allow the next distinct
199 // failure to surface a fresh snackbar again.
200 loadErrorSnackbarShown = false
201 // Clear the error on ANY successful emission, including an empty list. An empty
202 // session that hit one transient error would otherwise stay latched on the
203 // "Failed to load / Retry" screen forever, because the later successful EMPTY
204 // emission couldn't clear the flag. hasShownMessages stays gated on non-empty
205 // (an empty session has genuinely shown nothing yet).
206 _loadError.value = false
207 _loadErrorInline.value = false
208 if (it.isNotEmpty()) {
209 hasShownMessages = true
210 }
211 // Reconcile optimistic messages: drop any whose text now matches a real SSE
212 // UserMessage (the server echoed the prompt back). Matching by trimmed text,
213 // removing the oldest match first so duplicate texts are handled in order.
214 reconcileOptimistic(it)
215 }
216 .retryWhen { cause, attempt ->
217 _loading.value = false
218 // Only surface the persistent error state when there's nothing to
219 // show — if we already have messages, a transient re-fetch failure
220 // is better surfaced as a snackbar (via errorEvents) than by
221 // replacing the visible conversation with an error screen.
222 if (!hasShownMessages) _loadError.value = true
223 // Mid-conversation failure: surface a persistent inline banner so
224 // the user knows the stream is broken (the one-shot snackbar alone
225 // reads as "recovered" once it dismisses). Cleared on recovery.
226 if (hasShownMessages) _loadErrorInline.value = true
227 // Surface the snackbar only once per failure streak — retryWhen loops on a
228 // backoff, so emitting here unconditionally would spam a new snackbar every
229 // retry on a persistent load failure.
230 if (!loadErrorSnackbarShown) {
231 _errorEvents.trySend(ChatError(container.friendlyError(cause)))
232 loadErrorSnackbarShown = true
233 }
234 // Exponential backoff capped at retryMaxDelayMs so a persistently failing
235 // server is retried with growing delays (retryInitialDelayMs, then doubling)
236 // instead of hammered at a fixed interval forever. Clamp the shift so a long
237 // failure streak can't overflow the Long delay. Apply symmetric jitter
238 // (matching withRetry's jitteredBackoff) so concurrent retrying flows (e.g.
239 // messages + catalogs after a server blip) don't retry in lockstep and
240 // thundering-herd the recovering server.
241 val shift = attempt.coerceAtMost(16L).toInt()
242 val baseDelay = (NetworkConfig.retryInitialDelayMs shl shift)
243 .coerceAtMost(NetworkConfig.retryMaxDelayMs)
244 val jitter = ((baseDelay * NetworkConfig.retryJitterFactor) *
245 (kotlin.random.Random.nextDouble() * 2 - 1)).toLong()
246 val backoffMs = (baseDelay + jitter).coerceAtLeast(0)
247 delay(backoffMs)
248 true
249 }
251 val messages: StateFlow<List<MessageWithParts>> =
252 combine(sseMessages, _optimisticMessages) { sse, optimistic ->
253 mergeOptimistic(sse, optimistic)
254 }.stateIn(
255 scope = viewModelScope,
256 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
257 initialValue = emptyList(),
258 )
260 /** Whether the conversation has any messages. Derived separately so the top bar
261 * (share button enabled state) can observe this cheap boolean instead of the
262 * full messages list, avoiding per-token recomposition of the app bar during
263 * streaming. */
264 val hasMessages: StateFlow<Boolean> = messages
265 .map { it.isNotEmpty() }
266 .stateIn(
267 scope = viewModelScope,
268 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
269 initialValue = false,
270 )
272 /** Map of optimistic message tempId → failed flag, so the UI can render a "Sending…"
273 * or "Failed to send" indicator on the corresponding message bubble. */
274 val optimisticStatuses: StateFlow<Map<String, Boolean>> = _optimisticMessages
275 .map { entries -> entries.associate { it.tempId to it.failed } }
276 .stateIn(
277 scope = viewModelScope,
278 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
279 initialValue = emptyMap(),
280 )
282 /** Merge SSE-driven messages with optimistic entries. An optimistic entry whose trimmed
283 * text matches a real [UserMessage] in [sse] is dropped (the server echoed it back).
284 * Remaining entries are appended as synthetic [UserMessage]s at the end. Failed entries
285 * are kept so the user sees the failed indicator until they retry or dismiss.
286 *
287 * Only the OLDEST matching entry per real text is dropped, so duplicate prompts (the same
288 * text sent twice) each match their own server-echoed UserMessage instead of both
289 * vanishing on the first echo. */
290 private fun mergeOptimistic(
291 sse: List<MessageWithParts>,
292 optimistic: List<OptimisticEntry>,
293 ): List<MessageWithParts> {
294 if (optimistic.isEmpty()) return sse
295 // Collect the text of real user messages so optimistic entries can be matched.
296 val realUserTexts = mutableSetOf<String>()
297 for (msg in sse) {
298 if (msg.info !is UserMessage) continue
299 val text = msg.parts.filterIsInstance<TextPart>()
300 .joinToString("\n\n") { it.text }
301 .trim()
302 if (text.isNotEmpty()) realUserTexts.add(text)
303 }
304 val surviving = dropOldestMatches(optimistic, realUserTexts)
305 if (surviving.isEmpty()) return sse
306 return sse + surviving.map { it.toMessageWithParts() }
307 }
309 /** Remove optimistic entries whose text now matches a real user message delivered by SSE.
310 * Called from the SSE flow's onEach so stale optimistic entries are cleaned up promptly.
311 * Only the OLDEST matching entry per real text is removed, so duplicate prompts (the same
312 * text sent twice) each match their own server-echoed UserMessage instead of both
313 * vanishing on the first echo. */
314 private fun reconcileOptimistic(sse: List<MessageWithParts>) {
315 if (_optimisticMessages.value.isEmpty()) return
316 val realUserTexts = mutableSetOf<String>()
317 for (msg in sse) {
318 if (msg.info !is UserMessage) continue
319 val text = msg.parts.filterIsInstance<TextPart>()
320 .joinToString("\n\n") { it.text }
321 .trim()
322 if (text.isNotEmpty()) realUserTexts.add(text)
323 }
324 if (realUserTexts.isEmpty()) return
325 _optimisticMessages.update { entries -> dropOldestMatches(entries, realUserTexts) }
326 }
328 /** Drop the first (oldest) entry whose trimmed text matches each real user text, once per
329 * text. A second optimistic entry with the same text survives until a second matching
330 * UserMessage arrives, so duplicate prompts aren't both cleared by the first echo. */
331 private fun dropOldestMatches(
332 entries: List<OptimisticEntry>,
333 realUserTexts: Set<String>,
334 ): List<OptimisticEntry> {
335 if (entries.isEmpty() || realUserTexts.isEmpty()) return entries
336 val remaining = realUserTexts.toMutableSet()
337 val result = ArrayList<OptimisticEntry>(entries.size)
338 for (entry in entries) {
339 val text = entry.text.trim()
340 if (text in remaining) {
341 remaining.remove(text)
342 } else {
343 result.add(entry)
344 }
345 }
346 return result
347 }
349 /** Convert an optimistic entry to a [MessageWithParts] for display. */
350 private fun OptimisticEntry.toMessageWithParts(): MessageWithParts = MessageWithParts(
351 info = UserMessage(
352 id = tempId,
353 sessionID = sessionId,
354 time = soy.iko.opencode.data.model.TimeInfo(created = timestamp),
355 ),
356 parts = listOf(TextPart(id = "$tempId-text", text = text)),
357 )
359 /** The agent's current task plan (the latest `todowrite`), surfaced so the chat can pin a
360 * live progress checklist above the composer. distinctUntilChanged so a per-token messages
361 * emission that doesn't change the plan doesn't recompose the bar.
362 *
363 * A [TodoPlanCache] memoizes the scan against the input list's identity, so a `combine`
364 * re-evaluation that re-emits the same list instance (e.g. when the optimistic-messages
365 * StateFlow updates without an SSE change) skips the O(messages×parts) re-scan. */
366 private val todoPlanCache = TodoPlanCache()
367 val todoPlan: StateFlow<List<TodoItem>> = messages
368 .map { todoPlanCache.plan(it) }
369 // Run the scan off the main thread: it's an O(messages×parts) scan (worst case
370 // when the conversation has no todowrite) and runs on every conflated messages emission.
371 // distinctUntilChanged is downstream so it dedupes the result but doesn't prevent the
372 // scan — without flowOn this scans on Main.immediate per streamed token → jank.
373 .flowOn(Dispatchers.Default)
374 .distinctUntilChanged()
375 .stateIn(
376 scope = viewModelScope,
377 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
378 initialValue = emptyList(),
379 )
381 private val _running = MutableStateFlow(false)
382 val running: StateFlow<Boolean> = _running.asStateFlow()
384 /**
385 * True when the SSE stream dropped mid-run (a transient [Disconnected] while a run is
386 * active). The UI uses this to switch the working indicator to a "stream interrupted /
387 * reconnecting" visual instead of clearing [_running] outright — the run is still active
388 * on the server, so showing it as finished would mislead the user into typing a follow-up
389 * or navigating away. Cleared when the stream reconnects ([Connected]) or when a hard
390 * failure ([Failed]/[AuthFailed]) genuinely clears [_running]. See the state watcher in
391 * [init]. */
392 private val _streamInterrupted = MutableStateFlow(false)
393 val streamInterrupted: StateFlow<Boolean> = _streamInterrupted.asStateFlow()
395 /**
396 * Wall-clock millis at which the current run started (0L when no run is active). Drives
397 * the typing row's elapsed timer so it survives LazyColumn recycling — a local `remember`
398 * in the row resets to 0:00 when the row is disposed and scrolled back into view. Stamped
399 * only on a real false→[beginRun] transition (send/run/init), not on SSE-reconnect relight
400 * (which continues an existing run and should keep its original elapsed time).
401 */
402 private val _runStartMs = MutableStateFlow(0L)
403 val runStartMs: StateFlow<Long> = _runStartMs.asStateFlow()
405 /**
406 * Atomically transition `_running` false→true and stamp the run-start timestamp. Returns
407 * false (with no state change) when a run is already in flight, so callers can surface a
408 * "busy" message instead of silently dropping the action. Centralizes the transition so
409 * every run-start path captures [runStartMs] consistently.
410 */
411 private fun beginRun(): Boolean {
412 if (!_running.compareAndSet(false, true)) return false
413 _runStartMs.value = System.currentTimeMillis()
414 return true
415 }
417 /** True once the current run ended via SessionIdle/SessionError, until the next run starts
418 * (a send) or an SSE reconnect. Gates the run-indicator re-light below: isRunActivity
419 * returns true for ANY message.part.updated, so without this a trailing/replayed part
420 * arriving after a legitimate SessionIdle would re-light the "working" indicator with no
421 * further idle to clear it — a stuck spinner + foreground service. Cleared on reconnect so
422 * genuine reconnect-recovery re-lighting (its actual purpose) still works. */
423 @Volatile private var runEndedByIdle = false
425 /** The in-flight REST title resolution for the current connection. Launched from — and
426 * cancelled by — the per-connection reset block so it can't observe or restore a previous
427 * server's stale title. */
428 private var sessionTitleJob: Job? = null
430 /** True while an abort REST call is in flight, so the Stop button can show a
431 * spinner and prevent double-taps from firing a second abort. */
432 private val _aborting = MutableStateFlow(false)
433 val aborting: StateFlow<Boolean> = _aborting.asStateFlow()
435 /** A follow-up the user typed while a run was active. send() queues it here and
436 * auto-sends once the run completes (SessionIdle), so the user isn't blocked with
437 * no way to send and no indication why the Send button is gone. */
438 private val _queuedFollowUp = MutableStateFlow<String?>(null)
439 val queuedFollowUp: StateFlow<String?> = _queuedFollowUp.asStateFlow()
441 /** The text of the most recently-sent user prompt (captured in [send]), so an undo-able
442 * Stop can re-send it. Only text-bearing prompts are tracked — an image-only prompt has
443 * nothing to re-send text-wise. Cleared when a new run starts with different text. */
444 private var lastSentPrompt: String? = null
446 /** One-shot event signals for an undoable Stop. The UI collects this and shows an
447 * "Stopped — Undo" snackbar; tapping Undo calls [resendLastPrompt]. A Channel so an
448 * emit before the UI subscribes is still delivered. */
449 private val _stopUndoEvents = Channel<String?>(Channel.CONFLATED)
450 val stopUndoEvents: Flow<String?> = _stopUndoEvents.receiveAsFlow()
452 /** One-shot error events surfaced as snackbars. A Channel (not SharedFlow) so an event
453 * emitted before the UI subscribes is buffered and still delivered — a SharedFlow with
454 * replay=0 would drop it (e.g. a VM-init catalog fetch failing before first
455 * composition). Each event is delivered exactly once and not replayed to a
456 * re-subscribing collector, so rotation doesn't re-show a stale snackbar. */
457 private val _errorEvents = Channel<ChatError>(
458 capacity = NetworkConfig.snackbarEventBufferCapacity,
459 onBufferOverflow = BufferOverflow.DROP_OLDEST,
460 )
461 val errorEvents: Flow<ChatError> = _errorEvents.receiveAsFlow()
463 /** One-shot events signaling a user-initiated delete was scheduled and is undoable in-place.
464 * The ChatScreen collects these to show an Undo snackbar over the conversation the user is
465 * already viewing, instead of navigating away first and surfacing Undo on the session list.
466 * Carries the session id so a collector using `collectLatest` can withdraw a superseded
467 * delete (see [cancelSessionDelete]) — a `Unit` event would leave the prior session's
468 * deferred REST delete firing with no reachable Undo. */
469 private val _deleteUndoEvents = MutableSharedFlow<String>(
470 extraBufferCapacity = 1,
471 onBufferOverflow = BufferOverflow.DROP_OLDEST,
472 )
473 val deleteUndoEvents: SharedFlow<String> = _deleteUndoEvents.asSharedFlow()
475 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
476 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
478 private val _modelsLoading = MutableStateFlow(true)
479 val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
481 private val _modelsError = MutableStateFlow(false)
482 val modelsError: StateFlow<Boolean> = _modelsError.asStateFlow()
484 private val _selectedModel = MutableStateFlow<ModelOption?>(null)
485 val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
487 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
488 container.activeConnection
489 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
490 .stateIn(
491 scope = viewModelScope,
492 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
493 initialValue = EventStreamClient.ConnectionState.Disconnected,
494 )
496 /** SSE reconnect-attempt count for the active connection, surfaced so the chat's
497 * connection banner can show "Reconnecting (attempt N)…" during a sustained outage. */
498 val reconnectAttempts: StateFlow<Int> =
499 container.activeConnection
500 .flatMapLatest { it?.events?.reconnectAttempts ?: flowOf(0) }
501 .stateIn(
502 scope = viewModelScope,
503 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
504 initialValue = 0,
505 )
507 private val _pendingPermission = MutableStateFlow<Permission?>(null)
508 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
510 /** Backlog indicator for the permission dialog (see [PermissionProgress]). */
511 private val _permissionProgress = MutableStateFlow(PermissionProgress(0, 0))
512 val permissionProgress: StateFlow<PermissionProgress> = _permissionProgress.asStateFlow()
514 /** Max number of permissions simultaneously pending since the queue was last empty. Reset
515 * to 0 when the queue drains, so a fresh burst starts its count over. */
516 private var maxPendingSeen = 0
518 // Unanswered permission requests keyed by id, insertion-ordered. The dialog shows the most
519 // recent; when one is answered/replied — or a response fails — we fall back to the next so a
520 // request that arrived while another was in flight isn't silently dropped (its tool run would
521 // otherwise stay paused server-side with no UI affordance). Only touched on the VM's main
522 // dispatcher (event collector + viewModelScope launches), so no extra synchronization is needed.
523 private val pendingPermissions = LinkedHashMap<String, Permission>()
525 // Session-scoped permission grants (Allow-for-this-session). Each entry is a (type, patternText)
526 // pair the user approved with PermissionResponse.SESSION. Subsequent matching requests are
527 // auto-responded ONCE without showing the dialog, so e.g. a user granting "read" for one path
528 // isn't re-prompted for every other file read in the same conversation. Cleared on connection
529 // change (see the activeConnection collector) so it never leaks across servers. The server only
530 // knows once/always/reject; SESSION is a client-side scope that maps to ONCE on the wire.
531 private val sessionAllowed = mutableSetOf<Pair<String, String>>()
533 private fun enqueuePermission(permission: Permission) {
534 // Auto-respond to a permission whose (type, pattern) the user previously granted for this
535 // session, instead of showing the dialog again. Resolved immediately (not via the queue) so
536 // the tool run isn't paused waiting for a dialog the user will never see.
537 val type = permission.type.orEmpty()
538 val pattern = permission.patternText.orEmpty()
539 if (type.isNotEmpty() && sessionAllowed.contains(type to pattern)) {
540 respondPermission(permission, PermissionResponse.ONCE)
541 return
542 }
543 pendingPermissions[permission.id] = permission
544 _pendingPermission.value = pendingPermissions.values.lastOrNull()
545 updatePermissionProgress()
546 }
548 private fun resolvePermission(id: String) {
549 pendingPermissions.remove(id)
550 _pendingPermission.value = pendingPermissions.values.lastOrNull()
551 updatePermissionProgress()
552 }
554 private fun clearPermissions() {
555 pendingPermissions.clear()
556 _pendingPermission.value = null
557 updatePermissionProgress()
558 }
560 /** Recompute the "N of M" backlog indicator. [maxPendingSeen] grows with the queue and
561 * resets only when it drains, so position = total - remaining + 1 advances as requests are
562 * answered. */
563 private fun updatePermissionProgress() {
564 val remaining = pendingPermissions.size
565 if (remaining == 0) {
566 maxPendingSeen = 0
567 _permissionProgress.value = PermissionProgress(0, 0)
568 return
569 }
570 if (remaining > maxPendingSeen) maxPendingSeen = remaining
571 _permissionProgress.value = PermissionProgress(
572 position = maxPendingSeen - remaining + 1,
573 total = maxPendingSeen,
574 )
575 }
577 private val _agents = MutableStateFlow<List<Agent>>(emptyList())
578 val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
580 private val _agentsLoading = MutableStateFlow(true)
581 val agentsLoading: StateFlow<Boolean> = _agentsLoading.asStateFlow()
583 private val _agentsError = MutableStateFlow(false)
584 val agentsError: StateFlow<Boolean> = _agentsError.asStateFlow()
586 private val _selectedAgent = MutableStateFlow<String?>(null)
587 val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
589 private val _commands = MutableStateFlow<List<Command>>(emptyList())
590 val commands: StateFlow<List<Command>> = _commands.asStateFlow()
592 private val _commandsLoading = MutableStateFlow(true)
593 val commandsLoading: StateFlow<Boolean> = _commandsLoading.asStateFlow()
595 private val _commandsError = MutableStateFlow(false)
596 val commandsError: StateFlow<Boolean> = _commandsError.asStateFlow()
598 private val _sessionTitle = MutableStateFlow<String?>(null)
599 val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
601 /** Set when the current session is deleted via SSE, so the UI can navigate away. */
602 private val _sessionDeleted = MutableStateFlow(false)
603 val sessionDeleted: StateFlow<Boolean> = _sessionDeleted.asStateFlow()
605 /** The text of the last send that failed, surfaced so the UI can offer a retry. */
606 private val _failedDraft = MutableStateFlow<String?>(null)
607 val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
609 /** The idempotency key of the last failed online send, stashed alongside [_failedDraft] so
610 * [retryFailed] re-submits with the SAME key. A retry after a lost response is then
611 * deduplicated server-side instead of starting a duplicate agent run. */
612 @Volatile private var failedIdempotencyKey: String? = null
614 /** Per-session draft, persisted so it survives navigation/process death. */
615 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
616 val draft: StateFlow<String> = _draft.asStateFlow()
618 /** A pending quote-reply preview (the raw text the user quoted), shown as a dismissible
619 * card above the composer so the user can see and cancel the quote before sending. Cleared
620 * on send or via [cancelQuoteReply]. The actual `>` prefixed lines are still folded into
621 * the draft for the server; this state is purely for the visual preview chip. */
622 private val _pendingQuote = MutableStateFlow<String?>(null)
623 val pendingQuote: StateFlow<String?> = _pendingQuote.asStateFlow()
625 /** Messages composed for this session while offline/disconnected, queued in the outbox and
626 * awaiting an automatic flush on reconnect. Surfaced so the composer can show a "queued"
627 * chip with a discard/send-now affordance. */
628 val outbox: StateFlow<List<OutboxMessage>> =
629 container.outboxStore.messages
630 .map { list -> list.filter { it.sessionId == sessionId } }
631 .stateIn(
632 scope = viewModelScope,
633 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
634 initialValue = emptyList(),
635 )
637 /** Attachments staged for the next prompt (images/files). Persisted per-session (as their
638 * self-contained base64 data URLs) via [AttachmentDraftStore] so an interrupted compose
639 * survives process death — the source content Uris wouldn't survive, but the data URLs
640 * do. Cleared on a successful send. */
641 private val _attachments = MutableStateFlow<List<PendingAttachment>>(emptyList())
642 val attachments: StateFlow<List<PendingAttachment>> = _attachments.asStateFlow()
644 // Count of picks currently being read + base64-encoded off the main thread, so the
645 // composer can show a staging placeholder immediately (chips only materialize once done).
646 // Exposed as StateFlows (not Compose state) so they survive a config change: the staging
647 // work runs on viewModelScope (which survives rotation), so the counters must live on the
648 // VM too — remember { mutableStateOf(0) } in the composable would reset to 0 on rotation
649 // and lose the staging chip mid-encode.
650 private val _stagingCount = MutableStateFlow(0)
651 val stagingCount: StateFlow<Int> = _stagingCount.asStateFlow()
652 // Total number of files still being staged across all in-flight batches, so the
653 // staging chip can show "Staging N files…" rather than a bare indeterminate spinner.
654 private val _stagingFileTotal = MutableStateFlow(0)
655 val stagingFileTotal: StateFlow<Int> = _stagingFileTotal.asStateFlow()
657 /** True while a revert checkpoint is active for this session (messages after it are hidden
658 * server-side). Drives the "reverted" banner with its Undo. */
659 private val _reverted = MutableStateFlow(false)
660 val reverted: StateFlow<Boolean> = _reverted.asStateFlow()
662 /** True when the revert was triggered by an Edit action (vs a bare Revert). Drives the
663 * banner's copy so it reads "Editing message" rather than "Reverted", making the composer's
664 * prefilled text unambiguous. Cleared on unrevert and on send. */
665 private val _editing = MutableStateFlow(false)
666 val editing: StateFlow<Boolean> = _editing.asStateFlow()
668 /** The diff of the active revert checkpoint, when the server includes one. Shown in the
669 * revert banner so the user can see what changed before deciding to undo. Null when the
670 * session isn't reverted or the server didn't send a diff. */
671 private val _revertDiff = MutableStateFlow<String?>(null)
672 val revertDiff: StateFlow<String?> = _revertDiff.asStateFlow()
674 /** The active public share URL for this session, or null when not shared. */
675 private val _shareUrl = MutableStateFlow<String?>(null)
676 val shareUrl: StateFlow<String?> = _shareUrl.asStateFlow()
678 /** One-shot events carrying a freshly-created share URL so the UI can copy/share it. */
679 private val _shareLinkEvents = Channel<String>(
680 capacity = NetworkConfig.snackbarEventBufferCapacity,
681 onBufferOverflow = BufferOverflow.DROP_OLDEST,
682 )
683 val shareLinkEvents: Flow<String> = _shareLinkEvents.receiveAsFlow()
685 /** One-shot events carrying the id of a session freshly branched from a message, so the
686 * UI can navigate to it. */
687 private val _branchEvents = Channel<String>(
688 capacity = NetworkConfig.snackbarEventBufferCapacity,
689 onBufferOverflow = BufferOverflow.DROP_OLDEST,
690 )
691 val branchEvents: Flow<String> = _branchEvents.receiveAsFlow()
693 init {
694 // DraftStore loads SharedPreferences asynchronously; on a cold start the
695 // synchronous get() above returns "" until the background load completes.
696 // Observe the ready signal and re-seed the draft so it appears in the UI.
697 viewModelScope.launch {
698 container.draftStore.ready.collect { ready ->
699 if (!ready) return@collect
700 if (_draft.value.isEmpty()) {
701 _draft.value = container.draftStore.get(sessionId)
702 }
703 // Recover a follow-up queued before process death (its in-memory StateFlow
704 // is gone). If the input is now free, drop it back there for review/send and
705 // consume the stored copy; otherwise re-queue it so it auto-sends when the
706 // current run next idles. Guarded on == null so it never clobbers a
707 // follow-up the user queued after opening this session.
708 val savedFollowUp = container.draftStore.getFollowUp(sessionId)
709 if (savedFollowUp.isNotEmpty() && _queuedFollowUp.value == null) {
710 if (_draft.value.isEmpty()) {
711 _draft.value = savedFollowUp
712 container.draftStore.flushFollowUp(sessionId, "")
713 } else {
714 _queuedFollowUp.value = savedFollowUp
715 }
716 }
717 }
718 }
719 // Observe the drafts store for external mutations to this session's draft
720 // (e.g. a share injected via draftStore.set in two-pane mode, or setImmediate
721 // before navigation in single-pane mode). Without this, a share injected into
722 // an already-open session never appears in the input field — the init-time
723 // read and the ready-only re-seed miss it when the draft is non-empty.
724 // The VM's own debounced persist writes the same value _draft already holds,
725 // so those updates are no-ops here (storeValue == _draft.value). The
726 // suppressDraftPersist guard prevents a stale store value from clobbering
727 // the deliberate draft clear during a send (the store still holds the
728 // pre-send draft until the send resolves).
729 viewModelScope.launch {
730 container.draftStore.drafts.collect { drafts ->
731 // Ignore emissions until the initial disk load completes: the load itself
732 // emits the persisted snapshot, and applying it here would clobber text the
733 // user typed during the async load (the `ready` observer above seeds an
734 // empty draft). After ready, emissions are external writes.
735 if (!container.draftStore.ready.value) return@collect
736 val storeValue = drafts[sessionId].orEmpty()
737 // Ignore this VM's own (debounced, possibly stale) persistence echo — only a
738 // genuine external write (e.g. a two-pane share injection) should overwrite
739 // the live draft.
740 if (storeValue == lastPersistedDraft) return@collect
741 if (storeValue != _draft.value && !suppressDraftPersist.get()) {
742 _draft.value = storeValue
743 }
744 }
745 }
746 }
748 init {
749 // Restore attachments staged before process death. They were persisted as
750 // self-contained data URLs, so they re-send and (for images) re-preview intact.
751 // Guarded on empty so it never clobbers attachments the user staged after opening.
752 viewModelScope.launch {
753 val restored = runCatchingCancellable { container.attachmentDraftStore.load(sessionId) }
754 .getOrDefault(emptyList())
755 if (restored.isNotEmpty() && _attachments.value.isEmpty()) {
756 _attachments.value = restored.map { it.toPending() }
757 }
758 }
759 }
761 fun selectModel(option: ModelOption) { _selectedModel.value = option }
763 fun selectAgent(name: String?) { _selectedAgent.value = name }
765 fun reloadModels() {
766 val conn = connection ?: return
767 viewModelScope.launch {
768 conn.api.invalidateProvidersCache()
769 _modelsReload.value++
770 }
771 }
773 fun reloadAgents() {
774 val conn = connection ?: return
775 viewModelScope.launch {
776 conn.api.invalidateAgentsCache()
777 _agentsReload.value++
778 }
779 }
781 fun reloadCommands() {
782 val conn = connection ?: return
783 viewModelScope.launch {
784 conn.api.invalidateCommandsCache()
785 _commandsReload.value++
786 }
787 }
789 /**
790 * Shared helper for init-block catalog observers: re-fetches whenever the active
791 * connection or the catalog's reload trigger changes, invoking [onNull] to clear
792 * state on null so stale data from the old server doesn't persist. The
793 * [reloadTrigger] is merged with [activeConnection] so a manual reload (via
794 * reloadModels/reloadAgents/reloadCommands) cancels any in-flight observe fetch
795 * via collectLatest, preventing a stale observe result from overwriting a fresh
796 * reload result. Failure is non-fatal — the error flag is surfaced to the UI.
797 */
798 private fun <T> observeCatalog(
799 tag: String,
800 loading: MutableStateFlow<Boolean>,
801 error: MutableStateFlow<Boolean>,
802 fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
803 onSuccess: suspend (T) -> Unit,
804 onNull: () -> Unit,
805 reloadTrigger: StateFlow<Int>,
806 ) {
807 viewModelScope.launch {
808 merge(container.activeConnection, reloadTrigger).collectLatest { _ ->
809 val conn = container.activeConnection.value
810 if (conn == null) { onNull(); loading.value = false; error.value = false; return@collectLatest }
811 loading.value = true
812 error.value = false
813 runCatchingCancellable { fetch(conn.api) }
814 // fetch() does HTTP via withRetry, so the failure can be a
815 // ClientRequestException whose message embeds the full request URL
816 // (may contain auth/paths). Log only a scrubbed summary.
817 .onFailure { Log.w("ChatViewModel", "Failed to load $tag: ${safeExceptionSummary(it)}"); error.value = true }
818 .getOrNull()?.let { onSuccess(it) }
819 loading.value = false
820 }
821 }
822 }
824 fun updateDraft(text: String) {
825 _draft.value = text
826 }
828 init {
829 // Debounce draft persistence so we don't write to disk on every keystroke.
830 // Skip persisting empty drafts that were set by send() (suppressed via
831 // suppressDraftPersist) — send() clears the in-memory draft immediately for
832 // UI feedback but deliberately doesn't persist the clear until the send
833 // succeeds, so a failed send can restore the draft.
834 viewModelScope.launch {
835 _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
836 if (text.isBlank() && suppressDraftPersist.get()) return@collect
837 lastPersistedDraft = text
838 runCatchingCancellable { container.draftStore.set(sessionId, text) }
839 .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
840 }
841 }
842 // Reset per-connection state when the active server changes so stale spinners,
843 // permission dialogs, errors, and agent selections from the old server don't
844 // persist into the new one.
845 viewModelScope.launch {
846 container.activeConnection.collectLatest { conn ->
847 if (conn == null) {
848 // The connection dropped (e.g. disconnect() mid-run). No SSE stream will
849 // arrive to deliver SessionIdle/SessionError, so reset the run state here —
850 // otherwise the working spinner sticks on, ChatScreen keeps keepScreenOn +
851 // the RunForegroundService alive, and the Stop button no-ops (abort() early-
852 // returns with no connection) until a later reconnect happens to clear it.
853 _running.value = false
854 _streamInterrupted.value = false
855 _aborting.value = false
856 clearPermissions()
857 sessionAllowed.clear()
858 sessionTitleJob?.cancel()
859 _revertDiff.value = null
860 return@collectLatest
861 }
862 _running.value = false
863 _streamInterrupted.value = false
864 // Allow the re-light below to recover a still-running run after this (re)connect;
865 // a run that actually finished during the outage emits no live parts, so this
866 // won't spuriously re-light.
867 runEndedByIdle = false
868 clearPermissions()
869 sessionAllowed.clear()
870 _failedDraft.value = null
871 _revertDiff.value = null
872 // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
873 // user intent that is now persisted; wiping it on every (re)connect — which
874 // this collector does, including transient SSE drops and the cold-start
875 // restore path — would drop a legitimately queued/recovered follow-up.
876 _selectedAgent.value = null
877 // Apply the user's persisted preferred-agent for this new session/load, so a
878 // user who always runs a specific agent doesn't have to pick it every session.
879 // No-op when the preference is empty (the server default applies). Read here
880 // (not in the agent-catalog onSuccess) because the catalog loads once per
881 // connection while this reset fires per-session-open; setting it once on
882 // connect is enough and avoids re-applying it on every catalog refresh.
883 runCatchingCancellable {
884 val pref = container.settingsStore.preferredAgentName.first()
885 if (pref.isNotEmpty()) _selectedAgent.value = pref
886 }
887 _sessionTitle.value = null
888 // Resolve the app-bar title via REST for THIS connection. Launched from the same
889 // block that just nulled _sessionTitle (and cancelled the previous fetch) so it can
890 // never observe or restore the previous server's stale title — the old separate
891 // collector could, racing that reset. The assignment is guarded on a still-null
892 // title so a fresher SSE SessionUpdated (from the event collector below) wins.
893 sessionTitleJob?.cancel()
894 sessionTitleJob = viewModelScope.launch {
895 runCatchingCancellable { conn.repository.listSessions() }
896 .getOrNull()
897 ?.firstOrNull { it.id == sessionId }
898 ?.let { session ->
899 if (_sessionTitle.value == null) {
900 _sessionTitle.value = session.displayTitle
901 _reverted.value = session.isReverted
902 _revertDiff.value = session.revert?.diff?.takeIf { it.isNotBlank() }
903 _shareUrl.value = session.share?.url?.takeIf { it.isNotBlank() }
904 }
905 }
906 }
907 try {
908 // Loop the inner collect so a transient non-cancellation exception (a
909 // malformed SSE event decoding error, an unexpected channel close)
910 // doesn't terminate the event collector for this connection. Without
911 // the loop, the catch returns the collectLatest lambda normally, so
912 // collectLatest considers this connection's block complete and never
913 // re-runs it — the chat would go permanently silent (no streaming,
914 // no permission prompts, no title updates) until a server switch.
915 while (currentCoroutineContext().isActive) {
916 try {
917 conn.events.events.collect { event ->
918 if (SessionRepository.isIdle(event, sessionId)) {
919 _running.value = false
920 runEndedByIdle = true
921 // Auto-send a queued follow-up once the previous run finishes,
922 // so the user's drafted-while-running message isn't lost.
923 // The follow-up is cleared *inside* send()'s launched
924 // coroutine (via queuedFollowUpToClear), so a scope
925 // cancellation between this point and the coroutine
926 // starting drops the launch without wiping the persisted
927 // follow-up — recovery on next session open instead of
928 // silent data loss.
929 val queued = _queuedFollowUp.value
930 if (queued != null) {
931 send(queued, includeAttachments = false, queuedFollowUpToClear = queued)
932 }
933 }
934 if (SessionRepository.isError(event, sessionId)) {
935 _running.value = false
936 runEndedByIdle = true
937 // The run errored, so the queued follow-up won't auto-send.
938 // Restore it to the input (if free) so the user's typed text
939 // isn't silently lost; otherwise just clear the queue.
940 val queued = _queuedFollowUp.value
941 if (!queued.isNullOrEmpty() && _draft.value.isEmpty()) {
942 _draft.value = queued
943 }
944 setQueuedFollowUp(null)
945 _errorEvents.trySend(ChatError(container.string(R.string.error_agent_reported)))
946 }
947 // Restore the run indicator if a live streaming event arrives while it's
948 // cleared — e.g. an SSE reconnect mid-run reset _running to false, but the
949 // agent is still working. Don't revive it once the user has hit Stop.
950 val relightRunIndicator = SessionRepository.isRunActivity(event, sessionId) &&
951 !_aborting.value && !_running.value && !runEndedByIdle
952 if (relightRunIndicator) {
953 _running.value = true
954 }
955 when (event) {
956 is PermissionUpdated ->
957 if (event.properties.sessionID == sessionId) enqueuePermission(event.properties)
958 is PermissionReplied ->
959 if (event.properties.sessionID == sessionId) event.properties.permissionID?.let { resolvePermission(it) }
960 is SessionUpdated ->
961 if (event.properties.info.id == sessionId) {
962 val info = event.properties.info
963 _sessionTitle.value = info.displayTitle
964 _reverted.value = info.isReverted
965 _revertDiff.value = info.revert?.diff?.takeIf { it.isNotBlank() }
966 _shareUrl.value = info.share?.url?.takeIf { it.isNotBlank() }
967 }
968 is SessionDeleted ->
969 if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
970 _sessionDeleted.value = true
971 }
972 else -> {}
973 }
974 }
975 } catch (e: CancellationException) {
976 throw e
977 } catch (e: Exception) {
978 Log.w("ChatViewModel", "SSE event collector error, restarting", e)
979 }
980 }
981 } catch (e: CancellationException) {
982 throw e
983 } catch (e: Exception) {
984 Log.w("ChatViewModel", "SSE event collector loop error", e)
985 }
986 }
987 }
988 // Load the model catalog; preselect the server default. Failure is non-fatal —
989 // sending with no model just uses the server's default agent/model.
990 observeCatalog(
991 tag = "model catalog",
992 loading = _modelsLoading,
993 error = _modelsError,
994 fetch = { it.providers() },
995 onSuccess = { resp ->
996 val options = resp.toOptions()
997 _models.value = options
998 // Only (re)apply the default when there's no valid current selection. On
999 // re-fetch/reconnect/Retry the user's chosen model must survive — keep it as
1000 // long as it's still present in the refreshed list. When there's no current
1001 // selection, prefer the user's persisted default-model preference (if it's in
1002 // the list) over the server default, so a user who always picks a specific
1003 // model doesn't have to pick it every new session.
1004 val current = _selectedModel.value
1005 if (current == null || options.none { it.ref == current.ref }) {
1006 val prefId = runCatchingCancellable {
1007 container.settingsStore.preferredModelId.first()
1008 }.getOrDefault("")
1009 val preferredOption = if (prefId.isNotEmpty()) {
1010 options.firstOrNull { it.modelID == prefId }
1011 } else null
1012 _selectedModel.value = preferredOption ?: resp.defaultOption(options)
1014 },
1015 onNull = { _models.value = emptyList(); _selectedModel.value = null },
1016 reloadTrigger = _modelsReload,
1018 // Load the agent catalog (non-fatal).
1019 observeCatalog(
1020 tag = "agent catalog",
1021 loading = _agentsLoading,
1022 error = _agentsError,
1023 fetch = { it.agents() },
1024 onSuccess = { _agents.value = it },
1025 onNull = { _agents.value = emptyList() },
1026 reloadTrigger = _agentsReload,
1028 // Load the command catalog (non-fatal).
1029 observeCatalog(
1030 tag = "command catalog",
1031 loading = _commandsLoading,
1032 error = _commandsError,
1033 fetch = { it.commands() },
1034 onSuccess = { _commands.value = it },
1035 onNull = { _commands.value = emptyList() },
1036 reloadTrigger = _commandsReload,
1038 // If the SSE stream drops mid-run, the run indicator would spin forever;
1039 // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
1040 // On a *transient* drop (Disconnected) we don't clear _running — the run is still
1041 // active on the server, and showing it as finished would mislead the user. Instead
1042 // we set _streamInterrupted so the working row switches to a "reconnecting" visual.
1043 // On a *hard* failure (Failed/AuthFailed) the run genuinely can't continue without
1044 // user action, so _running is cleared. _streamInterrupted is cleared on Connected.
1045 viewModelScope.launch {
1046 container.activeConnection.collectLatest { conn ->
1047 if (conn == null) return@collectLatest
1048 try {
1049 // Loop so a transient non-cancellation exception doesn't terminate the
1050 // state watcher for this connection. Without the loop, a single thrown
1051 // error returns the collectLatest lambda and the _running reset on
1052 // Disconnected/Failed never fires again for this connection — a dropped
1053 // stream leaves the working spinner (and the RunForegroundService) stuck.
1054 while (currentCoroutineContext().isActive) {
1055 try {
1056 conn.events.state.collect { state ->
1057 when (state) {
1058 EventStreamClient.ConnectionState.Connected -> {
1059 _streamInterrupted.value = false
1061 EventStreamClient.ConnectionState.Disconnected -> {
1062 // Transient: the stream dropped but the system is
1063 // reconnecting. Flag interrupted so the working row
1064 // shows a "reconnecting" state instead of vanishing.
1065 if (_running.value) _streamInterrupted.value = true
1067 EventStreamClient.ConnectionState.Failed,
1068 EventStreamClient.ConnectionState.AuthFailed -> {
1069 // Hard failure: the run can't continue without user
1070 // action (fix credentials / server). Clear running and
1071 // the interrupted flag so the UI doesn't look stuck.
1072 _streamInterrupted.value = false
1073 if (_running.value) _running.value = false
1075 EventStreamClient.ConnectionState.Connecting -> {
1076 // Initial connect or reconnect attempt; leave running
1077 // as-is — interrupted (if set) stays until Connected.
1081 } catch (e: CancellationException) {
1082 throw e
1083 } catch (e: Exception) {
1084 Log.w("ChatViewModel", "SSE state collector error, restarting", e)
1087 } catch (e: CancellationException) {
1088 throw e
1089 } catch (e: Exception) {
1090 Log.w("ChatViewModel", "SSE state collector loop error", e)
1096 /** Sends [text] together with any staged attachments; returns true on success so the
1097 * caller can clear the draft only then. */
1098 fun send(text: String): Boolean = send(text, includeAttachments = true)
1100 /**
1101 * Core send. [includeAttachments] is false for the internal auto-send paths (a queued
1102 * follow-up, a retry) so staged attachments aren't silently re-sent with unrelated text.
1104 * [queuedFollowUpToClear] is the text of a queued follow-up being auto-sent (from
1105 * SessionIdle). When non-null, the follow-up is cleared from persistence *inside* the
1106 * launched coroutine — so if [viewModelScope] is cancelled before the coroutine runs
1107 * (e.g. the user navigates away at the exact moment SessionIdle arrives), the clear
1108 * never executes and the follow-up remains persisted, recovered on the next open of
1109 * this session. Clearing it before the launch (the prior approach) could lose the
1110 * follow-up entirely: the launch would be dropped by cancellation but the persisted
1111 * copy was already wiped.
1112 */
1113 @Suppress("CyclomaticComplexMethod")
1114 private fun send(
1115 text: String,
1116 includeAttachments: Boolean,
1117 idempotencyKey: String? = null,
1118 queuedFollowUpToClear: String? = null,
1119 ): Boolean {
1120 val trimmed = text.trim()
1121 val attachments = if (includeAttachments) _attachments.value else emptyList()
1122 // An image-only prompt (attachments, no text) is valid; a blank prompt with nothing
1123 // attached is not.
1124 if (trimmed.isEmpty() && attachments.isEmpty()) return false
1125 // Offline, or no active connection: queue the prompt to the outbox (flushed on
1126 // reconnect) instead of failing the send. Device-offline is caught even when a stale
1127 // connection object lingers, so a phone that lost signal mid-conversation still
1128 // captures the message rather than bouncing it off a dead socket.
1129 val conn = connection
1130 if (conn == null || !container.isOnline.value) {
1131 return enqueueOffline(trimmed, attachments)
1133 if (!beginRun()) return false
1134 // A new run is starting: reopen the re-light gate so this run's streamed parts keep the
1135 // indicator lit (and a later SessionIdle re-closes it).
1136 runEndedByIdle = false
1137 // A plain manual Send of the auto-restored failed draft must reuse the stashed key too
1138 // (retryFailed passes it explicitly; the composer's Send does not), so a lost-response
1139 // re-send stays deduplicated server-side instead of starting a duplicate agent run.
1140 // Capture the match BEFORE clearing _failedDraft just below.
1141 val reuseKey = if (idempotencyKey == null && trimmed == _failedDraft.value?.trim()) {
1142 failedIdempotencyKey
1143 } else {
1144 null
1146 _failedDraft.value = null
1147 // Stable idempotency key for THIS online attempt: a brand-new message gets a fresh key,
1148 // while retryFailed() (or a manual re-send of the restored failed draft) re-submits with the
1149 // key it stashed on the original failure — so a retry after a lost response is deduplicated
1150 // server-side (no duplicate agent run).
1151 val key = idempotencyKey ?: reuseKey ?: java.util.UUID.randomUUID().toString()
1152 // Clear the staged attachments optimistically so the composer empties immediately;
1153 // restore them if the send fails (mirrors the draft handling below).
1154 if (attachments.isNotEmpty()) _attachments.value = emptyList()
1155 // Clear the in-memory draft for the UI immediately, but don't persist the clear
1156 // yet — if the send fails and the process dies before we restore, the draft
1157 // would be lost forever. The persisted draft is cleared only on success.
1158 // suppressDraftPersist prevents the debounced collector from persisting the
1159 // empty draft before the send resolves.
1160 //
1161 // Only clear when the text being sent IS the current draft. send() is also
1162 // invoked for auto-sent queued follow-ups (SessionIdle) and retryFailed(), where
1163 // the user may have typed a NEW draft since — wiping it would lose that text.
1164 if (_draft.value.trim() == trimmed) {
1165 suppressDraftPersist.set(true)
1166 _draft.value = ""
1167 _pendingQuote.value = null
1168 _editing.value = false
1170 // Inject an optimistic user message so the outgoing prompt is visible immediately,
1171 // before the server echoes it back via SSE. Removed by reconcileOptimistic once the
1172 // real UserMessage arrives. Only for text-bearing prompts (an image-only prompt has
1173 // no text to show).
1174 val optimisticId = if (trimmed.isNotEmpty()) "__optimistic_${System.nanoTime()}" else null
1175 if (optimisticId != null) {
1176 // Clear any prior failed optimistic entry with the same text (a retry re-sends).
1177 _optimisticMessages.update { entries -> entries.filterNot { it.failed && it.text.trim() == trimmed } }
1178 _optimisticMessages.update { it + OptimisticEntry(optimisticId, trimmed, System.currentTimeMillis(), failed = false) }
1180 // Remember the sent text so an undoable Stop can re-send it. Overwrite unconditionally:
1181 // an image-only prompt has nothing to re-send, and leaving a prior text prompt tracked
1182 // would make Stop-Undo silently re-send that unrelated earlier message.
1183 lastSentPrompt = trimmed.takeIf { it.isNotEmpty() }
1184 viewModelScope.launch {
1185 // Clear a queued follow-up being auto-sent *inside* the launched coroutine, so a
1186 // scope cancellation between send() being called and the coroutine starting drops
1187 // the launch without wiping the persisted follow-up — the follow-up is then
1188 // recovered on the next session open instead of being silently lost.
1189 if (queuedFollowUpToClear != null) setQueuedFollowUp(null)
1190 runCatchingCancellable {
1191 conn.repository.sendPrompt(
1192 sessionId,
1193 trimmed,
1194 attachments = attachments.map { it.part },
1195 model = _selectedModel.value?.ref,
1196 agent = _selectedAgent.value,
1197 idempotencyKey = key,
1199 }.onFailure {
1200 // Mark the optimistic message as failed so the user sees a failure indicator
1201 // instead of a perpetual "sending" state.
1202 if (optimisticId != null) {
1203 _optimisticMessages.update { entries ->
1204 entries.map { if (it.tempId == optimisticId) it.copy(failed = true) else it }
1207 suppressDraftPersist.set(false)
1208 _failedDraft.value = trimmed
1209 failedIdempotencyKey = key
1210 // Only restore the draft if the user hasn't typed anything new since.
1211 if (_draft.value.isBlank()) updateDraft(trimmed)
1212 // Restore the staged attachments too, so a failed send doesn't lose them —
1213 // but only if the user hasn't staged new ones in the meantime.
1214 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
1215 // Re-persist so the restored attachments survive process death after a failure.
1216 persistAttachments()
1217 // Retryable: this is the failed send whose prompt Retry re-submits.
1218 _errorEvents.trySend(ChatError(container.friendlyError(it), retryable = true))
1219 _running.value = false
1220 }.onSuccess {
1221 suppressDraftPersist.set(false)
1222 // This attempt reached the server, so the stashed retry key is spent — drop it so a
1223 // later unrelated send can't reuse it (retryFailed clears it on success too).
1224 failedIdempotencyKey = null
1225 // Send succeeded — the attachments rode along, so clear their persisted copy
1226 // (or keep any the user staged after the optimistic clear).
1227 persistAttachments()
1228 // Send succeeded — persist the cleared draft, but only if the user hasn't
1229 // typed a new one while the send was in flight. Otherwise clearing the
1230 // store echoes back through the draft observer and wipes the in-progress
1231 // text (data loss). Mirrors the guard on the failure path above.
1232 if (_draft.value.isBlank()) {
1233 lastPersistedDraft = ""
1234 container.draftStore.set(sessionId, "")
1236 // Don't reset _running here: the agent continues streaming via SSE.
1237 // _running is cleared on SessionIdle/SessionError (see event collector)
1238 // or when the SSE stream drops (see connection state watcher below).
1241 return true
1244 /**
1245 * Queue [trimmed] (+ [attachments]) to the offline outbox, clearing the composer
1246 * optimistically like a real send. AppContainer flushes the outbox automatically on
1247 * reconnect. The prompt is attributed to the active (or most-recent) server profile so a
1248 * reconnect to a different server doesn't misfire it. On any failure the draft/attachments
1249 * are restored so nothing is lost.
1250 */
1251 private fun enqueueOffline(trimmed: String, attachments: List<PendingAttachment>): Boolean {
1252 _failedDraft.value = null
1253 if (attachments.isNotEmpty()) _attachments.value = emptyList()
1254 val clearDraft = _draft.value.trim() == trimmed
1255 if (clearDraft) {
1256 suppressDraftPersist.set(true)
1257 _draft.value = ""
1259 viewModelScope.launch {
1260 val profileId = connection?.profile?.id
1261 ?: runCatchingCancellable { container.profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
1262 if (profileId == null) {
1263 // No server to attribute the message to — restore the composer and report it
1264 // rather than silently dropping the prompt.
1265 suppressDraftPersist.set(false)
1266 if (_draft.value.isBlank()) updateDraft(trimmed)
1267 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
1268 persistAttachments()
1269 _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
1270 return@launch
1272 runCatchingCancellable {
1273 container.outboxStore.enqueue(
1274 OutboxMessage(
1275 id = java.util.UUID.randomUUID().toString(),
1276 profileId = profileId,
1277 sessionId = sessionId,
1278 text = trimmed,
1279 attachments = attachments.map { it.toPersisted() },
1280 model = _selectedModel.value?.ref,
1281 agent = _selectedAgent.value,
1282 createdAt = System.currentTimeMillis(),
1283 ),
1285 }.onSuccess {
1286 suppressDraftPersist.set(false)
1287 // The staged attachments now live in the outbox item; clear their draft copy.
1288 persistAttachments()
1289 if (_draft.value.isBlank()) {
1290 lastPersistedDraft = ""
1291 container.draftStore.set(sessionId, "")
1293 // Nudge a flush in case we're actually online with a live connection.
1294 container.flushOutbox()
1295 _errorEvents.trySend(ChatError(container.string(R.string.outbox_queued)))
1296 }.onFailure {
1297 suppressDraftPersist.set(false)
1298 if (_draft.value.isBlank()) updateDraft(trimmed)
1299 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
1300 persistAttachments()
1301 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1304 return true
1307 /** Discard a single queued (offline) message. */
1308 fun discardQueued(id: String) {
1309 viewModelScope.launch { runCatchingCancellable { container.outboxStore.remove(id) } }
1312 /** Discard every queued (offline) message for this session. */
1313 fun discardAllQueued() {
1314 viewModelScope.launch { runCatchingCancellable { container.outboxStore.removeForSession(sessionId) } }
1317 /** Attempt to flush the outbox now (e.g. the user tapped "Send now"). */
1318 fun flushQueued() { container.flushOutbox() }
1320 /** Re-send the last draft whose send failed, if any. */
1321 fun retryFailed() {
1322 val draft = _failedDraft.value ?: return
1323 // Reuse the failed send's idempotency key so a retry after a lost response is
1324 // deduplicated server-side rather than starting a duplicate run.
1325 val key = failedIdempotencyKey
1326 // Don't clear _failedDraft until send() accepts the text — if _running is
1327 // already true, send() returns false and the draft would be lost forever.
1328 // Attachments aren't re-sent on retry (they were optimistically restored to the
1329 // composer on the original failure, so they'll ride the next manual send).
1330 // send() accepts the text synchronously, but its onSuccess/onFailure callbacks run
1331 // asynchronously. Clearing failedIdempotencyKey here would open a window between this
1332 // synchronous clear and the async onFailure (which re-stashes the key) during which a
1333 // manual re-send of the same text gets a fresh UUID — defeating server-side deduplication.
1334 // Let send()'s own callbacks own the key's lifecycle, as they do for non-retry paths.
1335 if (send(draft, includeAttachments = false, idempotencyKey = key)) {
1336 _failedDraft.value = null
1340 /** Dismiss a single failed optimistic message by its tempId so it no longer lingers in the
1341 * list. Without this there's no recovery path for a failed message the user chose to abandon
1342 * (its text won't ever match a real server message, so reconcileOptimistic never clears it). */
1343 fun dismissOptimistic(tempId: String) {
1344 _optimisticMessages.update { entries -> entries.filterNot { it.tempId == tempId } }
1347 /** Re-send a specific failed optimistic message by its tempId. Loads its text into the
1348 * composer and fires a send, mirroring the snackbar's Retry path. Returns false (and is a
1349 * no-op) if the entry can't be found or a run is already active. */
1350 fun retryOptimisticMessage(tempId: String): Boolean {
1351 val entry = _optimisticMessages.value.firstOrNull { it.tempId == tempId && it.failed } ?: return false
1352 return send(entry.text, includeAttachments = false)
1355 /**
1356 * Queue [text] to be sent automatically when the current run finishes, or clear any
1357 * queued follow-up when [text] is blank. Used when the user taps Send while a run
1358 * is already active — the Send button is replaced by Stop during a run, but the
1359 * input field stays enabled, so a follow-up typed mid-run would otherwise have
1360 * nowhere to go.
1361 */
1362 fun queueFollowUp(text: String) {
1363 val trimmed = text.trim()
1364 setQueuedFollowUp(trimmed.takeIf { it.isNotEmpty() })
1365 // The text now lives in the queued chip, so clear the input field just as a
1366 // normal send would. No-op for the cancel case (blank text), which must leave
1367 // whatever the user has since typed untouched.
1368 if (trimmed.isNotEmpty()) {
1369 _draft.value = ""
1370 // Persist the cleared draft IMMEDIATELY, not just in memory: the follow-up was already
1371 // persisted synchronously above, so if the process is killed before the debounced
1372 // draft-persist fires, the same text would remain under BOTH the draft and follow-up
1373 // keys — and cold-start recovery would restore it to the composer AND re-queue it (a
1374 // duplicate). Mirror send()'s lastPersistedDraft update so the drafts observer treats
1375 // this store change as our own echo and doesn't write the cleared value back.
1376 lastPersistedDraft = ""
1377 container.draftStore.flushDraft(sessionId, "")
1381 /** Set or clear the queued follow-up and mirror it to persistence, so a follow-up
1382 * queued mid-run survives process death (recovered on the next open of this session).
1383 * Passing null / blank clears both the in-memory value and the persisted copy. */
1384 private fun setQueuedFollowUp(text: String?) {
1385 _queuedFollowUp.value = text
1386 container.draftStore.flushFollowUp(sessionId, text.orEmpty())
1389 /** Transient flag set by [refreshMessages] so the top-bar refresh icon can show
1390 * a brief spinner as immediate tap feedback. The SSE reconnect triggered by
1391 * refreshMessages may not visibly change [connectionState] (it's already
1392 * Connected), so without this the tap appears to do nothing. Clears after a
1393 * short delay or when the connection state next becomes Connected. */
1394 private val _refreshing = MutableStateFlow(false)
1395 val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
1397 /** Manually re-fetch messages by forcing the SSE stream to reconnect, which
1398 * triggers the repository's re-seed-from-REST logic. Gives the user a recovery
1399 * path when the SSE stream silently drops and the auto-reconnect re-seed is slow
1400 * or fails — a pull-to-refresh or top-bar refresh forces a fresh fetch under the
1401 * existing generation-guarded merge. Also clears [loadError] optimistically and
1402 * sets [refreshing] for immediate tap feedback. */
1403 fun refreshMessages() {
1404 val conn = connection ?: return
1405 _loadError.value = false
1406 _loadErrorInline.value = false
1407 _refreshing.value = true
1408 conn.events.triggerReconnect()
1409 viewModelScope.launch {
1410 delay(NetworkConfig.refreshFeedbackMs)
1411 _refreshing.value = false
1415 /** Invoke a slash-command by name via the server's /command endpoint. */
1416 fun runCommand(command: Command) {
1417 val conn = connection ?: return
1418 // A run is already in flight — surface feedback instead of silently dropping the
1419 // command (the picker stays openable during a run and dismisses on select).
1420 if (!beginRun()) {
1421 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1422 return
1424 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1425 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1426 runEndedByIdle = false
1427 viewModelScope.launch {
1428 runCatchingCancellable {
1429 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
1430 }.onFailure {
1431 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1432 _running.value = false
1437 fun abort() {
1438 val conn = connection ?: return
1439 // Drop any queued follow-up so the SessionIdle that follows the abort doesn't
1440 // auto-send it — the user tapped Stop to halt work, not to trigger the next turn.
1441 setQueuedFollowUp(null)
1442 if (!_aborting.compareAndSet(false, true)) return
1443 viewModelScope.launch {
1444 try {
1445 // Run the abort POST under NonCancellable so it survives this scope being
1446 // cancelled. The "Stop and exit" path calls abort() then navigates back
1447 // immediately, which pops this ViewModel's back-stack entry → onCleared() →
1448 // viewModelScope.cancel(); without this the in-flight abort is cancelled (often
1449 // before the request even reaches the server) and the run keeps burning tokens
1450 // server-side despite the user explicitly stopping it.
1451 runCatchingCancellable { withContext(NonCancellable) { conn.repository.abort(sessionId) } }
1452 .onSuccess {
1453 _running.value = false
1454 // Close the re-light gate so trailing SSE events in flight between the
1455 // abort POST's ack and the server's SessionIdle don't re-light the run
1456 // indicator (and re-arm the FGS / keepScreenOn) after the user hit Stop.
1457 // send()/runCommand()/continueRun() all open this gate on beginRun();
1458 // abort() is the one run-ending path that previously missed it.
1459 runEndedByIdle = true
1460 // Surface an undo opportunity so an accidental Stop is recoverable.
1461 // The UI shows a "Stopped — Undo" snackbar; tapping Undo re-sends the
1462 // last prompt. Only when there's a tracked prompt to undo back to.
1463 val prompt = lastSentPrompt
1464 if (prompt != null) _stopUndoEvents.trySend(prompt)
1466 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1467 } finally {
1468 _aborting.value = false
1473 /** Re-send the most recently-sent user prompt, used by the Stop-undo snackbar so an
1474 * accidental Stop can be recovered. No-op if no prompt is tracked (e.g. the run was
1475 * started by a command, not a text prompt). Clears the tracked prompt so a second Undo
1476 * tap doesn't double-send. */
1477 fun resendLastPrompt() {
1478 val prompt = lastSentPrompt ?: return
1479 lastSentPrompt = null
1480 send(prompt, includeAttachments = false)
1483 fun addAttachment(attachment: PendingAttachment) {
1484 // Enforce the cumulative size cap across the whole staged set (the per-file size and the
1485 // count caps are checked upstream). The base64 data URLs are held in memory and
1486 // re-serialized on every add/remove and again when the request body is built, so an
1487 // unbounded set OOMs. Reject rather than add, mirroring the other attachment limits'
1488 // snackbar via errorEvents. Size is the raw (pre-base64) byte count, matching the
1489 // per-file check, and includes attachments already staged (incl. restored ones).
1490 val staged = _attachments.value.sumOf { base64DataUrlByteSize(it.part.url) }
1491 if (staged + base64DataUrlByteSize(attachment.part.url) > NetworkConfig.maxTotalAttachmentBytes) {
1492 _errorEvents.trySend(ChatError(container.string(R.string.attachment_limit)))
1493 return
1495 _attachments.update { it + attachment }
1496 persistAttachments()
1499 fun removeAttachment(id: String) {
1500 _attachments.update { list -> list.filterNot { it.id == id } }
1501 persistAttachments()
1504 fun clearAttachments() {
1505 _attachments.value = emptyList()
1506 persistAttachments()
1509 /** Convert each picked Uri to a base64 attachment off the main thread, honoring the
1510 * per-prompt count cap and surfacing per-file errors via [errorEvents] without aborting
1511 * the batch. Runs on [viewModelScope] (which survives configuration changes) so an
1512 * in-flight encode isn't cancelled and its progress counters ([stagingCount] /
1513 * [stagingFileTotal]) aren't reset to 0 on rotation — the prior UI-side
1514 * `rememberCoroutineScope` implementation lost both the work and the counters on
1515 * every config change. */
1516 fun stageUris(uris: List<android.net.Uri>, context: android.content.Context) {
1517 if (uris.isEmpty()) return
1518 val appContext = context.applicationContext
1519 viewModelScope.launch {
1520 _stagingCount.value++
1521 _stagingFileTotal.value += uris.size
1522 try {
1523 uris.forEachIndexed { index, uri ->
1524 if (_attachments.value.size >= NetworkConfig.maxAttachments) {
1525 _errorEvents.trySend(ChatError(container.string(R.string.attachment_limit)))
1526 // The cap dropped the rest of this batch; account for the files we
1527 // won't process so stagingFileTotal doesn't linger over-counted,
1528 // then abandon the batch. return@launch (not return@forEachIndexed)
1529 // so the remaining uris aren't re-checked against a never-relieved
1530 // cap (which would over-decrement the counter and re-fire the
1531 // snackbar once per leftover uri). The `finally` below still
1532 // decrements stagingCount.
1533 _stagingFileTotal.value -= uris.size - index
1534 return@launch
1536 when (val result = uri.toAttachmentResult(appContext)) {
1537 is AttachmentResult.Ok -> addAttachment(result.attachment)
1538 AttachmentResult.TooLarge -> _errorEvents.trySend(ChatError(container.string(R.string.attachment_too_large)))
1539 AttachmentResult.Failed -> _errorEvents.trySend(ChatError(container.string(R.string.attachment_failed)))
1541 _stagingFileTotal.value--
1543 } finally {
1544 _stagingCount.value--
1549 /** Persist the current staged attachments so an interrupted compose survives process
1550 * death (mirrors draft persistence). Fire-and-forget on [viewModelScope]. */
1551 private fun persistAttachments() {
1552 val snapshot = _attachments.value
1553 viewModelScope.launch {
1554 runCatchingCancellable { container.attachmentDraftStore.save(sessionId, snapshot.map { it.toPersisted() }) }
1555 .onFailure { Log.w("ChatViewModel", "Failed to persist attachments", it) }
1559 /** Revert the conversation to just before [messageId], hiding everything after it. The
1560 * authoritative state also arrives via SessionUpdated; we flag it here for immediate
1561 * feedback. */
1562 fun revertTo(messageId: String) {
1563 val conn = connection ?: return
1564 viewModelScope.launch {
1565 runCatchingCancellable { conn.api.revert(sessionId, messageId) }
1566 .onSuccess {
1567 _reverted.value = it.isReverted
1568 _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1569 _editing.value = false
1571 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1575 /**
1576 * Regenerate an assistant reply: revert the conversation to that assistant message (removing
1577 * it and everything after), then re-send the user prompt that immediately preceded it. A
1578 * one-tap "try again" mirroring ChatGPT/Claude, instead of the two-step revert + re-Send.
1580 * Captures the preceding user text *before* the revert (the messages list updates
1581 * asynchronously via SSE once the revert lands). No-op if there's no preceding user prompt
1582 * or the assistant message isn't found.
1583 */
1584 fun regenerate(assistantMessageId: String) {
1585 val conn = connection ?: return
1586 val msgs = messages.value
1587 val idx = msgs.indexOfFirst { it.info.id == assistantMessageId }
1588 if (idx < 0) return
1589 val precedingUserText = (idx - 1 downTo 0)
1590 .asSequence()
1591 .map { msgs[it] }
1592 .firstOrNull { it.info is UserMessage }
1593 ?.parts
1594 ?.filterIsInstance<TextPart>()
1595 ?.joinToString("\n\n") { it.text }
1596 ?.takeIf { it.isNotBlank() }
1597 ?: return
1598 viewModelScope.launch {
1599 runCatchingCancellable { conn.api.revert(sessionId, assistantMessageId) }
1600 .onSuccess {
1601 // send() can return false if the beginRun() CAS lost to a concurrent run
1602 // or the message had to be enqueued offline and that failed. The revert has
1603 // already hidden the messages, so a silent false would leave the user with a
1604 // reverted conversation and no reply and no error — surface it instead.
1605 val sent = send(precedingUserText, includeAttachments = false)
1606 if (!sent) {
1607 _errorEvents.trySend(ChatError(container.string(R.string.error_generic)))
1610 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1614 /** Continue a partially-generated assistant reply by sending a "continue" prompt
1615 * without reverting (unlike [regenerate], which re-runs from the preceding user
1616 * prompt and loses the partial). The partial reply stays as context so the model
1617 * can pick up where it left off. This is the standard chat-app "continue" pattern
1618 * for a reply that was aborted mid-stream. */
1619 fun continueRun(@Suppress("UNUSED_PARAMETER") assistantMessageId: String) {
1620 val conn = connection ?: return
1621 if (running.value || aborting.value) return
1622 if (!beginRun()) return
1623 // Reopen the relight gate so this run's streamed parts keep the indicator lit (and a
1624 // later SessionIdle re-closes it). Without this, runEndedByIdle stays true from the
1625 // previous run's SessionIdle and the relight guard at the event collector would suppress
1626 // every streamed part of the continuation, leaving the working indicator / Stop button /
1627 // foreground service off for the whole run.
1628 runEndedByIdle = false
1629 viewModelScope.launch {
1630 // Route through the repository (not conn.api directly) so the prompt inherits
1631 // withRetry (transient-failure backoff) and the user's selected model/agent — a
1632 // direct api.sendPrompt would neither retry nor carry the picker selection, and
1633 // would mint a fresh idempotency key on every retry attempt instead of sharing one.
1634 runCatchingCancellable {
1635 conn.repository.sendPrompt(
1636 sessionId,
1637 text = "continue",
1638 model = _selectedModel.value?.ref,
1639 agent = _selectedAgent.value,
1641 }.onFailure {
1642 _running.value = false
1643 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1648 /** Load a previously-sent user prompt back into the composer to edit and re-send.
1649 * Reverts the conversation to just before [messageId] (hiding it and everything after)
1650 * and prefills the draft with [text]. The next Send continues from that checkpoint,
1651 * effectively replacing the edited message; the reverted banner's Undo restores it.
1652 * Overwrites the current draft — this is an explicit edit action on that message. */
1653 fun editMessage(messageId: String, text: String) {
1654 val conn = connection ?: return
1655 _draft.value = text.take(NetworkConfig.maxDraftLengthChars)
1656 _pendingQuote.value = null
1657 // Do NOT delegate to revertTo(): revertTo's onSuccess sets _editing = false, which races
1658 // this synchronous _editing = true (the async revert completes after editMessage returns
1659 // and clobbers the banner). Inline the revert so editMessage owns the _editing lifecycle:
1660 // set it true on success, matching the documented contract ("Cleared on unrevert and on
1661 // send"). revertTo() (the manual revert button) still clears _editing itself.
1662 viewModelScope.launch {
1663 runCatchingCancellable { conn.api.revert(sessionId, messageId) }
1664 .onSuccess {
1665 _reverted.value = it.isReverted
1666 _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1667 _editing.value = true
1669 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1673 /** Prefill the composer with [text] as a Markdown blockquote so the user can respond to a
1674 * specific message inline. Appends to any existing draft (with a blank line) rather than
1675 * overwriting, so quoting doesn't discard text already being typed. Also stashes the raw
1676 * quoted text in [pendingQuote] so the composer can show a dismissible preview card. */
1677 fun quoteReply(text: String) {
1678 val quoted = text.trim().lineSequence().joinToString("\n") { "> $it" }
1679 if (quoted.isBlank()) return
1680 val current = _draft.value
1681 val combined = if (current.isBlank()) "$quoted\n\n" else "$current\n\n$quoted\n\n"
1682 // Cap like the composer's typed/pasted/dictated input so quoting onto a near-full draft
1683 // can't push it past the limit (negative remaining counter + an over-cap prompt sent).
1684 _draft.value = combined.take(NetworkConfig.maxDraftLengthChars)
1685 _pendingQuote.value = text.trim().takeIf { it.isNotBlank() }
1688 /** Remove the pending quote-reply preview and strip the `>`-prefixed blockquote lines from
1689 * the draft so canceling the quote card actually removes the quoted text, not just the chip.
1690 * Strips by leading `> ` prefix (structural match) rather than by exact full-text replace,
1691 * so a user who edited any quoted line is still cleaned up — the exact-text replaces would
1692 * silently no-op on an edit, leaving orphan `>` lines in the draft after the chip vanished. */
1693 fun cancelQuoteReply() {
1694 val removed = _pendingQuote.value ?: return
1695 _pendingQuote.value = null
1696 val quoteLineCount = removed.trim().lineSequence().count()
1697 // Walk the draft and drop the first contiguous run of `> `-prefixed lines that matches
1698 // the stashed quote's line count (the block quoteReply inserted). Lines the user edited
1699 // still begin with `> ` (quoteReply prefixed every line), so a prefix match finds them
1700 // even when their trailing content diverges from the stashed original.
1701 val lines = _draft.value.split("\n").toMutableList()
1702 var runStart = -1
1703 var runLen = 0
1704 var i = 0
1705 while (i < lines.size) {
1706 if (lines[i].startsWith("> ") || lines[i] == ">") {
1707 if (runStart < 0) runStart = i
1708 runLen++
1709 if (runLen == quoteLineCount) {
1710 // Strip the run plus a single trailing blank line that separated the quote
1711 // from the reply (quoteReply appends "\n\n" after the block).
1712 val stripUntil = i + 1
1713 if (stripUntil < lines.size && lines[stripUntil].isEmpty()) {
1714 lines.subList(runStart, stripUntil + 1).clear()
1715 } else {
1716 lines.subList(runStart, stripUntil).clear()
1718 break
1720 i++
1721 } else {
1722 runStart = -1
1723 runLen = 0
1724 i++
1727 _draft.value = lines.joinToString("\n").trimStart()
1730 /** Fork the conversation by creating a brand-new session seeded with [text] as its first
1731 * prompt (using the currently-selected model/agent). Emits the new session id via
1732 * [branchEvents] so the UI can navigate to it. There's no server-side "branch" endpoint,
1733 * so this starts a fresh session from the chosen prompt rather than copying history. */
1734 fun branchFrom(text: String) {
1735 val conn = connection ?: return
1736 val prompt = text.trim()
1737 if (prompt.isEmpty()) return
1738 viewModelScope.launch {
1739 var createdId: String? = null
1740 runCatchingCancellable {
1741 val session = conn.repository.createSession(title = null)
1742 createdId = session.id
1743 conn.repository.sendPrompt(
1744 session.id,
1745 prompt,
1746 model = _selectedModel.value?.ref,
1747 agent = _selectedAgent.value,
1749 session.id
1751 .onSuccess { newId -> _branchEvents.trySend(newId) }
1752 .onFailure {
1753 // If the session was created but the prompt failed, delete the orphaned
1754 // empty session so failed branches don't accumulate on the server.
1755 val id = createdId
1756 if (id != null) {
1757 runCatchingCancellable { conn.repository.deleteSession(id) }
1759 _errorEvents.trySend(ChatError(container.string(R.string.branch_failed)))
1764 /** Undo the active revert checkpoint, restoring the hidden messages. */
1765 fun unrevert() {
1766 val conn = connection ?: return
1767 viewModelScope.launch {
1768 runCatchingCancellable { conn.api.unrevert(sessionId) }
1769 .onSuccess {
1770 _reverted.value = it.isReverted
1771 _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1772 _editing.value = false
1774 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1778 /** Create (or fetch the existing) public share link. The URL is exposed via [shareUrl]
1779 * and also emitted once via [shareLinkEvents] so the UI can copy it to the clipboard. */
1780 fun shareSession() {
1781 val conn = connection ?: return
1782 viewModelScope.launch {
1783 runCatchingCancellable { conn.api.shareSession(sessionId) }
1784 .onSuccess { session ->
1785 val url = session.share?.url?.takeIf { it.isNotBlank() }
1786 _shareUrl.value = url
1787 if (url != null) _shareLinkEvents.trySend(url)
1789 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1793 /** Revoke the session's public share link. */
1794 fun unshareSession() {
1795 val conn = connection ?: return
1796 viewModelScope.launch {
1797 runCatchingCancellable { conn.api.unshareSession(sessionId) }
1798 .onSuccess { _shareUrl.value = it.share?.url?.takeIf { it.isNotBlank() } }
1799 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1803 /** Compact the conversation via the summarize endpoint; the summary streams back via SSE.
1804 * Uses the currently-selected model (required by the endpoint). */
1805 fun summarize() {
1806 val conn = connection ?: return
1807 val model = _selectedModel.value?.ref ?: run {
1808 _errorEvents.trySend(ChatError(container.string(R.string.needs_model)))
1809 return
1811 if (!beginRun()) {
1812 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1813 return
1815 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1816 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1817 runEndedByIdle = false
1818 viewModelScope.launch {
1819 runCatchingCancellable { conn.api.summarize(sessionId, model) }
1820 .onFailure {
1821 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1822 _running.value = false
1827 /** Analyze the project and (re)generate its AGENTS.md; the run streams back via SSE. */
1828 fun initProject() {
1829 val conn = connection ?: return
1830 if (!beginRun()) {
1831 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1832 return
1834 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1835 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1836 runEndedByIdle = false
1837 viewModelScope.launch {
1838 runCatchingCancellable { conn.api.initSession(sessionId) }
1839 .onFailure {
1840 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1841 _running.value = false
1846 /** Run a one-off shell [command] in the session's worktree; output streams back via SSE.
1847 * The server requires an agent to scope the run — use the selected one, else the primary. */
1848 fun runShell(command: String) {
1849 val conn = connection ?: return
1850 val cmd = command.trim()
1851 if (cmd.isEmpty()) return
1852 val agent = _selectedAgent.value
1853 ?: _agents.value.firstOrNull { it.isPrimary }?.name
1854 ?: _agents.value.firstOrNull()?.name
1855 ?: DEFAULT_SHELL_AGENT
1856 if (!beginRun()) {
1857 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1858 return
1860 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1861 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1862 runEndedByIdle = false
1863 viewModelScope.launch {
1864 runCatchingCancellable { conn.api.shell(sessionId, cmd, agent, _selectedModel.value?.ref) }
1865 .onFailure {
1866 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1867 _running.value = false
1872 /** Rename the current session via PATCH /session/:id. On success updates [sessionTitle]
1873 * so the top bar reflects the new name immediately. A failure surfaces as a snackbar;
1874 * the caller keeps the dialog open so the user can retry without retyping. */
1875 fun renameSession(newTitle: String) {
1876 val conn = connection ?: return
1877 val title = newTitle.trim()
1878 if (title.isEmpty() || title == _sessionTitle.value) return
1879 viewModelScope.launch {
1880 runCatchingCancellable { conn.api.updateSession(sessionId, title) }
1881 .onSuccess { _sessionTitle.value = it.displayTitle }
1882 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1886 /** Delete the current session via DELETE /session/:id. Schedules a deferred delete with an
1887 * undo window and signals [deleteUndoEvents] so the ChatScreen shows an Undo snackbar
1888 * *over the conversation the user is viewing* — they can cancel without first being
1889 * navigated back to the session list. The actual REST delete runs after
1890 * [NetworkConfig.undoDeleteDelayMs]; cancel via [cancelSessionDelete]. If the window
1891 * expires, the scheduled delete's onDeleted sets [sessionDeleted] so the UI navigates away.
1892 * A failure surfaces as a snackbar. */
1893 fun deleteSession() {
1894 val conn = connection ?: return
1895 container.scheduleSessionDelete(
1896 id = sessionId,
1897 delayMs = NetworkConfig.undoDeleteDelayMs,
1898 // Navigate away only once the deferred delete actually commits — so an in-place Undo
1899 // before the window expires keeps the user on this conversation.
1900 onDeleted = { _sessionDeleted.value = true },
1901 onError = { _errorEvents.trySend(ChatError(container.friendlyError(it))) },
1903 _deleteUndoEvents.tryEmit(sessionId)
1906 /** Cancel a delete scheduled by [deleteSession] (the in-chat Undo action). Returns true if the
1907 * delete was still pending (Undo succeeded). */
1908 fun cancelSessionDelete(): Boolean = container.cancelSessionDelete(sessionId)
1910 /** Cancel a delete scheduled for [id] — the `collectLatest` undo path in ChatScreen calls
1911 * this with the tracked `pending` id when a newer delete supersedes an in-flight one, so
1912 * the prior session's deferred REST delete is withdrawn (no-op if it already fired)
1913 * instead of being left to fire with no reachable Undo button. Mirrors
1914 * [soy.iko.opencode.ui.session.SessionListViewModel.undoDelete]. Returns false when [id]
1915 * isn't this VM's session (defensive — a ChatScreen only ever deletes its own session). */
1916 fun cancelSessionDelete(id: String): Boolean {
1917 if (id != sessionId) return false
1918 return container.cancelSessionDelete(id)
1921 /** Reconnect to the most recently used server profile (used when the connection is gone). */
1922 fun reconnect() {
1923 if (container.activeConnection.value != null) return
1924 // Guard against concurrent reconnect calls: the user can tap the button
1925 // multiple times while _reconnecting is true, which would launch parallel
1926 // connect() coroutines that race to set the active connection, leaking the
1927 // intermediate connections.
1928 if (!_reconnecting.compareAndSet(false, true)) return
1929 viewModelScope.launch {
1930 try {
1931 val recent = runCatchingCancellable { container.profileStore.profiles.first() }
1932 .getOrDefault(emptyList())
1933 .firstOrNull()
1934 if (recent == null) {
1935 _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
1936 return@launch
1938 runCatchingCancellable {
1939 val conn = container.connect(recent)
1940 conn.api.ping()
1941 }.onFailure {
1942 container.disconnect()
1943 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1945 } finally {
1946 _reconnecting.value = false
1951 fun respondPermission(permission: Permission, response: PermissionResponse) {
1952 val conn = connection ?: return
1953 // Record a session-scoped grant so subsequent matching requests are auto-answered.
1954 // SESSION maps to ONCE on the wire (the server has no session-scope concept); the
1955 // client-side set is what gives it the "for this session" semantics.
1956 if (response == PermissionResponse.SESSION) {
1957 val type = permission.type.orEmpty()
1958 val pattern = permission.patternText.orEmpty()
1959 if (type.isNotEmpty()) sessionAllowed.add(type to pattern)
1961 // Dismiss optimistically (revealing any queued request); permission.replied will confirm.
1962 // If the call fails we re-surface this request so the user can retry instead of being stuck
1963 // with a dismissed dialog and a tool run that's still paused server-side.
1964 resolvePermission(permission.id)
1965 viewModelScope.launch {
1966 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
1967 .onFailure {
1968 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1969 // Revoke the session-scoped grant on failure: enqueuePermission() auto-responds
1970 // when (type, pattern) is in sessionAllowed, so leaving the entry in would loop
1971 // respondPermission -> fail -> enqueuePermission -> respondPermission ... forever,
1972 // spamming the dead server and the error snackbar without ever re-prompting the
1973 // user. Removing it makes enqueuePermission fall through to re-queueing the
1974 // request for the dialog so the user can retry.
1975 //
1976 // This must cover BOTH the SESSION and ONCE response paths: enqueuePermission's
1977 // auto-respond sends ONCE, not SESSION, so guarding only the SESSION branch (as
1978 // the original code did) leaves the entry in and still loops when the auto-reply
1979 // fails. Revoke unconditionally on any failure.
1980 val type = permission.type.orEmpty()
1981 val pattern = permission.patternText.orEmpty()
1982 if (type.isNotEmpty()) sessionAllowed.remove(type to pattern)
1983 enqueuePermission(permission)
1988 private companion object {
1989 /** Fallback agent for the shell endpoint when none is selected and no agent catalog
1990 * loaded. opencode's default primary agent is "build". */
1991 const val DEFAULT_SHELL_AGENT = "build"
1994 override fun onCleared() {
1995 super.onCleared()
1996 // Flush any pending debounced draft so it survives navigation. Uses an
1997 // asynchronous apply() (not a synchronous commit) so the main thread isn't
1998 // blocked on disk I/O — Android's SharedPreferences framework guarantees
1999 // pending apply() writes are flushed before the process exits.
2000 // Flush the pending draft — including an empty one. Persistence is otherwise debounced,
2001 // so if the user cleared the input and navigated back within the debounce window, the
2002 // debounce coroutine was cancelled with viewModelScope and the prefs still hold the
2003 // previous non-empty draft. flushDraft() removes the key when the text is blank, so
2004 // flushing commits the clear instead of resurrecting the deleted text.
2005 //
2006 // BUT respect the send-in-flight guard: send() optimistically clears _draft to "" WITHOUT
2007 // persisting the clear so a failed send can restore the text. If the user taps Send then
2008 // immediately navigates back (cancelling the in-flight send), flushing the blank _draft here
2009 // would remove the persisted key and erase the pre-send draft with no recovery. Skip the
2010 // flush while a send is in flight so the pre-send draft stays persisted (recoverable).
2011 if (!suppressDraftPersist.get()) container.draftStore.flushDraft(sessionId, _draft.value)