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
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.
76data class ChatError(val message: String, val retryable: Boolean = false)
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.
83@androidx.compose.runtime.Immutable
84data class PermissionProgress(val position: Int, val total: Int)
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
92@androidx.compose.runtime.Immutable
93data class PendingAttachment(
97 val previewModel: Any?,
98 val part: FilePromptPart,
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(
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)
124 private val container: AppContainer,
125 private val sessionId: String,
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())
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
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)
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
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)
244 val messages: StateFlow<List<MessageWithParts>> =
245 combine(sseMessages, _optimisticMessages) { sse, optimistic ->
246 mergeOptimistic(sse, optimistic)
248 scope = viewModelScope,
249 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
250 initialValue = emptyList(),
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
257 val hasMessages: StateFlow<Boolean> = messages
258 .map { it.isNotEmpty() }
260 scope = viewModelScope,
261 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
262 initialValue = false,
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 } }
270 scope = viewModelScope,
271 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
272 initialValue = emptyMap(),
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>()
287 if (msg.info !is UserMessage) continue
288 val text = msg.parts.filterIsInstance<TextPart>()
289 .joinToString("\n\n") { it.text }
291 if (text.isNotEmpty()) realUserTexts.add(text)
293 val surviving = optimistic.filter { it.text.trim() !in realUserTexts }
294 if (surviving.isEmpty()) return sse
295 return sse + surviving.map { it.toMessageWithParts() }
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>()
304 if (msg.info !is UserMessage) continue
305 val text = msg.parts.filterIsInstance<TextPart>()
306 .joinToString("\n\n") { it.text }
308 if (text.isNotEmpty()) realUserTexts.add(text)
310 if (realUserTexts.isEmpty()) return
311 _optimisticMessages.update { entries ->
312 entries.filterNot { it.text.trim() in realUserTexts }
316 /** Convert an optimistic entry to a [MessageWithParts] for display. */
317 private fun OptimisticEntry.toMessageWithParts(): MessageWithParts = MessageWithParts(
320 sessionID = sessionId,
321 time = soy.iko.opencode.data.model.TimeInfo(created = timestamp),
323 parts = listOf(TextPart(id = "$tempId-text", text = text)),
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.
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()
343 scope = viewModelScope,
344 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
345 initialValue = emptyList(),
348 private val _running = MutableStateFlow(false)
349 val running: StateFlow<Boolean> = _running.asStateFlow()
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
359 private val _streamInterrupted = MutableStateFlow(false)
360 val streamInterrupted: StateFlow<Boolean> = _streamInterrupted.asStateFlow()
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).
369 private val _runStartMs = MutableStateFlow(0L)
370 val runStartMs: StateFlow<Long> = _runStartMs.asStateFlow()
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.
378 private fun beginRun(): Boolean {
379 if (!_running.compareAndSet(false, true)) return false
380 _runStartMs.value = System.currentTimeMillis()
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,
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 * Carries the session id so a collector using `collectLatest` can withdraw a superseded
434 * delete (see [cancelSessionDelete]) — a `Unit` event would leave the prior session's
435 * deferred REST delete firing with no reachable Undo. */
436 private val _deleteUndoEvents = MutableSharedFlow<String>(
437 extraBufferCapacity = 1,
438 onBufferOverflow = BufferOverflow.DROP_OLDEST,
440 val deleteUndoEvents: SharedFlow<String> = _deleteUndoEvents.asSharedFlow()
442 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
443 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
445 private val _modelsLoading = MutableStateFlow(true)
446 val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
448 private val _modelsError = MutableStateFlow(false)
449 val modelsError: StateFlow<Boolean> = _modelsError.asStateFlow()
451 private val _selectedModel = MutableStateFlow<ModelOption?>(null)
452 val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
454 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
455 container.activeConnection
456 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
458 scope = viewModelScope,
459 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
460 initialValue = EventStreamClient.ConnectionState.Disconnected,
463 /** SSE reconnect-attempt count for the active connection, surfaced so the chat's
464 * connection banner can show "Reconnecting (attempt N)…" during a sustained outage. */
465 val reconnectAttempts: StateFlow<Int> =
466 container.activeConnection
467 .flatMapLatest { it?.events?.reconnectAttempts ?: flowOf(0) }
469 scope = viewModelScope,
470 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
474 private val _pendingPermission = MutableStateFlow<Permission?>(null)
475 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
477 /** Backlog indicator for the permission dialog (see [PermissionProgress]). */
478 private val _permissionProgress = MutableStateFlow(PermissionProgress(0, 0))
479 val permissionProgress: StateFlow<PermissionProgress> = _permissionProgress.asStateFlow()
481 /** Max number of permissions simultaneously pending since the queue was last empty. Reset
482 * to 0 when the queue drains, so a fresh burst starts its count over. */
483 private var maxPendingSeen = 0
485 // Unanswered permission requests keyed by id, insertion-ordered. The dialog shows the most
486 // recent; when one is answered/replied — or a response fails — we fall back to the next so a
487 // request that arrived while another was in flight isn't silently dropped (its tool run would
488 // otherwise stay paused server-side with no UI affordance). Only touched on the VM's main
489 // dispatcher (event collector + viewModelScope launches), so no extra synchronization is needed.
490 private val pendingPermissions = LinkedHashMap<String, Permission>()
492 // Session-scoped permission grants (Allow-for-this-session). Each entry is a (type, patternText)
493 // pair the user approved with PermissionResponse.SESSION. Subsequent matching requests are
494 // auto-responded ONCE without showing the dialog, so e.g. a user granting "read" for one path
495 // isn't re-prompted for every other file read in the same conversation. Cleared on connection
496 // change (see the activeConnection collector) so it never leaks across servers. The server only
497 // knows once/always/reject; SESSION is a client-side scope that maps to ONCE on the wire.
498 private val sessionAllowed = mutableSetOf<Pair<String, String>>()
500 private fun enqueuePermission(permission: Permission) {
501 // Auto-respond to a permission whose (type, pattern) the user previously granted for this
502 // session, instead of showing the dialog again. Resolved immediately (not via the queue) so
503 // the tool run isn't paused waiting for a dialog the user will never see.
504 val type = permission.type.orEmpty()
505 val pattern = permission.patternText.orEmpty()
506 if (type.isNotEmpty() && sessionAllowed.contains(type to pattern)) {
507 respondPermission(permission, PermissionResponse.ONCE)
510 pendingPermissions[permission.id] = permission
511 _pendingPermission.value = pendingPermissions.values.lastOrNull()
512 updatePermissionProgress()
515 private fun resolvePermission(id: String) {
516 pendingPermissions.remove(id)
517 _pendingPermission.value = pendingPermissions.values.lastOrNull()
518 updatePermissionProgress()
521 private fun clearPermissions() {
522 pendingPermissions.clear()
523 _pendingPermission.value = null
524 updatePermissionProgress()
527 /** Recompute the "N of M" backlog indicator. [maxPendingSeen] grows with the queue and
528 * resets only when it drains, so position = total - remaining + 1 advances as requests are
530 private fun updatePermissionProgress() {
531 val remaining = pendingPermissions.size
532 if (remaining == 0) {
534 _permissionProgress.value = PermissionProgress(0, 0)
537 if (remaining > maxPendingSeen) maxPendingSeen = remaining
538 _permissionProgress.value = PermissionProgress(
539 position = maxPendingSeen - remaining + 1,
540 total = maxPendingSeen,
544 private val _agents = MutableStateFlow<List<Agent>>(emptyList())
545 val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
547 private val _agentsLoading = MutableStateFlow(true)
548 val agentsLoading: StateFlow<Boolean> = _agentsLoading.asStateFlow()
550 private val _agentsError = MutableStateFlow(false)
551 val agentsError: StateFlow<Boolean> = _agentsError.asStateFlow()
553 private val _selectedAgent = MutableStateFlow<String?>(null)
554 val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
556 private val _commands = MutableStateFlow<List<Command>>(emptyList())
557 val commands: StateFlow<List<Command>> = _commands.asStateFlow()
559 private val _commandsLoading = MutableStateFlow(true)
560 val commandsLoading: StateFlow<Boolean> = _commandsLoading.asStateFlow()
562 private val _commandsError = MutableStateFlow(false)
563 val commandsError: StateFlow<Boolean> = _commandsError.asStateFlow()
565 private val _sessionTitle = MutableStateFlow<String?>(null)
566 val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
568 /** Set when the current session is deleted via SSE, so the UI can navigate away. */
569 private val _sessionDeleted = MutableStateFlow(false)
570 val sessionDeleted: StateFlow<Boolean> = _sessionDeleted.asStateFlow()
572 /** The text of the last send that failed, surfaced so the UI can offer a retry. */
573 private val _failedDraft = MutableStateFlow<String?>(null)
574 val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
576 /** The idempotency key of the last failed online send, stashed alongside [_failedDraft] so
577 * [retryFailed] re-submits with the SAME key. A retry after a lost response is then
578 * deduplicated server-side instead of starting a duplicate agent run. */
579 @Volatile private var failedIdempotencyKey: String? = null
581 /** Per-session draft, persisted so it survives navigation/process death. */
582 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
583 val draft: StateFlow<String> = _draft.asStateFlow()
585 /** A pending quote-reply preview (the raw text the user quoted), shown as a dismissible
586 * card above the composer so the user can see and cancel the quote before sending. Cleared
587 * on send or via [cancelQuoteReply]. The actual `>` prefixed lines are still folded into
588 * the draft for the server; this state is purely for the visual preview chip. */
589 private val _pendingQuote = MutableStateFlow<String?>(null)
590 val pendingQuote: StateFlow<String?> = _pendingQuote.asStateFlow()
592 /** Messages composed for this session while offline/disconnected, queued in the outbox and
593 * awaiting an automatic flush on reconnect. Surfaced so the composer can show a "queued"
594 * chip with a discard/send-now affordance. */
595 val outbox: StateFlow<List<OutboxMessage>> =
596 container.outboxStore.messages
597 .map { list -> list.filter { it.sessionId == sessionId } }
599 scope = viewModelScope,
600 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
601 initialValue = emptyList(),
604 /** Attachments staged for the next prompt (images/files). Persisted per-session (as their
605 * self-contained base64 data URLs) via [AttachmentDraftStore] so an interrupted compose
606 * survives process death — the source content Uris wouldn't survive, but the data URLs
607 * do. Cleared on a successful send. */
608 private val _attachments = MutableStateFlow<List<PendingAttachment>>(emptyList())
609 val attachments: StateFlow<List<PendingAttachment>> = _attachments.asStateFlow()
611 // Count of picks currently being read + base64-encoded off the main thread, so the
612 // composer can show a staging placeholder immediately (chips only materialize once done).
613 // Exposed as StateFlows (not Compose state) so they survive a config change: the staging
614 // work runs on viewModelScope (which survives rotation), so the counters must live on the
615 // VM too — remember { mutableStateOf(0) } in the composable would reset to 0 on rotation
616 // and lose the staging chip mid-encode.
617 private val _stagingCount = MutableStateFlow(0)
618 val stagingCount: StateFlow<Int> = _stagingCount.asStateFlow()
619 // Total number of files still being staged across all in-flight batches, so the
620 // staging chip can show "Staging N files…" rather than a bare indeterminate spinner.
621 private val _stagingFileTotal = MutableStateFlow(0)
622 val stagingFileTotal: StateFlow<Int> = _stagingFileTotal.asStateFlow()
624 /** True while a revert checkpoint is active for this session (messages after it are hidden
625 * server-side). Drives the "reverted" banner with its Undo. */
626 private val _reverted = MutableStateFlow(false)
627 val reverted: StateFlow<Boolean> = _reverted.asStateFlow()
629 /** True when the revert was triggered by an Edit action (vs a bare Revert). Drives the
630 * banner's copy so it reads "Editing message" rather than "Reverted", making the composer's
631 * prefilled text unambiguous. Cleared on unrevert and on send. */
632 private val _editing = MutableStateFlow(false)
633 val editing: StateFlow<Boolean> = _editing.asStateFlow()
635 /** The diff of the active revert checkpoint, when the server includes one. Shown in the
636 * revert banner so the user can see what changed before deciding to undo. Null when the
637 * session isn't reverted or the server didn't send a diff. */
638 private val _revertDiff = MutableStateFlow<String?>(null)
639 val revertDiff: StateFlow<String?> = _revertDiff.asStateFlow()
641 /** The active public share URL for this session, or null when not shared. */
642 private val _shareUrl = MutableStateFlow<String?>(null)
643 val shareUrl: StateFlow<String?> = _shareUrl.asStateFlow()
645 /** One-shot events carrying a freshly-created share URL so the UI can copy/share it. */
646 private val _shareLinkEvents = Channel<String>(
647 capacity = NetworkConfig.snackbarEventBufferCapacity,
648 onBufferOverflow = BufferOverflow.DROP_OLDEST,
650 val shareLinkEvents: Flow<String> = _shareLinkEvents.receiveAsFlow()
652 /** One-shot events carrying the id of a session freshly branched from a message, so the
653 * UI can navigate to it. */
654 private val _branchEvents = Channel<String>(
655 capacity = NetworkConfig.snackbarEventBufferCapacity,
656 onBufferOverflow = BufferOverflow.DROP_OLDEST,
658 val branchEvents: Flow<String> = _branchEvents.receiveAsFlow()
661 // DraftStore loads SharedPreferences asynchronously; on a cold start the
662 // synchronous get() above returns "" until the background load completes.
663 // Observe the ready signal and re-seed the draft so it appears in the UI.
664 viewModelScope.launch {
665 container.draftStore.ready.collect { ready ->
666 if (!ready) return@collect
667 if (_draft.value.isEmpty()) {
668 _draft.value = container.draftStore.get(sessionId)
670 // Recover a follow-up queued before process death (its in-memory StateFlow
671 // is gone). If the input is now free, drop it back there for review/send and
672 // consume the stored copy; otherwise re-queue it so it auto-sends when the
673 // current run next idles. Guarded on == null so it never clobbers a
674 // follow-up the user queued after opening this session.
675 val savedFollowUp = container.draftStore.getFollowUp(sessionId)
676 if (savedFollowUp.isNotEmpty() && _queuedFollowUp.value == null) {
677 if (_draft.value.isEmpty()) {
678 _draft.value = savedFollowUp
679 container.draftStore.flushFollowUp(sessionId, "")
681 _queuedFollowUp.value = savedFollowUp
686 // Observe the drafts store for external mutations to this session's draft
687 // (e.g. a share injected via draftStore.set in two-pane mode, or setImmediate
688 // before navigation in single-pane mode). Without this, a share injected into
689 // an already-open session never appears in the input field — the init-time
690 // read and the ready-only re-seed miss it when the draft is non-empty.
691 // The VM's own debounced persist writes the same value _draft already holds,
692 // so those updates are no-ops here (storeValue == _draft.value). The
693 // suppressDraftPersist guard prevents a stale store value from clobbering
694 // the deliberate draft clear during a send (the store still holds the
695 // pre-send draft until the send resolves).
696 viewModelScope.launch {
697 container.draftStore.drafts.collect { drafts ->
698 // Ignore emissions until the initial disk load completes: the load itself
699 // emits the persisted snapshot, and applying it here would clobber text the
700 // user typed during the async load (the `ready` observer above seeds an
701 // empty draft). After ready, emissions are external writes.
702 if (!container.draftStore.ready.value) return@collect
703 val storeValue = drafts[sessionId].orEmpty()
704 // Ignore this VM's own (debounced, possibly stale) persistence echo — only a
705 // genuine external write (e.g. a two-pane share injection) should overwrite
707 if (storeValue == lastPersistedDraft) return@collect
708 if (storeValue != _draft.value && !suppressDraftPersist.get()) {
709 _draft.value = storeValue
716 // Restore attachments staged before process death. They were persisted as
717 // self-contained data URLs, so they re-send and (for images) re-preview intact.
718 // Guarded on empty so it never clobbers attachments the user staged after opening.
719 viewModelScope.launch {
720 val restored = runCatchingCancellable { container.attachmentDraftStore.load(sessionId) }
721 .getOrDefault(emptyList())
722 if (restored.isNotEmpty() && _attachments.value.isEmpty()) {
723 _attachments.value = restored.map { it.toPending() }
728 fun selectModel(option: ModelOption) { _selectedModel.value = option }
730 fun selectAgent(name: String?) { _selectedAgent.value = name }
733 val conn = connection ?: return
734 viewModelScope.launch {
735 conn.api.invalidateProvidersCache()
736 _modelsReload.value++
741 val conn = connection ?: return
742 viewModelScope.launch {
743 conn.api.invalidateAgentsCache()
744 _agentsReload.value++
748 fun reloadCommands() {
749 val conn = connection ?: return
750 viewModelScope.launch {
751 conn.api.invalidateCommandsCache()
752 _commandsReload.value++
757 * Shared helper for init-block catalog observers: re-fetches whenever the active
758 * connection or the catalog's reload trigger changes, invoking [onNull] to clear
759 * state on null so stale data from the old server doesn't persist. The
760 * [reloadTrigger] is merged with [activeConnection] so a manual reload (via
761 * reloadModels/reloadAgents/reloadCommands) cancels any in-flight observe fetch
762 * via collectLatest, preventing a stale observe result from overwriting a fresh
763 * reload result. Failure is non-fatal — the error flag is surfaced to the UI.
765 private fun <T> observeCatalog(
767 loading: MutableStateFlow<Boolean>,
768 error: MutableStateFlow<Boolean>,
769 fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
770 onSuccess: suspend (T) -> Unit,
772 reloadTrigger: StateFlow<Int>,
774 viewModelScope.launch {
775 merge(container.activeConnection, reloadTrigger).collectLatest { _ ->
776 val conn = container.activeConnection.value
777 if (conn == null) { onNull(); loading.value = false; error.value = false; return@collectLatest }
780 runCatchingCancellable { fetch(conn.api) }
781 // fetch() does HTTP via withRetry, so the failure can be a
782 // ClientRequestException whose message embeds the full request URL
783 // (may contain auth/paths). Log only a scrubbed summary.
784 .onFailure { Log.w("ChatViewModel", "Failed to load $tag: ${safeExceptionSummary(it)}"); error.value = true }
785 .getOrNull()?.let { onSuccess(it) }
786 loading.value = false
791 fun updateDraft(text: String) {
796 // Debounce draft persistence so we don't write to disk on every keystroke.
797 // Skip persisting empty drafts that were set by send() (suppressed via
798 // suppressDraftPersist) — send() clears the in-memory draft immediately for
799 // UI feedback but deliberately doesn't persist the clear until the send
800 // succeeds, so a failed send can restore the draft.
801 viewModelScope.launch {
802 _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
803 if (text.isBlank() && suppressDraftPersist.get()) return@collect
804 lastPersistedDraft = text
805 runCatchingCancellable { container.draftStore.set(sessionId, text) }
806 .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
809 // Reset per-connection state when the active server changes so stale spinners,
810 // permission dialogs, errors, and agent selections from the old server don't
811 // persist into the new one.
812 viewModelScope.launch {
813 container.activeConnection.collectLatest { conn ->
815 // The connection dropped (e.g. disconnect() mid-run). No SSE stream will
816 // arrive to deliver SessionIdle/SessionError, so reset the run state here —
817 // otherwise the working spinner sticks on, ChatScreen keeps keepScreenOn +
818 // the RunForegroundService alive, and the Stop button no-ops (abort() early-
819 // returns with no connection) until a later reconnect happens to clear it.
820 _running.value = false
821 _streamInterrupted.value = false
822 _aborting.value = false
824 sessionAllowed.clear()
825 sessionTitleJob?.cancel()
826 _revertDiff.value = null
829 _running.value = false
830 _streamInterrupted.value = false
831 // Allow the re-light below to recover a still-running run after this (re)connect;
832 // a run that actually finished during the outage emits no live parts, so this
833 // won't spuriously re-light.
834 runEndedByIdle = false
836 sessionAllowed.clear()
837 _failedDraft.value = null
838 _revertDiff.value = null
839 // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
840 // user intent that is now persisted; wiping it on every (re)connect — which
841 // this collector does, including transient SSE drops and the cold-start
842 // restore path — would drop a legitimately queued/recovered follow-up.
843 _selectedAgent.value = null
844 // Apply the user's persisted preferred-agent for this new session/load, so a
845 // user who always runs a specific agent doesn't have to pick it every session.
846 // No-op when the preference is empty (the server default applies). Read here
847 // (not in the agent-catalog onSuccess) because the catalog loads once per
848 // connection while this reset fires per-session-open; setting it once on
849 // connect is enough and avoids re-applying it on every catalog refresh.
850 runCatchingCancellable {
851 val pref = container.settingsStore.preferredAgentName.first()
852 if (pref.isNotEmpty()) _selectedAgent.value = pref
854 _sessionTitle.value = null
855 // Resolve the app-bar title via REST for THIS connection. Launched from the same
856 // block that just nulled _sessionTitle (and cancelled the previous fetch) so it can
857 // never observe or restore the previous server's stale title — the old separate
858 // collector could, racing that reset. The assignment is guarded on a still-null
859 // title so a fresher SSE SessionUpdated (from the event collector below) wins.
860 sessionTitleJob?.cancel()
861 sessionTitleJob = viewModelScope.launch {
862 runCatchingCancellable { conn.repository.listSessions() }
864 ?.firstOrNull { it.id == sessionId }
866 if (_sessionTitle.value == null) {
867 _sessionTitle.value = session.displayTitle
868 _reverted.value = session.isReverted
869 _revertDiff.value = session.revert?.diff?.takeIf { it.isNotBlank() }
870 _shareUrl.value = session.share?.url?.takeIf { it.isNotBlank() }
875 // Loop the inner collect so a transient non-cancellation exception (a
876 // malformed SSE event decoding error, an unexpected channel close)
877 // doesn't terminate the event collector for this connection. Without
878 // the loop, the catch returns the collectLatest lambda normally, so
879 // collectLatest considers this connection's block complete and never
880 // re-runs it — the chat would go permanently silent (no streaming,
881 // no permission prompts, no title updates) until a server switch.
882 while (currentCoroutineContext().isActive) {
884 conn.events.events.collect { event ->
885 if (SessionRepository.isIdle(event, sessionId)) {
886 _running.value = false
887 runEndedByIdle = true
888 // Auto-send a queued follow-up once the previous run finishes,
889 // so the user's drafted-while-running message isn't lost.
890 // The follow-up is cleared *inside* send()'s launched
891 // coroutine (via queuedFollowUpToClear), so a scope
892 // cancellation between this point and the coroutine
893 // starting drops the launch without wiping the persisted
894 // follow-up — recovery on next session open instead of
896 val queued = _queuedFollowUp.value
897 if (queued != null) {
898 send(queued, includeAttachments = false, queuedFollowUpToClear = queued)
901 if (SessionRepository.isError(event, sessionId)) {
902 _running.value = false
903 runEndedByIdle = true
904 // The run errored, so the queued follow-up won't auto-send.
905 // Restore it to the input (if free) so the user's typed text
906 // isn't silently lost; otherwise just clear the queue.
907 val queued = _queuedFollowUp.value
908 if (!queued.isNullOrEmpty() && _draft.value.isEmpty()) {
909 _draft.value = queued
911 setQueuedFollowUp(null)
912 _errorEvents.trySend(ChatError(container.string(R.string.error_agent_reported)))
914 // Restore the run indicator if a live streaming event arrives while it's
915 // cleared — e.g. an SSE reconnect mid-run reset _running to false, but the
916 // agent is still working. Don't revive it once the user has hit Stop.
917 val relightRunIndicator = SessionRepository.isRunActivity(event, sessionId) &&
918 !_aborting.value && !_running.value && !runEndedByIdle
919 if (relightRunIndicator) {
920 _running.value = true
923 is PermissionUpdated ->
924 if (event.properties.sessionID == sessionId) enqueuePermission(event.properties)
925 is PermissionReplied ->
926 if (event.properties.sessionID == sessionId) event.properties.permissionID?.let { resolvePermission(it) }
928 if (event.properties.info.id == sessionId) {
929 val info = event.properties.info
930 _sessionTitle.value = info.displayTitle
931 _reverted.value = info.isReverted
932 _revertDiff.value = info.revert?.diff?.takeIf { it.isNotBlank() }
933 _shareUrl.value = info.share?.url?.takeIf { it.isNotBlank() }
936 if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
937 _sessionDeleted.value = true
942 } catch (e: CancellationException) {
944 } catch (e: Exception) {
945 Log.w("ChatViewModel", "SSE event collector error, restarting", e)
948 } catch (e: CancellationException) {
950 } catch (e: Exception) {
951 Log.w("ChatViewModel", "SSE event collector loop error", e)
955 // Load the model catalog; preselect the server default. Failure is non-fatal —
956 // sending with no model just uses the server's default agent/model.
958 tag = "model catalog",
959 loading = _modelsLoading,
960 error = _modelsError,
961 fetch = { it.providers() },
962 onSuccess = { resp ->
963 val options = resp.toOptions()
964 _models.value = options
965 // Only (re)apply the default when there's no valid current selection. On
966 // re-fetch/reconnect/Retry the user's chosen model must survive — keep it as
967 // long as it's still present in the refreshed list. When there's no current
968 // selection, prefer the user's persisted default-model preference (if it's in
969 // the list) over the server default, so a user who always picks a specific
970 // model doesn't have to pick it every new session.
971 val current = _selectedModel.value
972 if (current == null || options.none { it.ref == current.ref }) {
973 val prefId = runCatchingCancellable {
974 container.settingsStore.preferredModelId.first()
976 val preferredOption = if (prefId.isNotEmpty()) {
977 options.firstOrNull { it.modelID == prefId }
979 _selectedModel.value = preferredOption ?: resp.defaultOption(options)
982 onNull = { _models.value = emptyList(); _selectedModel.value = null },
983 reloadTrigger = _modelsReload,
985 // Load the agent catalog (non-fatal).
987 tag = "agent catalog",
988 loading = _agentsLoading,
989 error = _agentsError,
990 fetch = { it.agents() },
991 onSuccess = { _agents.value = it },
992 onNull = { _agents.value = emptyList() },
993 reloadTrigger = _agentsReload,
995 // Load the command catalog (non-fatal).
997 tag = "command catalog",
998 loading = _commandsLoading,
999 error = _commandsError,
1000 fetch = { it.commands() },
1001 onSuccess = { _commands.value = it },
1002 onNull = { _commands.value = emptyList() },
1003 reloadTrigger = _commandsReload,
1005 // If the SSE stream drops mid-run, the run indicator would spin forever;
1006 // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
1007 // On a *transient* drop (Disconnected) we don't clear _running — the run is still
1008 // active on the server, and showing it as finished would mislead the user. Instead
1009 // we set _streamInterrupted so the working row switches to a "reconnecting" visual.
1010 // On a *hard* failure (Failed/AuthFailed) the run genuinely can't continue without
1011 // user action, so _running is cleared. _streamInterrupted is cleared on Connected.
1012 viewModelScope.launch {
1013 container.activeConnection.collectLatest { conn ->
1014 if (conn == null) return@collectLatest
1016 // Loop so a transient non-cancellation exception doesn't terminate the
1017 // state watcher for this connection. Without the loop, a single thrown
1018 // error returns the collectLatest lambda and the _running reset on
1019 // Disconnected/Failed never fires again for this connection — a dropped
1020 // stream leaves the working spinner (and the RunForegroundService) stuck.
1021 while (currentCoroutineContext().isActive) {
1023 conn.events.state.collect { state ->
1025 EventStreamClient.ConnectionState.Connected -> {
1026 _streamInterrupted.value = false
1028 EventStreamClient.ConnectionState.Disconnected -> {
1029 // Transient: the stream dropped but the system is
1030 // reconnecting. Flag interrupted so the working row
1031 // shows a "reconnecting" state instead of vanishing.
1032 if (_running.value) _streamInterrupted.value = true
1034 EventStreamClient.ConnectionState.Failed,
1035 EventStreamClient.ConnectionState.AuthFailed -> {
1036 // Hard failure: the run can't continue without user
1037 // action (fix credentials / server). Clear running and
1038 // the interrupted flag so the UI doesn't look stuck.
1039 _streamInterrupted.value = false
1040 if (_running.value) _running.value = false
1042 EventStreamClient.ConnectionState.Connecting -> {
1043 // Initial connect or reconnect attempt; leave running
1044 // as-is — interrupted (if set) stays until Connected.
1048 } catch (e: CancellationException) {
1050 } catch (e: Exception) {
1051 Log.w("ChatViewModel", "SSE state collector error, restarting", e)
1054 } catch (e: CancellationException) {
1056 } catch (e: Exception) {
1057 Log.w("ChatViewModel", "SSE state collector loop error", e)
1063 /** Sends [text] together with any staged attachments; returns true on success so the
1064 * caller can clear the draft only then. */
1065 fun send(text: String): Boolean = send(text, includeAttachments = true)
1068 * Core send. [includeAttachments] is false for the internal auto-send paths (a queued
1069 * follow-up, a retry) so staged attachments aren't silently re-sent with unrelated text.
1071 * [queuedFollowUpToClear] is the text of a queued follow-up being auto-sent (from
1072 * SessionIdle). When non-null, the follow-up is cleared from persistence *inside* the
1073 * launched coroutine — so if [viewModelScope] is cancelled before the coroutine runs
1074 * (e.g. the user navigates away at the exact moment SessionIdle arrives), the clear
1075 * never executes and the follow-up remains persisted, recovered on the next open of
1076 * this session. Clearing it before the launch (the prior approach) could lose the
1077 * follow-up entirely: the launch would be dropped by cancellation but the persisted
1078 * copy was already wiped.
1080 @Suppress("CyclomaticComplexMethod")
1083 includeAttachments: Boolean,
1084 idempotencyKey: String? = null,
1085 queuedFollowUpToClear: String? = null,
1087 val trimmed = text.trim()
1088 val attachments = if (includeAttachments) _attachments.value else emptyList()
1089 // An image-only prompt (attachments, no text) is valid; a blank prompt with nothing
1091 if (trimmed.isEmpty() && attachments.isEmpty()) return false
1092 // Offline, or no active connection: queue the prompt to the outbox (flushed on
1093 // reconnect) instead of failing the send. Device-offline is caught even when a stale
1094 // connection object lingers, so a phone that lost signal mid-conversation still
1095 // captures the message rather than bouncing it off a dead socket.
1096 val conn = connection
1097 if (conn == null || !container.isOnline.value) {
1098 return enqueueOffline(trimmed, attachments)
1100 if (!beginRun()) return false
1101 // A new run is starting: reopen the re-light gate so this run's streamed parts keep the
1102 // indicator lit (and a later SessionIdle re-closes it).
1103 runEndedByIdle = false
1104 // A plain manual Send of the auto-restored failed draft must reuse the stashed key too
1105 // (retryFailed passes it explicitly; the composer's Send does not), so a lost-response
1106 // re-send stays deduplicated server-side instead of starting a duplicate agent run.
1107 // Capture the match BEFORE clearing _failedDraft just below.
1108 val reuseKey = if (idempotencyKey == null && trimmed == _failedDraft.value?.trim()) {
1109 failedIdempotencyKey
1113 _failedDraft.value = null
1114 // Stable idempotency key for THIS online attempt: a brand-new message gets a fresh key,
1115 // while retryFailed() (or a manual re-send of the restored failed draft) re-submits with the
1116 // key it stashed on the original failure — so a retry after a lost response is deduplicated
1117 // server-side (no duplicate agent run).
1118 val key = idempotencyKey ?: reuseKey ?: java.util.UUID.randomUUID().toString()
1119 // Clear the staged attachments optimistically so the composer empties immediately;
1120 // restore them if the send fails (mirrors the draft handling below).
1121 if (attachments.isNotEmpty()) _attachments.value = emptyList()
1122 // Clear the in-memory draft for the UI immediately, but don't persist the clear
1123 // yet — if the send fails and the process dies before we restore, the draft
1124 // would be lost forever. The persisted draft is cleared only on success.
1125 // suppressDraftPersist prevents the debounced collector from persisting the
1126 // empty draft before the send resolves.
1128 // Only clear when the text being sent IS the current draft. send() is also
1129 // invoked for auto-sent queued follow-ups (SessionIdle) and retryFailed(), where
1130 // the user may have typed a NEW draft since — wiping it would lose that text.
1131 if (_draft.value.trim() == trimmed) {
1132 suppressDraftPersist.set(true)
1134 _pendingQuote.value = null
1135 _editing.value = false
1137 // Inject an optimistic user message so the outgoing prompt is visible immediately,
1138 // before the server echoes it back via SSE. Removed by reconcileOptimistic once the
1139 // real UserMessage arrives. Only for text-bearing prompts (an image-only prompt has
1140 // no text to show).
1141 val optimisticId = if (trimmed.isNotEmpty()) "__optimistic_${System.nanoTime()}" else null
1142 if (optimisticId != null) {
1143 // Clear any prior failed optimistic entry with the same text (a retry re-sends).
1144 _optimisticMessages.update { entries -> entries.filterNot { it.failed && it.text.trim() == trimmed } }
1145 _optimisticMessages.update { it + OptimisticEntry(optimisticId, trimmed, System.currentTimeMillis(), failed = false) }
1147 // Remember the sent text so an undoable Stop can re-send it. Overwrite unconditionally:
1148 // an image-only prompt has nothing to re-send, and leaving a prior text prompt tracked
1149 // would make Stop-Undo silently re-send that unrelated earlier message.
1150 lastSentPrompt = trimmed.takeIf { it.isNotEmpty() }
1151 viewModelScope.launch {
1152 // Clear a queued follow-up being auto-sent *inside* the launched coroutine, so a
1153 // scope cancellation between send() being called and the coroutine starting drops
1154 // the launch without wiping the persisted follow-up — the follow-up is then
1155 // recovered on the next session open instead of being silently lost.
1156 if (queuedFollowUpToClear != null) setQueuedFollowUp(null)
1157 runCatchingCancellable {
1158 conn.repository.sendPrompt(
1161 attachments = attachments.map { it.part },
1162 model = _selectedModel.value?.ref,
1163 agent = _selectedAgent.value,
1164 idempotencyKey = key,
1167 // Mark the optimistic message as failed so the user sees a failure indicator
1168 // instead of a perpetual "sending" state.
1169 if (optimisticId != null) {
1170 _optimisticMessages.update { entries ->
1171 entries.map { if (it.tempId == optimisticId) it.copy(failed = true) else it }
1174 suppressDraftPersist.set(false)
1175 _failedDraft.value = trimmed
1176 failedIdempotencyKey = key
1177 // Only restore the draft if the user hasn't typed anything new since.
1178 if (_draft.value.isBlank()) updateDraft(trimmed)
1179 // Restore the staged attachments too, so a failed send doesn't lose them —
1180 // but only if the user hasn't staged new ones in the meantime.
1181 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
1182 // Re-persist so the restored attachments survive process death after a failure.
1183 persistAttachments()
1184 // Retryable: this is the failed send whose prompt Retry re-submits.
1185 _errorEvents.trySend(ChatError(container.friendlyError(it), retryable = true))
1186 _running.value = false
1188 suppressDraftPersist.set(false)
1189 // This attempt reached the server, so the stashed retry key is spent — drop it so a
1190 // later unrelated send can't reuse it (retryFailed clears it on success too).
1191 failedIdempotencyKey = null
1192 // Send succeeded — the attachments rode along, so clear their persisted copy
1193 // (or keep any the user staged after the optimistic clear).
1194 persistAttachments()
1195 // Send succeeded — persist the cleared draft, but only if the user hasn't
1196 // typed a new one while the send was in flight. Otherwise clearing the
1197 // store echoes back through the draft observer and wipes the in-progress
1198 // text (data loss). Mirrors the guard on the failure path above.
1199 if (_draft.value.isBlank()) {
1200 lastPersistedDraft = ""
1201 container.draftStore.set(sessionId, "")
1203 // Don't reset _running here: the agent continues streaming via SSE.
1204 // _running is cleared on SessionIdle/SessionError (see event collector)
1205 // or when the SSE stream drops (see connection state watcher below).
1212 * Queue [trimmed] (+ [attachments]) to the offline outbox, clearing the composer
1213 * optimistically like a real send. AppContainer flushes the outbox automatically on
1214 * reconnect. The prompt is attributed to the active (or most-recent) server profile so a
1215 * reconnect to a different server doesn't misfire it. On any failure the draft/attachments
1216 * are restored so nothing is lost.
1218 private fun enqueueOffline(trimmed: String, attachments: List<PendingAttachment>): Boolean {
1219 _failedDraft.value = null
1220 if (attachments.isNotEmpty()) _attachments.value = emptyList()
1221 val clearDraft = _draft.value.trim() == trimmed
1223 suppressDraftPersist.set(true)
1226 viewModelScope.launch {
1227 val profileId = connection?.profile?.id
1228 ?: runCatchingCancellable { container.profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
1229 if (profileId == null) {
1230 // No server to attribute the message to — restore the composer and report it
1231 // rather than silently dropping the prompt.
1232 suppressDraftPersist.set(false)
1233 if (_draft.value.isBlank()) updateDraft(trimmed)
1234 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
1235 persistAttachments()
1236 _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
1239 runCatchingCancellable {
1240 container.outboxStore.enqueue(
1242 id = java.util.UUID.randomUUID().toString(),
1243 profileId = profileId,
1244 sessionId = sessionId,
1246 attachments = attachments.map { it.toPersisted() },
1247 model = _selectedModel.value?.ref,
1248 agent = _selectedAgent.value,
1249 createdAt = System.currentTimeMillis(),
1253 suppressDraftPersist.set(false)
1254 // The staged attachments now live in the outbox item; clear their draft copy.
1255 persistAttachments()
1256 if (_draft.value.isBlank()) {
1257 lastPersistedDraft = ""
1258 container.draftStore.set(sessionId, "")
1260 // Nudge a flush in case we're actually online with a live connection.
1261 container.flushOutbox()
1262 _errorEvents.trySend(ChatError(container.string(R.string.outbox_queued)))
1264 suppressDraftPersist.set(false)
1265 if (_draft.value.isBlank()) updateDraft(trimmed)
1266 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
1267 persistAttachments()
1268 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1274 /** Discard a single queued (offline) message. */
1275 fun discardQueued(id: String) {
1276 viewModelScope.launch { runCatchingCancellable { container.outboxStore.remove(id) } }
1279 /** Discard every queued (offline) message for this session. */
1280 fun discardAllQueued() {
1281 viewModelScope.launch { runCatchingCancellable { container.outboxStore.removeForSession(sessionId) } }
1284 /** Attempt to flush the outbox now (e.g. the user tapped "Send now"). */
1285 fun flushQueued() { container.flushOutbox() }
1287 /** Re-send the last draft whose send failed, if any. */
1289 val draft = _failedDraft.value ?: return
1290 // Reuse the failed send's idempotency key so a retry after a lost response is
1291 // deduplicated server-side rather than starting a duplicate run.
1292 val key = failedIdempotencyKey
1293 // Don't clear _failedDraft until send() accepts the text — if _running is
1294 // already true, send() returns false and the draft would be lost forever.
1295 // Attachments aren't re-sent on retry (they were optimistically restored to the
1296 // composer on the original failure, so they'll ride the next manual send).
1297 // send() accepts the text synchronously, but its onSuccess/onFailure callbacks run
1298 // asynchronously. Clearing failedIdempotencyKey here would open a window between this
1299 // synchronous clear and the async onFailure (which re-stashes the key) during which a
1300 // manual re-send of the same text gets a fresh UUID — defeating server-side deduplication.
1301 // Let send()'s own callbacks own the key's lifecycle, as they do for non-retry paths.
1302 if (send(draft, includeAttachments = false, idempotencyKey = key)) {
1303 _failedDraft.value = null
1307 /** Dismiss a single failed optimistic message by its tempId so it no longer lingers in the
1308 * list. Without this there's no recovery path for a failed message the user chose to abandon
1309 * (its text won't ever match a real server message, so reconcileOptimistic never clears it). */
1310 fun dismissOptimistic(tempId: String) {
1311 _optimisticMessages.update { entries -> entries.filterNot { it.tempId == tempId } }
1314 /** Re-send a specific failed optimistic message by its tempId. Loads its text into the
1315 * composer and fires a send, mirroring the snackbar's Retry path. Returns false (and is a
1316 * no-op) if the entry can't be found or a run is already active. */
1317 fun retryOptimisticMessage(tempId: String): Boolean {
1318 val entry = _optimisticMessages.value.firstOrNull { it.tempId == tempId && it.failed } ?: return false
1319 return send(entry.text, includeAttachments = false)
1323 * Queue [text] to be sent automatically when the current run finishes, or clear any
1324 * queued follow-up when [text] is blank. Used when the user taps Send while a run
1325 * is already active — the Send button is replaced by Stop during a run, but the
1326 * input field stays enabled, so a follow-up typed mid-run would otherwise have
1329 fun queueFollowUp(text: String) {
1330 val trimmed = text.trim()
1331 setQueuedFollowUp(trimmed.takeIf { it.isNotEmpty() })
1332 // The text now lives in the queued chip, so clear the input field just as a
1333 // normal send would. No-op for the cancel case (blank text), which must leave
1334 // whatever the user has since typed untouched.
1335 if (trimmed.isNotEmpty()) {
1337 // Persist the cleared draft IMMEDIATELY, not just in memory: the follow-up was already
1338 // persisted synchronously above, so if the process is killed before the debounced
1339 // draft-persist fires, the same text would remain under BOTH the draft and follow-up
1340 // keys — and cold-start recovery would restore it to the composer AND re-queue it (a
1341 // duplicate). Mirror send()'s lastPersistedDraft update so the drafts observer treats
1342 // this store change as our own echo and doesn't write the cleared value back.
1343 lastPersistedDraft = ""
1344 container.draftStore.flushDraft(sessionId, "")
1348 /** Set or clear the queued follow-up and mirror it to persistence, so a follow-up
1349 * queued mid-run survives process death (recovered on the next open of this session).
1350 * Passing null / blank clears both the in-memory value and the persisted copy. */
1351 private fun setQueuedFollowUp(text: String?) {
1352 _queuedFollowUp.value = text
1353 container.draftStore.flushFollowUp(sessionId, text.orEmpty())
1356 /** Transient flag set by [refreshMessages] so the top-bar refresh icon can show
1357 * a brief spinner as immediate tap feedback. The SSE reconnect triggered by
1358 * refreshMessages may not visibly change [connectionState] (it's already
1359 * Connected), so without this the tap appears to do nothing. Clears after a
1360 * short delay or when the connection state next becomes Connected. */
1361 private val _refreshing = MutableStateFlow(false)
1362 val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
1364 /** Manually re-fetch messages by forcing the SSE stream to reconnect, which
1365 * triggers the repository's re-seed-from-REST logic. Gives the user a recovery
1366 * path when the SSE stream silently drops and the auto-reconnect re-seed is slow
1367 * or fails — a pull-to-refresh or top-bar refresh forces a fresh fetch under the
1368 * existing generation-guarded merge. Also clears [loadError] optimistically and
1369 * sets [refreshing] for immediate tap feedback. */
1370 fun refreshMessages() {
1371 val conn = connection ?: return
1372 _loadError.value = false
1373 _loadErrorInline.value = false
1374 _refreshing.value = true
1375 conn.events.triggerReconnect()
1376 viewModelScope.launch {
1377 delay(NetworkConfig.refreshFeedbackMs)
1378 _refreshing.value = false
1382 /** Invoke a slash-command by name via the server's /command endpoint. */
1383 fun runCommand(command: Command) {
1384 val conn = connection ?: return
1385 // A run is already in flight — surface feedback instead of silently dropping the
1386 // command (the picker stays openable during a run and dismisses on select).
1388 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1391 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1392 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1393 runEndedByIdle = false
1394 viewModelScope.launch {
1395 runCatchingCancellable {
1396 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
1398 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1399 _running.value = false
1405 val conn = connection ?: return
1406 // Drop any queued follow-up so the SessionIdle that follows the abort doesn't
1407 // auto-send it — the user tapped Stop to halt work, not to trigger the next turn.
1408 setQueuedFollowUp(null)
1409 if (!_aborting.compareAndSet(false, true)) return
1410 viewModelScope.launch {
1412 // Run the abort POST under NonCancellable so it survives this scope being
1413 // cancelled. The "Stop and exit" path calls abort() then navigates back
1414 // immediately, which pops this ViewModel's back-stack entry → onCleared() →
1415 // viewModelScope.cancel(); without this the in-flight abort is cancelled (often
1416 // before the request even reaches the server) and the run keeps burning tokens
1417 // server-side despite the user explicitly stopping it.
1418 runCatchingCancellable { withContext(NonCancellable) { conn.repository.abort(sessionId) } }
1420 _running.value = false
1421 // Close the re-light gate so trailing SSE events in flight between the
1422 // abort POST's ack and the server's SessionIdle don't re-light the run
1423 // indicator (and re-arm the FGS / keepScreenOn) after the user hit Stop.
1424 // send()/runCommand()/continueRun() all open this gate on beginRun();
1425 // abort() is the one run-ending path that previously missed it.
1426 runEndedByIdle = true
1427 // Surface an undo opportunity so an accidental Stop is recoverable.
1428 // The UI shows a "Stopped — Undo" snackbar; tapping Undo re-sends the
1429 // last prompt. Only when there's a tracked prompt to undo back to.
1430 val prompt = lastSentPrompt
1431 if (prompt != null) _stopUndoEvents.trySend(prompt)
1433 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1435 _aborting.value = false
1440 /** Re-send the most recently-sent user prompt, used by the Stop-undo snackbar so an
1441 * accidental Stop can be recovered. No-op if no prompt is tracked (e.g. the run was
1442 * started by a command, not a text prompt). Clears the tracked prompt so a second Undo
1443 * tap doesn't double-send. */
1444 fun resendLastPrompt() {
1445 val prompt = lastSentPrompt ?: return
1446 lastSentPrompt = null
1447 send(prompt, includeAttachments = false)
1450 fun addAttachment(attachment: PendingAttachment) {
1451 // Enforce the cumulative size cap across the whole staged set (the per-file size and the
1452 // count caps are checked upstream). The base64 data URLs are held in memory and
1453 // re-serialized on every add/remove and again when the request body is built, so an
1454 // unbounded set OOMs. Reject rather than add, mirroring the other attachment limits'
1455 // snackbar via errorEvents. Size is the raw (pre-base64) byte count, matching the
1456 // per-file check, and includes attachments already staged (incl. restored ones).
1457 val staged = _attachments.value.sumOf { base64DataUrlByteSize(it.part.url) }
1458 if (staged + base64DataUrlByteSize(attachment.part.url) > NetworkConfig.maxTotalAttachmentBytes) {
1459 _errorEvents.trySend(ChatError(container.string(R.string.attachment_limit)))
1462 _attachments.update { it + attachment }
1463 persistAttachments()
1466 fun removeAttachment(id: String) {
1467 _attachments.update { list -> list.filterNot { it.id == id } }
1468 persistAttachments()
1471 fun clearAttachments() {
1472 _attachments.value = emptyList()
1473 persistAttachments()
1476 /** Convert each picked Uri to a base64 attachment off the main thread, honoring the
1477 * per-prompt count cap and surfacing per-file errors via [errorEvents] without aborting
1478 * the batch. Runs on [viewModelScope] (which survives configuration changes) so an
1479 * in-flight encode isn't cancelled and its progress counters ([stagingCount] /
1480 * [stagingFileTotal]) aren't reset to 0 on rotation — the prior UI-side
1481 * `rememberCoroutineScope` implementation lost both the work and the counters on
1482 * every config change. */
1483 fun stageUris(uris: List<android.net.Uri>, context: android.content.Context) {
1484 if (uris.isEmpty()) return
1485 val appContext = context.applicationContext
1486 viewModelScope.launch {
1487 _stagingCount.value++
1488 _stagingFileTotal.value += uris.size
1490 uris.forEachIndexed { index, uri ->
1491 if (_attachments.value.size >= NetworkConfig.maxAttachments) {
1492 _errorEvents.trySend(ChatError(container.string(R.string.attachment_limit)))
1493 // The cap dropped the rest of this batch; account for the files we
1494 // won't process so stagingFileTotal doesn't linger over-counted,
1495 // then abandon the batch. return@launch (not return@forEachIndexed)
1496 // so the remaining uris aren't re-checked against a never-relieved
1497 // cap (which would over-decrement the counter and re-fire the
1498 // snackbar once per leftover uri). The `finally` below still
1499 // decrements stagingCount.
1500 _stagingFileTotal.value -= uris.size - index
1503 when (val result = uri.toAttachmentResult(appContext)) {
1504 is AttachmentResult.Ok -> addAttachment(result.attachment)
1505 AttachmentResult.TooLarge -> _errorEvents.trySend(ChatError(container.string(R.string.attachment_too_large)))
1506 AttachmentResult.Failed -> _errorEvents.trySend(ChatError(container.string(R.string.attachment_failed)))
1508 _stagingFileTotal.value--
1511 _stagingCount.value--
1516 /** Persist the current staged attachments so an interrupted compose survives process
1517 * death (mirrors draft persistence). Fire-and-forget on [viewModelScope]. */
1518 private fun persistAttachments() {
1519 val snapshot = _attachments.value
1520 viewModelScope.launch {
1521 runCatchingCancellable { container.attachmentDraftStore.save(sessionId, snapshot.map { it.toPersisted() }) }
1522 .onFailure { Log.w("ChatViewModel", "Failed to persist attachments", it) }
1526 /** Revert the conversation to just before [messageId], hiding everything after it. The
1527 * authoritative state also arrives via SessionUpdated; we flag it here for immediate
1529 fun revertTo(messageId: String) {
1530 val conn = connection ?: return
1531 viewModelScope.launch {
1532 runCatchingCancellable { conn.api.revert(sessionId, messageId) }
1534 _reverted.value = it.isReverted
1535 _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1536 _editing.value = false
1538 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1543 * Regenerate an assistant reply: revert the conversation to that assistant message (removing
1544 * it and everything after), then re-send the user prompt that immediately preceded it. A
1545 * one-tap "try again" mirroring ChatGPT/Claude, instead of the two-step revert + re-Send.
1547 * Captures the preceding user text *before* the revert (the messages list updates
1548 * asynchronously via SSE once the revert lands). No-op if there's no preceding user prompt
1549 * or the assistant message isn't found.
1551 fun regenerate(assistantMessageId: String) {
1552 val conn = connection ?: return
1553 val msgs = messages.value
1554 val idx = msgs.indexOfFirst { it.info.id == assistantMessageId }
1556 val precedingUserText = (idx - 1 downTo 0)
1559 .firstOrNull { it.info is UserMessage }
1561 ?.filterIsInstance<TextPart>()
1562 ?.joinToString("\n\n") { it.text }
1563 ?.takeIf { it.isNotBlank() }
1565 viewModelScope.launch {
1566 runCatchingCancellable { conn.api.revert(sessionId, assistantMessageId) }
1567 .onSuccess { send(precedingUserText, includeAttachments = false) }
1568 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1572 /** Continue a partially-generated assistant reply by sending a "continue" prompt
1573 * without reverting (unlike [regenerate], which re-runs from the preceding user
1574 * prompt and loses the partial). The partial reply stays as context so the model
1575 * can pick up where it left off. This is the standard chat-app "continue" pattern
1576 * for a reply that was aborted mid-stream. */
1577 fun continueRun(@Suppress("UNUSED_PARAMETER") assistantMessageId: String) {
1578 val conn = connection ?: return
1579 if (running.value || aborting.value) return
1580 if (!beginRun()) return
1581 // Reopen the relight gate so this run's streamed parts keep the indicator lit (and a
1582 // later SessionIdle re-closes it). Without this, runEndedByIdle stays true from the
1583 // previous run's SessionIdle and the relight guard at the event collector would suppress
1584 // every streamed part of the continuation, leaving the working indicator / Stop button /
1585 // foreground service off for the whole run.
1586 runEndedByIdle = false
1587 viewModelScope.launch {
1588 runCatchingCancellable {
1589 conn.api.sendPrompt(sessionId, text = "continue")
1591 _running.value = false
1592 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1597 /** Load a previously-sent user prompt back into the composer to edit and re-send.
1598 * Reverts the conversation to just before [messageId] (hiding it and everything after)
1599 * and prefills the draft with [text]. The next Send continues from that checkpoint,
1600 * effectively replacing the edited message; the reverted banner's Undo restores it.
1601 * Overwrites the current draft — this is an explicit edit action on that message. */
1602 fun editMessage(messageId: String, text: String) {
1603 val conn = connection ?: return
1604 _draft.value = text.take(NetworkConfig.maxDraftLengthChars)
1605 _pendingQuote.value = null
1606 // Do NOT delegate to revertTo(): revertTo's onSuccess sets _editing = false, which races
1607 // this synchronous _editing = true (the async revert completes after editMessage returns
1608 // and clobbers the banner). Inline the revert so editMessage owns the _editing lifecycle:
1609 // set it true on success, matching the documented contract ("Cleared on unrevert and on
1610 // send"). revertTo() (the manual revert button) still clears _editing itself.
1611 viewModelScope.launch {
1612 runCatchingCancellable { conn.api.revert(sessionId, messageId) }
1614 _reverted.value = it.isReverted
1615 _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1616 _editing.value = true
1618 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1622 /** Prefill the composer with [text] as a Markdown blockquote so the user can respond to a
1623 * specific message inline. Appends to any existing draft (with a blank line) rather than
1624 * overwriting, so quoting doesn't discard text already being typed. Also stashes the raw
1625 * quoted text in [pendingQuote] so the composer can show a dismissible preview card. */
1626 fun quoteReply(text: String) {
1627 val quoted = text.trim().lineSequence().joinToString("\n") { "> $it" }
1628 if (quoted.isBlank()) return
1629 val current = _draft.value
1630 val combined = if (current.isBlank()) "$quoted\n\n" else "$current\n\n$quoted\n\n"
1631 // Cap like the composer's typed/pasted/dictated input so quoting onto a near-full draft
1632 // can't push it past the limit (negative remaining counter + an over-cap prompt sent).
1633 _draft.value = combined.take(NetworkConfig.maxDraftLengthChars)
1634 _pendingQuote.value = text.trim().takeIf { it.isNotBlank() }
1637 /** Remove the pending quote-reply preview and strip the `>`-prefixed blockquote lines from
1638 * the draft so canceling the quote card actually removes the quoted text, not just the chip.
1639 * Strips by leading `> ` prefix (structural match) rather than by exact full-text replace,
1640 * so a user who edited any quoted line is still cleaned up — the exact-text replaces would
1641 * silently no-op on an edit, leaving orphan `>` lines in the draft after the chip vanished. */
1642 fun cancelQuoteReply() {
1643 val removed = _pendingQuote.value ?: return
1644 _pendingQuote.value = null
1645 val quoteLineCount = removed.trim().lineSequence().count()
1646 // Walk the draft and drop the first contiguous run of `> `-prefixed lines that matches
1647 // the stashed quote's line count (the block quoteReply inserted). Lines the user edited
1648 // still begin with `> ` (quoteReply prefixed every line), so a prefix match finds them
1649 // even when their trailing content diverges from the stashed original.
1650 val lines = _draft.value.split("\n").toMutableList()
1654 while (i < lines.size) {
1655 if (lines[i].startsWith("> ") || lines[i] == ">") {
1656 if (runStart < 0) runStart = i
1658 if (runLen == quoteLineCount) {
1659 // Strip the run plus a single trailing blank line that separated the quote
1660 // from the reply (quoteReply appends "\n\n" after the block).
1661 val stripUntil = i + 1
1662 if (stripUntil < lines.size && lines[stripUntil].isEmpty()) {
1663 lines.subList(runStart, stripUntil + 1).clear()
1665 lines.subList(runStart, stripUntil).clear()
1676 _draft.value = lines.joinToString("\n").trimStart()
1679 /** Fork the conversation by creating a brand-new session seeded with [text] as its first
1680 * prompt (using the currently-selected model/agent). Emits the new session id via
1681 * [branchEvents] so the UI can navigate to it. There's no server-side "branch" endpoint,
1682 * so this starts a fresh session from the chosen prompt rather than copying history. */
1683 fun branchFrom(text: String) {
1684 val conn = connection ?: return
1685 val prompt = text.trim()
1686 if (prompt.isEmpty()) return
1687 viewModelScope.launch {
1688 var createdId: String? = null
1689 runCatchingCancellable {
1690 val session = conn.repository.createSession(title = null)
1691 createdId = session.id
1692 conn.repository.sendPrompt(
1695 model = _selectedModel.value?.ref,
1696 agent = _selectedAgent.value,
1700 .onSuccess { newId -> _branchEvents.trySend(newId) }
1702 // If the session was created but the prompt failed, delete the orphaned
1703 // empty session so failed branches don't accumulate on the server.
1706 runCatchingCancellable { conn.repository.deleteSession(id) }
1708 _errorEvents.trySend(ChatError(container.string(R.string.branch_failed)))
1713 /** Undo the active revert checkpoint, restoring the hidden messages. */
1715 val conn = connection ?: return
1716 viewModelScope.launch {
1717 runCatchingCancellable { conn.api.unrevert(sessionId) }
1719 _reverted.value = it.isReverted
1720 _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1721 _editing.value = false
1723 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1727 /** Create (or fetch the existing) public share link. The URL is exposed via [shareUrl]
1728 * and also emitted once via [shareLinkEvents] so the UI can copy it to the clipboard. */
1729 fun shareSession() {
1730 val conn = connection ?: return
1731 viewModelScope.launch {
1732 runCatchingCancellable { conn.api.shareSession(sessionId) }
1733 .onSuccess { session ->
1734 val url = session.share?.url?.takeIf { it.isNotBlank() }
1735 _shareUrl.value = url
1736 if (url != null) _shareLinkEvents.trySend(url)
1738 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1742 /** Revoke the session's public share link. */
1743 fun unshareSession() {
1744 val conn = connection ?: return
1745 viewModelScope.launch {
1746 runCatchingCancellable { conn.api.unshareSession(sessionId) }
1747 .onSuccess { _shareUrl.value = it.share?.url?.takeIf { it.isNotBlank() } }
1748 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1752 /** Compact the conversation via the summarize endpoint; the summary streams back via SSE.
1753 * Uses the currently-selected model (required by the endpoint). */
1755 val conn = connection ?: return
1756 val model = _selectedModel.value?.ref ?: run {
1757 _errorEvents.trySend(ChatError(container.string(R.string.needs_model)))
1761 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1764 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1765 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1766 runEndedByIdle = false
1767 viewModelScope.launch {
1768 runCatchingCancellable { conn.api.summarize(sessionId, model) }
1770 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1771 _running.value = false
1776 /** Analyze the project and (re)generate its AGENTS.md; the run streams back via SSE. */
1778 val conn = connection ?: return
1780 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1783 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1784 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1785 runEndedByIdle = false
1786 viewModelScope.launch {
1787 runCatchingCancellable { conn.api.initSession(sessionId) }
1789 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1790 _running.value = false
1795 /** Run a one-off shell [command] in the session's worktree; output streams back via SSE.
1796 * The server requires an agent to scope the run — use the selected one, else the primary. */
1797 fun runShell(command: String) {
1798 val conn = connection ?: return
1799 val cmd = command.trim()
1800 if (cmd.isEmpty()) return
1801 val agent = _selectedAgent.value
1802 ?: _agents.value.firstOrNull { it.isPrimary }?.name
1803 ?: _agents.value.firstOrNull()?.name
1804 ?: DEFAULT_SHELL_AGENT
1806 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1809 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1810 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1811 runEndedByIdle = false
1812 viewModelScope.launch {
1813 runCatchingCancellable { conn.api.shell(sessionId, cmd, agent, _selectedModel.value?.ref) }
1815 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1816 _running.value = false
1821 /** Rename the current session via PATCH /session/:id. On success updates [sessionTitle]
1822 * so the top bar reflects the new name immediately. A failure surfaces as a snackbar;
1823 * the caller keeps the dialog open so the user can retry without retyping. */
1824 fun renameSession(newTitle: String) {
1825 val conn = connection ?: return
1826 val title = newTitle.trim()
1827 if (title.isEmpty() || title == _sessionTitle.value) return
1828 viewModelScope.launch {
1829 runCatchingCancellable { conn.api.updateSession(sessionId, title) }
1830 .onSuccess { _sessionTitle.value = it.displayTitle }
1831 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1835 /** Delete the current session via DELETE /session/:id. Schedules a deferred delete with an
1836 * undo window and signals [deleteUndoEvents] so the ChatScreen shows an Undo snackbar
1837 * *over the conversation the user is viewing* — they can cancel without first being
1838 * navigated back to the session list. The actual REST delete runs after
1839 * [NetworkConfig.undoDeleteDelayMs]; cancel via [cancelSessionDelete]. If the window
1840 * expires, the scheduled delete's onDeleted sets [sessionDeleted] so the UI navigates away.
1841 * A failure surfaces as a snackbar. */
1842 fun deleteSession() {
1843 val conn = connection ?: return
1844 container.scheduleSessionDelete(
1846 delayMs = NetworkConfig.undoDeleteDelayMs,
1847 // Navigate away only once the deferred delete actually commits — so an in-place Undo
1848 // before the window expires keeps the user on this conversation.
1849 onDeleted = { _sessionDeleted.value = true },
1850 onError = { _errorEvents.trySend(ChatError(container.friendlyError(it))) },
1852 _deleteUndoEvents.tryEmit(sessionId)
1855 /** Cancel a delete scheduled by [deleteSession] (the in-chat Undo action). Returns true if the
1856 * delete was still pending (Undo succeeded). */
1857 fun cancelSessionDelete(): Boolean = container.cancelSessionDelete(sessionId)
1859 /** Cancel a delete scheduled for [id] — the `collectLatest` undo path in ChatScreen calls
1860 * this with the tracked `pending` id when a newer delete supersedes an in-flight one, so
1861 * the prior session's deferred REST delete is withdrawn (no-op if it already fired)
1862 * instead of being left to fire with no reachable Undo button. Mirrors
1863 * [soy.iko.opencode.ui.session.SessionListViewModel.undoDelete]. Returns false when [id]
1864 * isn't this VM's session (defensive — a ChatScreen only ever deletes its own session). */
1865 fun cancelSessionDelete(id: String): Boolean {
1866 if (id != sessionId) return false
1867 return container.cancelSessionDelete(id)
1870 /** Reconnect to the most recently used server profile (used when the connection is gone). */
1872 if (container.activeConnection.value != null) return
1873 // Guard against concurrent reconnect calls: the user can tap the button
1874 // multiple times while _reconnecting is true, which would launch parallel
1875 // connect() coroutines that race to set the active connection, leaking the
1876 // intermediate connections.
1877 if (!_reconnecting.compareAndSet(false, true)) return
1878 viewModelScope.launch {
1880 val recent = runCatchingCancellable { container.profileStore.profiles.first() }
1881 .getOrDefault(emptyList())
1883 if (recent == null) {
1884 _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
1887 runCatchingCancellable {
1888 val conn = container.connect(recent)
1891 container.disconnect()
1892 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1895 _reconnecting.value = false
1900 fun respondPermission(permission: Permission, response: PermissionResponse) {
1901 val conn = connection ?: return
1902 // Record a session-scoped grant so subsequent matching requests are auto-answered.
1903 // SESSION maps to ONCE on the wire (the server has no session-scope concept); the
1904 // client-side set is what gives it the "for this session" semantics.
1905 if (response == PermissionResponse.SESSION) {
1906 val type = permission.type.orEmpty()
1907 val pattern = permission.patternText.orEmpty()
1908 if (type.isNotEmpty()) sessionAllowed.add(type to pattern)
1910 // Dismiss optimistically (revealing any queued request); permission.replied will confirm.
1911 // If the call fails we re-surface this request so the user can retry instead of being stuck
1912 // with a dismissed dialog and a tool run that's still paused server-side.
1913 resolvePermission(permission.id)
1914 viewModelScope.launch {
1915 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
1917 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1918 // Revoke the session-scoped grant on failure: enqueuePermission() auto-responds
1919 // when (type, pattern) is in sessionAllowed, so leaving the entry in would loop
1920 // respondPermission -> fail -> enqueuePermission -> respondPermission ... forever,
1921 // spamming the dead server and the error snackbar without ever re-prompting the
1922 // user. Removing it makes enqueuePermission fall through to re-queueing the
1923 // request for the dialog so the user can retry.
1925 // This must cover BOTH the SESSION and ONCE response paths: enqueuePermission's
1926 // auto-respond sends ONCE, not SESSION, so guarding only the SESSION branch (as
1927 // the original code did) leaves the entry in and still loops when the auto-reply
1928 // fails. Revoke unconditionally on any failure.
1929 val type = permission.type.orEmpty()
1930 val pattern = permission.patternText.orEmpty()
1931 if (type.isNotEmpty()) sessionAllowed.remove(type to pattern)
1932 enqueuePermission(permission)
1937 private companion object {
1938 /** Fallback agent for the shell endpoint when none is selected and no agent catalog
1939 * loaded. opencode's default primary agent is "build". */
1940 const val DEFAULT_SHELL_AGENT = "build"
1943 override fun onCleared() {
1945 // Flush any pending debounced draft so it survives navigation. Uses an
1946 // asynchronous apply() (not a synchronous commit) so the main thread isn't
1947 // blocked on disk I/O — Android's SharedPreferences framework guarantees
1948 // pending apply() writes are flushed before the process exits.
1949 // Flush the pending draft — including an empty one. Persistence is otherwise debounced,
1950 // so if the user cleared the input and navigated back within the debounce window, the
1951 // debounce coroutine was cancelled with viewModelScope and the prefs still hold the
1952 // previous non-empty draft. flushDraft() removes the key when the text is blank, so
1953 // flushing commits the clear instead of resurrecting the deleted text.
1955 // BUT respect the send-in-flight guard: send() optimistically clears _draft to "" WITHOUT
1956 // persisting the clear so a failed send can restore the text. If the user taps Send then
1957 // immediately navigates back (cancelling the in-flight send), flushing the blank _draft here
1958 // would remove the persisted key and erase the pre-send draft with no recovery. Skip the
1959 // flush while a send is in flight so the pre-send draft stays persisted (recoverable).
1960 if (!suppressDraftPersist.get()) container.draftStore.flushDraft(sessionId, _draft.value)
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list