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
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.
75data class ChatError(val message: String, val retryable: Boolean = false)
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.
82@androidx.compose.runtime.Immutable
83data class PermissionProgress(val position: Int, val total: Int)
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
91@androidx.compose.runtime.Immutable
92data class PendingAttachment(
96 val previewModel: Any?,
97 val part: FilePromptPart,
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(
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)
123 private val container: AppContainer,
124 private val sessionId: String,
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())
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
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)
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
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)
243 val messages: StateFlow<List<MessageWithParts>> =
244 combine(sseMessages, _optimisticMessages) { sse, optimistic ->
245 mergeOptimistic(sse, optimistic)
247 scope = viewModelScope,
248 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
249 initialValue = emptyList(),
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
256 val hasMessages: StateFlow<Boolean> = messages
257 .map { it.isNotEmpty() }
259 scope = viewModelScope,
260 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
261 initialValue = false,
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 } }
269 scope = viewModelScope,
270 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
271 initialValue = emptyMap(),
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>()
286 if (msg.info !is UserMessage) continue
287 val text = msg.parts.filterIsInstance<TextPart>()
288 .joinToString("\n\n") { it.text }
290 if (text.isNotEmpty()) realUserTexts.add(text)
292 val surviving = optimistic.filter { it.text.trim() !in realUserTexts }
293 if (surviving.isEmpty()) return sse
294 return sse + surviving.map { it.toMessageWithParts() }
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>()
303 if (msg.info !is UserMessage) continue
304 val text = msg.parts.filterIsInstance<TextPart>()
305 .joinToString("\n\n") { it.text }
307 if (text.isNotEmpty()) realUserTexts.add(text)
309 if (realUserTexts.isEmpty()) return
310 _optimisticMessages.update { entries ->
311 entries.filterNot { it.text.trim() in realUserTexts }
315 /** Convert an optimistic entry to a [MessageWithParts] for display. */
316 private fun OptimisticEntry.toMessageWithParts(): MessageWithParts = MessageWithParts(
319 sessionID = sessionId,
320 time = soy.iko.opencode.data.model.TimeInfo(created = timestamp),
322 parts = listOf(TextPart(id = "$tempId-text", text = text)),
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()
337 scope = viewModelScope,
338 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
339 initialValue = emptyList(),
342 private val _running = MutableStateFlow(false)
343 val running: StateFlow<Boolean> = _running.asStateFlow()
346 * True when the SSE stream dropped mid-run (a transient [Disconnected] while a run is
347 * active). The UI uses this to switch the working indicator to a "stream interrupted /
348 * reconnecting" visual instead of clearing [_running] outright — the run is still active
349 * on the server, so showing it as finished would mislead the user into typing a follow-up
350 * or navigating away. Cleared when the stream reconnects ([Connected]) or when a hard
351 * failure ([Failed]/[AuthFailed]) genuinely clears [_running]. See the state watcher in
353 private val _streamInterrupted = MutableStateFlow(false)
354 val streamInterrupted: StateFlow<Boolean> = _streamInterrupted.asStateFlow()
357 * Wall-clock millis at which the current run started (0L when no run is active). Drives
358 * the typing row's elapsed timer so it survives LazyColumn recycling — a local `remember`
359 * in the row resets to 0:00 when the row is disposed and scrolled back into view. Stamped
360 * only on a real false→[beginRun] transition (send/run/init), not on SSE-reconnect relight
361 * (which continues an existing run and should keep its original elapsed time).
363 private val _runStartMs = MutableStateFlow(0L)
364 val runStartMs: StateFlow<Long> = _runStartMs.asStateFlow()
367 * Atomically transition `_running` false→true and stamp the run-start timestamp. Returns
368 * false (with no state change) when a run is already in flight, so callers can surface a
369 * "busy" message instead of silently dropping the action. Centralizes the transition so
370 * every run-start path captures [runStartMs] consistently.
372 private fun beginRun(): Boolean {
373 if (!_running.compareAndSet(false, true)) return false
374 _runStartMs.value = System.currentTimeMillis()
378 /** True once the current run ended via SessionIdle/SessionError, until the next run starts
379 * (a send) or an SSE reconnect. Gates the run-indicator re-light below: isRunActivity
380 * returns true for ANY message.part.updated, so without this a trailing/replayed part
381 * arriving after a legitimate SessionIdle would re-light the "working" indicator with no
382 * further idle to clear it — a stuck spinner + foreground service. Cleared on reconnect so
383 * genuine reconnect-recovery re-lighting (its actual purpose) still works. */
384 @Volatile private var runEndedByIdle = false
386 /** The in-flight REST title resolution for the current connection. Launched from — and
387 * cancelled by — the per-connection reset block so it can't observe or restore a previous
388 * server's stale title. */
389 private var sessionTitleJob: Job? = null
391 /** True while an abort REST call is in flight, so the Stop button can show a
392 * spinner and prevent double-taps from firing a second abort. */
393 private val _aborting = MutableStateFlow(false)
394 val aborting: StateFlow<Boolean> = _aborting.asStateFlow()
396 /** A follow-up the user typed while a run was active. send() queues it here and
397 * auto-sends once the run completes (SessionIdle), so the user isn't blocked with
398 * no way to send and no indication why the Send button is gone. */
399 private val _queuedFollowUp = MutableStateFlow<String?>(null)
400 val queuedFollowUp: StateFlow<String?> = _queuedFollowUp.asStateFlow()
402 /** The text of the most recently-sent user prompt (captured in [send]), so an undo-able
403 * Stop can re-send it. Only text-bearing prompts are tracked — an image-only prompt has
404 * nothing to re-send text-wise. Cleared when a new run starts with different text. */
405 private var lastSentPrompt: String? = null
407 /** One-shot event signals for an undoable Stop. The UI collects this and shows an
408 * "Stopped — Undo" snackbar; tapping Undo calls [resendLastPrompt]. A Channel so an
409 * emit before the UI subscribes is still delivered. */
410 private val _stopUndoEvents = Channel<String?>(Channel.CONFLATED)
411 val stopUndoEvents: Flow<String?> = _stopUndoEvents.receiveAsFlow()
413 /** One-shot error events surfaced as snackbars. A Channel (not SharedFlow) so an event
414 * emitted before the UI subscribes is buffered and still delivered — a SharedFlow with
415 * replay=0 would drop it (e.g. a VM-init catalog fetch failing before first
416 * composition). Each event is delivered exactly once and not replayed to a
417 * re-subscribing collector, so rotation doesn't re-show a stale snackbar. */
418 private val _errorEvents = Channel<ChatError>(
419 capacity = NetworkConfig.snackbarEventBufferCapacity,
420 onBufferOverflow = BufferOverflow.DROP_OLDEST,
422 val errorEvents: Flow<ChatError> = _errorEvents.receiveAsFlow()
424 /** One-shot events signaling a user-initiated delete was scheduled and is undoable in-place.
425 * The ChatScreen collects these to show an Undo snackbar over the conversation the user is
426 * already viewing, instead of navigating away first and surfacing Undo on the session list. */
427 private val _deleteUndoEvents = MutableSharedFlow<Unit>(
428 extraBufferCapacity = 1,
429 onBufferOverflow = BufferOverflow.DROP_OLDEST,
431 val deleteUndoEvents: SharedFlow<Unit> = _deleteUndoEvents.asSharedFlow()
433 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
434 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
436 private val _modelsLoading = MutableStateFlow(true)
437 val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
439 private val _modelsError = MutableStateFlow(false)
440 val modelsError: StateFlow<Boolean> = _modelsError.asStateFlow()
442 private val _selectedModel = MutableStateFlow<ModelOption?>(null)
443 val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
445 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
446 container.activeConnection
447 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
449 scope = viewModelScope,
450 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
451 initialValue = EventStreamClient.ConnectionState.Disconnected,
454 /** SSE reconnect-attempt count for the active connection, surfaced so the chat's
455 * connection banner can show "Reconnecting (attempt N)…" during a sustained outage. */
456 val reconnectAttempts: StateFlow<Int> =
457 container.activeConnection
458 .flatMapLatest { it?.events?.reconnectAttempts ?: flowOf(0) }
460 scope = viewModelScope,
461 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
465 private val _pendingPermission = MutableStateFlow<Permission?>(null)
466 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
468 /** Backlog indicator for the permission dialog (see [PermissionProgress]). */
469 private val _permissionProgress = MutableStateFlow(PermissionProgress(0, 0))
470 val permissionProgress: StateFlow<PermissionProgress> = _permissionProgress.asStateFlow()
472 /** Max number of permissions simultaneously pending since the queue was last empty. Reset
473 * to 0 when the queue drains, so a fresh burst starts its count over. */
474 private var maxPendingSeen = 0
476 // Unanswered permission requests keyed by id, insertion-ordered. The dialog shows the most
477 // recent; when one is answered/replied — or a response fails — we fall back to the next so a
478 // request that arrived while another was in flight isn't silently dropped (its tool run would
479 // otherwise stay paused server-side with no UI affordance). Only touched on the VM's main
480 // dispatcher (event collector + viewModelScope launches), so no extra synchronization is needed.
481 private val pendingPermissions = LinkedHashMap<String, Permission>()
483 // Session-scoped permission grants (Allow-for-this-session). Each entry is a (type, patternText)
484 // pair the user approved with PermissionResponse.SESSION. Subsequent matching requests are
485 // auto-responded ONCE without showing the dialog, so e.g. a user granting "read" for one path
486 // isn't re-prompted for every other file read in the same conversation. Cleared on connection
487 // change (see the activeConnection collector) so it never leaks across servers. The server only
488 // knows once/always/reject; SESSION is a client-side scope that maps to ONCE on the wire.
489 private val sessionAllowed = mutableSetOf<Pair<String, String>>()
491 private fun enqueuePermission(permission: Permission) {
492 // Auto-respond to a permission whose (type, pattern) the user previously granted for this
493 // session, instead of showing the dialog again. Resolved immediately (not via the queue) so
494 // the tool run isn't paused waiting for a dialog the user will never see.
495 val type = permission.type.orEmpty()
496 val pattern = permission.patternText.orEmpty()
497 if (type.isNotEmpty() && sessionAllowed.contains(type to pattern)) {
498 respondPermission(permission, PermissionResponse.ONCE)
501 pendingPermissions[permission.id] = permission
502 _pendingPermission.value = pendingPermissions.values.lastOrNull()
503 updatePermissionProgress()
506 private fun resolvePermission(id: String) {
507 pendingPermissions.remove(id)
508 _pendingPermission.value = pendingPermissions.values.lastOrNull()
509 updatePermissionProgress()
512 private fun clearPermissions() {
513 pendingPermissions.clear()
514 _pendingPermission.value = null
515 updatePermissionProgress()
518 /** Recompute the "N of M" backlog indicator. [maxPendingSeen] grows with the queue and
519 * resets only when it drains, so position = total - remaining + 1 advances as requests are
521 private fun updatePermissionProgress() {
522 val remaining = pendingPermissions.size
523 if (remaining == 0) {
525 _permissionProgress.value = PermissionProgress(0, 0)
528 if (remaining > maxPendingSeen) maxPendingSeen = remaining
529 _permissionProgress.value = PermissionProgress(
530 position = maxPendingSeen - remaining + 1,
531 total = maxPendingSeen,
535 private val _agents = MutableStateFlow<List<Agent>>(emptyList())
536 val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
538 private val _agentsLoading = MutableStateFlow(true)
539 val agentsLoading: StateFlow<Boolean> = _agentsLoading.asStateFlow()
541 private val _agentsError = MutableStateFlow(false)
542 val agentsError: StateFlow<Boolean> = _agentsError.asStateFlow()
544 private val _selectedAgent = MutableStateFlow<String?>(null)
545 val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
547 private val _commands = MutableStateFlow<List<Command>>(emptyList())
548 val commands: StateFlow<List<Command>> = _commands.asStateFlow()
550 private val _commandsLoading = MutableStateFlow(true)
551 val commandsLoading: StateFlow<Boolean> = _commandsLoading.asStateFlow()
553 private val _commandsError = MutableStateFlow(false)
554 val commandsError: StateFlow<Boolean> = _commandsError.asStateFlow()
556 private val _sessionTitle = MutableStateFlow<String?>(null)
557 val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
559 /** Set when the current session is deleted via SSE, so the UI can navigate away. */
560 private val _sessionDeleted = MutableStateFlow(false)
561 val sessionDeleted: StateFlow<Boolean> = _sessionDeleted.asStateFlow()
563 /** The text of the last send that failed, surfaced so the UI can offer a retry. */
564 private val _failedDraft = MutableStateFlow<String?>(null)
565 val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
567 /** The idempotency key of the last failed online send, stashed alongside [_failedDraft] so
568 * [retryFailed] re-submits with the SAME key. A retry after a lost response is then
569 * deduplicated server-side instead of starting a duplicate agent run. */
570 @Volatile private var failedIdempotencyKey: String? = null
572 /** Per-session draft, persisted so it survives navigation/process death. */
573 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
574 val draft: StateFlow<String> = _draft.asStateFlow()
576 /** A pending quote-reply preview (the raw text the user quoted), shown as a dismissible
577 * card above the composer so the user can see and cancel the quote before sending. Cleared
578 * on send or via [cancelQuoteReply]. The actual `>` prefixed lines are still folded into
579 * the draft for the server; this state is purely for the visual preview chip. */
580 private val _pendingQuote = MutableStateFlow<String?>(null)
581 val pendingQuote: StateFlow<String?> = _pendingQuote.asStateFlow()
583 /** Messages composed for this session while offline/disconnected, queued in the outbox and
584 * awaiting an automatic flush on reconnect. Surfaced so the composer can show a "queued"
585 * chip with a discard/send-now affordance. */
586 val outbox: StateFlow<List<OutboxMessage>> =
587 container.outboxStore.messages
588 .map { list -> list.filter { it.sessionId == sessionId } }
590 scope = viewModelScope,
591 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
592 initialValue = emptyList(),
595 /** Attachments staged for the next prompt (images/files). Persisted per-session (as their
596 * self-contained base64 data URLs) via [AttachmentDraftStore] so an interrupted compose
597 * survives process death — the source content Uris wouldn't survive, but the data URLs
598 * do. Cleared on a successful send. */
599 private val _attachments = MutableStateFlow<List<PendingAttachment>>(emptyList())
600 val attachments: StateFlow<List<PendingAttachment>> = _attachments.asStateFlow()
602 /** True while a revert checkpoint is active for this session (messages after it are hidden
603 * server-side). Drives the "reverted" banner with its Undo. */
604 private val _reverted = MutableStateFlow(false)
605 val reverted: StateFlow<Boolean> = _reverted.asStateFlow()
607 /** True when the revert was triggered by an Edit action (vs a bare Revert). Drives the
608 * banner's copy so it reads "Editing message" rather than "Reverted", making the composer's
609 * prefilled text unambiguous. Cleared on unrevert and on send. */
610 private val _editing = MutableStateFlow(false)
611 val editing: StateFlow<Boolean> = _editing.asStateFlow()
613 /** The diff of the active revert checkpoint, when the server includes one. Shown in the
614 * revert banner so the user can see what changed before deciding to undo. Null when the
615 * session isn't reverted or the server didn't send a diff. */
616 private val _revertDiff = MutableStateFlow<String?>(null)
617 val revertDiff: StateFlow<String?> = _revertDiff.asStateFlow()
619 /** The active public share URL for this session, or null when not shared. */
620 private val _shareUrl = MutableStateFlow<String?>(null)
621 val shareUrl: StateFlow<String?> = _shareUrl.asStateFlow()
623 /** One-shot events carrying a freshly-created share URL so the UI can copy/share it. */
624 private val _shareLinkEvents = Channel<String>(
625 capacity = NetworkConfig.snackbarEventBufferCapacity,
626 onBufferOverflow = BufferOverflow.DROP_OLDEST,
628 val shareLinkEvents: Flow<String> = _shareLinkEvents.receiveAsFlow()
630 /** One-shot events carrying the id of a session freshly branched from a message, so the
631 * UI can navigate to it. */
632 private val _branchEvents = Channel<String>(
633 capacity = NetworkConfig.snackbarEventBufferCapacity,
634 onBufferOverflow = BufferOverflow.DROP_OLDEST,
636 val branchEvents: Flow<String> = _branchEvents.receiveAsFlow()
639 // DraftStore loads SharedPreferences asynchronously; on a cold start the
640 // synchronous get() above returns "" until the background load completes.
641 // Observe the ready signal and re-seed the draft so it appears in the UI.
642 viewModelScope.launch {
643 container.draftStore.ready.collect { ready ->
644 if (!ready) return@collect
645 if (_draft.value.isEmpty()) {
646 _draft.value = container.draftStore.get(sessionId)
648 // Recover a follow-up queued before process death (its in-memory StateFlow
649 // is gone). If the input is now free, drop it back there for review/send and
650 // consume the stored copy; otherwise re-queue it so it auto-sends when the
651 // current run next idles. Guarded on == null so it never clobbers a
652 // follow-up the user queued after opening this session.
653 val savedFollowUp = container.draftStore.getFollowUp(sessionId)
654 if (savedFollowUp.isNotEmpty() && _queuedFollowUp.value == null) {
655 if (_draft.value.isEmpty()) {
656 _draft.value = savedFollowUp
657 container.draftStore.flushFollowUp(sessionId, "")
659 _queuedFollowUp.value = savedFollowUp
664 // Observe the drafts store for external mutations to this session's draft
665 // (e.g. a share injected via draftStore.set in two-pane mode, or setImmediate
666 // before navigation in single-pane mode). Without this, a share injected into
667 // an already-open session never appears in the input field — the init-time
668 // read and the ready-only re-seed miss it when the draft is non-empty.
669 // The VM's own debounced persist writes the same value _draft already holds,
670 // so those updates are no-ops here (storeValue == _draft.value). The
671 // suppressDraftPersist guard prevents a stale store value from clobbering
672 // the deliberate draft clear during a send (the store still holds the
673 // pre-send draft until the send resolves).
674 viewModelScope.launch {
675 container.draftStore.drafts.collect { drafts ->
676 // Ignore emissions until the initial disk load completes: the load itself
677 // emits the persisted snapshot, and applying it here would clobber text the
678 // user typed during the async load (the `ready` observer above seeds an
679 // empty draft). After ready, emissions are external writes.
680 if (!container.draftStore.ready.value) return@collect
681 val storeValue = drafts[sessionId].orEmpty()
682 // Ignore this VM's own (debounced, possibly stale) persistence echo — only a
683 // genuine external write (e.g. a two-pane share injection) should overwrite
685 if (storeValue == lastPersistedDraft) return@collect
686 if (storeValue != _draft.value && !suppressDraftPersist.get()) {
687 _draft.value = storeValue
694 // Restore attachments staged before process death. They were persisted as
695 // self-contained data URLs, so they re-send and (for images) re-preview intact.
696 // Guarded on empty so it never clobbers attachments the user staged after opening.
697 viewModelScope.launch {
698 val restored = runCatchingCancellable { container.attachmentDraftStore.load(sessionId) }
699 .getOrDefault(emptyList())
700 if (restored.isNotEmpty() && _attachments.value.isEmpty()) {
701 _attachments.value = restored.map { it.toPending() }
706 fun selectModel(option: ModelOption) { _selectedModel.value = option }
708 fun selectAgent(name: String?) { _selectedAgent.value = name }
711 val conn = connection ?: return
712 viewModelScope.launch {
713 conn.api.invalidateProvidersCache()
714 _modelsReload.value++
719 val conn = connection ?: return
720 viewModelScope.launch {
721 conn.api.invalidateAgentsCache()
722 _agentsReload.value++
726 fun reloadCommands() {
727 val conn = connection ?: return
728 viewModelScope.launch {
729 conn.api.invalidateCommandsCache()
730 _commandsReload.value++
735 * Shared helper for init-block catalog observers: re-fetches whenever the active
736 * connection or the catalog's reload trigger changes, invoking [onNull] to clear
737 * state on null so stale data from the old server doesn't persist. The
738 * [reloadTrigger] is merged with [activeConnection] so a manual reload (via
739 * reloadModels/reloadAgents/reloadCommands) cancels any in-flight observe fetch
740 * via collectLatest, preventing a stale observe result from overwriting a fresh
741 * reload result. Failure is non-fatal — the error flag is surfaced to the UI.
743 private fun <T> observeCatalog(
745 loading: MutableStateFlow<Boolean>,
746 error: MutableStateFlow<Boolean>,
747 fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
748 onSuccess: suspend (T) -> Unit,
750 reloadTrigger: StateFlow<Int>,
752 viewModelScope.launch {
753 merge(container.activeConnection, reloadTrigger).collectLatest { _ ->
754 val conn = container.activeConnection.value
755 if (conn == null) { onNull(); loading.value = false; error.value = false; return@collectLatest }
758 runCatchingCancellable { fetch(conn.api) }
759 // fetch() does HTTP via withRetry, so the failure can be a
760 // ClientRequestException whose message embeds the full request URL
761 // (may contain auth/paths). Log only a scrubbed summary.
762 .onFailure { Log.w("ChatViewModel", "Failed to load $tag: ${safeExceptionSummary(it)}"); error.value = true }
763 .getOrNull()?.let { onSuccess(it) }
764 loading.value = false
769 fun updateDraft(text: String) {
774 // Debounce draft persistence so we don't write to disk on every keystroke.
775 // Skip persisting empty drafts that were set by send() (suppressed via
776 // suppressDraftPersist) — send() clears the in-memory draft immediately for
777 // UI feedback but deliberately doesn't persist the clear until the send
778 // succeeds, so a failed send can restore the draft.
779 viewModelScope.launch {
780 _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
781 if (text.isBlank() && suppressDraftPersist.get()) return@collect
782 lastPersistedDraft = text
783 runCatchingCancellable { container.draftStore.set(sessionId, text) }
784 .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
787 // Reset per-connection state when the active server changes so stale spinners,
788 // permission dialogs, errors, and agent selections from the old server don't
789 // persist into the new one.
790 viewModelScope.launch {
791 container.activeConnection.collectLatest { conn ->
793 // The connection dropped (e.g. disconnect() mid-run). No SSE stream will
794 // arrive to deliver SessionIdle/SessionError, so reset the run state here —
795 // otherwise the working spinner sticks on, ChatScreen keeps keepScreenOn +
796 // the RunForegroundService alive, and the Stop button no-ops (abort() early-
797 // returns with no connection) until a later reconnect happens to clear it.
798 _running.value = false
799 _streamInterrupted.value = false
800 _aborting.value = false
802 sessionAllowed.clear()
803 sessionTitleJob?.cancel()
804 _revertDiff.value = null
807 _running.value = false
808 _streamInterrupted.value = false
809 // Allow the re-light below to recover a still-running run after this (re)connect;
810 // a run that actually finished during the outage emits no live parts, so this
811 // won't spuriously re-light.
812 runEndedByIdle = false
814 sessionAllowed.clear()
815 _failedDraft.value = null
816 _revertDiff.value = null
817 // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
818 // user intent that is now persisted; wiping it on every (re)connect — which
819 // this collector does, including transient SSE drops and the cold-start
820 // restore path — would drop a legitimately queued/recovered follow-up.
821 _selectedAgent.value = null
822 // Apply the user's persisted preferred-agent for this new session/load, so a
823 // user who always runs a specific agent doesn't have to pick it every session.
824 // No-op when the preference is empty (the server default applies). Read here
825 // (not in the agent-catalog onSuccess) because the catalog loads once per
826 // connection while this reset fires per-session-open; setting it once on
827 // connect is enough and avoids re-applying it on every catalog refresh.
828 runCatchingCancellable {
829 val pref = container.settingsStore.preferredAgentName.first()
830 if (pref.isNotEmpty()) _selectedAgent.value = pref
832 _sessionTitle.value = null
833 // Resolve the app-bar title via REST for THIS connection. Launched from the same
834 // block that just nulled _sessionTitle (and cancelled the previous fetch) so it can
835 // never observe or restore the previous server's stale title — the old separate
836 // collector could, racing that reset. The assignment is guarded on a still-null
837 // title so a fresher SSE SessionUpdated (from the event collector below) wins.
838 sessionTitleJob?.cancel()
839 sessionTitleJob = viewModelScope.launch {
840 runCatchingCancellable { conn.repository.listSessions() }
842 ?.firstOrNull { it.id == sessionId }
844 if (_sessionTitle.value == null) {
845 _sessionTitle.value = session.displayTitle
846 _reverted.value = session.isReverted
847 _revertDiff.value = session.revert?.diff?.takeIf { it.isNotBlank() }
848 _shareUrl.value = session.share?.url?.takeIf { it.isNotBlank() }
853 // Loop the inner collect so a transient non-cancellation exception (a
854 // malformed SSE event decoding error, an unexpected channel close)
855 // doesn't terminate the event collector for this connection. Without
856 // the loop, the catch returns the collectLatest lambda normally, so
857 // collectLatest considers this connection's block complete and never
858 // re-runs it — the chat would go permanently silent (no streaming,
859 // no permission prompts, no title updates) until a server switch.
860 while (currentCoroutineContext().isActive) {
862 conn.events.events.collect { event ->
863 if (SessionRepository.isIdle(event, sessionId)) {
864 _running.value = false
865 runEndedByIdle = true
866 // Auto-send a queued follow-up once the previous run finishes,
867 // so the user's drafted-while-running message isn't lost.
868 val queued = _queuedFollowUp.value
869 if (queued != null) {
870 setQueuedFollowUp(null)
871 send(queued, includeAttachments = false)
874 if (SessionRepository.isError(event, sessionId)) {
875 _running.value = false
876 runEndedByIdle = true
877 // The run errored, so the queued follow-up won't auto-send.
878 // Restore it to the input (if free) so the user's typed text
879 // isn't silently lost; otherwise just clear the queue.
880 val queued = _queuedFollowUp.value
881 if (!queued.isNullOrEmpty() && _draft.value.isEmpty()) {
882 _draft.value = queued
884 setQueuedFollowUp(null)
885 _errorEvents.trySend(ChatError(container.string(R.string.error_agent_reported)))
887 // Restore the run indicator if a live streaming event arrives while it's
888 // cleared — e.g. an SSE reconnect mid-run reset _running to false, but the
889 // agent is still working. Don't revive it once the user has hit Stop.
890 val relightRunIndicator = SessionRepository.isRunActivity(event, sessionId) &&
891 !_aborting.value && !_running.value && !runEndedByIdle
892 if (relightRunIndicator) {
893 _running.value = true
896 is PermissionUpdated ->
897 if (event.properties.sessionID == sessionId) enqueuePermission(event.properties)
898 is PermissionReplied ->
899 if (event.properties.sessionID == sessionId) event.properties.permissionID?.let { resolvePermission(it) }
901 if (event.properties.info.id == sessionId) {
902 val info = event.properties.info
903 _sessionTitle.value = info.displayTitle
904 _reverted.value = info.isReverted
905 _revertDiff.value = info.revert?.diff?.takeIf { it.isNotBlank() }
906 _shareUrl.value = info.share?.url?.takeIf { it.isNotBlank() }
909 if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
910 _sessionDeleted.value = true
915 } catch (e: CancellationException) {
917 } catch (e: Exception) {
918 Log.w("ChatViewModel", "SSE event collector error, restarting", e)
921 } catch (e: CancellationException) {
923 } catch (e: Exception) {
924 Log.w("ChatViewModel", "SSE event collector loop error", e)
928 // Load the model catalog; preselect the server default. Failure is non-fatal —
929 // sending with no model just uses the server's default agent/model.
931 tag = "model catalog",
932 loading = _modelsLoading,
933 error = _modelsError,
934 fetch = { it.providers() },
935 onSuccess = { resp ->
936 val options = resp.toOptions()
937 _models.value = options
938 // Only (re)apply the default when there's no valid current selection. On
939 // re-fetch/reconnect/Retry the user's chosen model must survive — keep it as
940 // long as it's still present in the refreshed list. When there's no current
941 // selection, prefer the user's persisted default-model preference (if it's in
942 // the list) over the server default, so a user who always picks a specific
943 // model doesn't have to pick it every new session.
944 val current = _selectedModel.value
945 if (current == null || options.none { it.ref == current.ref }) {
946 val prefId = runCatchingCancellable {
947 container.settingsStore.preferredModelId.first()
949 val preferredOption = if (prefId.isNotEmpty()) {
950 options.firstOrNull { it.modelID == prefId }
952 _selectedModel.value = preferredOption ?: resp.defaultOption(options)
955 onNull = { _models.value = emptyList(); _selectedModel.value = null },
956 reloadTrigger = _modelsReload,
958 // Load the agent catalog (non-fatal).
960 tag = "agent catalog",
961 loading = _agentsLoading,
962 error = _agentsError,
963 fetch = { it.agents() },
964 onSuccess = { _agents.value = it },
965 onNull = { _agents.value = emptyList() },
966 reloadTrigger = _agentsReload,
968 // Load the command catalog (non-fatal).
970 tag = "command catalog",
971 loading = _commandsLoading,
972 error = _commandsError,
973 fetch = { it.commands() },
974 onSuccess = { _commands.value = it },
975 onNull = { _commands.value = emptyList() },
976 reloadTrigger = _commandsReload,
978 // If the SSE stream drops mid-run, the run indicator would spin forever;
979 // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
980 // On a *transient* drop (Disconnected) we don't clear _running — the run is still
981 // active on the server, and showing it as finished would mislead the user. Instead
982 // we set _streamInterrupted so the working row switches to a "reconnecting" visual.
983 // On a *hard* failure (Failed/AuthFailed) the run genuinely can't continue without
984 // user action, so _running is cleared. _streamInterrupted is cleared on Connected.
985 viewModelScope.launch {
986 container.activeConnection.collectLatest { conn ->
987 if (conn == null) return@collectLatest
989 // Loop so a transient non-cancellation exception doesn't terminate the
990 // state watcher for this connection. Without the loop, a single thrown
991 // error returns the collectLatest lambda and the _running reset on
992 // Disconnected/Failed never fires again for this connection — a dropped
993 // stream leaves the working spinner (and the RunForegroundService) stuck.
994 while (currentCoroutineContext().isActive) {
996 conn.events.state.collect { state ->
998 EventStreamClient.ConnectionState.Connected -> {
999 _streamInterrupted.value = false
1001 EventStreamClient.ConnectionState.Disconnected -> {
1002 // Transient: the stream dropped but the system is
1003 // reconnecting. Flag interrupted so the working row
1004 // shows a "reconnecting" state instead of vanishing.
1005 if (_running.value) _streamInterrupted.value = true
1007 EventStreamClient.ConnectionState.Failed,
1008 EventStreamClient.ConnectionState.AuthFailed -> {
1009 // Hard failure: the run can't continue without user
1010 // action (fix credentials / server). Clear running and
1011 // the interrupted flag so the UI doesn't look stuck.
1012 _streamInterrupted.value = false
1013 if (_running.value) _running.value = false
1015 EventStreamClient.ConnectionState.Connecting -> {
1016 // Initial connect or reconnect attempt; leave running
1017 // as-is — interrupted (if set) stays until Connected.
1021 } catch (e: CancellationException) {
1023 } catch (e: Exception) {
1024 Log.w("ChatViewModel", "SSE state collector error, restarting", e)
1027 } catch (e: CancellationException) {
1029 } catch (e: Exception) {
1030 Log.w("ChatViewModel", "SSE state collector loop error", e)
1036 /** Sends [text] together with any staged attachments; returns true on success so the
1037 * caller can clear the draft only then. */
1038 fun send(text: String): Boolean = send(text, includeAttachments = true)
1041 * Core send. [includeAttachments] is false for the internal auto-send paths (a queued
1042 * follow-up, a retry) so staged attachments aren't silently re-sent with unrelated text.
1044 private fun send(text: String, includeAttachments: Boolean, idempotencyKey: String? = null): Boolean {
1045 val trimmed = text.trim()
1046 val attachments = if (includeAttachments) _attachments.value else emptyList()
1047 // An image-only prompt (attachments, no text) is valid; a blank prompt with nothing
1049 if (trimmed.isEmpty() && attachments.isEmpty()) return false
1050 // Offline, or no active connection: queue the prompt to the outbox (flushed on
1051 // reconnect) instead of failing the send. Device-offline is caught even when a stale
1052 // connection object lingers, so a phone that lost signal mid-conversation still
1053 // captures the message rather than bouncing it off a dead socket.
1054 val conn = connection
1055 if (conn == null || !container.isOnline.value) {
1056 return enqueueOffline(trimmed, attachments)
1058 if (!beginRun()) return false
1059 // A new run is starting: reopen the re-light gate so this run's streamed parts keep the
1060 // indicator lit (and a later SessionIdle re-closes it).
1061 runEndedByIdle = false
1062 // A plain manual Send of the auto-restored failed draft must reuse the stashed key too
1063 // (retryFailed passes it explicitly; the composer's Send does not), so a lost-response
1064 // re-send stays deduplicated server-side instead of starting a duplicate agent run.
1065 // Capture the match BEFORE clearing _failedDraft just below.
1066 val reuseKey = if (idempotencyKey == null && trimmed == _failedDraft.value?.trim()) {
1067 failedIdempotencyKey
1071 _failedDraft.value = null
1072 // Stable idempotency key for THIS online attempt: a brand-new message gets a fresh key,
1073 // while retryFailed() (or a manual re-send of the restored failed draft) re-submits with the
1074 // key it stashed on the original failure — so a retry after a lost response is deduplicated
1075 // server-side (no duplicate agent run).
1076 val key = idempotencyKey ?: reuseKey ?: java.util.UUID.randomUUID().toString()
1077 // Clear the staged attachments optimistically so the composer empties immediately;
1078 // restore them if the send fails (mirrors the draft handling below).
1079 if (attachments.isNotEmpty()) _attachments.value = emptyList()
1080 // Clear the in-memory draft for the UI immediately, but don't persist the clear
1081 // yet — if the send fails and the process dies before we restore, the draft
1082 // would be lost forever. The persisted draft is cleared only on success.
1083 // suppressDraftPersist prevents the debounced collector from persisting the
1084 // empty draft before the send resolves.
1086 // Only clear when the text being sent IS the current draft. send() is also
1087 // invoked for auto-sent queued follow-ups (SessionIdle) and retryFailed(), where
1088 // the user may have typed a NEW draft since — wiping it would lose that text.
1089 if (_draft.value.trim() == trimmed) {
1090 suppressDraftPersist.set(true)
1092 _pendingQuote.value = null
1093 _editing.value = false
1095 // Inject an optimistic user message so the outgoing prompt is visible immediately,
1096 // before the server echoes it back via SSE. Removed by reconcileOptimistic once the
1097 // real UserMessage arrives. Only for text-bearing prompts (an image-only prompt has
1098 // no text to show).
1099 val optimisticId = if (trimmed.isNotEmpty()) "__optimistic_${System.nanoTime()}" else null
1100 if (optimisticId != null) {
1101 // Clear any prior failed optimistic entry with the same text (a retry re-sends).
1102 _optimisticMessages.update { entries -> entries.filterNot { it.failed && it.text.trim() == trimmed } }
1103 _optimisticMessages.update { it + OptimisticEntry(optimisticId, trimmed, System.currentTimeMillis(), failed = false) }
1105 // Remember the sent text so an undoable Stop can re-send it. Only text-bearing prompts
1106 // are tracked (an image-only prompt has nothing to re-send).
1107 if (trimmed.isNotEmpty()) lastSentPrompt = trimmed
1108 viewModelScope.launch {
1109 runCatchingCancellable {
1110 conn.repository.sendPrompt(
1113 attachments = attachments.map { it.part },
1114 model = _selectedModel.value?.ref,
1115 agent = _selectedAgent.value,
1116 idempotencyKey = key,
1119 // Mark the optimistic message as failed so the user sees a failure indicator
1120 // instead of a perpetual "sending" state.
1121 if (optimisticId != null) {
1122 _optimisticMessages.update { entries ->
1123 entries.map { if (it.tempId == optimisticId) it.copy(failed = true) else it }
1126 suppressDraftPersist.set(false)
1127 _failedDraft.value = trimmed
1128 failedIdempotencyKey = key
1129 // Only restore the draft if the user hasn't typed anything new since.
1130 if (_draft.value.isBlank()) updateDraft(trimmed)
1131 // Restore the staged attachments too, so a failed send doesn't lose them —
1132 // but only if the user hasn't staged new ones in the meantime.
1133 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
1134 // Re-persist so the restored attachments survive process death after a failure.
1135 persistAttachments()
1136 // Retryable: this is the failed send whose prompt Retry re-submits.
1137 _errorEvents.trySend(ChatError(container.friendlyError(it), retryable = true))
1138 _running.value = false
1140 suppressDraftPersist.set(false)
1141 // This attempt reached the server, so the stashed retry key is spent — drop it so a
1142 // later unrelated send can't reuse it (retryFailed clears it on success too).
1143 failedIdempotencyKey = null
1144 // Send succeeded — the attachments rode along, so clear their persisted copy
1145 // (or keep any the user staged after the optimistic clear).
1146 persistAttachments()
1147 // Send succeeded — persist the cleared draft, but only if the user hasn't
1148 // typed a new one while the send was in flight. Otherwise clearing the
1149 // store echoes back through the draft observer and wipes the in-progress
1150 // text (data loss). Mirrors the guard on the failure path above.
1151 if (_draft.value.isBlank()) {
1152 lastPersistedDraft = ""
1153 container.draftStore.set(sessionId, "")
1155 // Don't reset _running here: the agent continues streaming via SSE.
1156 // _running is cleared on SessionIdle/SessionError (see event collector)
1157 // or when the SSE stream drops (see connection state watcher below).
1164 * Queue [trimmed] (+ [attachments]) to the offline outbox, clearing the composer
1165 * optimistically like a real send. AppContainer flushes the outbox automatically on
1166 * reconnect. The prompt is attributed to the active (or most-recent) server profile so a
1167 * reconnect to a different server doesn't misfire it. On any failure the draft/attachments
1168 * are restored so nothing is lost.
1170 private fun enqueueOffline(trimmed: String, attachments: List<PendingAttachment>): Boolean {
1171 _failedDraft.value = null
1172 if (attachments.isNotEmpty()) _attachments.value = emptyList()
1173 val clearDraft = _draft.value.trim() == trimmed
1175 suppressDraftPersist.set(true)
1178 viewModelScope.launch {
1179 val profileId = connection?.profile?.id
1180 ?: runCatchingCancellable { container.profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
1181 if (profileId == null) {
1182 // No server to attribute the message to — restore the composer and report it
1183 // rather than silently dropping the prompt.
1184 suppressDraftPersist.set(false)
1185 if (_draft.value.isBlank()) updateDraft(trimmed)
1186 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
1187 persistAttachments()
1188 _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
1191 runCatchingCancellable {
1192 container.outboxStore.enqueue(
1194 id = java.util.UUID.randomUUID().toString(),
1195 profileId = profileId,
1196 sessionId = sessionId,
1198 attachments = attachments.map { it.toPersisted() },
1199 model = _selectedModel.value?.ref,
1200 agent = _selectedAgent.value,
1201 createdAt = System.currentTimeMillis(),
1205 suppressDraftPersist.set(false)
1206 // The staged attachments now live in the outbox item; clear their draft copy.
1207 persistAttachments()
1208 if (_draft.value.isBlank()) {
1209 lastPersistedDraft = ""
1210 container.draftStore.set(sessionId, "")
1212 // Nudge a flush in case we're actually online with a live connection.
1213 container.flushOutbox()
1214 _errorEvents.trySend(ChatError(container.string(R.string.outbox_queued)))
1216 suppressDraftPersist.set(false)
1217 if (_draft.value.isBlank()) updateDraft(trimmed)
1218 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
1219 persistAttachments()
1220 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1226 /** Discard a single queued (offline) message. */
1227 fun discardQueued(id: String) {
1228 viewModelScope.launch { runCatchingCancellable { container.outboxStore.remove(id) } }
1231 /** Discard every queued (offline) message for this session. */
1232 fun discardAllQueued() {
1233 viewModelScope.launch { runCatchingCancellable { container.outboxStore.removeForSession(sessionId) } }
1236 /** Attempt to flush the outbox now (e.g. the user tapped "Send now"). */
1237 fun flushQueued() { container.flushOutbox() }
1239 /** Re-send the last draft whose send failed, if any. */
1241 val draft = _failedDraft.value ?: return
1242 // Reuse the failed send's idempotency key so a retry after a lost response is
1243 // deduplicated server-side rather than starting a duplicate run.
1244 val key = failedIdempotencyKey
1245 // Don't clear _failedDraft until send() accepts the text — if _running is
1246 // already true, send() returns false and the draft would be lost forever.
1247 // Attachments aren't re-sent on retry (they were optimistically restored to the
1248 // composer on the original failure, so they'll ride the next manual send).
1249 if (send(draft, includeAttachments = false, idempotencyKey = key)) {
1250 _failedDraft.value = null
1251 failedIdempotencyKey = null
1255 /** Dismiss a single failed optimistic message by its tempId so it no longer lingers in the
1256 * list. Without this there's no recovery path for a failed message the user chose to abandon
1257 * (its text won't ever match a real server message, so reconcileOptimistic never clears it). */
1258 fun dismissOptimistic(tempId: String) {
1259 _optimisticMessages.update { entries -> entries.filterNot { it.tempId == tempId } }
1262 /** Re-send a specific failed optimistic message by its tempId. Loads its text into the
1263 * composer and fires a send, mirroring the snackbar's Retry path. Returns false (and is a
1264 * no-op) if the entry can't be found or a run is already active. */
1265 fun retryOptimisticMessage(tempId: String): Boolean {
1266 val entry = _optimisticMessages.value.firstOrNull { it.tempId == tempId && it.failed } ?: return false
1267 return send(entry.text, includeAttachments = false)
1271 * Queue [text] to be sent automatically when the current run finishes, or clear any
1272 * queued follow-up when [text] is blank. Used when the user taps Send while a run
1273 * is already active — the Send button is replaced by Stop during a run, but the
1274 * input field stays enabled, so a follow-up typed mid-run would otherwise have
1277 fun queueFollowUp(text: String) {
1278 val trimmed = text.trim()
1279 setQueuedFollowUp(trimmed.takeIf { it.isNotEmpty() })
1280 // The text now lives in the queued chip, so clear the input field just as a
1281 // normal send would. No-op for the cancel case (blank text), which must leave
1282 // whatever the user has since typed untouched.
1283 if (trimmed.isNotEmpty()) {
1285 // Persist the cleared draft IMMEDIATELY, not just in memory: the follow-up was already
1286 // persisted synchronously above, so if the process is killed before the debounced
1287 // draft-persist fires, the same text would remain under BOTH the draft and follow-up
1288 // keys — and cold-start recovery would restore it to the composer AND re-queue it (a
1289 // duplicate). Mirror send()'s lastPersistedDraft update so the drafts observer treats
1290 // this store change as our own echo and doesn't write the cleared value back.
1291 lastPersistedDraft = ""
1292 container.draftStore.flushDraft(sessionId, "")
1296 /** Set or clear the queued follow-up and mirror it to persistence, so a follow-up
1297 * queued mid-run survives process death (recovered on the next open of this session).
1298 * Passing null / blank clears both the in-memory value and the persisted copy. */
1299 private fun setQueuedFollowUp(text: String?) {
1300 _queuedFollowUp.value = text
1301 container.draftStore.flushFollowUp(sessionId, text.orEmpty())
1304 /** Transient flag set by [refreshMessages] so the top-bar refresh icon can show
1305 * a brief spinner as immediate tap feedback. The SSE reconnect triggered by
1306 * refreshMessages may not visibly change [connectionState] (it's already
1307 * Connected), so without this the tap appears to do nothing. Clears after a
1308 * short delay or when the connection state next becomes Connected. */
1309 private val _refreshing = MutableStateFlow(false)
1310 val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
1312 /** Manually re-fetch messages by forcing the SSE stream to reconnect, which
1313 * triggers the repository's re-seed-from-REST logic. Gives the user a recovery
1314 * path when the SSE stream silently drops and the auto-reconnect re-seed is slow
1315 * or fails — a pull-to-refresh or top-bar refresh forces a fresh fetch under the
1316 * existing generation-guarded merge. Also clears [loadError] optimistically and
1317 * sets [refreshing] for immediate tap feedback. */
1318 fun refreshMessages() {
1319 val conn = connection ?: return
1320 _loadError.value = false
1321 _loadErrorInline.value = false
1322 _refreshing.value = true
1323 conn.events.triggerReconnect()
1324 viewModelScope.launch {
1325 delay(NetworkConfig.refreshFeedbackMs)
1326 _refreshing.value = false
1330 /** Invoke a slash-command by name via the server's /command endpoint. */
1331 fun runCommand(command: Command) {
1332 val conn = connection ?: return
1333 // A run is already in flight — surface feedback instead of silently dropping the
1334 // command (the picker stays openable during a run and dismisses on select).
1336 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1339 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1340 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1341 runEndedByIdle = false
1342 viewModelScope.launch {
1343 runCatchingCancellable {
1344 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
1346 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1347 _running.value = false
1353 val conn = connection ?: return
1354 // Drop any queued follow-up so the SessionIdle that follows the abort doesn't
1355 // auto-send it — the user tapped Stop to halt work, not to trigger the next turn.
1356 setQueuedFollowUp(null)
1357 if (!_aborting.compareAndSet(false, true)) return
1358 viewModelScope.launch {
1360 // Run the abort POST under NonCancellable so it survives this scope being
1361 // cancelled. The "Stop and exit" path calls abort() then navigates back
1362 // immediately, which pops this ViewModel's back-stack entry → onCleared() →
1363 // viewModelScope.cancel(); without this the in-flight abort is cancelled (often
1364 // before the request even reaches the server) and the run keeps burning tokens
1365 // server-side despite the user explicitly stopping it.
1366 runCatchingCancellable { withContext(NonCancellable) { conn.repository.abort(sessionId) } }
1368 _running.value = false
1369 // Surface an undo opportunity so an accidental Stop is recoverable.
1370 // The UI shows a "Stopped — Undo" snackbar; tapping Undo re-sends the
1371 // last prompt. Only when there's a tracked prompt to undo back to.
1372 val prompt = lastSentPrompt
1373 if (prompt != null) _stopUndoEvents.trySend(prompt)
1375 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1377 _aborting.value = false
1382 /** Re-send the most recently-sent user prompt, used by the Stop-undo snackbar so an
1383 * accidental Stop can be recovered. No-op if no prompt is tracked (e.g. the run was
1384 * started by a command, not a text prompt). Clears the tracked prompt so a second Undo
1385 * tap doesn't double-send. */
1386 fun resendLastPrompt() {
1387 val prompt = lastSentPrompt ?: return
1388 lastSentPrompt = null
1389 send(prompt, includeAttachments = false)
1392 fun addAttachment(attachment: PendingAttachment) {
1393 // Enforce the cumulative size cap across the whole staged set (the per-file size and the
1394 // count caps are checked upstream). The base64 data URLs are held in memory and
1395 // re-serialized on every add/remove and again when the request body is built, so an
1396 // unbounded set OOMs. Reject rather than add, mirroring the other attachment limits'
1397 // snackbar via errorEvents. Size is the raw (pre-base64) byte count, matching the
1398 // per-file check, and includes attachments already staged (incl. restored ones).
1399 val staged = _attachments.value.sumOf { base64DataUrlByteSize(it.part.url) }
1400 if (staged + base64DataUrlByteSize(attachment.part.url) > NetworkConfig.maxTotalAttachmentBytes) {
1401 _errorEvents.trySend(ChatError(container.string(R.string.attachment_limit)))
1404 _attachments.update { it + attachment }
1405 persistAttachments()
1408 fun removeAttachment(id: String) {
1409 _attachments.update { list -> list.filterNot { it.id == id } }
1410 persistAttachments()
1413 fun clearAttachments() {
1414 _attachments.value = emptyList()
1415 persistAttachments()
1418 /** Persist the current staged attachments so an interrupted compose survives process
1419 * death (mirrors draft persistence). Fire-and-forget on [viewModelScope]. */
1420 private fun persistAttachments() {
1421 val snapshot = _attachments.value
1422 viewModelScope.launch {
1423 runCatchingCancellable { container.attachmentDraftStore.save(sessionId, snapshot.map { it.toPersisted() }) }
1424 .onFailure { Log.w("ChatViewModel", "Failed to persist attachments", it) }
1428 /** Revert the conversation to just before [messageId], hiding everything after it. The
1429 * authoritative state also arrives via SessionUpdated; we flag it here for immediate
1431 fun revertTo(messageId: String) {
1432 val conn = connection ?: return
1433 viewModelScope.launch {
1434 runCatchingCancellable { conn.api.revert(sessionId, messageId) }
1436 _reverted.value = it.isReverted
1437 _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1438 _editing.value = false
1440 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1445 * Regenerate an assistant reply: revert the conversation to that assistant message (removing
1446 * it and everything after), then re-send the user prompt that immediately preceded it. A
1447 * one-tap "try again" mirroring ChatGPT/Claude, instead of the two-step revert + re-Send.
1449 * Captures the preceding user text *before* the revert (the messages list updates
1450 * asynchronously via SSE once the revert lands). No-op if there's no preceding user prompt
1451 * or the assistant message isn't found.
1453 fun regenerate(assistantMessageId: String) {
1454 val conn = connection ?: return
1455 val msgs = messages.value
1456 val idx = msgs.indexOfFirst { it.info.id == assistantMessageId }
1458 val precedingUserText = (idx - 1 downTo 0)
1461 .firstOrNull { it.info is UserMessage }
1463 ?.filterIsInstance<TextPart>()
1464 ?.joinToString("\n\n") { it.text }
1465 ?.takeIf { it.isNotBlank() }
1467 viewModelScope.launch {
1468 runCatchingCancellable { conn.api.revert(sessionId, assistantMessageId) }
1469 .onSuccess { send(precedingUserText, includeAttachments = false) }
1470 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1474 /** Continue a partially-generated assistant reply by sending a "continue" prompt
1475 * without reverting (unlike [regenerate], which re-runs from the preceding user
1476 * prompt and loses the partial). The partial reply stays as context so the model
1477 * can pick up where it left off. This is the standard chat-app "continue" pattern
1478 * for a reply that was aborted mid-stream. */
1479 fun continueRun(@Suppress("UNUSED_PARAMETER") assistantMessageId: String) {
1480 val conn = connection ?: return
1481 if (running.value || aborting.value) return
1482 if (!beginRun()) return
1483 // Reopen the relight gate so this run's streamed parts keep the indicator lit (and a
1484 // later SessionIdle re-closes it). Without this, runEndedByIdle stays true from the
1485 // previous run's SessionIdle and the relight guard at the event collector would suppress
1486 // every streamed part of the continuation, leaving the working indicator / Stop button /
1487 // foreground service off for the whole run.
1488 runEndedByIdle = false
1489 viewModelScope.launch {
1490 runCatchingCancellable {
1491 conn.api.sendPrompt(sessionId, text = "continue")
1493 _running.value = false
1494 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1499 /** Load a previously-sent user prompt back into the composer to edit and re-send.
1500 * Reverts the conversation to just before [messageId] (hiding it and everything after)
1501 * and prefills the draft with [text]. The next Send continues from that checkpoint,
1502 * effectively replacing the edited message; the reverted banner's Undo restores it.
1503 * Overwrites the current draft — this is an explicit edit action on that message. */
1504 fun editMessage(messageId: String, text: String) {
1505 val conn = connection ?: return
1506 _draft.value = text.take(NetworkConfig.maxDraftLengthChars)
1507 _pendingQuote.value = null
1508 // Do NOT delegate to revertTo(): revertTo's onSuccess sets _editing = false, which races
1509 // this synchronous _editing = true (the async revert completes after editMessage returns
1510 // and clobbers the banner). Inline the revert so editMessage owns the _editing lifecycle:
1511 // set it true on success, matching the documented contract ("Cleared on unrevert and on
1512 // send"). revertTo() (the manual revert button) still clears _editing itself.
1513 viewModelScope.launch {
1514 runCatchingCancellable { conn.api.revert(sessionId, messageId) }
1516 _reverted.value = it.isReverted
1517 _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1518 _editing.value = true
1520 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1524 /** Prefill the composer with [text] as a Markdown blockquote so the user can respond to a
1525 * specific message inline. Appends to any existing draft (with a blank line) rather than
1526 * overwriting, so quoting doesn't discard text already being typed. Also stashes the raw
1527 * quoted text in [pendingQuote] so the composer can show a dismissible preview card. */
1528 fun quoteReply(text: String) {
1529 val quoted = text.trim().lineSequence().joinToString("\n") { "> $it" }
1530 if (quoted.isBlank()) return
1531 val current = _draft.value
1532 val combined = if (current.isBlank()) "$quoted\n\n" else "$current\n\n$quoted\n\n"
1533 // Cap like the composer's typed/pasted/dictated input so quoting onto a near-full draft
1534 // can't push it past the limit (negative remaining counter + an over-cap prompt sent).
1535 _draft.value = combined.take(NetworkConfig.maxDraftLengthChars)
1536 _pendingQuote.value = text.trim().takeIf { it.isNotBlank() }
1539 /** Remove the pending quote-reply preview and strip the `>`-prefixed blockquote lines from
1540 * the draft so canceling the quote card actually removes the quoted text, not just the chip. */
1541 fun cancelQuoteReply() {
1542 val removed = _pendingQuote.value ?: return
1543 _pendingQuote.value = null
1544 val quotedBlock = removed.trim().lineSequence().joinToString("\n") { "> $it" }
1545 _draft.value = _draft.value
1546 .replace("\n\n$quotedBlock\n\n", "\n\n", ignoreCase = false)
1547 .replace("$quotedBlock\n\n", "", ignoreCase = false)
1548 .replace("\n\n$quotedBlock", "", ignoreCase = false)
1549 .replace(quotedBlock, "", ignoreCase = false)
1553 /** Fork the conversation by creating a brand-new session seeded with [text] as its first
1554 * prompt (using the currently-selected model/agent). Emits the new session id via
1555 * [branchEvents] so the UI can navigate to it. There's no server-side "branch" endpoint,
1556 * so this starts a fresh session from the chosen prompt rather than copying history. */
1557 fun branchFrom(text: String) {
1558 val conn = connection ?: return
1559 val prompt = text.trim()
1560 if (prompt.isEmpty()) return
1561 viewModelScope.launch {
1562 runCatchingCancellable {
1563 val session = conn.repository.createSession(title = null)
1564 conn.repository.sendPrompt(
1567 model = _selectedModel.value?.ref,
1568 agent = _selectedAgent.value,
1572 .onSuccess { newId -> _branchEvents.trySend(newId) }
1573 .onFailure { _errorEvents.trySend(ChatError(container.string(R.string.branch_failed))) }
1577 /** Undo the active revert checkpoint, restoring the hidden messages. */
1579 val conn = connection ?: return
1580 viewModelScope.launch {
1581 runCatchingCancellable { conn.api.unrevert(sessionId) }
1583 _reverted.value = it.isReverted
1584 _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1585 _editing.value = false
1587 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1591 /** Create (or fetch the existing) public share link. The URL is exposed via [shareUrl]
1592 * and also emitted once via [shareLinkEvents] so the UI can copy it to the clipboard. */
1593 fun shareSession() {
1594 val conn = connection ?: return
1595 viewModelScope.launch {
1596 runCatchingCancellable { conn.api.shareSession(sessionId) }
1597 .onSuccess { session ->
1598 val url = session.share?.url?.takeIf { it.isNotBlank() }
1599 _shareUrl.value = url
1600 if (url != null) _shareLinkEvents.trySend(url)
1602 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1606 /** Revoke the session's public share link. */
1607 fun unshareSession() {
1608 val conn = connection ?: return
1609 viewModelScope.launch {
1610 runCatchingCancellable { conn.api.unshareSession(sessionId) }
1611 .onSuccess { _shareUrl.value = it.share?.url?.takeIf { it.isNotBlank() } }
1612 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1616 /** Compact the conversation via the summarize endpoint; the summary streams back via SSE.
1617 * Uses the currently-selected model (required by the endpoint). */
1619 val conn = connection ?: return
1620 val model = _selectedModel.value?.ref ?: run {
1621 _errorEvents.trySend(ChatError(container.string(R.string.needs_model)))
1625 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1628 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1629 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1630 runEndedByIdle = false
1631 viewModelScope.launch {
1632 runCatchingCancellable { conn.api.summarize(sessionId, model) }
1634 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1635 _running.value = false
1640 /** Analyze the project and (re)generate its AGENTS.md; the run streams back via SSE. */
1642 val conn = connection ?: return
1644 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1647 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1648 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1649 runEndedByIdle = false
1650 viewModelScope.launch {
1651 runCatchingCancellable { conn.api.initSession(sessionId) }
1653 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1654 _running.value = false
1659 /** Run a one-off shell [command] in the session's worktree; output streams back via SSE.
1660 * The server requires an agent to scope the run — use the selected one, else the primary. */
1661 fun runShell(command: String) {
1662 val conn = connection ?: return
1663 val cmd = command.trim()
1664 if (cmd.isEmpty()) return
1665 val agent = _selectedAgent.value
1666 ?: _agents.value.firstOrNull { it.isPrimary }?.name
1667 ?: _agents.value.firstOrNull()?.name
1668 ?: DEFAULT_SHELL_AGENT
1670 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1673 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1674 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1675 runEndedByIdle = false
1676 viewModelScope.launch {
1677 runCatchingCancellable { conn.api.shell(sessionId, cmd, agent, _selectedModel.value?.ref) }
1679 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1680 _running.value = false
1685 /** Rename the current session via PATCH /session/:id. On success updates [sessionTitle]
1686 * so the top bar reflects the new name immediately. A failure surfaces as a snackbar;
1687 * the caller keeps the dialog open so the user can retry without retyping. */
1688 fun renameSession(newTitle: String) {
1689 val conn = connection ?: return
1690 val title = newTitle.trim()
1691 if (title.isEmpty() || title == _sessionTitle.value) return
1692 viewModelScope.launch {
1693 runCatchingCancellable { conn.api.updateSession(sessionId, title) }
1694 .onSuccess { _sessionTitle.value = it.displayTitle }
1695 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1699 /** Delete the current session via DELETE /session/:id. Schedules a deferred delete with an
1700 * undo window and signals [deleteUndoEvents] so the ChatScreen shows an Undo snackbar
1701 * *over the conversation the user is viewing* — they can cancel without first being
1702 * navigated back to the session list. The actual REST delete runs after
1703 * [NetworkConfig.undoDeleteDelayMs]; cancel via [cancelSessionDelete]. If the window
1704 * expires, the scheduled delete's onDeleted sets [sessionDeleted] so the UI navigates away.
1705 * A failure surfaces as a snackbar. */
1706 fun deleteSession() {
1707 val conn = connection ?: return
1708 container.scheduleSessionDelete(
1710 delayMs = NetworkConfig.undoDeleteDelayMs,
1711 // Navigate away only once the deferred delete actually commits — so an in-place Undo
1712 // before the window expires keeps the user on this conversation.
1713 onDeleted = { _sessionDeleted.value = true },
1714 onError = { _errorEvents.trySend(ChatError(container.friendlyError(it))) },
1716 _deleteUndoEvents.tryEmit(Unit)
1719 /** Cancel a delete scheduled by [deleteSession] (the in-chat Undo action). Returns true if the
1720 * delete was still pending (Undo succeeded). */
1721 fun cancelSessionDelete(): Boolean = container.cancelSessionDelete(sessionId)
1723 /** Reconnect to the most recently used server profile (used when the connection is gone). */
1725 if (container.activeConnection.value != null) return
1726 // Guard against concurrent reconnect calls: the user can tap the button
1727 // multiple times while _reconnecting is true, which would launch parallel
1728 // connect() coroutines that race to set the active connection, leaking the
1729 // intermediate connections.
1730 if (!_reconnecting.compareAndSet(false, true)) return
1731 viewModelScope.launch {
1733 val recent = runCatchingCancellable { container.profileStore.profiles.first() }
1734 .getOrDefault(emptyList())
1736 if (recent == null) {
1737 _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
1740 runCatchingCancellable {
1741 val conn = container.connect(recent)
1744 container.disconnect()
1745 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1748 _reconnecting.value = false
1753 fun respondPermission(permission: Permission, response: PermissionResponse) {
1754 val conn = connection ?: return
1755 // Record a session-scoped grant so subsequent matching requests are auto-answered.
1756 // SESSION maps to ONCE on the wire (the server has no session-scope concept); the
1757 // client-side set is what gives it the "for this session" semantics.
1758 if (response == PermissionResponse.SESSION) {
1759 val type = permission.type.orEmpty()
1760 val pattern = permission.patternText.orEmpty()
1761 if (type.isNotEmpty()) sessionAllowed.add(type to pattern)
1763 // Dismiss optimistically (revealing any queued request); permission.replied will confirm.
1764 // If the call fails we re-surface this request so the user can retry instead of being stuck
1765 // with a dismissed dialog and a tool run that's still paused server-side.
1766 resolvePermission(permission.id)
1767 viewModelScope.launch {
1768 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
1770 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1771 // Revoke the session-scoped grant on failure: enqueuePermission() auto-responds
1772 // when (type, pattern) is in sessionAllowed, so leaving the entry in would loop
1773 // respondPermission -> fail -> enqueuePermission -> respondPermission ... forever,
1774 // spamming the dead server and the error snackbar without ever re-prompting the
1775 // user. Removing it makes enqueuePermission fall through to re-queueing the
1776 // request for the dialog so the user can retry.
1777 if (response == PermissionResponse.SESSION) {
1778 val type = permission.type.orEmpty()
1779 val pattern = permission.patternText.orEmpty()
1780 if (type.isNotEmpty()) sessionAllowed.remove(type to pattern)
1782 enqueuePermission(permission)
1787 private companion object {
1788 /** Fallback agent for the shell endpoint when none is selected and no agent catalog
1789 * loaded. opencode's default primary agent is "build". */
1790 const val DEFAULT_SHELL_AGENT = "build"
1793 override fun onCleared() {
1795 // Flush any pending debounced draft so it survives navigation. Uses an
1796 // asynchronous apply() (not a synchronous commit) so the main thread isn't
1797 // blocked on disk I/O — Android's SharedPreferences framework guarantees
1798 // pending apply() writes are flushed before the process exits.
1799 // Flush the pending draft — including an empty one. Persistence is otherwise debounced,
1800 // so if the user cleared the input and navigated back within the debounce window, the
1801 // debounce coroutine was cancelled with viewModelScope and the prefs still hold the
1802 // previous non-empty draft. flushDraft() removes the key when the text is blank, so
1803 // flushing commits the clear instead of resurrecting the deleted text.
1805 // BUT respect the send-in-flight guard: send() optimistically clears _draft to "" WITHOUT
1806 // persisting the clear so a failed send can restore the text. If the user taps Send then
1807 // immediately navigates back (cancelling the in-flight send), flushing the blank _draft here
1808 // would remove the persisted key and erase the pre-send draft with no recovery. Skip the
1809 // flush while a send is in flight so the pre-send draft stays persisted (recoverable).
1810 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