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