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.Permission
11import soy.iko.opencode.data.model.PermissionReplied
12import soy.iko.opencode.data.model.PermissionResponse
13import soy.iko.opencode.data.model.PermissionUpdated
14import soy.iko.opencode.data.model.SessionDeleted
15import soy.iko.opencode.data.model.SessionUpdated
16import soy.iko.opencode.data.model.TextPart
17import soy.iko.opencode.data.model.TodoItem
18import soy.iko.opencode.data.model.currentTodoPlan
19import soy.iko.opencode.data.model.defaultOption
20import soy.iko.opencode.data.model.toOptions
21import soy.iko.opencode.data.network.EventStreamClient
22import soy.iko.opencode.data.network.NetworkConfig
23import soy.iko.opencode.data.repo.OutboxMessage
24import soy.iko.opencode.data.repo.SessionRepository
25import soy.iko.opencode.di.AppContainer
26import soy.iko.opencode.R
27import soy.iko.opencode.util.runCatchingCancellable
28import soy.iko.opencode.util.safeExceptionSummary
29import android.util.Log
30import kotlinx.coroutines.ExperimentalCoroutinesApi
31import kotlinx.coroutines.channels.BufferOverflow
32import kotlinx.coroutines.channels.Channel
33import kotlinx.coroutines.flow.Flow
34import kotlinx.coroutines.flow.MutableStateFlow
35import kotlinx.coroutines.flow.SharingStarted
36import kotlinx.coroutines.flow.StateFlow
37import kotlinx.coroutines.flow.asStateFlow
38import kotlinx.coroutines.flow.receiveAsFlow
39import kotlinx.coroutines.CancellationException
40import kotlinx.coroutines.flow.collectLatest
41import kotlinx.coroutines.flow.debounce
42import kotlinx.coroutines.flow.distinctUntilChanged
43import kotlinx.coroutines.flow.drop
44import kotlinx.coroutines.flow.first
45import kotlinx.coroutines.flow.filterIsInstance
46import kotlinx.coroutines.flow.flatMapLatest
47import kotlinx.coroutines.flow.flowOf
48import kotlinx.coroutines.flow.flowOn
49import kotlinx.coroutines.flow.map
50import kotlinx.coroutines.flow.merge
51import kotlinx.coroutines.flow.onEach
52import kotlinx.coroutines.flow.retryWhen
53import kotlinx.coroutines.flow.stateIn
54import kotlinx.coroutines.flow.update
55import kotlinx.coroutines.Dispatchers
56import kotlinx.coroutines.Job
57import kotlinx.coroutines.NonCancellable
58import kotlinx.coroutines.delay
59import kotlinx.coroutines.isActive
60import kotlinx.coroutines.currentCoroutineContext
61import kotlinx.coroutines.launch
62import kotlinx.coroutines.withContext
65 * A user-facing error to surface as a snackbar. [retryable] is true only for a failed
66 * message *send* (whose draft is held in [ChatViewModel.failedDraft]); the snackbar attaches
67 * a Retry action solely for those so an unrelated error (e.g. a message-load failure) can't
68 * inherit a Retry that silently re-submits the last prompt.
70data class ChatError(val message: String, val retryable: Boolean = false)
73 * An attachment staged to send with the next prompt. [previewModel] is a Coil-loadable model
74 * for the thumbnail (the source content Uri, as a string) for images, or null for non-image
75 * files (rendered with a generic icon). [part] is the wire form (a base64 data URL) sent to
78@androidx.compose.runtime.Immutable
79data class PendingAttachment(
83 val previewModel: Any?,
84 val part: FilePromptPart,
86 val isImage: Boolean get() = mime.startsWith("image/")
89/** Reduce a staged attachment to its persistable form (the self-contained data URL). */
90private fun PendingAttachment.toPersisted() =
91 soy.iko.opencode.data.repo.PersistedAttachment(id, name, mime, part.url, part.filename)
93/** Rebuild a staged attachment from persistence. The base64 data URL doubles as the Coil
94 * preview model for images (the original content Uri didn't survive the process restart). */
95private fun soy.iko.opencode.data.repo.PersistedAttachment.toPending() = PendingAttachment(
99 previewModel = if (mime.startsWith("image/")) url else null,
100 part = FilePromptPart(mime = mime, url = url, filename = filename),
103// Large by design: this VM owns the whole chat surface (streaming, drafts, attachments,
104// catalogs, permissions, revert/edit, sharing, summarize/init/shell, TTS wiring). It's
105// already grandfathered for TooManyFunctions in the detekt baseline; the class-size rule is
106// suppressed for the same reason. A future split into sub-controllers is tracked separately.
107@Suppress("LargeClass")
108@OptIn(ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
110 private val container: AppContainer,
111 private val sessionId: String,
114 private val connection get() = container.activeConnection.value
116 val connected: Boolean get() = connection != null
118 private val _loading = MutableStateFlow(true)
119 val loading: StateFlow<Boolean> = _loading.asStateFlow()
121 /** Set when the messages flow failed to load (and the list is empty), so the UI
122 * can render a distinct error state instead of masquerading as an empty
123 * conversation. Cleared on a successful non-empty emission. */
124 private val _loadError = MutableStateFlow(false)
125 val loadError: StateFlow<Boolean> = _loadError.asStateFlow()
127 /** Tracks whether the messages flow has ever emitted a non-empty list, so
128 * [messages]' retryWhen can decide whether a re-fetch failure should set
129 * [loadError] (nothing shown yet → error screen) or just emit a snackbar
130 * (already showing messages → don't replace the conversation with an error). */
131 private var hasShownMessages = false
133 /** True once we've surfaced a snackbar for the current message-load failure streak.
134 * Reset on any successful emission so a persistent failure doesn't spam a fresh
135 * snackbar every retry cycle (every few seconds). */
136 private var loadErrorSnackbarShown = false
138 /** Separate from [loading]: tracks the manual reconnect() flow so its spinner
139 * doesn't conflict with the messages flow's loading state. */
140 private val _reconnecting = MutableStateFlow(false)
141 val reconnecting: StateFlow<Boolean> = _reconnecting.asStateFlow()
143 /** When true, the debounced draft collector skips persisting an empty draft so
144 * [send]'s deliberate non-persistence of the cleared draft isn't undone by the
145 * debounce timer firing before the send completes. */
146 private val suppressDraftPersist = java.util.concurrent.atomic.AtomicBoolean(false)
148 /** The draft value this VM last persisted, so the drafts observer can tell its own
149 * (debounced, possibly stale) echo apart from a genuine external write. Without this
150 * a debounced persist of an older value could echo back and overwrite newer text the
151 * user has since typed. */
152 @Volatile private var lastPersistedDraft: String? = null
154 /** Per-catalog reload triggers: incrementing one causes [observeCatalog]'s
155 * collectLatest to cancel any in-flight fetch and start a fresh one, so a
156 * manual reload supersedes a stale observeCatalog fetch. */
157 private val _modelsReload = MutableStateFlow(0)
158 private val _agentsReload = MutableStateFlow(0)
159 private val _commandsReload = MutableStateFlow(0)
161 val messages: StateFlow<List<MessageWithParts>> =
162 container.activeConnection
163 .flatMapLatest { conn ->
164 conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
167 _loading.value = false
168 // A value flowed through: the stream recovered, so allow the next distinct
169 // failure to surface a fresh snackbar again.
170 loadErrorSnackbarShown = false
171 // Clear the error on ANY successful emission, including an empty list. An empty
172 // session that hit one transient error would otherwise stay latched on the
173 // "Failed to load / Retry" screen forever, because the later successful EMPTY
174 // emission couldn't clear the flag. hasShownMessages stays gated on non-empty
175 // (an empty session has genuinely shown nothing yet).
176 _loadError.value = false
177 if (it.isNotEmpty()) {
178 hasShownMessages = true
181 .retryWhen { cause, attempt ->
182 _loading.value = false
183 // Only surface the persistent error state when there's nothing to
184 // show — if we already have messages, a transient re-fetch failure
185 // is better surfaced as a snackbar (via errorEvents) than by
186 // replacing the visible conversation with an error screen.
187 if (!hasShownMessages) _loadError.value = true
188 // Surface the snackbar only once per failure streak — retryWhen loops on a
189 // backoff, so emitting here unconditionally would spam a new snackbar every
190 // retry on a persistent load failure.
191 if (!loadErrorSnackbarShown) {
192 _errorEvents.trySend(ChatError(container.friendlyError(cause)))
193 loadErrorSnackbarShown = true
195 // Exponential backoff capped at retryMaxDelayMs so a persistently failing
196 // server is retried with growing delays (retryInitialDelayMs, then doubling)
197 // instead of hammered at a fixed interval forever. Clamp the shift so a long
198 // failure streak can't overflow the Long delay.
199 val backoffMs = (NetworkConfig.retryInitialDelayMs shl attempt.coerceAtMost(16L).toInt())
200 .coerceAtMost(NetworkConfig.retryMaxDelayMs)
205 scope = viewModelScope,
206 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
207 initialValue = emptyList(),
210 /** Whether the conversation has any messages. Derived separately so the top bar
211 * (share button enabled state) can observe this cheap boolean instead of the
212 * full messages list, avoiding per-token recomposition of the app bar during
214 val hasMessages: StateFlow<Boolean> = messages
215 .map { it.isNotEmpty() }
217 scope = viewModelScope,
218 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
219 initialValue = false,
222 /** The agent's current task plan (the latest `todowrite`), surfaced so the chat can pin a
223 * live progress checklist above the composer. distinctUntilChanged so a per-token messages
224 * emission that doesn't change the plan doesn't recompose the bar. */
225 val todoPlan: StateFlow<List<TodoItem>> = messages
226 .map { currentTodoPlan(it) }
227 // Run currentTodoPlan off the main thread: it's an O(messages×parts) scan (worst case
228 // when the conversation has no todowrite) and runs on every conflated messages emission.
229 // distinctUntilChanged is downstream so it dedupes the result but doesn't prevent the
230 // scan — without flowOn this scans on Main.immediate per streamed token → jank.
231 .flowOn(Dispatchers.Default)
232 .distinctUntilChanged()
234 scope = viewModelScope,
235 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
236 initialValue = emptyList(),
239 private val _running = MutableStateFlow(false)
240 val running: StateFlow<Boolean> = _running.asStateFlow()
242 /** True once the current run ended via SessionIdle/SessionError, until the next run starts
243 * (a send) or an SSE reconnect. Gates the run-indicator re-light below: isRunActivity
244 * returns true for ANY message.part.updated, so without this a trailing/replayed part
245 * arriving after a legitimate SessionIdle would re-light the "working" indicator with no
246 * further idle to clear it — a stuck spinner + foreground service. Cleared on reconnect so
247 * genuine reconnect-recovery re-lighting (its actual purpose) still works. */
248 @Volatile private var runEndedByIdle = false
250 /** The in-flight REST title resolution for the current connection. Launched from — and
251 * cancelled by — the per-connection reset block so it can't observe or restore a previous
252 * server's stale title. */
253 private var sessionTitleJob: Job? = null
255 /** True while an abort REST call is in flight, so the Stop button can show a
256 * spinner and prevent double-taps from firing a second abort. */
257 private val _aborting = MutableStateFlow(false)
258 val aborting: StateFlow<Boolean> = _aborting.asStateFlow()
260 /** A follow-up the user typed while a run was active. send() queues it here and
261 * auto-sends once the run completes (SessionIdle), so the user isn't blocked with
262 * no way to send and no indication why the Send button is gone. */
263 private val _queuedFollowUp = MutableStateFlow<String?>(null)
264 val queuedFollowUp: StateFlow<String?> = _queuedFollowUp.asStateFlow()
266 /** One-shot error events surfaced as snackbars. A Channel (not SharedFlow) so an event
267 * emitted before the UI subscribes is buffered and still delivered — a SharedFlow with
268 * replay=0 would drop it (e.g. a VM-init catalog fetch failing before first
269 * composition). Each event is delivered exactly once and not replayed to a
270 * re-subscribing collector, so rotation doesn't re-show a stale snackbar. */
271 private val _errorEvents = Channel<ChatError>(
272 capacity = NetworkConfig.snackbarEventBufferCapacity,
273 onBufferOverflow = BufferOverflow.DROP_OLDEST,
275 val errorEvents: Flow<ChatError> = _errorEvents.receiveAsFlow()
277 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
278 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
280 private val _modelsLoading = MutableStateFlow(true)
281 val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
283 private val _modelsError = MutableStateFlow(false)
284 val modelsError: StateFlow<Boolean> = _modelsError.asStateFlow()
286 private val _selectedModel = MutableStateFlow<ModelOption?>(null)
287 val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
289 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
290 container.activeConnection
291 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
293 scope = viewModelScope,
294 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
295 initialValue = EventStreamClient.ConnectionState.Disconnected,
298 private val _pendingPermission = MutableStateFlow<Permission?>(null)
299 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
301 // Unanswered permission requests keyed by id, insertion-ordered. The dialog shows the most
302 // recent; when one is answered/replied — or a response fails — we fall back to the next so a
303 // request that arrived while another was in flight isn't silently dropped (its tool run would
304 // otherwise stay paused server-side with no UI affordance). Only touched on the VM's main
305 // dispatcher (event collector + viewModelScope launches), so no extra synchronization is needed.
306 private val pendingPermissions = LinkedHashMap<String, Permission>()
308 private fun enqueuePermission(permission: Permission) {
309 pendingPermissions[permission.id] = permission
310 _pendingPermission.value = pendingPermissions.values.lastOrNull()
313 private fun resolvePermission(id: String) {
314 pendingPermissions.remove(id)
315 _pendingPermission.value = pendingPermissions.values.lastOrNull()
318 private fun clearPermissions() {
319 pendingPermissions.clear()
320 _pendingPermission.value = null
323 private val _agents = MutableStateFlow<List<Agent>>(emptyList())
324 val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
326 private val _agentsLoading = MutableStateFlow(true)
327 val agentsLoading: StateFlow<Boolean> = _agentsLoading.asStateFlow()
329 private val _agentsError = MutableStateFlow(false)
330 val agentsError: StateFlow<Boolean> = _agentsError.asStateFlow()
332 private val _selectedAgent = MutableStateFlow<String?>(null)
333 val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
335 private val _commands = MutableStateFlow<List<Command>>(emptyList())
336 val commands: StateFlow<List<Command>> = _commands.asStateFlow()
338 private val _commandsLoading = MutableStateFlow(true)
339 val commandsLoading: StateFlow<Boolean> = _commandsLoading.asStateFlow()
341 private val _commandsError = MutableStateFlow(false)
342 val commandsError: StateFlow<Boolean> = _commandsError.asStateFlow()
344 private val _sessionTitle = MutableStateFlow<String?>(null)
345 val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
347 /** Set when the current session is deleted via SSE, so the UI can navigate away. */
348 private val _sessionDeleted = MutableStateFlow(false)
349 val sessionDeleted: StateFlow<Boolean> = _sessionDeleted.asStateFlow()
351 /** The text of the last send that failed, surfaced so the UI can offer a retry. */
352 private val _failedDraft = MutableStateFlow<String?>(null)
353 val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
355 /** The idempotency key of the last failed online send, stashed alongside [_failedDraft] so
356 * [retryFailed] re-submits with the SAME key. A retry after a lost response is then
357 * deduplicated server-side instead of starting a duplicate agent run. */
358 @Volatile private var failedIdempotencyKey: String? = null
360 /** Per-session draft, persisted so it survives navigation/process death. */
361 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
362 val draft: StateFlow<String> = _draft.asStateFlow()
364 /** Messages composed for this session while offline/disconnected, queued in the outbox and
365 * awaiting an automatic flush on reconnect. Surfaced so the composer can show a "queued"
366 * chip with a discard/send-now affordance. */
367 val outbox: StateFlow<List<OutboxMessage>> =
368 container.outboxStore.messages
369 .map { list -> list.filter { it.sessionId == sessionId } }
371 scope = viewModelScope,
372 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
373 initialValue = emptyList(),
376 /** Attachments staged for the next prompt (images/files). Persisted per-session (as their
377 * self-contained base64 data URLs) via [AttachmentDraftStore] so an interrupted compose
378 * survives process death — the source content Uris wouldn't survive, but the data URLs
379 * do. Cleared on a successful send. */
380 private val _attachments = MutableStateFlow<List<PendingAttachment>>(emptyList())
381 val attachments: StateFlow<List<PendingAttachment>> = _attachments.asStateFlow()
383 /** True while a revert checkpoint is active for this session (messages after it are hidden
384 * server-side). Drives the "reverted" banner with its Undo. */
385 private val _reverted = MutableStateFlow(false)
386 val reverted: StateFlow<Boolean> = _reverted.asStateFlow()
388 /** The active public share URL for this session, or null when not shared. */
389 private val _shareUrl = MutableStateFlow<String?>(null)
390 val shareUrl: StateFlow<String?> = _shareUrl.asStateFlow()
392 /** One-shot events carrying a freshly-created share URL so the UI can copy/share it. */
393 private val _shareLinkEvents = Channel<String>(
394 capacity = NetworkConfig.snackbarEventBufferCapacity,
395 onBufferOverflow = BufferOverflow.DROP_OLDEST,
397 val shareLinkEvents: Flow<String> = _shareLinkEvents.receiveAsFlow()
399 /** One-shot events carrying the id of a session freshly branched from a message, so the
400 * UI can navigate to it. */
401 private val _branchEvents = Channel<String>(
402 capacity = NetworkConfig.snackbarEventBufferCapacity,
403 onBufferOverflow = BufferOverflow.DROP_OLDEST,
405 val branchEvents: Flow<String> = _branchEvents.receiveAsFlow()
408 // DraftStore loads SharedPreferences asynchronously; on a cold start the
409 // synchronous get() above returns "" until the background load completes.
410 // Observe the ready signal and re-seed the draft so it appears in the UI.
411 viewModelScope.launch {
412 container.draftStore.ready.collect { ready ->
413 if (!ready) return@collect
414 if (_draft.value.isEmpty()) {
415 _draft.value = container.draftStore.get(sessionId)
417 // Recover a follow-up queued before process death (its in-memory StateFlow
418 // is gone). If the input is now free, drop it back there for review/send and
419 // consume the stored copy; otherwise re-queue it so it auto-sends when the
420 // current run next idles. Guarded on == null so it never clobbers a
421 // follow-up the user queued after opening this session.
422 val savedFollowUp = container.draftStore.getFollowUp(sessionId)
423 if (savedFollowUp.isNotEmpty() && _queuedFollowUp.value == null) {
424 if (_draft.value.isEmpty()) {
425 _draft.value = savedFollowUp
426 container.draftStore.flushFollowUp(sessionId, "")
428 _queuedFollowUp.value = savedFollowUp
433 // Observe the drafts store for external mutations to this session's draft
434 // (e.g. a share injected via draftStore.set in two-pane mode, or setImmediate
435 // before navigation in single-pane mode). Without this, a share injected into
436 // an already-open session never appears in the input field — the init-time
437 // read and the ready-only re-seed miss it when the draft is non-empty.
438 // The VM's own debounced persist writes the same value _draft already holds,
439 // so those updates are no-ops here (storeValue == _draft.value). The
440 // suppressDraftPersist guard prevents a stale store value from clobbering
441 // the deliberate draft clear during a send (the store still holds the
442 // pre-send draft until the send resolves).
443 viewModelScope.launch {
444 container.draftStore.drafts.collect { drafts ->
445 // Ignore emissions until the initial disk load completes: the load itself
446 // emits the persisted snapshot, and applying it here would clobber text the
447 // user typed during the async load (the `ready` observer above seeds an
448 // empty draft). After ready, emissions are external writes.
449 if (!container.draftStore.ready.value) return@collect
450 val storeValue = drafts[sessionId].orEmpty()
451 // Ignore this VM's own (debounced, possibly stale) persistence echo — only a
452 // genuine external write (e.g. a two-pane share injection) should overwrite
454 if (storeValue == lastPersistedDraft) return@collect
455 if (storeValue != _draft.value && !suppressDraftPersist.get()) {
456 _draft.value = storeValue
463 // Restore attachments staged before process death. They were persisted as
464 // self-contained data URLs, so they re-send and (for images) re-preview intact.
465 // Guarded on empty so it never clobbers attachments the user staged after opening.
466 viewModelScope.launch {
467 val restored = runCatchingCancellable { container.attachmentDraftStore.load(sessionId) }
468 .getOrDefault(emptyList())
469 if (restored.isNotEmpty() && _attachments.value.isEmpty()) {
470 _attachments.value = restored.map { it.toPending() }
475 fun selectModel(option: ModelOption) { _selectedModel.value = option }
477 fun selectAgent(name: String?) { _selectedAgent.value = name }
480 val conn = connection ?: return
481 viewModelScope.launch {
482 conn.api.invalidateProvidersCache()
483 _modelsReload.value++
488 val conn = connection ?: return
489 viewModelScope.launch {
490 conn.api.invalidateAgentsCache()
491 _agentsReload.value++
495 fun reloadCommands() {
496 val conn = connection ?: return
497 viewModelScope.launch {
498 conn.api.invalidateCommandsCache()
499 _commandsReload.value++
504 * Shared helper for init-block catalog observers: re-fetches whenever the active
505 * connection or the catalog's reload trigger changes, invoking [onNull] to clear
506 * state on null so stale data from the old server doesn't persist. The
507 * [reloadTrigger] is merged with [activeConnection] so a manual reload (via
508 * reloadModels/reloadAgents/reloadCommands) cancels any in-flight observe fetch
509 * via collectLatest, preventing a stale observe result from overwriting a fresh
510 * reload result. Failure is non-fatal — the error flag is surfaced to the UI.
512 private fun <T> observeCatalog(
514 loading: MutableStateFlow<Boolean>,
515 error: MutableStateFlow<Boolean>,
516 fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
517 onSuccess: (T) -> Unit,
519 reloadTrigger: StateFlow<Int>,
521 viewModelScope.launch {
522 merge(container.activeConnection, reloadTrigger).collectLatest { _ ->
523 val conn = container.activeConnection.value
524 if (conn == null) { onNull(); loading.value = false; error.value = false; return@collectLatest }
527 runCatchingCancellable { fetch(conn.api) }
528 // fetch() does HTTP via withRetry, so the failure can be a
529 // ClientRequestException whose message embeds the full request URL
530 // (may contain auth/paths). Log only a scrubbed summary.
531 .onFailure { Log.w("ChatViewModel", "Failed to load $tag: ${safeExceptionSummary(it)}"); error.value = true }
532 .getOrNull()?.let(onSuccess)
533 loading.value = false
538 fun updateDraft(text: String) {
543 // Debounce draft persistence so we don't write to disk on every keystroke.
544 // Skip persisting empty drafts that were set by send() (suppressed via
545 // suppressDraftPersist) — send() clears the in-memory draft immediately for
546 // UI feedback but deliberately doesn't persist the clear until the send
547 // succeeds, so a failed send can restore the draft.
548 viewModelScope.launch {
549 _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
550 if (text.isBlank() && suppressDraftPersist.get()) return@collect
551 lastPersistedDraft = text
552 runCatchingCancellable { container.draftStore.set(sessionId, text) }
553 .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
556 // Reset per-connection state when the active server changes so stale spinners,
557 // permission dialogs, errors, and agent selections from the old server don't
558 // persist into the new one.
559 viewModelScope.launch {
560 container.activeConnection.collectLatest { conn ->
562 // The connection dropped (e.g. disconnect() mid-run). No SSE stream will
563 // arrive to deliver SessionIdle/SessionError, so reset the run state here —
564 // otherwise the working spinner sticks on, ChatScreen keeps keepScreenOn +
565 // the RunForegroundService alive, and the Stop button no-ops (abort() early-
566 // returns with no connection) until a later reconnect happens to clear it.
567 _running.value = false
568 _aborting.value = false
570 sessionTitleJob?.cancel()
573 _running.value = false
574 // Allow the re-light below to recover a still-running run after this (re)connect;
575 // a run that actually finished during the outage emits no live parts, so this
576 // won't spuriously re-light.
577 runEndedByIdle = false
579 _failedDraft.value = null
580 // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
581 // user intent that is now persisted; wiping it on every (re)connect — which
582 // this collector does, including transient SSE drops and the cold-start
583 // restore path — would drop a legitimately queued/recovered follow-up.
584 _selectedAgent.value = null
585 _sessionTitle.value = null
586 // Resolve the app-bar title via REST for THIS connection. Launched from the same
587 // block that just nulled _sessionTitle (and cancelled the previous fetch) so it can
588 // never observe or restore the previous server's stale title — the old separate
589 // collector could, racing that reset. The assignment is guarded on a still-null
590 // title so a fresher SSE SessionUpdated (from the event collector below) wins.
591 sessionTitleJob?.cancel()
592 sessionTitleJob = viewModelScope.launch {
593 runCatchingCancellable { conn.repository.listSessions() }
595 ?.firstOrNull { it.id == sessionId }
597 if (_sessionTitle.value == null) {
598 _sessionTitle.value = session.displayTitle
599 _reverted.value = session.isReverted
600 _shareUrl.value = session.share?.url?.takeIf { it.isNotBlank() }
605 // Loop the inner collect so a transient non-cancellation exception (a
606 // malformed SSE event decoding error, an unexpected channel close)
607 // doesn't terminate the event collector for this connection. Without
608 // the loop, the catch returns the collectLatest lambda normally, so
609 // collectLatest considers this connection's block complete and never
610 // re-runs it — the chat would go permanently silent (no streaming,
611 // no permission prompts, no title updates) until a server switch.
612 while (currentCoroutineContext().isActive) {
614 conn.events.events.collect { event ->
615 if (SessionRepository.isIdle(event, sessionId)) {
616 _running.value = false
617 runEndedByIdle = true
618 // Auto-send a queued follow-up once the previous run finishes,
619 // so the user's drafted-while-running message isn't lost.
620 val queued = _queuedFollowUp.value
621 if (queued != null) {
622 setQueuedFollowUp(null)
623 send(queued, includeAttachments = false)
626 if (SessionRepository.isError(event, sessionId)) {
627 _running.value = false
628 runEndedByIdle = true
629 // The run errored, so the queued follow-up won't auto-send.
630 // Restore it to the input (if free) so the user's typed text
631 // isn't silently lost; otherwise just clear the queue.
632 val queued = _queuedFollowUp.value
633 if (!queued.isNullOrEmpty() && _draft.value.isEmpty()) {
634 _draft.value = queued
636 setQueuedFollowUp(null)
637 _errorEvents.trySend(ChatError(container.string(R.string.error_agent_reported)))
639 // Restore the run indicator if a live streaming event arrives while it's
640 // cleared — e.g. an SSE reconnect mid-run reset _running to false, but the
641 // agent is still working. Don't revive it once the user has hit Stop.
642 val relightRunIndicator = SessionRepository.isRunActivity(event, sessionId) &&
643 !_aborting.value && !_running.value && !runEndedByIdle
644 if (relightRunIndicator) {
645 _running.value = true
648 is PermissionUpdated ->
649 if (event.properties.sessionID == sessionId) enqueuePermission(event.properties)
650 is PermissionReplied ->
651 if (event.properties.sessionID == sessionId) event.properties.permissionID?.let { resolvePermission(it) }
653 if (event.properties.info.id == sessionId) {
654 val info = event.properties.info
655 _sessionTitle.value = info.displayTitle
656 _reverted.value = info.isReverted
657 _shareUrl.value = info.share?.url?.takeIf { it.isNotBlank() }
660 if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
661 _sessionDeleted.value = true
666 } catch (e: CancellationException) {
668 } catch (e: Exception) {
669 Log.w("ChatViewModel", "SSE event collector error, restarting", e)
672 } catch (e: CancellationException) {
674 } catch (e: Exception) {
675 Log.w("ChatViewModel", "SSE event collector loop error", e)
679 // Load the model catalog; preselect the server default. Failure is non-fatal —
680 // sending with no model just uses the server's default agent/model.
682 tag = "model catalog",
683 loading = _modelsLoading,
684 error = _modelsError,
685 fetch = { it.providers() },
686 onSuccess = { resp ->
687 val options = resp.toOptions()
688 _models.value = options
689 // Only (re)apply the server default when there's no valid current
690 // selection. On re-fetch/reconnect/Retry the user's chosen model must
691 // survive — keep it as long as it's still present in the refreshed list.
692 val current = _selectedModel.value
693 if (current == null || options.none { it.ref == current.ref }) {
694 _selectedModel.value = resp.defaultOption(options)
697 onNull = { _models.value = emptyList(); _selectedModel.value = null },
698 reloadTrigger = _modelsReload,
700 // Load the agent catalog (non-fatal).
702 tag = "agent catalog",
703 loading = _agentsLoading,
704 error = _agentsError,
705 fetch = { it.agents() },
706 onSuccess = { _agents.value = it },
707 onNull = { _agents.value = emptyList() },
708 reloadTrigger = _agentsReload,
710 // Load the command catalog (non-fatal).
712 tag = "command catalog",
713 loading = _commandsLoading,
714 error = _commandsError,
715 fetch = { it.commands() },
716 onSuccess = { _commands.value = it },
717 onNull = { _commands.value = emptyList() },
718 reloadTrigger = _commandsReload,
720 // If the SSE stream drops mid-run, the run indicator would spin forever;
721 // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
722 viewModelScope.launch {
723 container.activeConnection.collectLatest { conn ->
724 if (conn == null) return@collectLatest
726 // Loop so a transient non-cancellation exception doesn't terminate the
727 // state watcher for this connection. Without the loop, a single thrown
728 // error returns the collectLatest lambda and the _running reset on
729 // Disconnected/Failed never fires again for this connection — a dropped
730 // stream leaves the working spinner (and the RunForegroundService) stuck.
731 while (currentCoroutineContext().isActive) {
733 conn.events.state.collect { state ->
734 if ((state == EventStreamClient.ConnectionState.Disconnected ||
735 state == EventStreamClient.ConnectionState.Failed) && _running.value) {
736 _running.value = false
739 } catch (e: CancellationException) {
741 } catch (e: Exception) {
742 Log.w("ChatViewModel", "SSE state collector error, restarting", e)
745 } catch (e: CancellationException) {
747 } catch (e: Exception) {
748 Log.w("ChatViewModel", "SSE state collector loop error", e)
754 /** Sends [text] together with any staged attachments; returns true on success so the
755 * caller can clear the draft only then. */
756 fun send(text: String): Boolean = send(text, includeAttachments = true)
759 * Core send. [includeAttachments] is false for the internal auto-send paths (a queued
760 * follow-up, a retry) so staged attachments aren't silently re-sent with unrelated text.
762 private fun send(text: String, includeAttachments: Boolean, idempotencyKey: String? = null): Boolean {
763 val trimmed = text.trim()
764 val attachments = if (includeAttachments) _attachments.value else emptyList()
765 // An image-only prompt (attachments, no text) is valid; a blank prompt with nothing
767 if (trimmed.isEmpty() && attachments.isEmpty()) return false
768 // Offline, or no active connection: queue the prompt to the outbox (flushed on
769 // reconnect) instead of failing the send. Device-offline is caught even when a stale
770 // connection object lingers, so a phone that lost signal mid-conversation still
771 // captures the message rather than bouncing it off a dead socket.
772 val conn = connection
773 if (conn == null || !container.isOnline.value) {
774 return enqueueOffline(trimmed, attachments)
776 if (!_running.compareAndSet(false, true)) return false
777 // A new run is starting: reopen the re-light gate so this run's streamed parts keep the
778 // indicator lit (and a later SessionIdle re-closes it).
779 runEndedByIdle = false
780 // A plain manual Send of the auto-restored failed draft must reuse the stashed key too
781 // (retryFailed passes it explicitly; the composer's Send does not), so a lost-response
782 // re-send stays deduplicated server-side instead of starting a duplicate agent run.
783 // Capture the match BEFORE clearing _failedDraft just below.
784 val reuseKey = if (idempotencyKey == null && trimmed == _failedDraft.value?.trim()) {
789 _failedDraft.value = null
790 // Stable idempotency key for THIS online attempt: a brand-new message gets a fresh key,
791 // while retryFailed() (or a manual re-send of the restored failed draft) re-submits with the
792 // key it stashed on the original failure — so a retry after a lost response is deduplicated
793 // server-side (no duplicate agent run).
794 val key = idempotencyKey ?: reuseKey ?: java.util.UUID.randomUUID().toString()
795 // Clear the staged attachments optimistically so the composer empties immediately;
796 // restore them if the send fails (mirrors the draft handling below).
797 if (attachments.isNotEmpty()) _attachments.value = emptyList()
798 // Clear the in-memory draft for the UI immediately, but don't persist the clear
799 // yet — if the send fails and the process dies before we restore, the draft
800 // would be lost forever. The persisted draft is cleared only on success.
801 // suppressDraftPersist prevents the debounced collector from persisting the
802 // empty draft before the send resolves.
804 // Only clear when the text being sent IS the current draft. send() is also
805 // invoked for auto-sent queued follow-ups (SessionIdle) and retryFailed(), where
806 // the user may have typed a NEW draft since — wiping it would lose that text.
807 if (_draft.value.trim() == trimmed) {
808 suppressDraftPersist.set(true)
811 viewModelScope.launch {
812 runCatchingCancellable {
813 conn.repository.sendPrompt(
816 attachments = attachments.map { it.part },
817 model = _selectedModel.value?.ref,
818 agent = _selectedAgent.value,
819 idempotencyKey = key,
822 suppressDraftPersist.set(false)
823 _failedDraft.value = trimmed
824 failedIdempotencyKey = key
825 // Only restore the draft if the user hasn't typed anything new since.
826 if (_draft.value.isBlank()) updateDraft(trimmed)
827 // Restore the staged attachments too, so a failed send doesn't lose them —
828 // but only if the user hasn't staged new ones in the meantime.
829 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
830 // Re-persist so the restored attachments survive process death after a failure.
832 // Retryable: this is the failed send whose prompt Retry re-submits.
833 _errorEvents.trySend(ChatError(container.friendlyError(it), retryable = true))
834 _running.value = false
836 suppressDraftPersist.set(false)
837 // This attempt reached the server, so the stashed retry key is spent — drop it so a
838 // later unrelated send can't reuse it (retryFailed clears it on success too).
839 failedIdempotencyKey = null
840 // Send succeeded — the attachments rode along, so clear their persisted copy
841 // (or keep any the user staged after the optimistic clear).
843 // Send succeeded — persist the cleared draft, but only if the user hasn't
844 // typed a new one while the send was in flight. Otherwise clearing the
845 // store echoes back through the draft observer and wipes the in-progress
846 // text (data loss). Mirrors the guard on the failure path above.
847 if (_draft.value.isBlank()) {
848 lastPersistedDraft = ""
849 container.draftStore.set(sessionId, "")
851 // Don't reset _running here: the agent continues streaming via SSE.
852 // _running is cleared on SessionIdle/SessionError (see event collector)
853 // or when the SSE stream drops (see connection state watcher below).
860 * Queue [trimmed] (+ [attachments]) to the offline outbox, clearing the composer
861 * optimistically like a real send. AppContainer flushes the outbox automatically on
862 * reconnect. The prompt is attributed to the active (or most-recent) server profile so a
863 * reconnect to a different server doesn't misfire it. On any failure the draft/attachments
864 * are restored so nothing is lost.
866 private fun enqueueOffline(trimmed: String, attachments: List<PendingAttachment>): Boolean {
867 _failedDraft.value = null
868 if (attachments.isNotEmpty()) _attachments.value = emptyList()
869 val clearDraft = _draft.value.trim() == trimmed
871 suppressDraftPersist.set(true)
874 viewModelScope.launch {
875 val profileId = connection?.profile?.id
876 ?: runCatchingCancellable { container.profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
877 if (profileId == null) {
878 // No server to attribute the message to — restore the composer and report it
879 // rather than silently dropping the prompt.
880 suppressDraftPersist.set(false)
881 if (_draft.value.isBlank()) updateDraft(trimmed)
882 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
884 _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
887 runCatchingCancellable {
888 container.outboxStore.enqueue(
890 id = java.util.UUID.randomUUID().toString(),
891 profileId = profileId,
892 sessionId = sessionId,
894 attachments = attachments.map { it.toPersisted() },
895 model = _selectedModel.value?.ref,
896 agent = _selectedAgent.value,
897 createdAt = System.currentTimeMillis(),
901 suppressDraftPersist.set(false)
902 // The staged attachments now live in the outbox item; clear their draft copy.
904 if (_draft.value.isBlank()) {
905 lastPersistedDraft = ""
906 container.draftStore.set(sessionId, "")
908 // Nudge a flush in case we're actually online with a live connection.
909 container.flushOutbox()
910 _errorEvents.trySend(ChatError(container.string(R.string.outbox_queued)))
912 suppressDraftPersist.set(false)
913 if (_draft.value.isBlank()) updateDraft(trimmed)
914 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
916 _errorEvents.trySend(ChatError(container.friendlyError(it)))
922 /** Discard a single queued (offline) message. */
923 fun discardQueued(id: String) {
924 viewModelScope.launch { runCatchingCancellable { container.outboxStore.remove(id) } }
927 /** Discard every queued (offline) message for this session. */
928 fun discardAllQueued() {
929 viewModelScope.launch { runCatchingCancellable { container.outboxStore.removeForSession(sessionId) } }
932 /** Attempt to flush the outbox now (e.g. the user tapped "Send now"). */
933 fun flushQueued() { container.flushOutbox() }
935 /** Re-send the last draft whose send failed, if any. */
937 val draft = _failedDraft.value ?: return
938 // Reuse the failed send's idempotency key so a retry after a lost response is
939 // deduplicated server-side rather than starting a duplicate run.
940 val key = failedIdempotencyKey
941 // Don't clear _failedDraft until send() accepts the text — if _running is
942 // already true, send() returns false and the draft would be lost forever.
943 // Attachments aren't re-sent on retry (they were optimistically restored to the
944 // composer on the original failure, so they'll ride the next manual send).
945 if (send(draft, includeAttachments = false, idempotencyKey = key)) {
946 _failedDraft.value = null
947 failedIdempotencyKey = null
952 * Queue [text] to be sent automatically when the current run finishes, or clear any
953 * queued follow-up when [text] is blank. Used when the user taps Send while a run
954 * is already active — the Send button is replaced by Stop during a run, but the
955 * input field stays enabled, so a follow-up typed mid-run would otherwise have
958 fun queueFollowUp(text: String) {
959 val trimmed = text.trim()
960 setQueuedFollowUp(trimmed.takeIf { it.isNotEmpty() })
961 // The text now lives in the queued chip, so clear the input field just as a
962 // normal send would. No-op for the cancel case (blank text), which must leave
963 // whatever the user has since typed untouched.
964 if (trimmed.isNotEmpty()) {
966 // Persist the cleared draft IMMEDIATELY, not just in memory: the follow-up was already
967 // persisted synchronously above, so if the process is killed before the debounced
968 // draft-persist fires, the same text would remain under BOTH the draft and follow-up
969 // keys — and cold-start recovery would restore it to the composer AND re-queue it (a
970 // duplicate). Mirror send()'s lastPersistedDraft update so the drafts observer treats
971 // this store change as our own echo and doesn't write the cleared value back.
972 lastPersistedDraft = ""
973 container.draftStore.flushDraft(sessionId, "")
977 /** Set or clear the queued follow-up and mirror it to persistence, so a follow-up
978 * queued mid-run survives process death (recovered on the next open of this session).
979 * Passing null / blank clears both the in-memory value and the persisted copy. */
980 private fun setQueuedFollowUp(text: String?) {
981 _queuedFollowUp.value = text
982 container.draftStore.flushFollowUp(sessionId, text.orEmpty())
985 /** Transient flag set by [refreshMessages] so the top-bar refresh icon can show
986 * a brief spinner as immediate tap feedback. The SSE reconnect triggered by
987 * refreshMessages may not visibly change [connectionState] (it's already
988 * Connected), so without this the tap appears to do nothing. Clears after a
989 * short delay or when the connection state next becomes Connected. */
990 private val _refreshing = MutableStateFlow(false)
991 val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
993 /** Manually re-fetch messages by forcing the SSE stream to reconnect, which
994 * triggers the repository's re-seed-from-REST logic. Gives the user a recovery
995 * path when the SSE stream silently drops and the auto-reconnect re-seed is slow
996 * or fails — a pull-to-refresh or top-bar refresh forces a fresh fetch under the
997 * existing generation-guarded merge. Also clears [loadError] optimistically and
998 * sets [refreshing] for immediate tap feedback. */
999 fun refreshMessages() {
1000 val conn = connection ?: return
1001 _loadError.value = false
1002 _refreshing.value = true
1003 conn.events.triggerReconnect()
1004 viewModelScope.launch {
1005 delay(NetworkConfig.refreshFeedbackMs)
1006 _refreshing.value = false
1010 /** Invoke a slash-command by name via the server's /command endpoint. */
1011 fun runCommand(command: Command) {
1012 val conn = connection ?: return
1013 // A run is already in flight — surface feedback instead of silently dropping the
1014 // command (the picker stays openable during a run and dismisses on select).
1015 if (!_running.compareAndSet(false, true)) {
1016 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1019 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1020 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1021 runEndedByIdle = false
1022 viewModelScope.launch {
1023 runCatchingCancellable {
1024 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
1026 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1027 _running.value = false
1033 val conn = connection ?: return
1034 // Drop any queued follow-up so the SessionIdle that follows the abort doesn't
1035 // auto-send it — the user tapped Stop to halt work, not to trigger the next turn.
1036 setQueuedFollowUp(null)
1037 if (!_aborting.compareAndSet(false, true)) return
1038 viewModelScope.launch {
1040 // Run the abort POST under NonCancellable so it survives this scope being
1041 // cancelled. The "Stop and exit" path calls abort() then navigates back
1042 // immediately, which pops this ViewModel's back-stack entry → onCleared() →
1043 // viewModelScope.cancel(); without this the in-flight abort is cancelled (often
1044 // before the request even reaches the server) and the run keeps burning tokens
1045 // server-side despite the user explicitly stopping it.
1046 runCatchingCancellable { withContext(NonCancellable) { conn.repository.abort(sessionId) } }
1047 .onSuccess { _running.value = false }
1048 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1050 _aborting.value = false
1055 fun addAttachment(attachment: PendingAttachment) {
1056 // Enforce the cumulative size cap across the whole staged set (the per-file size and the
1057 // count caps are checked upstream). The base64 data URLs are held in memory and
1058 // re-serialized on every add/remove and again when the request body is built, so an
1059 // unbounded set OOMs. Reject rather than add, mirroring the other attachment limits'
1060 // snackbar via errorEvents. Size is the raw (pre-base64) byte count, matching the
1061 // per-file check, and includes attachments already staged (incl. restored ones).
1062 val staged = _attachments.value.sumOf { base64DataUrlByteSize(it.part.url) }
1063 if (staged + base64DataUrlByteSize(attachment.part.url) > NetworkConfig.maxTotalAttachmentBytes) {
1064 _errorEvents.trySend(ChatError(container.string(R.string.attachment_limit)))
1067 _attachments.update { it + attachment }
1068 persistAttachments()
1071 fun removeAttachment(id: String) {
1072 _attachments.update { list -> list.filterNot { it.id == id } }
1073 persistAttachments()
1076 fun clearAttachments() {
1077 _attachments.value = emptyList()
1078 persistAttachments()
1081 /** Persist the current staged attachments so an interrupted compose survives process
1082 * death (mirrors draft persistence). Fire-and-forget on [viewModelScope]. */
1083 private fun persistAttachments() {
1084 val snapshot = _attachments.value
1085 viewModelScope.launch {
1086 runCatchingCancellable { container.attachmentDraftStore.save(sessionId, snapshot.map { it.toPersisted() }) }
1087 .onFailure { Log.w("ChatViewModel", "Failed to persist attachments", it) }
1091 /** Revert the conversation to just before [messageId], hiding everything after it. The
1092 * authoritative state also arrives via SessionUpdated; we flag it here for immediate
1094 fun revertTo(messageId: String) {
1095 val conn = connection ?: return
1096 viewModelScope.launch {
1097 runCatchingCancellable { conn.api.revert(sessionId, messageId) }
1098 .onSuccess { _reverted.value = it.isReverted }
1099 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1103 /** Load a previously-sent user prompt back into the composer to edit and re-send.
1104 * Reverts the conversation to just before [messageId] (hiding it and everything after)
1105 * and prefills the draft with [text]. The next Send continues from that checkpoint,
1106 * effectively replacing the edited message; the reverted banner's Undo restores it.
1107 * Overwrites the current draft — this is an explicit edit action on that message. */
1108 fun editMessage(messageId: String, text: String) {
1110 _draft.value = text.take(NetworkConfig.maxDraftLengthChars)
1113 /** Prefill the composer with [text] as a Markdown blockquote so the user can respond to a
1114 * specific message inline. Appends to any existing draft (with a blank line) rather than
1115 * overwriting, so quoting doesn't discard text already being typed. */
1116 fun quoteReply(text: String) {
1117 val quoted = text.trim().lineSequence().joinToString("\n") { "> $it" }
1118 if (quoted.isBlank()) return
1119 val current = _draft.value
1120 val combined = if (current.isBlank()) "$quoted\n\n" else "$current\n\n$quoted\n\n"
1121 // Cap like the composer's typed/pasted/dictated input so quoting onto a near-full draft
1122 // can't push it past the limit (negative remaining counter + an over-cap prompt sent).
1123 _draft.value = combined.take(NetworkConfig.maxDraftLengthChars)
1126 /** Fork the conversation by creating a brand-new session seeded with [text] as its first
1127 * prompt (using the currently-selected model/agent). Emits the new session id via
1128 * [branchEvents] so the UI can navigate to it. There's no server-side "branch" endpoint,
1129 * so this starts a fresh session from the chosen prompt rather than copying history. */
1130 fun branchFrom(text: String) {
1131 val conn = connection ?: return
1132 val prompt = text.trim()
1133 if (prompt.isEmpty()) return
1134 viewModelScope.launch {
1135 runCatchingCancellable {
1136 val session = conn.repository.createSession(title = null)
1137 conn.repository.sendPrompt(
1140 model = _selectedModel.value?.ref,
1141 agent = _selectedAgent.value,
1145 .onSuccess { newId -> _branchEvents.trySend(newId) }
1146 .onFailure { _errorEvents.trySend(ChatError(container.string(R.string.branch_failed))) }
1150 /** Undo the active revert checkpoint, restoring the hidden messages. */
1152 val conn = connection ?: return
1153 viewModelScope.launch {
1154 runCatchingCancellable { conn.api.unrevert(sessionId) }
1155 .onSuccess { _reverted.value = it.isReverted }
1156 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1160 /** Create (or fetch the existing) public share link. The URL is exposed via [shareUrl]
1161 * and also emitted once via [shareLinkEvents] so the UI can copy it to the clipboard. */
1162 fun shareSession() {
1163 val conn = connection ?: return
1164 viewModelScope.launch {
1165 runCatchingCancellable { conn.api.shareSession(sessionId) }
1166 .onSuccess { session ->
1167 val url = session.share?.url?.takeIf { it.isNotBlank() }
1168 _shareUrl.value = url
1169 if (url != null) _shareLinkEvents.trySend(url)
1171 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1175 /** Revoke the session's public share link. */
1176 fun unshareSession() {
1177 val conn = connection ?: return
1178 viewModelScope.launch {
1179 runCatchingCancellable { conn.api.unshareSession(sessionId) }
1180 .onSuccess { _shareUrl.value = it.share?.url?.takeIf { it.isNotBlank() } }
1181 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1185 /** Compact the conversation via the summarize endpoint; the summary streams back via SSE.
1186 * Uses the currently-selected model (required by the endpoint). */
1188 val conn = connection ?: return
1189 val model = _selectedModel.value?.ref ?: run {
1190 _errorEvents.trySend(ChatError(container.string(R.string.needs_model)))
1193 if (!_running.compareAndSet(false, true)) {
1194 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1197 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1198 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1199 runEndedByIdle = false
1200 viewModelScope.launch {
1201 runCatchingCancellable { conn.api.summarize(sessionId, model) }
1203 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1204 _running.value = false
1209 /** Analyze the project and (re)generate its AGENTS.md; the run streams back via SSE. */
1211 val conn = connection ?: return
1212 if (!_running.compareAndSet(false, true)) {
1213 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1216 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1217 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1218 runEndedByIdle = false
1219 viewModelScope.launch {
1220 runCatchingCancellable { conn.api.initSession(sessionId) }
1222 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1223 _running.value = false
1228 /** Run a one-off shell [command] in the session's worktree; output streams back via SSE.
1229 * The server requires an agent to scope the run — use the selected one, else the primary. */
1230 fun runShell(command: String) {
1231 val conn = connection ?: return
1232 val cmd = command.trim()
1233 if (cmd.isEmpty()) return
1234 val agent = _selectedAgent.value
1235 ?: _agents.value.firstOrNull { it.isPrimary }?.name
1236 ?: _agents.value.firstOrNull()?.name
1237 ?: DEFAULT_SHELL_AGENT
1238 if (!_running.compareAndSet(false, true)) {
1239 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1242 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1243 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1244 runEndedByIdle = false
1245 viewModelScope.launch {
1246 runCatchingCancellable { conn.api.shell(sessionId, cmd, agent, _selectedModel.value?.ref) }
1248 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1249 _running.value = false
1254 /** Rename the current session via PATCH /session/:id. On success updates [sessionTitle]
1255 * so the top bar reflects the new name immediately. A failure surfaces as a snackbar;
1256 * the caller keeps the dialog open so the user can retry without retyping. */
1257 fun renameSession(newTitle: String) {
1258 val conn = connection ?: return
1259 val title = newTitle.trim()
1260 if (title.isEmpty() || title == _sessionTitle.value) return
1261 viewModelScope.launch {
1262 runCatchingCancellable { conn.api.updateSession(sessionId, title) }
1263 .onSuccess { _sessionTitle.value = it.displayTitle }
1264 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1268 /** Delete the current session via DELETE /session/:id. Surfaces a toast (via the UI's
1269 * sessionDeleted flow) and navigates away on success; a failure surfaces as a snackbar.
1270 * Unlike [SessionListViewModel.deleteSession] there's no undo window — the user is
1271 * already viewing the session, so a confirmation dialog guards the action instead. */
1272 fun deleteSession() {
1273 val conn = connection ?: return
1274 viewModelScope.launch {
1275 runCatchingCancellable { conn.repository.deleteSession(sessionId) }
1277 container.draftStore.remove(sessionId)
1278 container.attachmentDraftStore.remove(sessionId)
1279 container.messageCacheStore.remove(conn.profile.id, sessionId)
1280 container.outboxStore.removeForSession(sessionId)
1281 container.clearUnread(sessionId)
1282 _sessionDeleted.value = true
1284 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1288 /** Reconnect to the most recently used server profile (used when the connection is gone). */
1290 if (container.activeConnection.value != null) return
1291 // Guard against concurrent reconnect calls: the user can tap the button
1292 // multiple times while _reconnecting is true, which would launch parallel
1293 // connect() coroutines that race to set the active connection, leaking the
1294 // intermediate connections.
1295 if (!_reconnecting.compareAndSet(false, true)) return
1296 viewModelScope.launch {
1298 val recent = runCatchingCancellable { container.profileStore.profiles.first() }
1299 .getOrDefault(emptyList())
1301 if (recent == null) {
1302 _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
1305 runCatchingCancellable {
1306 val conn = container.connect(recent)
1309 container.disconnect()
1310 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1313 _reconnecting.value = false
1318 fun respondPermission(permission: Permission, response: PermissionResponse) {
1319 val conn = connection ?: return
1320 // Dismiss optimistically (revealing any queued request); permission.replied will confirm.
1321 // If the call fails we re-surface this request so the user can retry instead of being stuck
1322 // with a dismissed dialog and a tool run that's still paused server-side.
1323 resolvePermission(permission.id)
1324 viewModelScope.launch {
1325 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
1327 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1328 enqueuePermission(permission)
1333 private companion object {
1334 /** Fallback agent for the shell endpoint when none is selected and no agent catalog
1335 * loaded. opencode's default primary agent is "build". */
1336 const val DEFAULT_SHELL_AGENT = "build"
1339 override fun onCleared() {
1341 // Flush any pending debounced draft so it survives navigation. Uses an
1342 // asynchronous apply() (not a synchronous commit) so the main thread isn't
1343 // blocked on disk I/O — Android's SharedPreferences framework guarantees
1344 // pending apply() writes are flushed before the process exits.
1345 // Flush the pending draft — including an empty one. Persistence is otherwise debounced,
1346 // so if the user cleared the input and navigated back within the debounce window, the
1347 // debounce coroutine was cancelled with viewModelScope and the prefs still hold the
1348 // previous non-empty draft. flushDraft() removes the key when the text is blank, so
1349 // flushing commits the clear instead of resurrecting the deleted text.
1351 // BUT respect the send-in-flight guard: send() optimistically clears _draft to "" WITHOUT
1352 // persisting the clear so a failed send can restore the text. If the user taps Send then
1353 // immediately navigates back (cancelling the in-flight send), flushing the blank _draft here
1354 // would remove the persisted key and erase the pre-send draft with no recovery. Skip the
1355 // flush while a send is in flight so the pre-send draft stays persisted (recoverable).
1356 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