git.iko.soy/opencode-android
on a0ce9ce help sign in
1948 lines 107 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.
238 val backoffMs = (NetworkConfig.retryInitialDelayMs shl attempt.coerceAtMost(16L).toInt())
239 .coerceAtMost(NetworkConfig.retryMaxDelayMs)
240 delay(backoffMs)
241 true
242 }
244 val messages: StateFlow<List<MessageWithParts>> =
245 combine(sseMessages, _optimisticMessages) { sse, optimistic ->
246 mergeOptimistic(sse, optimistic)
247 }.stateIn(
248 scope = viewModelScope,
249 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
250 initialValue = emptyList(),
251 )
253 /** Whether the conversation has any messages. Derived separately so the top bar
254 * (share button enabled state) can observe this cheap boolean instead of the
255 * full messages list, avoiding per-token recomposition of the app bar during
256 * streaming. */
257 val hasMessages: StateFlow<Boolean> = messages
258 .map { it.isNotEmpty() }
259 .stateIn(
260 scope = viewModelScope,
261 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
262 initialValue = false,
263 )
265 /** Map of optimistic message tempId → failed flag, so the UI can render a "Sending…"
266 * or "Failed to send" indicator on the corresponding message bubble. */
267 val optimisticStatuses: StateFlow<Map<String, Boolean>> = _optimisticMessages
268 .map { entries -> entries.associate { it.tempId to it.failed } }
269 .stateIn(
270 scope = viewModelScope,
271 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
272 initialValue = emptyMap(),
273 )
275 /** Merge SSE-driven messages with optimistic entries. An optimistic entry whose trimmed
276 * text matches a real [UserMessage] in [sse] is dropped (the server echoed it back).
277 * Remaining entries are appended as synthetic [UserMessage]s at the end. Failed entries
278 * are kept so the user sees the failed indicator until they retry or dismiss. */
279 private fun mergeOptimistic(
280 sse: List<MessageWithParts>,
281 optimistic: List<OptimisticEntry>,
282 ): List<MessageWithParts> {
283 if (optimistic.isEmpty()) return sse
284 // Collect the text of real user messages so optimistic entries can be matched.
285 val realUserTexts = mutableSetOf<String>()
286 for (msg in sse) {
287 if (msg.info !is UserMessage) continue
288 val text = msg.parts.filterIsInstance<TextPart>()
289 .joinToString("\n\n") { it.text }
290 .trim()
291 if (text.isNotEmpty()) realUserTexts.add(text)
292 }
293 val surviving = optimistic.filter { it.text.trim() !in realUserTexts }
294 if (surviving.isEmpty()) return sse
295 return sse + surviving.map { it.toMessageWithParts() }
296 }
298 /** Remove optimistic entries whose text now matches a real user message delivered by SSE.
299 * Called from the SSE flow's onEach so stale optimistic entries are cleaned up promptly. */
300 private fun reconcileOptimistic(sse: List<MessageWithParts>) {
301 if (_optimisticMessages.value.isEmpty()) return
302 val realUserTexts = mutableSetOf<String>()
303 for (msg in sse) {
304 if (msg.info !is UserMessage) continue
305 val text = msg.parts.filterIsInstance<TextPart>()
306 .joinToString("\n\n") { it.text }
307 .trim()
308 if (text.isNotEmpty()) realUserTexts.add(text)
309 }
310 if (realUserTexts.isEmpty()) return
311 _optimisticMessages.update { entries ->
312 entries.filterNot { it.text.trim() in realUserTexts }
313 }
314 }
316 /** Convert an optimistic entry to a [MessageWithParts] for display. */
317 private fun OptimisticEntry.toMessageWithParts(): MessageWithParts = MessageWithParts(
318 info = UserMessage(
319 id = tempId,
320 sessionID = sessionId,
321 time = soy.iko.opencode.data.model.TimeInfo(created = timestamp),
322 ),
323 parts = listOf(TextPart(id = "$tempId-text", text = text)),
324 )
326 /** The agent's current task plan (the latest `todowrite`), surfaced so the chat can pin a
327 * live progress checklist above the composer. distinctUntilChanged so a per-token messages
328 * emission that doesn't change the plan doesn't recompose the bar.
329 *
330 * A [TodoPlanCache] memoizes the scan against the input list's identity, so a `combine`
331 * re-evaluation that re-emits the same list instance (e.g. when the optimistic-messages
332 * StateFlow updates without an SSE change) skips the O(messages×parts) re-scan. */
333 private val todoPlanCache = TodoPlanCache()
334 val todoPlan: StateFlow<List<TodoItem>> = messages
335 .map { todoPlanCache.plan(it) }
336 // Run the scan off the main thread: it's an O(messages×parts) scan (worst case
337 // when the conversation has no todowrite) and runs on every conflated messages emission.
338 // distinctUntilChanged is downstream so it dedupes the result but doesn't prevent the
339 // scan — without flowOn this scans on Main.immediate per streamed token → jank.
340 .flowOn(Dispatchers.Default)
341 .distinctUntilChanged()
342 .stateIn(
343 scope = viewModelScope,
344 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
345 initialValue = emptyList(),
346 )
348 private val _running = MutableStateFlow(false)
349 val running: StateFlow<Boolean> = _running.asStateFlow()
351 /**
352 * True when the SSE stream dropped mid-run (a transient [Disconnected] while a run is
353 * active). The UI uses this to switch the working indicator to a "stream interrupted /
354 * reconnecting" visual instead of clearing [_running] outright — the run is still active
355 * on the server, so showing it as finished would mislead the user into typing a follow-up
356 * or navigating away. Cleared when the stream reconnects ([Connected]) or when a hard
357 * failure ([Failed]/[AuthFailed]) genuinely clears [_running]. See the state watcher in
358 * [init]. */
359 private val _streamInterrupted = MutableStateFlow(false)
360 val streamInterrupted: StateFlow<Boolean> = _streamInterrupted.asStateFlow()
362 /**
363 * Wall-clock millis at which the current run started (0L when no run is active). Drives
364 * the typing row's elapsed timer so it survives LazyColumn recycling — a local `remember`
365 * in the row resets to 0:00 when the row is disposed and scrolled back into view. Stamped
366 * only on a real false→[beginRun] transition (send/run/init), not on SSE-reconnect relight
367 * (which continues an existing run and should keep its original elapsed time).
368 */
369 private val _runStartMs = MutableStateFlow(0L)
370 val runStartMs: StateFlow<Long> = _runStartMs.asStateFlow()
372 /**
373 * Atomically transition `_running` false→true and stamp the run-start timestamp. Returns
374 * false (with no state change) when a run is already in flight, so callers can surface a
375 * "busy" message instead of silently dropping the action. Centralizes the transition so
376 * every run-start path captures [runStartMs] consistently.
377 */
378 private fun beginRun(): Boolean {
379 if (!_running.compareAndSet(false, true)) return false
380 _runStartMs.value = System.currentTimeMillis()
381 return true
382 }
384 /** True once the current run ended via SessionIdle/SessionError, until the next run starts
385 * (a send) or an SSE reconnect. Gates the run-indicator re-light below: isRunActivity
386 * returns true for ANY message.part.updated, so without this a trailing/replayed part
387 * arriving after a legitimate SessionIdle would re-light the "working" indicator with no
388 * further idle to clear it — a stuck spinner + foreground service. Cleared on reconnect so
389 * genuine reconnect-recovery re-lighting (its actual purpose) still works. */
390 @Volatile private var runEndedByIdle = false
392 /** The in-flight REST title resolution for the current connection. Launched from — and
393 * cancelled by — the per-connection reset block so it can't observe or restore a previous
394 * server's stale title. */
395 private var sessionTitleJob: Job? = null
397 /** True while an abort REST call is in flight, so the Stop button can show a
398 * spinner and prevent double-taps from firing a second abort. */
399 private val _aborting = MutableStateFlow(false)
400 val aborting: StateFlow<Boolean> = _aborting.asStateFlow()
402 /** A follow-up the user typed while a run was active. send() queues it here and
403 * auto-sends once the run completes (SessionIdle), so the user isn't blocked with
404 * no way to send and no indication why the Send button is gone. */
405 private val _queuedFollowUp = MutableStateFlow<String?>(null)
406 val queuedFollowUp: StateFlow<String?> = _queuedFollowUp.asStateFlow()
408 /** The text of the most recently-sent user prompt (captured in [send]), so an undo-able
409 * Stop can re-send it. Only text-bearing prompts are tracked — an image-only prompt has
410 * nothing to re-send text-wise. Cleared when a new run starts with different text. */
411 private var lastSentPrompt: String? = null
413 /** One-shot event signals for an undoable Stop. The UI collects this and shows an
414 * "Stopped — Undo" snackbar; tapping Undo calls [resendLastPrompt]. A Channel so an
415 * emit before the UI subscribes is still delivered. */
416 private val _stopUndoEvents = Channel<String?>(Channel.CONFLATED)
417 val stopUndoEvents: Flow<String?> = _stopUndoEvents.receiveAsFlow()
419 /** One-shot error events surfaced as snackbars. A Channel (not SharedFlow) so an event
420 * emitted before the UI subscribes is buffered and still delivered — a SharedFlow with
421 * replay=0 would drop it (e.g. a VM-init catalog fetch failing before first
422 * composition). Each event is delivered exactly once and not replayed to a
423 * re-subscribing collector, so rotation doesn't re-show a stale snackbar. */
424 private val _errorEvents = Channel<ChatError>(
425 capacity = NetworkConfig.snackbarEventBufferCapacity,
426 onBufferOverflow = BufferOverflow.DROP_OLDEST,
427 )
428 val errorEvents: Flow<ChatError> = _errorEvents.receiveAsFlow()
430 /** One-shot events signaling a user-initiated delete was scheduled and is undoable in-place.
431 * The ChatScreen collects these to show an Undo snackbar over the conversation the user is
432 * already viewing, instead of navigating away first and surfacing Undo on the session list. */
433 private val _deleteUndoEvents = MutableSharedFlow<Unit>(
434 extraBufferCapacity = 1,
435 onBufferOverflow = BufferOverflow.DROP_OLDEST,
436 )
437 val deleteUndoEvents: SharedFlow<Unit> = _deleteUndoEvents.asSharedFlow()
439 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
440 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
442 private val _modelsLoading = MutableStateFlow(true)
443 val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
445 private val _modelsError = MutableStateFlow(false)
446 val modelsError: StateFlow<Boolean> = _modelsError.asStateFlow()
448 private val _selectedModel = MutableStateFlow<ModelOption?>(null)
449 val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
451 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
452 container.activeConnection
453 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
454 .stateIn(
455 scope = viewModelScope,
456 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
457 initialValue = EventStreamClient.ConnectionState.Disconnected,
458 )
460 /** SSE reconnect-attempt count for the active connection, surfaced so the chat's
461 * connection banner can show "Reconnecting (attempt N)…" during a sustained outage. */
462 val reconnectAttempts: StateFlow<Int> =
463 container.activeConnection
464 .flatMapLatest { it?.events?.reconnectAttempts ?: flowOf(0) }
465 .stateIn(
466 scope = viewModelScope,
467 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
468 initialValue = 0,
469 )
471 private val _pendingPermission = MutableStateFlow<Permission?>(null)
472 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
474 /** Backlog indicator for the permission dialog (see [PermissionProgress]). */
475 private val _permissionProgress = MutableStateFlow(PermissionProgress(0, 0))
476 val permissionProgress: StateFlow<PermissionProgress> = _permissionProgress.asStateFlow()
478 /** Max number of permissions simultaneously pending since the queue was last empty. Reset
479 * to 0 when the queue drains, so a fresh burst starts its count over. */
480 private var maxPendingSeen = 0
482 // Unanswered permission requests keyed by id, insertion-ordered. The dialog shows the most
483 // recent; when one is answered/replied — or a response fails — we fall back to the next so a
484 // request that arrived while another was in flight isn't silently dropped (its tool run would
485 // otherwise stay paused server-side with no UI affordance). Only touched on the VM's main
486 // dispatcher (event collector + viewModelScope launches), so no extra synchronization is needed.
487 private val pendingPermissions = LinkedHashMap<String, Permission>()
489 // Session-scoped permission grants (Allow-for-this-session). Each entry is a (type, patternText)
490 // pair the user approved with PermissionResponse.SESSION. Subsequent matching requests are
491 // auto-responded ONCE without showing the dialog, so e.g. a user granting "read" for one path
492 // isn't re-prompted for every other file read in the same conversation. Cleared on connection
493 // change (see the activeConnection collector) so it never leaks across servers. The server only
494 // knows once/always/reject; SESSION is a client-side scope that maps to ONCE on the wire.
495 private val sessionAllowed = mutableSetOf<Pair<String, String>>()
497 private fun enqueuePermission(permission: Permission) {
498 // Auto-respond to a permission whose (type, pattern) the user previously granted for this
499 // session, instead of showing the dialog again. Resolved immediately (not via the queue) so
500 // the tool run isn't paused waiting for a dialog the user will never see.
501 val type = permission.type.orEmpty()
502 val pattern = permission.patternText.orEmpty()
503 if (type.isNotEmpty() && sessionAllowed.contains(type to pattern)) {
504 respondPermission(permission, PermissionResponse.ONCE)
505 return
506 }
507 pendingPermissions[permission.id] = permission
508 _pendingPermission.value = pendingPermissions.values.lastOrNull()
509 updatePermissionProgress()
510 }
512 private fun resolvePermission(id: String) {
513 pendingPermissions.remove(id)
514 _pendingPermission.value = pendingPermissions.values.lastOrNull()
515 updatePermissionProgress()
516 }
518 private fun clearPermissions() {
519 pendingPermissions.clear()
520 _pendingPermission.value = null
521 updatePermissionProgress()
522 }
524 /** Recompute the "N of M" backlog indicator. [maxPendingSeen] grows with the queue and
525 * resets only when it drains, so position = total - remaining + 1 advances as requests are
526 * answered. */
527 private fun updatePermissionProgress() {
528 val remaining = pendingPermissions.size
529 if (remaining == 0) {
530 maxPendingSeen = 0
531 _permissionProgress.value = PermissionProgress(0, 0)
532 return
533 }
534 if (remaining > maxPendingSeen) maxPendingSeen = remaining
535 _permissionProgress.value = PermissionProgress(
536 position = maxPendingSeen - remaining + 1,
537 total = maxPendingSeen,
538 )
539 }
541 private val _agents = MutableStateFlow<List<Agent>>(emptyList())
542 val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
544 private val _agentsLoading = MutableStateFlow(true)
545 val agentsLoading: StateFlow<Boolean> = _agentsLoading.asStateFlow()
547 private val _agentsError = MutableStateFlow(false)
548 val agentsError: StateFlow<Boolean> = _agentsError.asStateFlow()
550 private val _selectedAgent = MutableStateFlow<String?>(null)
551 val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
553 private val _commands = MutableStateFlow<List<Command>>(emptyList())
554 val commands: StateFlow<List<Command>> = _commands.asStateFlow()
556 private val _commandsLoading = MutableStateFlow(true)
557 val commandsLoading: StateFlow<Boolean> = _commandsLoading.asStateFlow()
559 private val _commandsError = MutableStateFlow(false)
560 val commandsError: StateFlow<Boolean> = _commandsError.asStateFlow()
562 private val _sessionTitle = MutableStateFlow<String?>(null)
563 val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
565 /** Set when the current session is deleted via SSE, so the UI can navigate away. */
566 private val _sessionDeleted = MutableStateFlow(false)
567 val sessionDeleted: StateFlow<Boolean> = _sessionDeleted.asStateFlow()
569 /** The text of the last send that failed, surfaced so the UI can offer a retry. */
570 private val _failedDraft = MutableStateFlow<String?>(null)
571 val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
573 /** The idempotency key of the last failed online send, stashed alongside [_failedDraft] so
574 * [retryFailed] re-submits with the SAME key. A retry after a lost response is then
575 * deduplicated server-side instead of starting a duplicate agent run. */
576 @Volatile private var failedIdempotencyKey: String? = null
578 /** Per-session draft, persisted so it survives navigation/process death. */
579 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
580 val draft: StateFlow<String> = _draft.asStateFlow()
582 /** A pending quote-reply preview (the raw text the user quoted), shown as a dismissible
583 * card above the composer so the user can see and cancel the quote before sending. Cleared
584 * on send or via [cancelQuoteReply]. The actual `>` prefixed lines are still folded into
585 * the draft for the server; this state is purely for the visual preview chip. */
586 private val _pendingQuote = MutableStateFlow<String?>(null)
587 val pendingQuote: StateFlow<String?> = _pendingQuote.asStateFlow()
589 /** Messages composed for this session while offline/disconnected, queued in the outbox and
590 * awaiting an automatic flush on reconnect. Surfaced so the composer can show a "queued"
591 * chip with a discard/send-now affordance. */
592 val outbox: StateFlow<List<OutboxMessage>> =
593 container.outboxStore.messages
594 .map { list -> list.filter { it.sessionId == sessionId } }
595 .stateIn(
596 scope = viewModelScope,
597 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
598 initialValue = emptyList(),
599 )
601 /** Attachments staged for the next prompt (images/files). Persisted per-session (as their
602 * self-contained base64 data URLs) via [AttachmentDraftStore] so an interrupted compose
603 * survives process death — the source content Uris wouldn't survive, but the data URLs
604 * do. Cleared on a successful send. */
605 private val _attachments = MutableStateFlow<List<PendingAttachment>>(emptyList())
606 val attachments: StateFlow<List<PendingAttachment>> = _attachments.asStateFlow()
608 // Count of picks currently being read + base64-encoded off the main thread, so the
609 // composer can show a staging placeholder immediately (chips only materialize once done).
610 // Exposed as StateFlows (not Compose state) so they survive a config change: the staging
611 // work runs on viewModelScope (which survives rotation), so the counters must live on the
612 // VM too — remember { mutableStateOf(0) } in the composable would reset to 0 on rotation
613 // and lose the staging chip mid-encode.
614 private val _stagingCount = MutableStateFlow(0)
615 val stagingCount: StateFlow<Int> = _stagingCount.asStateFlow()
616 // Total number of files still being staged across all in-flight batches, so the
617 // staging chip can show "Staging N files…" rather than a bare indeterminate spinner.
618 private val _stagingFileTotal = MutableStateFlow(0)
619 val stagingFileTotal: StateFlow<Int> = _stagingFileTotal.asStateFlow()
621 /** True while a revert checkpoint is active for this session (messages after it are hidden
622 * server-side). Drives the "reverted" banner with its Undo. */
623 private val _reverted = MutableStateFlow(false)
624 val reverted: StateFlow<Boolean> = _reverted.asStateFlow()
626 /** True when the revert was triggered by an Edit action (vs a bare Revert). Drives the
627 * banner's copy so it reads "Editing message" rather than "Reverted", making the composer's
628 * prefilled text unambiguous. Cleared on unrevert and on send. */
629 private val _editing = MutableStateFlow(false)
630 val editing: StateFlow<Boolean> = _editing.asStateFlow()
632 /** The diff of the active revert checkpoint, when the server includes one. Shown in the
633 * revert banner so the user can see what changed before deciding to undo. Null when the
634 * session isn't reverted or the server didn't send a diff. */
635 private val _revertDiff = MutableStateFlow<String?>(null)
636 val revertDiff: StateFlow<String?> = _revertDiff.asStateFlow()
638 /** The active public share URL for this session, or null when not shared. */
639 private val _shareUrl = MutableStateFlow<String?>(null)
640 val shareUrl: StateFlow<String?> = _shareUrl.asStateFlow()
642 /** One-shot events carrying a freshly-created share URL so the UI can copy/share it. */
643 private val _shareLinkEvents = Channel<String>(
644 capacity = NetworkConfig.snackbarEventBufferCapacity,
645 onBufferOverflow = BufferOverflow.DROP_OLDEST,
646 )
647 val shareLinkEvents: Flow<String> = _shareLinkEvents.receiveAsFlow()
649 /** One-shot events carrying the id of a session freshly branched from a message, so the
650 * UI can navigate to it. */
651 private val _branchEvents = Channel<String>(
652 capacity = NetworkConfig.snackbarEventBufferCapacity,
653 onBufferOverflow = BufferOverflow.DROP_OLDEST,
654 )
655 val branchEvents: Flow<String> = _branchEvents.receiveAsFlow()
657 init {
658 // DraftStore loads SharedPreferences asynchronously; on a cold start the
659 // synchronous get() above returns "" until the background load completes.
660 // Observe the ready signal and re-seed the draft so it appears in the UI.
661 viewModelScope.launch {
662 container.draftStore.ready.collect { ready ->
663 if (!ready) return@collect
664 if (_draft.value.isEmpty()) {
665 _draft.value = container.draftStore.get(sessionId)
666 }
667 // Recover a follow-up queued before process death (its in-memory StateFlow
668 // is gone). If the input is now free, drop it back there for review/send and
669 // consume the stored copy; otherwise re-queue it so it auto-sends when the
670 // current run next idles. Guarded on == null so it never clobbers a
671 // follow-up the user queued after opening this session.
672 val savedFollowUp = container.draftStore.getFollowUp(sessionId)
673 if (savedFollowUp.isNotEmpty() && _queuedFollowUp.value == null) {
674 if (_draft.value.isEmpty()) {
675 _draft.value = savedFollowUp
676 container.draftStore.flushFollowUp(sessionId, "")
677 } else {
678 _queuedFollowUp.value = savedFollowUp
679 }
680 }
681 }
682 }
683 // Observe the drafts store for external mutations to this session's draft
684 // (e.g. a share injected via draftStore.set in two-pane mode, or setImmediate
685 // before navigation in single-pane mode). Without this, a share injected into
686 // an already-open session never appears in the input field — the init-time
687 // read and the ready-only re-seed miss it when the draft is non-empty.
688 // The VM's own debounced persist writes the same value _draft already holds,
689 // so those updates are no-ops here (storeValue == _draft.value). The
690 // suppressDraftPersist guard prevents a stale store value from clobbering
691 // the deliberate draft clear during a send (the store still holds the
692 // pre-send draft until the send resolves).
693 viewModelScope.launch {
694 container.draftStore.drafts.collect { drafts ->
695 // Ignore emissions until the initial disk load completes: the load itself
696 // emits the persisted snapshot, and applying it here would clobber text the
697 // user typed during the async load (the `ready` observer above seeds an
698 // empty draft). After ready, emissions are external writes.
699 if (!container.draftStore.ready.value) return@collect
700 val storeValue = drafts[sessionId].orEmpty()
701 // Ignore this VM's own (debounced, possibly stale) persistence echo — only a
702 // genuine external write (e.g. a two-pane share injection) should overwrite
703 // the live draft.
704 if (storeValue == lastPersistedDraft) return@collect
705 if (storeValue != _draft.value && !suppressDraftPersist.get()) {
706 _draft.value = storeValue
707 }
708 }
709 }
710 }
712 init {
713 // Restore attachments staged before process death. They were persisted as
714 // self-contained data URLs, so they re-send and (for images) re-preview intact.
715 // Guarded on empty so it never clobbers attachments the user staged after opening.
716 viewModelScope.launch {
717 val restored = runCatchingCancellable { container.attachmentDraftStore.load(sessionId) }
718 .getOrDefault(emptyList())
719 if (restored.isNotEmpty() && _attachments.value.isEmpty()) {
720 _attachments.value = restored.map { it.toPending() }
721 }
722 }
723 }
725 fun selectModel(option: ModelOption) { _selectedModel.value = option }
727 fun selectAgent(name: String?) { _selectedAgent.value = name }
729 fun reloadModels() {
730 val conn = connection ?: return
731 viewModelScope.launch {
732 conn.api.invalidateProvidersCache()
733 _modelsReload.value++
734 }
735 }
737 fun reloadAgents() {
738 val conn = connection ?: return
739 viewModelScope.launch {
740 conn.api.invalidateAgentsCache()
741 _agentsReload.value++
742 }
743 }
745 fun reloadCommands() {
746 val conn = connection ?: return
747 viewModelScope.launch {
748 conn.api.invalidateCommandsCache()
749 _commandsReload.value++
750 }
751 }
753 /**
754 * Shared helper for init-block catalog observers: re-fetches whenever the active
755 * connection or the catalog's reload trigger changes, invoking [onNull] to clear
756 * state on null so stale data from the old server doesn't persist. The
757 * [reloadTrigger] is merged with [activeConnection] so a manual reload (via
758 * reloadModels/reloadAgents/reloadCommands) cancels any in-flight observe fetch
759 * via collectLatest, preventing a stale observe result from overwriting a fresh
760 * reload result. Failure is non-fatal — the error flag is surfaced to the UI.
761 */
762 private fun <T> observeCatalog(
763 tag: String,
764 loading: MutableStateFlow<Boolean>,
765 error: MutableStateFlow<Boolean>,
766 fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
767 onSuccess: suspend (T) -> Unit,
768 onNull: () -> Unit,
769 reloadTrigger: StateFlow<Int>,
770 ) {
771 viewModelScope.launch {
772 merge(container.activeConnection, reloadTrigger).collectLatest { _ ->
773 val conn = container.activeConnection.value
774 if (conn == null) { onNull(); loading.value = false; error.value = false; return@collectLatest }
775 loading.value = true
776 error.value = false
777 runCatchingCancellable { fetch(conn.api) }
778 // fetch() does HTTP via withRetry, so the failure can be a
779 // ClientRequestException whose message embeds the full request URL
780 // (may contain auth/paths). Log only a scrubbed summary.
781 .onFailure { Log.w("ChatViewModel", "Failed to load $tag: ${safeExceptionSummary(it)}"); error.value = true }
782 .getOrNull()?.let { onSuccess(it) }
783 loading.value = false
784 }
785 }
786 }
788 fun updateDraft(text: String) {
789 _draft.value = text
790 }
792 init {
793 // Debounce draft persistence so we don't write to disk on every keystroke.
794 // Skip persisting empty drafts that were set by send() (suppressed via
795 // suppressDraftPersist) — send() clears the in-memory draft immediately for
796 // UI feedback but deliberately doesn't persist the clear until the send
797 // succeeds, so a failed send can restore the draft.
798 viewModelScope.launch {
799 _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
800 if (text.isBlank() && suppressDraftPersist.get()) return@collect
801 lastPersistedDraft = text
802 runCatchingCancellable { container.draftStore.set(sessionId, text) }
803 .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
804 }
805 }
806 // Reset per-connection state when the active server changes so stale spinners,
807 // permission dialogs, errors, and agent selections from the old server don't
808 // persist into the new one.
809 viewModelScope.launch {
810 container.activeConnection.collectLatest { conn ->
811 if (conn == null) {
812 // The connection dropped (e.g. disconnect() mid-run). No SSE stream will
813 // arrive to deliver SessionIdle/SessionError, so reset the run state here —
814 // otherwise the working spinner sticks on, ChatScreen keeps keepScreenOn +
815 // the RunForegroundService alive, and the Stop button no-ops (abort() early-
816 // returns with no connection) until a later reconnect happens to clear it.
817 _running.value = false
818 _streamInterrupted.value = false
819 _aborting.value = false
820 clearPermissions()
821 sessionAllowed.clear()
822 sessionTitleJob?.cancel()
823 _revertDiff.value = null
824 return@collectLatest
825 }
826 _running.value = false
827 _streamInterrupted.value = false
828 // Allow the re-light below to recover a still-running run after this (re)connect;
829 // a run that actually finished during the outage emits no live parts, so this
830 // won't spuriously re-light.
831 runEndedByIdle = false
832 clearPermissions()
833 sessionAllowed.clear()
834 _failedDraft.value = null
835 _revertDiff.value = null
836 // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
837 // user intent that is now persisted; wiping it on every (re)connect — which
838 // this collector does, including transient SSE drops and the cold-start
839 // restore path — would drop a legitimately queued/recovered follow-up.
840 _selectedAgent.value = null
841 // Apply the user's persisted preferred-agent for this new session/load, so a
842 // user who always runs a specific agent doesn't have to pick it every session.
843 // No-op when the preference is empty (the server default applies). Read here
844 // (not in the agent-catalog onSuccess) because the catalog loads once per
845 // connection while this reset fires per-session-open; setting it once on
846 // connect is enough and avoids re-applying it on every catalog refresh.
847 runCatchingCancellable {
848 val pref = container.settingsStore.preferredAgentName.first()
849 if (pref.isNotEmpty()) _selectedAgent.value = pref
850 }
851 _sessionTitle.value = null
852 // Resolve the app-bar title via REST for THIS connection. Launched from the same
853 // block that just nulled _sessionTitle (and cancelled the previous fetch) so it can
854 // never observe or restore the previous server's stale title — the old separate
855 // collector could, racing that reset. The assignment is guarded on a still-null
856 // title so a fresher SSE SessionUpdated (from the event collector below) wins.
857 sessionTitleJob?.cancel()
858 sessionTitleJob = viewModelScope.launch {
859 runCatchingCancellable { conn.repository.listSessions() }
860 .getOrNull()
861 ?.firstOrNull { it.id == sessionId }
862 ?.let { session ->
863 if (_sessionTitle.value == null) {
864 _sessionTitle.value = session.displayTitle
865 _reverted.value = session.isReverted
866 _revertDiff.value = session.revert?.diff?.takeIf { it.isNotBlank() }
867 _shareUrl.value = session.share?.url?.takeIf { it.isNotBlank() }
868 }
869 }
870 }
871 try {
872 // Loop the inner collect so a transient non-cancellation exception (a
873 // malformed SSE event decoding error, an unexpected channel close)
874 // doesn't terminate the event collector for this connection. Without
875 // the loop, the catch returns the collectLatest lambda normally, so
876 // collectLatest considers this connection's block complete and never
877 // re-runs it — the chat would go permanently silent (no streaming,
878 // no permission prompts, no title updates) until a server switch.
879 while (currentCoroutineContext().isActive) {
880 try {
881 conn.events.events.collect { event ->
882 if (SessionRepository.isIdle(event, sessionId)) {
883 _running.value = false
884 runEndedByIdle = true
885 // Auto-send a queued follow-up once the previous run finishes,
886 // so the user's drafted-while-running message isn't lost.
887 // The follow-up is cleared *inside* send()'s launched
888 // coroutine (via queuedFollowUpToClear), so a scope
889 // cancellation between this point and the coroutine
890 // starting drops the launch without wiping the persisted
891 // follow-up — recovery on next session open instead of
892 // silent data loss.
893 val queued = _queuedFollowUp.value
894 if (queued != null) {
895 send(queued, includeAttachments = false, queuedFollowUpToClear = queued)
896 }
897 }
898 if (SessionRepository.isError(event, sessionId)) {
899 _running.value = false
900 runEndedByIdle = true
901 // The run errored, so the queued follow-up won't auto-send.
902 // Restore it to the input (if free) so the user's typed text
903 // isn't silently lost; otherwise just clear the queue.
904 val queued = _queuedFollowUp.value
905 if (!queued.isNullOrEmpty() && _draft.value.isEmpty()) {
906 _draft.value = queued
907 }
908 setQueuedFollowUp(null)
909 _errorEvents.trySend(ChatError(container.string(R.string.error_agent_reported)))
910 }
911 // Restore the run indicator if a live streaming event arrives while it's
912 // cleared — e.g. an SSE reconnect mid-run reset _running to false, but the
913 // agent is still working. Don't revive it once the user has hit Stop.
914 val relightRunIndicator = SessionRepository.isRunActivity(event, sessionId) &&
915 !_aborting.value && !_running.value && !runEndedByIdle
916 if (relightRunIndicator) {
917 _running.value = true
918 }
919 when (event) {
920 is PermissionUpdated ->
921 if (event.properties.sessionID == sessionId) enqueuePermission(event.properties)
922 is PermissionReplied ->
923 if (event.properties.sessionID == sessionId) event.properties.permissionID?.let { resolvePermission(it) }
924 is SessionUpdated ->
925 if (event.properties.info.id == sessionId) {
926 val info = event.properties.info
927 _sessionTitle.value = info.displayTitle
928 _reverted.value = info.isReverted
929 _revertDiff.value = info.revert?.diff?.takeIf { it.isNotBlank() }
930 _shareUrl.value = info.share?.url?.takeIf { it.isNotBlank() }
931 }
932 is SessionDeleted ->
933 if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
934 _sessionDeleted.value = true
935 }
936 else -> {}
937 }
938 }
939 } catch (e: CancellationException) {
940 throw e
941 } catch (e: Exception) {
942 Log.w("ChatViewModel", "SSE event collector error, restarting", e)
943 }
944 }
945 } catch (e: CancellationException) {
946 throw e
947 } catch (e: Exception) {
948 Log.w("ChatViewModel", "SSE event collector loop error", e)
949 }
950 }
951 }
952 // Load the model catalog; preselect the server default. Failure is non-fatal —
953 // sending with no model just uses the server's default agent/model.
954 observeCatalog(
955 tag = "model catalog",
956 loading = _modelsLoading,
957 error = _modelsError,
958 fetch = { it.providers() },
959 onSuccess = { resp ->
960 val options = resp.toOptions()
961 _models.value = options
962 // Only (re)apply the default when there's no valid current selection. On
963 // re-fetch/reconnect/Retry the user's chosen model must survive — keep it as
964 // long as it's still present in the refreshed list. When there's no current
965 // selection, prefer the user's persisted default-model preference (if it's in
966 // the list) over the server default, so a user who always picks a specific
967 // model doesn't have to pick it every new session.
968 val current = _selectedModel.value
969 if (current == null || options.none { it.ref == current.ref }) {
970 val prefId = runCatchingCancellable {
971 container.settingsStore.preferredModelId.first()
972 }.getOrDefault("")
973 val preferredOption = if (prefId.isNotEmpty()) {
974 options.firstOrNull { it.modelID == prefId }
975 } else null
976 _selectedModel.value = preferredOption ?: resp.defaultOption(options)
977 }
978 },
979 onNull = { _models.value = emptyList(); _selectedModel.value = null },
980 reloadTrigger = _modelsReload,
981 )
982 // Load the agent catalog (non-fatal).
983 observeCatalog(
984 tag = "agent catalog",
985 loading = _agentsLoading,
986 error = _agentsError,
987 fetch = { it.agents() },
988 onSuccess = { _agents.value = it },
989 onNull = { _agents.value = emptyList() },
990 reloadTrigger = _agentsReload,
991 )
992 // Load the command catalog (non-fatal).
993 observeCatalog(
994 tag = "command catalog",
995 loading = _commandsLoading,
996 error = _commandsError,
997 fetch = { it.commands() },
998 onSuccess = { _commands.value = it },
999 onNull = { _commands.value = emptyList() },
1000 reloadTrigger = _commandsReload,
1002 // If the SSE stream drops mid-run, the run indicator would spin forever;
1003 // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
1004 // On a *transient* drop (Disconnected) we don't clear _running — the run is still
1005 // active on the server, and showing it as finished would mislead the user. Instead
1006 // we set _streamInterrupted so the working row switches to a "reconnecting" visual.
1007 // On a *hard* failure (Failed/AuthFailed) the run genuinely can't continue without
1008 // user action, so _running is cleared. _streamInterrupted is cleared on Connected.
1009 viewModelScope.launch {
1010 container.activeConnection.collectLatest { conn ->
1011 if (conn == null) return@collectLatest
1012 try {
1013 // Loop so a transient non-cancellation exception doesn't terminate the
1014 // state watcher for this connection. Without the loop, a single thrown
1015 // error returns the collectLatest lambda and the _running reset on
1016 // Disconnected/Failed never fires again for this connection — a dropped
1017 // stream leaves the working spinner (and the RunForegroundService) stuck.
1018 while (currentCoroutineContext().isActive) {
1019 try {
1020 conn.events.state.collect { state ->
1021 when (state) {
1022 EventStreamClient.ConnectionState.Connected -> {
1023 _streamInterrupted.value = false
1025 EventStreamClient.ConnectionState.Disconnected -> {
1026 // Transient: the stream dropped but the system is
1027 // reconnecting. Flag interrupted so the working row
1028 // shows a "reconnecting" state instead of vanishing.
1029 if (_running.value) _streamInterrupted.value = true
1031 EventStreamClient.ConnectionState.Failed,
1032 EventStreamClient.ConnectionState.AuthFailed -> {
1033 // Hard failure: the run can't continue without user
1034 // action (fix credentials / server). Clear running and
1035 // the interrupted flag so the UI doesn't look stuck.
1036 _streamInterrupted.value = false
1037 if (_running.value) _running.value = false
1039 EventStreamClient.ConnectionState.Connecting -> {
1040 // Initial connect or reconnect attempt; leave running
1041 // as-is — interrupted (if set) stays until Connected.
1045 } catch (e: CancellationException) {
1046 throw e
1047 } catch (e: Exception) {
1048 Log.w("ChatViewModel", "SSE state collector error, restarting", e)
1051 } catch (e: CancellationException) {
1052 throw e
1053 } catch (e: Exception) {
1054 Log.w("ChatViewModel", "SSE state collector loop error", e)
1060 /** Sends [text] together with any staged attachments; returns true on success so the
1061 * caller can clear the draft only then. */
1062 fun send(text: String): Boolean = send(text, includeAttachments = true)
1064 /**
1065 * Core send. [includeAttachments] is false for the internal auto-send paths (a queued
1066 * follow-up, a retry) so staged attachments aren't silently re-sent with unrelated text.
1068 * [queuedFollowUpToClear] is the text of a queued follow-up being auto-sent (from
1069 * SessionIdle). When non-null, the follow-up is cleared from persistence *inside* the
1070 * launched coroutine — so if [viewModelScope] is cancelled before the coroutine runs
1071 * (e.g. the user navigates away at the exact moment SessionIdle arrives), the clear
1072 * never executes and the follow-up remains persisted, recovered on the next open of
1073 * this session. Clearing it before the launch (the prior approach) could lose the
1074 * follow-up entirely: the launch would be dropped by cancellation but the persisted
1075 * copy was already wiped.
1076 */
1077 @Suppress("CyclomaticComplexMethod")
1078 private fun send(
1079 text: String,
1080 includeAttachments: Boolean,
1081 idempotencyKey: String? = null,
1082 queuedFollowUpToClear: String? = null,
1083 ): Boolean {
1084 val trimmed = text.trim()
1085 val attachments = if (includeAttachments) _attachments.value else emptyList()
1086 // An image-only prompt (attachments, no text) is valid; a blank prompt with nothing
1087 // attached is not.
1088 if (trimmed.isEmpty() && attachments.isEmpty()) return false
1089 // Offline, or no active connection: queue the prompt to the outbox (flushed on
1090 // reconnect) instead of failing the send. Device-offline is caught even when a stale
1091 // connection object lingers, so a phone that lost signal mid-conversation still
1092 // captures the message rather than bouncing it off a dead socket.
1093 val conn = connection
1094 if (conn == null || !container.isOnline.value) {
1095 return enqueueOffline(trimmed, attachments)
1097 if (!beginRun()) return false
1098 // A new run is starting: reopen the re-light gate so this run's streamed parts keep the
1099 // indicator lit (and a later SessionIdle re-closes it).
1100 runEndedByIdle = false
1101 // A plain manual Send of the auto-restored failed draft must reuse the stashed key too
1102 // (retryFailed passes it explicitly; the composer's Send does not), so a lost-response
1103 // re-send stays deduplicated server-side instead of starting a duplicate agent run.
1104 // Capture the match BEFORE clearing _failedDraft just below.
1105 val reuseKey = if (idempotencyKey == null && trimmed == _failedDraft.value?.trim()) {
1106 failedIdempotencyKey
1107 } else {
1108 null
1110 _failedDraft.value = null
1111 // Stable idempotency key for THIS online attempt: a brand-new message gets a fresh key,
1112 // while retryFailed() (or a manual re-send of the restored failed draft) re-submits with the
1113 // key it stashed on the original failure — so a retry after a lost response is deduplicated
1114 // server-side (no duplicate agent run).
1115 val key = idempotencyKey ?: reuseKey ?: java.util.UUID.randomUUID().toString()
1116 // Clear the staged attachments optimistically so the composer empties immediately;
1117 // restore them if the send fails (mirrors the draft handling below).
1118 if (attachments.isNotEmpty()) _attachments.value = emptyList()
1119 // Clear the in-memory draft for the UI immediately, but don't persist the clear
1120 // yet — if the send fails and the process dies before we restore, the draft
1121 // would be lost forever. The persisted draft is cleared only on success.
1122 // suppressDraftPersist prevents the debounced collector from persisting the
1123 // empty draft before the send resolves.
1124 //
1125 // Only clear when the text being sent IS the current draft. send() is also
1126 // invoked for auto-sent queued follow-ups (SessionIdle) and retryFailed(), where
1127 // the user may have typed a NEW draft since — wiping it would lose that text.
1128 if (_draft.value.trim() == trimmed) {
1129 suppressDraftPersist.set(true)
1130 _draft.value = ""
1131 _pendingQuote.value = null
1132 _editing.value = false
1134 // Inject an optimistic user message so the outgoing prompt is visible immediately,
1135 // before the server echoes it back via SSE. Removed by reconcileOptimistic once the
1136 // real UserMessage arrives. Only for text-bearing prompts (an image-only prompt has
1137 // no text to show).
1138 val optimisticId = if (trimmed.isNotEmpty()) "__optimistic_${System.nanoTime()}" else null
1139 if (optimisticId != null) {
1140 // Clear any prior failed optimistic entry with the same text (a retry re-sends).
1141 _optimisticMessages.update { entries -> entries.filterNot { it.failed && it.text.trim() == trimmed } }
1142 _optimisticMessages.update { it + OptimisticEntry(optimisticId, trimmed, System.currentTimeMillis(), failed = false) }
1144 // Remember the sent text so an undoable Stop can re-send it. Overwrite unconditionally:
1145 // an image-only prompt has nothing to re-send, and leaving a prior text prompt tracked
1146 // would make Stop-Undo silently re-send that unrelated earlier message.
1147 lastSentPrompt = trimmed.takeIf { it.isNotEmpty() }
1148 viewModelScope.launch {
1149 // Clear a queued follow-up being auto-sent *inside* the launched coroutine, so a
1150 // scope cancellation between send() being called and the coroutine starting drops
1151 // the launch without wiping the persisted follow-up — the follow-up is then
1152 // recovered on the next session open instead of being silently lost.
1153 if (queuedFollowUpToClear != null) setQueuedFollowUp(null)
1154 runCatchingCancellable {
1155 conn.repository.sendPrompt(
1156 sessionId,
1157 trimmed,
1158 attachments = attachments.map { it.part },
1159 model = _selectedModel.value?.ref,
1160 agent = _selectedAgent.value,
1161 idempotencyKey = key,
1163 }.onFailure {
1164 // Mark the optimistic message as failed so the user sees a failure indicator
1165 // instead of a perpetual "sending" state.
1166 if (optimisticId != null) {
1167 _optimisticMessages.update { entries ->
1168 entries.map { if (it.tempId == optimisticId) it.copy(failed = true) else it }
1171 suppressDraftPersist.set(false)
1172 _failedDraft.value = trimmed
1173 failedIdempotencyKey = key
1174 // Only restore the draft if the user hasn't typed anything new since.
1175 if (_draft.value.isBlank()) updateDraft(trimmed)
1176 // Restore the staged attachments too, so a failed send doesn't lose them —
1177 // but only if the user hasn't staged new ones in the meantime.
1178 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
1179 // Re-persist so the restored attachments survive process death after a failure.
1180 persistAttachments()
1181 // Retryable: this is the failed send whose prompt Retry re-submits.
1182 _errorEvents.trySend(ChatError(container.friendlyError(it), retryable = true))
1183 _running.value = false
1184 }.onSuccess {
1185 suppressDraftPersist.set(false)
1186 // This attempt reached the server, so the stashed retry key is spent — drop it so a
1187 // later unrelated send can't reuse it (retryFailed clears it on success too).
1188 failedIdempotencyKey = null
1189 // Send succeeded — the attachments rode along, so clear their persisted copy
1190 // (or keep any the user staged after the optimistic clear).
1191 persistAttachments()
1192 // Send succeeded — persist the cleared draft, but only if the user hasn't
1193 // typed a new one while the send was in flight. Otherwise clearing the
1194 // store echoes back through the draft observer and wipes the in-progress
1195 // text (data loss). Mirrors the guard on the failure path above.
1196 if (_draft.value.isBlank()) {
1197 lastPersistedDraft = ""
1198 container.draftStore.set(sessionId, "")
1200 // Don't reset _running here: the agent continues streaming via SSE.
1201 // _running is cleared on SessionIdle/SessionError (see event collector)
1202 // or when the SSE stream drops (see connection state watcher below).
1205 return true
1208 /**
1209 * Queue [trimmed] (+ [attachments]) to the offline outbox, clearing the composer
1210 * optimistically like a real send. AppContainer flushes the outbox automatically on
1211 * reconnect. The prompt is attributed to the active (or most-recent) server profile so a
1212 * reconnect to a different server doesn't misfire it. On any failure the draft/attachments
1213 * are restored so nothing is lost.
1214 */
1215 private fun enqueueOffline(trimmed: String, attachments: List<PendingAttachment>): Boolean {
1216 _failedDraft.value = null
1217 if (attachments.isNotEmpty()) _attachments.value = emptyList()
1218 val clearDraft = _draft.value.trim() == trimmed
1219 if (clearDraft) {
1220 suppressDraftPersist.set(true)
1221 _draft.value = ""
1223 viewModelScope.launch {
1224 val profileId = connection?.profile?.id
1225 ?: runCatchingCancellable { container.profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
1226 if (profileId == null) {
1227 // No server to attribute the message to — restore the composer and report it
1228 // rather than silently dropping the prompt.
1229 suppressDraftPersist.set(false)
1230 if (_draft.value.isBlank()) updateDraft(trimmed)
1231 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
1232 persistAttachments()
1233 _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
1234 return@launch
1236 runCatchingCancellable {
1237 container.outboxStore.enqueue(
1238 OutboxMessage(
1239 id = java.util.UUID.randomUUID().toString(),
1240 profileId = profileId,
1241 sessionId = sessionId,
1242 text = trimmed,
1243 attachments = attachments.map { it.toPersisted() },
1244 model = _selectedModel.value?.ref,
1245 agent = _selectedAgent.value,
1246 createdAt = System.currentTimeMillis(),
1247 ),
1249 }.onSuccess {
1250 suppressDraftPersist.set(false)
1251 // The staged attachments now live in the outbox item; clear their draft copy.
1252 persistAttachments()
1253 if (_draft.value.isBlank()) {
1254 lastPersistedDraft = ""
1255 container.draftStore.set(sessionId, "")
1257 // Nudge a flush in case we're actually online with a live connection.
1258 container.flushOutbox()
1259 _errorEvents.trySend(ChatError(container.string(R.string.outbox_queued)))
1260 }.onFailure {
1261 suppressDraftPersist.set(false)
1262 if (_draft.value.isBlank()) updateDraft(trimmed)
1263 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
1264 persistAttachments()
1265 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1268 return true
1271 /** Discard a single queued (offline) message. */
1272 fun discardQueued(id: String) {
1273 viewModelScope.launch { runCatchingCancellable { container.outboxStore.remove(id) } }
1276 /** Discard every queued (offline) message for this session. */
1277 fun discardAllQueued() {
1278 viewModelScope.launch { runCatchingCancellable { container.outboxStore.removeForSession(sessionId) } }
1281 /** Attempt to flush the outbox now (e.g. the user tapped "Send now"). */
1282 fun flushQueued() { container.flushOutbox() }
1284 /** Re-send the last draft whose send failed, if any. */
1285 fun retryFailed() {
1286 val draft = _failedDraft.value ?: return
1287 // Reuse the failed send's idempotency key so a retry after a lost response is
1288 // deduplicated server-side rather than starting a duplicate run.
1289 val key = failedIdempotencyKey
1290 // Don't clear _failedDraft until send() accepts the text — if _running is
1291 // already true, send() returns false and the draft would be lost forever.
1292 // Attachments aren't re-sent on retry (they were optimistically restored to the
1293 // composer on the original failure, so they'll ride the next manual send).
1294 // send() accepts the text synchronously, but its onSuccess/onFailure callbacks run
1295 // asynchronously. Clearing failedIdempotencyKey here would open a window between this
1296 // synchronous clear and the async onFailure (which re-stashes the key) during which a
1297 // manual re-send of the same text gets a fresh UUID — defeating server-side deduplication.
1298 // Let send()'s own callbacks own the key's lifecycle, as they do for non-retry paths.
1299 if (send(draft, includeAttachments = false, idempotencyKey = key)) {
1300 _failedDraft.value = null
1304 /** Dismiss a single failed optimistic message by its tempId so it no longer lingers in the
1305 * list. Without this there's no recovery path for a failed message the user chose to abandon
1306 * (its text won't ever match a real server message, so reconcileOptimistic never clears it). */
1307 fun dismissOptimistic(tempId: String) {
1308 _optimisticMessages.update { entries -> entries.filterNot { it.tempId == tempId } }
1311 /** Re-send a specific failed optimistic message by its tempId. Loads its text into the
1312 * composer and fires a send, mirroring the snackbar's Retry path. Returns false (and is a
1313 * no-op) if the entry can't be found or a run is already active. */
1314 fun retryOptimisticMessage(tempId: String): Boolean {
1315 val entry = _optimisticMessages.value.firstOrNull { it.tempId == tempId && it.failed } ?: return false
1316 return send(entry.text, includeAttachments = false)
1319 /**
1320 * Queue [text] to be sent automatically when the current run finishes, or clear any
1321 * queued follow-up when [text] is blank. Used when the user taps Send while a run
1322 * is already active — the Send button is replaced by Stop during a run, but the
1323 * input field stays enabled, so a follow-up typed mid-run would otherwise have
1324 * nowhere to go.
1325 */
1326 fun queueFollowUp(text: String) {
1327 val trimmed = text.trim()
1328 setQueuedFollowUp(trimmed.takeIf { it.isNotEmpty() })
1329 // The text now lives in the queued chip, so clear the input field just as a
1330 // normal send would. No-op for the cancel case (blank text), which must leave
1331 // whatever the user has since typed untouched.
1332 if (trimmed.isNotEmpty()) {
1333 _draft.value = ""
1334 // Persist the cleared draft IMMEDIATELY, not just in memory: the follow-up was already
1335 // persisted synchronously above, so if the process is killed before the debounced
1336 // draft-persist fires, the same text would remain under BOTH the draft and follow-up
1337 // keys — and cold-start recovery would restore it to the composer AND re-queue it (a
1338 // duplicate). Mirror send()'s lastPersistedDraft update so the drafts observer treats
1339 // this store change as our own echo and doesn't write the cleared value back.
1340 lastPersistedDraft = ""
1341 container.draftStore.flushDraft(sessionId, "")
1345 /** Set or clear the queued follow-up and mirror it to persistence, so a follow-up
1346 * queued mid-run survives process death (recovered on the next open of this session).
1347 * Passing null / blank clears both the in-memory value and the persisted copy. */
1348 private fun setQueuedFollowUp(text: String?) {
1349 _queuedFollowUp.value = text
1350 container.draftStore.flushFollowUp(sessionId, text.orEmpty())
1353 /** Transient flag set by [refreshMessages] so the top-bar refresh icon can show
1354 * a brief spinner as immediate tap feedback. The SSE reconnect triggered by
1355 * refreshMessages may not visibly change [connectionState] (it's already
1356 * Connected), so without this the tap appears to do nothing. Clears after a
1357 * short delay or when the connection state next becomes Connected. */
1358 private val _refreshing = MutableStateFlow(false)
1359 val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
1361 /** Manually re-fetch messages by forcing the SSE stream to reconnect, which
1362 * triggers the repository's re-seed-from-REST logic. Gives the user a recovery
1363 * path when the SSE stream silently drops and the auto-reconnect re-seed is slow
1364 * or fails — a pull-to-refresh or top-bar refresh forces a fresh fetch under the
1365 * existing generation-guarded merge. Also clears [loadError] optimistically and
1366 * sets [refreshing] for immediate tap feedback. */
1367 fun refreshMessages() {
1368 val conn = connection ?: return
1369 _loadError.value = false
1370 _loadErrorInline.value = false
1371 _refreshing.value = true
1372 conn.events.triggerReconnect()
1373 viewModelScope.launch {
1374 delay(NetworkConfig.refreshFeedbackMs)
1375 _refreshing.value = false
1379 /** Invoke a slash-command by name via the server's /command endpoint. */
1380 fun runCommand(command: Command) {
1381 val conn = connection ?: return
1382 // A run is already in flight — surface feedback instead of silently dropping the
1383 // command (the picker stays openable during a run and dismisses on select).
1384 if (!beginRun()) {
1385 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1386 return
1388 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1389 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1390 runEndedByIdle = false
1391 viewModelScope.launch {
1392 runCatchingCancellable {
1393 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
1394 }.onFailure {
1395 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1396 _running.value = false
1401 fun abort() {
1402 val conn = connection ?: return
1403 // Drop any queued follow-up so the SessionIdle that follows the abort doesn't
1404 // auto-send it — the user tapped Stop to halt work, not to trigger the next turn.
1405 setQueuedFollowUp(null)
1406 if (!_aborting.compareAndSet(false, true)) return
1407 viewModelScope.launch {
1408 try {
1409 // Run the abort POST under NonCancellable so it survives this scope being
1410 // cancelled. The "Stop and exit" path calls abort() then navigates back
1411 // immediately, which pops this ViewModel's back-stack entry → onCleared() →
1412 // viewModelScope.cancel(); without this the in-flight abort is cancelled (often
1413 // before the request even reaches the server) and the run keeps burning tokens
1414 // server-side despite the user explicitly stopping it.
1415 runCatchingCancellable { withContext(NonCancellable) { conn.repository.abort(sessionId) } }
1416 .onSuccess {
1417 _running.value = false
1418 // Close the re-light gate so trailing SSE events in flight between the
1419 // abort POST's ack and the server's SessionIdle don't re-light the run
1420 // indicator (and re-arm the FGS / keepScreenOn) after the user hit Stop.
1421 // send()/runCommand()/continueRun() all open this gate on beginRun();
1422 // abort() is the one run-ending path that previously missed it.
1423 runEndedByIdle = true
1424 // Surface an undo opportunity so an accidental Stop is recoverable.
1425 // The UI shows a "Stopped — Undo" snackbar; tapping Undo re-sends the
1426 // last prompt. Only when there's a tracked prompt to undo back to.
1427 val prompt = lastSentPrompt
1428 if (prompt != null) _stopUndoEvents.trySend(prompt)
1430 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1431 } finally {
1432 _aborting.value = false
1437 /** Re-send the most recently-sent user prompt, used by the Stop-undo snackbar so an
1438 * accidental Stop can be recovered. No-op if no prompt is tracked (e.g. the run was
1439 * started by a command, not a text prompt). Clears the tracked prompt so a second Undo
1440 * tap doesn't double-send. */
1441 fun resendLastPrompt() {
1442 val prompt = lastSentPrompt ?: return
1443 lastSentPrompt = null
1444 send(prompt, includeAttachments = false)
1447 fun addAttachment(attachment: PendingAttachment) {
1448 // Enforce the cumulative size cap across the whole staged set (the per-file size and the
1449 // count caps are checked upstream). The base64 data URLs are held in memory and
1450 // re-serialized on every add/remove and again when the request body is built, so an
1451 // unbounded set OOMs. Reject rather than add, mirroring the other attachment limits'
1452 // snackbar via errorEvents. Size is the raw (pre-base64) byte count, matching the
1453 // per-file check, and includes attachments already staged (incl. restored ones).
1454 val staged = _attachments.value.sumOf { base64DataUrlByteSize(it.part.url) }
1455 if (staged + base64DataUrlByteSize(attachment.part.url) > NetworkConfig.maxTotalAttachmentBytes) {
1456 _errorEvents.trySend(ChatError(container.string(R.string.attachment_limit)))
1457 return
1459 _attachments.update { it + attachment }
1460 persistAttachments()
1463 fun removeAttachment(id: String) {
1464 _attachments.update { list -> list.filterNot { it.id == id } }
1465 persistAttachments()
1468 fun clearAttachments() {
1469 _attachments.value = emptyList()
1470 persistAttachments()
1473 /** Convert each picked Uri to a base64 attachment off the main thread, honoring the
1474 * per-prompt count cap and surfacing per-file errors via [errorEvents] without aborting
1475 * the batch. Runs on [viewModelScope] (which survives configuration changes) so an
1476 * in-flight encode isn't cancelled and its progress counters ([stagingCount] /
1477 * [stagingFileTotal]) aren't reset to 0 on rotation — the prior UI-side
1478 * `rememberCoroutineScope` implementation lost both the work and the counters on
1479 * every config change. */
1480 fun stageUris(uris: List<android.net.Uri>, context: android.content.Context) {
1481 if (uris.isEmpty()) return
1482 val appContext = context.applicationContext
1483 viewModelScope.launch {
1484 _stagingCount.value++
1485 _stagingFileTotal.value += uris.size
1486 try {
1487 uris.forEachIndexed { index, uri ->
1488 if (_attachments.value.size >= NetworkConfig.maxAttachments) {
1489 _errorEvents.trySend(ChatError(container.string(R.string.attachment_limit)))
1490 // The cap dropped the rest of this batch; account for the files we
1491 // won't process so stagingFileTotal doesn't linger over-counted,
1492 // then abandon the batch. return@launch (not return@forEachIndexed)
1493 // so the remaining uris aren't re-checked against a never-relieved
1494 // cap (which would over-decrement the counter and re-fire the
1495 // snackbar once per leftover uri). The `finally` below still
1496 // decrements stagingCount.
1497 _stagingFileTotal.value -= uris.size - index
1498 return@launch
1500 when (val result = uri.toAttachmentResult(appContext)) {
1501 is AttachmentResult.Ok -> addAttachment(result.attachment)
1502 AttachmentResult.TooLarge -> _errorEvents.trySend(ChatError(container.string(R.string.attachment_too_large)))
1503 AttachmentResult.Failed -> _errorEvents.trySend(ChatError(container.string(R.string.attachment_failed)))
1505 _stagingFileTotal.value--
1507 } finally {
1508 _stagingCount.value--
1513 /** Persist the current staged attachments so an interrupted compose survives process
1514 * death (mirrors draft persistence). Fire-and-forget on [viewModelScope]. */
1515 private fun persistAttachments() {
1516 val snapshot = _attachments.value
1517 viewModelScope.launch {
1518 runCatchingCancellable { container.attachmentDraftStore.save(sessionId, snapshot.map { it.toPersisted() }) }
1519 .onFailure { Log.w("ChatViewModel", "Failed to persist attachments", it) }
1523 /** Revert the conversation to just before [messageId], hiding everything after it. The
1524 * authoritative state also arrives via SessionUpdated; we flag it here for immediate
1525 * feedback. */
1526 fun revertTo(messageId: String) {
1527 val conn = connection ?: return
1528 viewModelScope.launch {
1529 runCatchingCancellable { conn.api.revert(sessionId, messageId) }
1530 .onSuccess {
1531 _reverted.value = it.isReverted
1532 _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1533 _editing.value = false
1535 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1539 /**
1540 * Regenerate an assistant reply: revert the conversation to that assistant message (removing
1541 * it and everything after), then re-send the user prompt that immediately preceded it. A
1542 * one-tap "try again" mirroring ChatGPT/Claude, instead of the two-step revert + re-Send.
1544 * Captures the preceding user text *before* the revert (the messages list updates
1545 * asynchronously via SSE once the revert lands). No-op if there's no preceding user prompt
1546 * or the assistant message isn't found.
1547 */
1548 fun regenerate(assistantMessageId: String) {
1549 val conn = connection ?: return
1550 val msgs = messages.value
1551 val idx = msgs.indexOfFirst { it.info.id == assistantMessageId }
1552 if (idx < 0) return
1553 val precedingUserText = (idx - 1 downTo 0)
1554 .asSequence()
1555 .map { msgs[it] }
1556 .firstOrNull { it.info is UserMessage }
1557 ?.parts
1558 ?.filterIsInstance<TextPart>()
1559 ?.joinToString("\n\n") { it.text }
1560 ?.takeIf { it.isNotBlank() }
1561 ?: return
1562 viewModelScope.launch {
1563 runCatchingCancellable { conn.api.revert(sessionId, assistantMessageId) }
1564 .onSuccess { send(precedingUserText, includeAttachments = false) }
1565 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1569 /** Continue a partially-generated assistant reply by sending a "continue" prompt
1570 * without reverting (unlike [regenerate], which re-runs from the preceding user
1571 * prompt and loses the partial). The partial reply stays as context so the model
1572 * can pick up where it left off. This is the standard chat-app "continue" pattern
1573 * for a reply that was aborted mid-stream. */
1574 fun continueRun(@Suppress("UNUSED_PARAMETER") assistantMessageId: String) {
1575 val conn = connection ?: return
1576 if (running.value || aborting.value) return
1577 if (!beginRun()) return
1578 // Reopen the relight gate so this run's streamed parts keep the indicator lit (and a
1579 // later SessionIdle re-closes it). Without this, runEndedByIdle stays true from the
1580 // previous run's SessionIdle and the relight guard at the event collector would suppress
1581 // every streamed part of the continuation, leaving the working indicator / Stop button /
1582 // foreground service off for the whole run.
1583 runEndedByIdle = false
1584 viewModelScope.launch {
1585 runCatchingCancellable {
1586 conn.api.sendPrompt(sessionId, text = "continue")
1587 }.onFailure {
1588 _running.value = false
1589 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1594 /** Load a previously-sent user prompt back into the composer to edit and re-send.
1595 * Reverts the conversation to just before [messageId] (hiding it and everything after)
1596 * and prefills the draft with [text]. The next Send continues from that checkpoint,
1597 * effectively replacing the edited message; the reverted banner's Undo restores it.
1598 * Overwrites the current draft — this is an explicit edit action on that message. */
1599 fun editMessage(messageId: String, text: String) {
1600 val conn = connection ?: return
1601 _draft.value = text.take(NetworkConfig.maxDraftLengthChars)
1602 _pendingQuote.value = null
1603 // Do NOT delegate to revertTo(): revertTo's onSuccess sets _editing = false, which races
1604 // this synchronous _editing = true (the async revert completes after editMessage returns
1605 // and clobbers the banner). Inline the revert so editMessage owns the _editing lifecycle:
1606 // set it true on success, matching the documented contract ("Cleared on unrevert and on
1607 // send"). revertTo() (the manual revert button) still clears _editing itself.
1608 viewModelScope.launch {
1609 runCatchingCancellable { conn.api.revert(sessionId, messageId) }
1610 .onSuccess {
1611 _reverted.value = it.isReverted
1612 _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1613 _editing.value = true
1615 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1619 /** Prefill the composer with [text] as a Markdown blockquote so the user can respond to a
1620 * specific message inline. Appends to any existing draft (with a blank line) rather than
1621 * overwriting, so quoting doesn't discard text already being typed. Also stashes the raw
1622 * quoted text in [pendingQuote] so the composer can show a dismissible preview card. */
1623 fun quoteReply(text: String) {
1624 val quoted = text.trim().lineSequence().joinToString("\n") { "> $it" }
1625 if (quoted.isBlank()) return
1626 val current = _draft.value
1627 val combined = if (current.isBlank()) "$quoted\n\n" else "$current\n\n$quoted\n\n"
1628 // Cap like the composer's typed/pasted/dictated input so quoting onto a near-full draft
1629 // can't push it past the limit (negative remaining counter + an over-cap prompt sent).
1630 _draft.value = combined.take(NetworkConfig.maxDraftLengthChars)
1631 _pendingQuote.value = text.trim().takeIf { it.isNotBlank() }
1634 /** Remove the pending quote-reply preview and strip the `>`-prefixed blockquote lines from
1635 * the draft so canceling the quote card actually removes the quoted text, not just the chip.
1636 * Strips by leading `> ` prefix (structural match) rather than by exact full-text replace,
1637 * so a user who edited any quoted line is still cleaned up — the exact-text replaces would
1638 * silently no-op on an edit, leaving orphan `>` lines in the draft after the chip vanished. */
1639 fun cancelQuoteReply() {
1640 val removed = _pendingQuote.value ?: return
1641 _pendingQuote.value = null
1642 val quoteLineCount = removed.trim().lineSequence().count()
1643 // Walk the draft and drop the first contiguous run of `> `-prefixed lines that matches
1644 // the stashed quote's line count (the block quoteReply inserted). Lines the user edited
1645 // still begin with `> ` (quoteReply prefixed every line), so a prefix match finds them
1646 // even when their trailing content diverges from the stashed original.
1647 val lines = _draft.value.split("\n").toMutableList()
1648 var runStart = -1
1649 var runLen = 0
1650 var i = 0
1651 while (i < lines.size) {
1652 if (lines[i].startsWith("> ") || lines[i] == ">") {
1653 if (runStart < 0) runStart = i
1654 runLen++
1655 if (runLen == quoteLineCount) {
1656 // Strip the run plus a single trailing blank line that separated the quote
1657 // from the reply (quoteReply appends "\n\n" after the block).
1658 val stripUntil = i + 1
1659 if (stripUntil < lines.size && lines[stripUntil].isEmpty()) {
1660 lines.subList(runStart, stripUntil + 1).clear()
1661 } else {
1662 lines.subList(runStart, stripUntil).clear()
1664 break
1666 i++
1667 } else {
1668 runStart = -1
1669 runLen = 0
1670 i++
1673 _draft.value = lines.joinToString("\n").trimStart()
1676 /** Fork the conversation by creating a brand-new session seeded with [text] as its first
1677 * prompt (using the currently-selected model/agent). Emits the new session id via
1678 * [branchEvents] so the UI can navigate to it. There's no server-side "branch" endpoint,
1679 * so this starts a fresh session from the chosen prompt rather than copying history. */
1680 fun branchFrom(text: String) {
1681 val conn = connection ?: return
1682 val prompt = text.trim()
1683 if (prompt.isEmpty()) return
1684 viewModelScope.launch {
1685 var createdId: String? = null
1686 runCatchingCancellable {
1687 val session = conn.repository.createSession(title = null)
1688 createdId = session.id
1689 conn.repository.sendPrompt(
1690 session.id,
1691 prompt,
1692 model = _selectedModel.value?.ref,
1693 agent = _selectedAgent.value,
1695 session.id
1697 .onSuccess { newId -> _branchEvents.trySend(newId) }
1698 .onFailure {
1699 // If the session was created but the prompt failed, delete the orphaned
1700 // empty session so failed branches don't accumulate on the server.
1701 val id = createdId
1702 if (id != null) {
1703 runCatchingCancellable { conn.repository.deleteSession(id) }
1705 _errorEvents.trySend(ChatError(container.string(R.string.branch_failed)))
1710 /** Undo the active revert checkpoint, restoring the hidden messages. */
1711 fun unrevert() {
1712 val conn = connection ?: return
1713 viewModelScope.launch {
1714 runCatchingCancellable { conn.api.unrevert(sessionId) }
1715 .onSuccess {
1716 _reverted.value = it.isReverted
1717 _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1718 _editing.value = false
1720 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1724 /** Create (or fetch the existing) public share link. The URL is exposed via [shareUrl]
1725 * and also emitted once via [shareLinkEvents] so the UI can copy it to the clipboard. */
1726 fun shareSession() {
1727 val conn = connection ?: return
1728 viewModelScope.launch {
1729 runCatchingCancellable { conn.api.shareSession(sessionId) }
1730 .onSuccess { session ->
1731 val url = session.share?.url?.takeIf { it.isNotBlank() }
1732 _shareUrl.value = url
1733 if (url != null) _shareLinkEvents.trySend(url)
1735 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1739 /** Revoke the session's public share link. */
1740 fun unshareSession() {
1741 val conn = connection ?: return
1742 viewModelScope.launch {
1743 runCatchingCancellable { conn.api.unshareSession(sessionId) }
1744 .onSuccess { _shareUrl.value = it.share?.url?.takeIf { it.isNotBlank() } }
1745 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1749 /** Compact the conversation via the summarize endpoint; the summary streams back via SSE.
1750 * Uses the currently-selected model (required by the endpoint). */
1751 fun summarize() {
1752 val conn = connection ?: return
1753 val model = _selectedModel.value?.ref ?: run {
1754 _errorEvents.trySend(ChatError(container.string(R.string.needs_model)))
1755 return
1757 if (!beginRun()) {
1758 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1759 return
1761 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1762 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1763 runEndedByIdle = false
1764 viewModelScope.launch {
1765 runCatchingCancellable { conn.api.summarize(sessionId, model) }
1766 .onFailure {
1767 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1768 _running.value = false
1773 /** Analyze the project and (re)generate its AGENTS.md; the run streams back via SSE. */
1774 fun initProject() {
1775 val conn = connection ?: return
1776 if (!beginRun()) {
1777 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1778 return
1780 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1781 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1782 runEndedByIdle = false
1783 viewModelScope.launch {
1784 runCatchingCancellable { conn.api.initSession(sessionId) }
1785 .onFailure {
1786 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1787 _running.value = false
1792 /** Run a one-off shell [command] in the session's worktree; output streams back via SSE.
1793 * The server requires an agent to scope the run — use the selected one, else the primary. */
1794 fun runShell(command: String) {
1795 val conn = connection ?: return
1796 val cmd = command.trim()
1797 if (cmd.isEmpty()) return
1798 val agent = _selectedAgent.value
1799 ?: _agents.value.firstOrNull { it.isPrimary }?.name
1800 ?: _agents.value.firstOrNull()?.name
1801 ?: DEFAULT_SHELL_AGENT
1802 if (!beginRun()) {
1803 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1804 return
1806 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1807 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1808 runEndedByIdle = false
1809 viewModelScope.launch {
1810 runCatchingCancellable { conn.api.shell(sessionId, cmd, agent, _selectedModel.value?.ref) }
1811 .onFailure {
1812 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1813 _running.value = false
1818 /** Rename the current session via PATCH /session/:id. On success updates [sessionTitle]
1819 * so the top bar reflects the new name immediately. A failure surfaces as a snackbar;
1820 * the caller keeps the dialog open so the user can retry without retyping. */
1821 fun renameSession(newTitle: String) {
1822 val conn = connection ?: return
1823 val title = newTitle.trim()
1824 if (title.isEmpty() || title == _sessionTitle.value) return
1825 viewModelScope.launch {
1826 runCatchingCancellable { conn.api.updateSession(sessionId, title) }
1827 .onSuccess { _sessionTitle.value = it.displayTitle }
1828 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1832 /** Delete the current session via DELETE /session/:id. Schedules a deferred delete with an
1833 * undo window and signals [deleteUndoEvents] so the ChatScreen shows an Undo snackbar
1834 * *over the conversation the user is viewing* — they can cancel without first being
1835 * navigated back to the session list. The actual REST delete runs after
1836 * [NetworkConfig.undoDeleteDelayMs]; cancel via [cancelSessionDelete]. If the window
1837 * expires, the scheduled delete's onDeleted sets [sessionDeleted] so the UI navigates away.
1838 * A failure surfaces as a snackbar. */
1839 fun deleteSession() {
1840 val conn = connection ?: return
1841 container.scheduleSessionDelete(
1842 id = sessionId,
1843 delayMs = NetworkConfig.undoDeleteDelayMs,
1844 // Navigate away only once the deferred delete actually commits — so an in-place Undo
1845 // before the window expires keeps the user on this conversation.
1846 onDeleted = { _sessionDeleted.value = true },
1847 onError = { _errorEvents.trySend(ChatError(container.friendlyError(it))) },
1849 _deleteUndoEvents.tryEmit(Unit)
1852 /** Cancel a delete scheduled by [deleteSession] (the in-chat Undo action). Returns true if the
1853 * delete was still pending (Undo succeeded). */
1854 fun cancelSessionDelete(): Boolean = container.cancelSessionDelete(sessionId)
1856 /** Reconnect to the most recently used server profile (used when the connection is gone). */
1857 fun reconnect() {
1858 if (container.activeConnection.value != null) return
1859 // Guard against concurrent reconnect calls: the user can tap the button
1860 // multiple times while _reconnecting is true, which would launch parallel
1861 // connect() coroutines that race to set the active connection, leaking the
1862 // intermediate connections.
1863 if (!_reconnecting.compareAndSet(false, true)) return
1864 viewModelScope.launch {
1865 try {
1866 val recent = runCatchingCancellable { container.profileStore.profiles.first() }
1867 .getOrDefault(emptyList())
1868 .firstOrNull()
1869 if (recent == null) {
1870 _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
1871 return@launch
1873 runCatchingCancellable {
1874 val conn = container.connect(recent)
1875 conn.api.ping()
1876 }.onFailure {
1877 container.disconnect()
1878 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1880 } finally {
1881 _reconnecting.value = false
1886 fun respondPermission(permission: Permission, response: PermissionResponse) {
1887 val conn = connection ?: return
1888 // Record a session-scoped grant so subsequent matching requests are auto-answered.
1889 // SESSION maps to ONCE on the wire (the server has no session-scope concept); the
1890 // client-side set is what gives it the "for this session" semantics.
1891 if (response == PermissionResponse.SESSION) {
1892 val type = permission.type.orEmpty()
1893 val pattern = permission.patternText.orEmpty()
1894 if (type.isNotEmpty()) sessionAllowed.add(type to pattern)
1896 // Dismiss optimistically (revealing any queued request); permission.replied will confirm.
1897 // If the call fails we re-surface this request so the user can retry instead of being stuck
1898 // with a dismissed dialog and a tool run that's still paused server-side.
1899 resolvePermission(permission.id)
1900 viewModelScope.launch {
1901 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
1902 .onFailure {
1903 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1904 // Revoke the session-scoped grant on failure: enqueuePermission() auto-responds
1905 // when (type, pattern) is in sessionAllowed, so leaving the entry in would loop
1906 // respondPermission -> fail -> enqueuePermission -> respondPermission ... forever,
1907 // spamming the dead server and the error snackbar without ever re-prompting the
1908 // user. Removing it makes enqueuePermission fall through to re-queueing the
1909 // request for the dialog so the user can retry.
1910 //
1911 // This must cover BOTH the SESSION and ONCE response paths: enqueuePermission's
1912 // auto-respond sends ONCE, not SESSION, so guarding only the SESSION branch (as
1913 // the original code did) leaves the entry in and still loops when the auto-reply
1914 // fails. Revoke unconditionally on any failure.
1915 val type = permission.type.orEmpty()
1916 val pattern = permission.patternText.orEmpty()
1917 if (type.isNotEmpty()) sessionAllowed.remove(type to pattern)
1918 enqueuePermission(permission)
1923 private companion object {
1924 /** Fallback agent for the shell endpoint when none is selected and no agent catalog
1925 * loaded. opencode's default primary agent is "build". */
1926 const val DEFAULT_SHELL_AGENT = "build"
1929 override fun onCleared() {
1930 super.onCleared()
1931 // Flush any pending debounced draft so it survives navigation. Uses an
1932 // asynchronous apply() (not a synchronous commit) so the main thread isn't
1933 // blocked on disk I/O — Android's SharedPreferences framework guarantees
1934 // pending apply() writes are flushed before the process exits.
1935 // Flush the pending draft — including an empty one. Persistence is otherwise debounced,
1936 // so if the user cleared the input and navigated back within the debounce window, the
1937 // debounce coroutine was cancelled with viewModelScope and the prefs still hold the
1938 // previous non-empty draft. flushDraft() removes the key when the text is blank, so
1939 // flushing commits the clear instead of resurrecting the deleted text.
1940 //
1941 // BUT respect the send-in-flight guard: send() optimistically clears _draft to "" WITHOUT
1942 // persisting the clear so a failed send can restore the text. If the user taps Send then
1943 // immediately navigates back (cancelling the in-flight send), flushing the blank _draft here
1944 // would remove the persisted key and erase the pre-send draft with no recovery. Skip the
1945 // flush while a send is in flight so the pre-send draft stays persisted (recoverable).
1946 if (!suppressDraftPersist.get()) container.draftStore.flushDraft(sessionId, _draft.value)