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 * Progress of the current permission within a burst of requests. [total] is the max number
74 * simultaneously pending since the queue was last empty; [position] advances as the user clears
75 * the backlog. A [total] of 0/1 means there's no meaningful backlog to surface.
77@androidx.compose.runtime.Immutable
78data class PermissionProgress(val position: Int, val total: Int)
81 * An attachment staged to send with the next prompt. [previewModel] is a Coil-loadable model
82 * for the thumbnail (the source content Uri, as a string) for images, or null for non-image
83 * files (rendered with a generic icon). [part] is the wire form (a base64 data URL) sent to
86@androidx.compose.runtime.Immutable
87data class PendingAttachment(
91 val previewModel: Any?,
92 val part: FilePromptPart,
94 val isImage: Boolean get() = mime.startsWith("image/")
97/** Reduce a staged attachment to its persistable form (the self-contained data URL). */
98private fun PendingAttachment.toPersisted() =
99 soy.iko.opencode.data.repo.PersistedAttachment(id, name, mime, part.url, part.filename)
101/** Rebuild a staged attachment from persistence. The base64 data URL doubles as the Coil
102 * preview model for images (the original content Uri didn't survive the process restart). */
103private fun soy.iko.opencode.data.repo.PersistedAttachment.toPending() = PendingAttachment(
107 previewModel = if (mime.startsWith("image/")) url else null,
108 part = FilePromptPart(mime = mime, url = url, filename = filename),
111// Large by design: this VM owns the whole chat surface (streaming, drafts, attachments,
112// catalogs, permissions, revert/edit, sharing, summarize/init/shell, TTS wiring). It's
113// already grandfathered for TooManyFunctions in the detekt baseline; the class-size rule is
114// suppressed for the same reason. A future split into sub-controllers is tracked separately.
115@Suppress("LargeClass")
116@OptIn(ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
118 private val container: AppContainer,
119 private val sessionId: String,
122 private val connection get() = container.activeConnection.value
124 val connected: Boolean get() = connection != null
126 private val _loading = MutableStateFlow(true)
127 val loading: StateFlow<Boolean> = _loading.asStateFlow()
129 /** Set when the messages flow failed to load (and the list is empty), so the UI
130 * can render a distinct error state instead of masquerading as an empty
131 * conversation. Cleared on a successful non-empty emission. */
132 private val _loadError = MutableStateFlow(false)
133 val loadError: StateFlow<Boolean> = _loadError.asStateFlow()
135 /** Tracks whether the messages flow has ever emitted a non-empty list, so
136 * [messages]' retryWhen can decide whether a re-fetch failure should set
137 * [loadError] (nothing shown yet → error screen) or just emit a snackbar
138 * (already showing messages → don't replace the conversation with an error). */
139 private var hasShownMessages = false
141 /** True once we've surfaced a snackbar for the current message-load failure streak.
142 * Reset on any successful emission so a persistent failure doesn't spam a fresh
143 * snackbar every retry cycle (every few seconds). */
144 private var loadErrorSnackbarShown = false
146 /** Separate from [loading]: tracks the manual reconnect() flow so its spinner
147 * doesn't conflict with the messages flow's loading state. */
148 private val _reconnecting = MutableStateFlow(false)
149 val reconnecting: StateFlow<Boolean> = _reconnecting.asStateFlow()
151 /** When true, the debounced draft collector skips persisting an empty draft so
152 * [send]'s deliberate non-persistence of the cleared draft isn't undone by the
153 * debounce timer firing before the send completes. */
154 private val suppressDraftPersist = java.util.concurrent.atomic.AtomicBoolean(false)
156 /** The draft value this VM last persisted, so the drafts observer can tell its own
157 * (debounced, possibly stale) echo apart from a genuine external write. Without this
158 * a debounced persist of an older value could echo back and overwrite newer text the
159 * user has since typed. */
160 @Volatile private var lastPersistedDraft: String? = null
162 /** Per-catalog reload triggers: incrementing one causes [observeCatalog]'s
163 * collectLatest to cancel any in-flight fetch and start a fresh one, so a
164 * manual reload supersedes a stale observeCatalog fetch. */
165 private val _modelsReload = MutableStateFlow(0)
166 private val _agentsReload = MutableStateFlow(0)
167 private val _commandsReload = MutableStateFlow(0)
169 val messages: StateFlow<List<MessageWithParts>> =
170 container.activeConnection
171 .flatMapLatest { conn ->
172 conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
175 _loading.value = false
176 // A value flowed through: the stream recovered, so allow the next distinct
177 // failure to surface a fresh snackbar again.
178 loadErrorSnackbarShown = false
179 // Clear the error on ANY successful emission, including an empty list. An empty
180 // session that hit one transient error would otherwise stay latched on the
181 // "Failed to load / Retry" screen forever, because the later successful EMPTY
182 // emission couldn't clear the flag. hasShownMessages stays gated on non-empty
183 // (an empty session has genuinely shown nothing yet).
184 _loadError.value = false
185 if (it.isNotEmpty()) {
186 hasShownMessages = true
189 .retryWhen { cause, attempt ->
190 _loading.value = false
191 // Only surface the persistent error state when there's nothing to
192 // show — if we already have messages, a transient re-fetch failure
193 // is better surfaced as a snackbar (via errorEvents) than by
194 // replacing the visible conversation with an error screen.
195 if (!hasShownMessages) _loadError.value = true
196 // Surface the snackbar only once per failure streak — retryWhen loops on a
197 // backoff, so emitting here unconditionally would spam a new snackbar every
198 // retry on a persistent load failure.
199 if (!loadErrorSnackbarShown) {
200 _errorEvents.trySend(ChatError(container.friendlyError(cause)))
201 loadErrorSnackbarShown = true
203 // Exponential backoff capped at retryMaxDelayMs so a persistently failing
204 // server is retried with growing delays (retryInitialDelayMs, then doubling)
205 // instead of hammered at a fixed interval forever. Clamp the shift so a long
206 // failure streak can't overflow the Long delay.
207 val backoffMs = (NetworkConfig.retryInitialDelayMs shl attempt.coerceAtMost(16L).toInt())
208 .coerceAtMost(NetworkConfig.retryMaxDelayMs)
213 scope = viewModelScope,
214 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
215 initialValue = emptyList(),
218 /** Whether the conversation has any messages. Derived separately so the top bar
219 * (share button enabled state) can observe this cheap boolean instead of the
220 * full messages list, avoiding per-token recomposition of the app bar during
222 val hasMessages: StateFlow<Boolean> = messages
223 .map { it.isNotEmpty() }
225 scope = viewModelScope,
226 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
227 initialValue = false,
230 /** The agent's current task plan (the latest `todowrite`), surfaced so the chat can pin a
231 * live progress checklist above the composer. distinctUntilChanged so a per-token messages
232 * emission that doesn't change the plan doesn't recompose the bar. */
233 val todoPlan: StateFlow<List<TodoItem>> = messages
234 .map { currentTodoPlan(it) }
235 // Run currentTodoPlan off the main thread: it's an O(messages×parts) scan (worst case
236 // when the conversation has no todowrite) and runs on every conflated messages emission.
237 // distinctUntilChanged is downstream so it dedupes the result but doesn't prevent the
238 // scan — without flowOn this scans on Main.immediate per streamed token → jank.
239 .flowOn(Dispatchers.Default)
240 .distinctUntilChanged()
242 scope = viewModelScope,
243 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
244 initialValue = emptyList(),
247 private val _running = MutableStateFlow(false)
248 val running: StateFlow<Boolean> = _running.asStateFlow()
250 /** True once the current run ended via SessionIdle/SessionError, until the next run starts
251 * (a send) or an SSE reconnect. Gates the run-indicator re-light below: isRunActivity
252 * returns true for ANY message.part.updated, so without this a trailing/replayed part
253 * arriving after a legitimate SessionIdle would re-light the "working" indicator with no
254 * further idle to clear it — a stuck spinner + foreground service. Cleared on reconnect so
255 * genuine reconnect-recovery re-lighting (its actual purpose) still works. */
256 @Volatile private var runEndedByIdle = false
258 /** The in-flight REST title resolution for the current connection. Launched from — and
259 * cancelled by — the per-connection reset block so it can't observe or restore a previous
260 * server's stale title. */
261 private var sessionTitleJob: Job? = null
263 /** True while an abort REST call is in flight, so the Stop button can show a
264 * spinner and prevent double-taps from firing a second abort. */
265 private val _aborting = MutableStateFlow(false)
266 val aborting: StateFlow<Boolean> = _aborting.asStateFlow()
268 /** A follow-up the user typed while a run was active. send() queues it here and
269 * auto-sends once the run completes (SessionIdle), so the user isn't blocked with
270 * no way to send and no indication why the Send button is gone. */
271 private val _queuedFollowUp = MutableStateFlow<String?>(null)
272 val queuedFollowUp: StateFlow<String?> = _queuedFollowUp.asStateFlow()
274 /** One-shot error events surfaced as snackbars. A Channel (not SharedFlow) so an event
275 * emitted before the UI subscribes is buffered and still delivered — a SharedFlow with
276 * replay=0 would drop it (e.g. a VM-init catalog fetch failing before first
277 * composition). Each event is delivered exactly once and not replayed to a
278 * re-subscribing collector, so rotation doesn't re-show a stale snackbar. */
279 private val _errorEvents = Channel<ChatError>(
280 capacity = NetworkConfig.snackbarEventBufferCapacity,
281 onBufferOverflow = BufferOverflow.DROP_OLDEST,
283 val errorEvents: Flow<ChatError> = _errorEvents.receiveAsFlow()
285 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
286 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
288 private val _modelsLoading = MutableStateFlow(true)
289 val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
291 private val _modelsError = MutableStateFlow(false)
292 val modelsError: StateFlow<Boolean> = _modelsError.asStateFlow()
294 private val _selectedModel = MutableStateFlow<ModelOption?>(null)
295 val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
297 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
298 container.activeConnection
299 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
301 scope = viewModelScope,
302 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
303 initialValue = EventStreamClient.ConnectionState.Disconnected,
306 private val _pendingPermission = MutableStateFlow<Permission?>(null)
307 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
309 /** Backlog indicator for the permission dialog (see [PermissionProgress]). */
310 private val _permissionProgress = MutableStateFlow(PermissionProgress(0, 0))
311 val permissionProgress: StateFlow<PermissionProgress> = _permissionProgress.asStateFlow()
313 /** Max number of permissions simultaneously pending since the queue was last empty. Reset
314 * to 0 when the queue drains, so a fresh burst starts its count over. */
315 private var maxPendingSeen = 0
317 // Unanswered permission requests keyed by id, insertion-ordered. The dialog shows the most
318 // recent; when one is answered/replied — or a response fails — we fall back to the next so a
319 // request that arrived while another was in flight isn't silently dropped (its tool run would
320 // otherwise stay paused server-side with no UI affordance). Only touched on the VM's main
321 // dispatcher (event collector + viewModelScope launches), so no extra synchronization is needed.
322 private val pendingPermissions = LinkedHashMap<String, Permission>()
324 private fun enqueuePermission(permission: Permission) {
325 pendingPermissions[permission.id] = permission
326 _pendingPermission.value = pendingPermissions.values.lastOrNull()
327 updatePermissionProgress()
330 private fun resolvePermission(id: String) {
331 pendingPermissions.remove(id)
332 _pendingPermission.value = pendingPermissions.values.lastOrNull()
333 updatePermissionProgress()
336 private fun clearPermissions() {
337 pendingPermissions.clear()
338 _pendingPermission.value = null
339 updatePermissionProgress()
342 /** Recompute the "N of M" backlog indicator. [maxPendingSeen] grows with the queue and
343 * resets only when it drains, so position = total - remaining + 1 advances as requests are
345 private fun updatePermissionProgress() {
346 val remaining = pendingPermissions.size
347 if (remaining == 0) {
349 _permissionProgress.value = PermissionProgress(0, 0)
352 if (remaining > maxPendingSeen) maxPendingSeen = remaining
353 _permissionProgress.value = PermissionProgress(
354 position = maxPendingSeen - remaining + 1,
355 total = maxPendingSeen,
359 private val _agents = MutableStateFlow<List<Agent>>(emptyList())
360 val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
362 private val _agentsLoading = MutableStateFlow(true)
363 val agentsLoading: StateFlow<Boolean> = _agentsLoading.asStateFlow()
365 private val _agentsError = MutableStateFlow(false)
366 val agentsError: StateFlow<Boolean> = _agentsError.asStateFlow()
368 private val _selectedAgent = MutableStateFlow<String?>(null)
369 val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
371 private val _commands = MutableStateFlow<List<Command>>(emptyList())
372 val commands: StateFlow<List<Command>> = _commands.asStateFlow()
374 private val _commandsLoading = MutableStateFlow(true)
375 val commandsLoading: StateFlow<Boolean> = _commandsLoading.asStateFlow()
377 private val _commandsError = MutableStateFlow(false)
378 val commandsError: StateFlow<Boolean> = _commandsError.asStateFlow()
380 private val _sessionTitle = MutableStateFlow<String?>(null)
381 val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
383 /** Set when the current session is deleted via SSE, so the UI can navigate away. */
384 private val _sessionDeleted = MutableStateFlow(false)
385 val sessionDeleted: StateFlow<Boolean> = _sessionDeleted.asStateFlow()
387 /** The text of the last send that failed, surfaced so the UI can offer a retry. */
388 private val _failedDraft = MutableStateFlow<String?>(null)
389 val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
391 /** The idempotency key of the last failed online send, stashed alongside [_failedDraft] so
392 * [retryFailed] re-submits with the SAME key. A retry after a lost response is then
393 * deduplicated server-side instead of starting a duplicate agent run. */
394 @Volatile private var failedIdempotencyKey: String? = null
396 /** Per-session draft, persisted so it survives navigation/process death. */
397 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
398 val draft: StateFlow<String> = _draft.asStateFlow()
400 /** Messages composed for this session while offline/disconnected, queued in the outbox and
401 * awaiting an automatic flush on reconnect. Surfaced so the composer can show a "queued"
402 * chip with a discard/send-now affordance. */
403 val outbox: StateFlow<List<OutboxMessage>> =
404 container.outboxStore.messages
405 .map { list -> list.filter { it.sessionId == sessionId } }
407 scope = viewModelScope,
408 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
409 initialValue = emptyList(),
412 /** Attachments staged for the next prompt (images/files). Persisted per-session (as their
413 * self-contained base64 data URLs) via [AttachmentDraftStore] so an interrupted compose
414 * survives process death — the source content Uris wouldn't survive, but the data URLs
415 * do. Cleared on a successful send. */
416 private val _attachments = MutableStateFlow<List<PendingAttachment>>(emptyList())
417 val attachments: StateFlow<List<PendingAttachment>> = _attachments.asStateFlow()
419 /** True while a revert checkpoint is active for this session (messages after it are hidden
420 * server-side). Drives the "reverted" banner with its Undo. */
421 private val _reverted = MutableStateFlow(false)
422 val reverted: StateFlow<Boolean> = _reverted.asStateFlow()
424 /** The active public share URL for this session, or null when not shared. */
425 private val _shareUrl = MutableStateFlow<String?>(null)
426 val shareUrl: StateFlow<String?> = _shareUrl.asStateFlow()
428 /** One-shot events carrying a freshly-created share URL so the UI can copy/share it. */
429 private val _shareLinkEvents = Channel<String>(
430 capacity = NetworkConfig.snackbarEventBufferCapacity,
431 onBufferOverflow = BufferOverflow.DROP_OLDEST,
433 val shareLinkEvents: Flow<String> = _shareLinkEvents.receiveAsFlow()
435 /** One-shot events carrying the id of a session freshly branched from a message, so the
436 * UI can navigate to it. */
437 private val _branchEvents = Channel<String>(
438 capacity = NetworkConfig.snackbarEventBufferCapacity,
439 onBufferOverflow = BufferOverflow.DROP_OLDEST,
441 val branchEvents: Flow<String> = _branchEvents.receiveAsFlow()
444 // DraftStore loads SharedPreferences asynchronously; on a cold start the
445 // synchronous get() above returns "" until the background load completes.
446 // Observe the ready signal and re-seed the draft so it appears in the UI.
447 viewModelScope.launch {
448 container.draftStore.ready.collect { ready ->
449 if (!ready) return@collect
450 if (_draft.value.isEmpty()) {
451 _draft.value = container.draftStore.get(sessionId)
453 // Recover a follow-up queued before process death (its in-memory StateFlow
454 // is gone). If the input is now free, drop it back there for review/send and
455 // consume the stored copy; otherwise re-queue it so it auto-sends when the
456 // current run next idles. Guarded on == null so it never clobbers a
457 // follow-up the user queued after opening this session.
458 val savedFollowUp = container.draftStore.getFollowUp(sessionId)
459 if (savedFollowUp.isNotEmpty() && _queuedFollowUp.value == null) {
460 if (_draft.value.isEmpty()) {
461 _draft.value = savedFollowUp
462 container.draftStore.flushFollowUp(sessionId, "")
464 _queuedFollowUp.value = savedFollowUp
469 // Observe the drafts store for external mutations to this session's draft
470 // (e.g. a share injected via draftStore.set in two-pane mode, or setImmediate
471 // before navigation in single-pane mode). Without this, a share injected into
472 // an already-open session never appears in the input field — the init-time
473 // read and the ready-only re-seed miss it when the draft is non-empty.
474 // The VM's own debounced persist writes the same value _draft already holds,
475 // so those updates are no-ops here (storeValue == _draft.value). The
476 // suppressDraftPersist guard prevents a stale store value from clobbering
477 // the deliberate draft clear during a send (the store still holds the
478 // pre-send draft until the send resolves).
479 viewModelScope.launch {
480 container.draftStore.drafts.collect { drafts ->
481 // Ignore emissions until the initial disk load completes: the load itself
482 // emits the persisted snapshot, and applying it here would clobber text the
483 // user typed during the async load (the `ready` observer above seeds an
484 // empty draft). After ready, emissions are external writes.
485 if (!container.draftStore.ready.value) return@collect
486 val storeValue = drafts[sessionId].orEmpty()
487 // Ignore this VM's own (debounced, possibly stale) persistence echo — only a
488 // genuine external write (e.g. a two-pane share injection) should overwrite
490 if (storeValue == lastPersistedDraft) return@collect
491 if (storeValue != _draft.value && !suppressDraftPersist.get()) {
492 _draft.value = storeValue
499 // Restore attachments staged before process death. They were persisted as
500 // self-contained data URLs, so they re-send and (for images) re-preview intact.
501 // Guarded on empty so it never clobbers attachments the user staged after opening.
502 viewModelScope.launch {
503 val restored = runCatchingCancellable { container.attachmentDraftStore.load(sessionId) }
504 .getOrDefault(emptyList())
505 if (restored.isNotEmpty() && _attachments.value.isEmpty()) {
506 _attachments.value = restored.map { it.toPending() }
511 fun selectModel(option: ModelOption) { _selectedModel.value = option }
513 fun selectAgent(name: String?) { _selectedAgent.value = name }
516 val conn = connection ?: return
517 viewModelScope.launch {
518 conn.api.invalidateProvidersCache()
519 _modelsReload.value++
524 val conn = connection ?: return
525 viewModelScope.launch {
526 conn.api.invalidateAgentsCache()
527 _agentsReload.value++
531 fun reloadCommands() {
532 val conn = connection ?: return
533 viewModelScope.launch {
534 conn.api.invalidateCommandsCache()
535 _commandsReload.value++
540 * Shared helper for init-block catalog observers: re-fetches whenever the active
541 * connection or the catalog's reload trigger changes, invoking [onNull] to clear
542 * state on null so stale data from the old server doesn't persist. The
543 * [reloadTrigger] is merged with [activeConnection] so a manual reload (via
544 * reloadModels/reloadAgents/reloadCommands) cancels any in-flight observe fetch
545 * via collectLatest, preventing a stale observe result from overwriting a fresh
546 * reload result. Failure is non-fatal — the error flag is surfaced to the UI.
548 private fun <T> observeCatalog(
550 loading: MutableStateFlow<Boolean>,
551 error: MutableStateFlow<Boolean>,
552 fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
553 onSuccess: (T) -> Unit,
555 reloadTrigger: StateFlow<Int>,
557 viewModelScope.launch {
558 merge(container.activeConnection, reloadTrigger).collectLatest { _ ->
559 val conn = container.activeConnection.value
560 if (conn == null) { onNull(); loading.value = false; error.value = false; return@collectLatest }
563 runCatchingCancellable { fetch(conn.api) }
564 // fetch() does HTTP via withRetry, so the failure can be a
565 // ClientRequestException whose message embeds the full request URL
566 // (may contain auth/paths). Log only a scrubbed summary.
567 .onFailure { Log.w("ChatViewModel", "Failed to load $tag: ${safeExceptionSummary(it)}"); error.value = true }
568 .getOrNull()?.let(onSuccess)
569 loading.value = false
574 fun updateDraft(text: String) {
579 // Debounce draft persistence so we don't write to disk on every keystroke.
580 // Skip persisting empty drafts that were set by send() (suppressed via
581 // suppressDraftPersist) — send() clears the in-memory draft immediately for
582 // UI feedback but deliberately doesn't persist the clear until the send
583 // succeeds, so a failed send can restore the draft.
584 viewModelScope.launch {
585 _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
586 if (text.isBlank() && suppressDraftPersist.get()) return@collect
587 lastPersistedDraft = text
588 runCatchingCancellable { container.draftStore.set(sessionId, text) }
589 .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
592 // Reset per-connection state when the active server changes so stale spinners,
593 // permission dialogs, errors, and agent selections from the old server don't
594 // persist into the new one.
595 viewModelScope.launch {
596 container.activeConnection.collectLatest { conn ->
598 // The connection dropped (e.g. disconnect() mid-run). No SSE stream will
599 // arrive to deliver SessionIdle/SessionError, so reset the run state here —
600 // otherwise the working spinner sticks on, ChatScreen keeps keepScreenOn +
601 // the RunForegroundService alive, and the Stop button no-ops (abort() early-
602 // returns with no connection) until a later reconnect happens to clear it.
603 _running.value = false
604 _aborting.value = false
606 sessionTitleJob?.cancel()
609 _running.value = false
610 // Allow the re-light below to recover a still-running run after this (re)connect;
611 // a run that actually finished during the outage emits no live parts, so this
612 // won't spuriously re-light.
613 runEndedByIdle = false
615 _failedDraft.value = null
616 // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
617 // user intent that is now persisted; wiping it on every (re)connect — which
618 // this collector does, including transient SSE drops and the cold-start
619 // restore path — would drop a legitimately queued/recovered follow-up.
620 _selectedAgent.value = null
621 _sessionTitle.value = null
622 // Resolve the app-bar title via REST for THIS connection. Launched from the same
623 // block that just nulled _sessionTitle (and cancelled the previous fetch) so it can
624 // never observe or restore the previous server's stale title — the old separate
625 // collector could, racing that reset. The assignment is guarded on a still-null
626 // title so a fresher SSE SessionUpdated (from the event collector below) wins.
627 sessionTitleJob?.cancel()
628 sessionTitleJob = viewModelScope.launch {
629 runCatchingCancellable { conn.repository.listSessions() }
631 ?.firstOrNull { it.id == sessionId }
633 if (_sessionTitle.value == null) {
634 _sessionTitle.value = session.displayTitle
635 _reverted.value = session.isReverted
636 _shareUrl.value = session.share?.url?.takeIf { it.isNotBlank() }
641 // Loop the inner collect so a transient non-cancellation exception (a
642 // malformed SSE event decoding error, an unexpected channel close)
643 // doesn't terminate the event collector for this connection. Without
644 // the loop, the catch returns the collectLatest lambda normally, so
645 // collectLatest considers this connection's block complete and never
646 // re-runs it — the chat would go permanently silent (no streaming,
647 // no permission prompts, no title updates) until a server switch.
648 while (currentCoroutineContext().isActive) {
650 conn.events.events.collect { event ->
651 if (SessionRepository.isIdle(event, sessionId)) {
652 _running.value = false
653 runEndedByIdle = true
654 // Auto-send a queued follow-up once the previous run finishes,
655 // so the user's drafted-while-running message isn't lost.
656 val queued = _queuedFollowUp.value
657 if (queued != null) {
658 setQueuedFollowUp(null)
659 send(queued, includeAttachments = false)
662 if (SessionRepository.isError(event, sessionId)) {
663 _running.value = false
664 runEndedByIdle = true
665 // The run errored, so the queued follow-up won't auto-send.
666 // Restore it to the input (if free) so the user's typed text
667 // isn't silently lost; otherwise just clear the queue.
668 val queued = _queuedFollowUp.value
669 if (!queued.isNullOrEmpty() && _draft.value.isEmpty()) {
670 _draft.value = queued
672 setQueuedFollowUp(null)
673 _errorEvents.trySend(ChatError(container.string(R.string.error_agent_reported)))
675 // Restore the run indicator if a live streaming event arrives while it's
676 // cleared — e.g. an SSE reconnect mid-run reset _running to false, but the
677 // agent is still working. Don't revive it once the user has hit Stop.
678 val relightRunIndicator = SessionRepository.isRunActivity(event, sessionId) &&
679 !_aborting.value && !_running.value && !runEndedByIdle
680 if (relightRunIndicator) {
681 _running.value = true
684 is PermissionUpdated ->
685 if (event.properties.sessionID == sessionId) enqueuePermission(event.properties)
686 is PermissionReplied ->
687 if (event.properties.sessionID == sessionId) event.properties.permissionID?.let { resolvePermission(it) }
689 if (event.properties.info.id == sessionId) {
690 val info = event.properties.info
691 _sessionTitle.value = info.displayTitle
692 _reverted.value = info.isReverted
693 _shareUrl.value = info.share?.url?.takeIf { it.isNotBlank() }
696 if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
697 _sessionDeleted.value = true
702 } catch (e: CancellationException) {
704 } catch (e: Exception) {
705 Log.w("ChatViewModel", "SSE event collector error, restarting", e)
708 } catch (e: CancellationException) {
710 } catch (e: Exception) {
711 Log.w("ChatViewModel", "SSE event collector loop error", e)
715 // Load the model catalog; preselect the server default. Failure is non-fatal —
716 // sending with no model just uses the server's default agent/model.
718 tag = "model catalog",
719 loading = _modelsLoading,
720 error = _modelsError,
721 fetch = { it.providers() },
722 onSuccess = { resp ->
723 val options = resp.toOptions()
724 _models.value = options
725 // Only (re)apply the server default when there's no valid current
726 // selection. On re-fetch/reconnect/Retry the user's chosen model must
727 // survive — keep it as long as it's still present in the refreshed list.
728 val current = _selectedModel.value
729 if (current == null || options.none { it.ref == current.ref }) {
730 _selectedModel.value = resp.defaultOption(options)
733 onNull = { _models.value = emptyList(); _selectedModel.value = null },
734 reloadTrigger = _modelsReload,
736 // Load the agent catalog (non-fatal).
738 tag = "agent catalog",
739 loading = _agentsLoading,
740 error = _agentsError,
741 fetch = { it.agents() },
742 onSuccess = { _agents.value = it },
743 onNull = { _agents.value = emptyList() },
744 reloadTrigger = _agentsReload,
746 // Load the command catalog (non-fatal).
748 tag = "command catalog",
749 loading = _commandsLoading,
750 error = _commandsError,
751 fetch = { it.commands() },
752 onSuccess = { _commands.value = it },
753 onNull = { _commands.value = emptyList() },
754 reloadTrigger = _commandsReload,
756 // If the SSE stream drops mid-run, the run indicator would spin forever;
757 // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
758 viewModelScope.launch {
759 container.activeConnection.collectLatest { conn ->
760 if (conn == null) return@collectLatest
762 // Loop so a transient non-cancellation exception doesn't terminate the
763 // state watcher for this connection. Without the loop, a single thrown
764 // error returns the collectLatest lambda and the _running reset on
765 // Disconnected/Failed never fires again for this connection — a dropped
766 // stream leaves the working spinner (and the RunForegroundService) stuck.
767 while (currentCoroutineContext().isActive) {
769 conn.events.state.collect { state ->
770 if ((state == EventStreamClient.ConnectionState.Disconnected ||
771 state == EventStreamClient.ConnectionState.Failed) && _running.value) {
772 _running.value = false
775 } catch (e: CancellationException) {
777 } catch (e: Exception) {
778 Log.w("ChatViewModel", "SSE state collector error, restarting", e)
781 } catch (e: CancellationException) {
783 } catch (e: Exception) {
784 Log.w("ChatViewModel", "SSE state collector loop error", e)
790 /** Sends [text] together with any staged attachments; returns true on success so the
791 * caller can clear the draft only then. */
792 fun send(text: String): Boolean = send(text, includeAttachments = true)
795 * Core send. [includeAttachments] is false for the internal auto-send paths (a queued
796 * follow-up, a retry) so staged attachments aren't silently re-sent with unrelated text.
798 private fun send(text: String, includeAttachments: Boolean, idempotencyKey: String? = null): Boolean {
799 val trimmed = text.trim()
800 val attachments = if (includeAttachments) _attachments.value else emptyList()
801 // An image-only prompt (attachments, no text) is valid; a blank prompt with nothing
803 if (trimmed.isEmpty() && attachments.isEmpty()) return false
804 // Offline, or no active connection: queue the prompt to the outbox (flushed on
805 // reconnect) instead of failing the send. Device-offline is caught even when a stale
806 // connection object lingers, so a phone that lost signal mid-conversation still
807 // captures the message rather than bouncing it off a dead socket.
808 val conn = connection
809 if (conn == null || !container.isOnline.value) {
810 return enqueueOffline(trimmed, attachments)
812 if (!_running.compareAndSet(false, true)) return false
813 // A new run is starting: reopen the re-light gate so this run's streamed parts keep the
814 // indicator lit (and a later SessionIdle re-closes it).
815 runEndedByIdle = false
816 // A plain manual Send of the auto-restored failed draft must reuse the stashed key too
817 // (retryFailed passes it explicitly; the composer's Send does not), so a lost-response
818 // re-send stays deduplicated server-side instead of starting a duplicate agent run.
819 // Capture the match BEFORE clearing _failedDraft just below.
820 val reuseKey = if (idempotencyKey == null && trimmed == _failedDraft.value?.trim()) {
825 _failedDraft.value = null
826 // Stable idempotency key for THIS online attempt: a brand-new message gets a fresh key,
827 // while retryFailed() (or a manual re-send of the restored failed draft) re-submits with the
828 // key it stashed on the original failure — so a retry after a lost response is deduplicated
829 // server-side (no duplicate agent run).
830 val key = idempotencyKey ?: reuseKey ?: java.util.UUID.randomUUID().toString()
831 // Clear the staged attachments optimistically so the composer empties immediately;
832 // restore them if the send fails (mirrors the draft handling below).
833 if (attachments.isNotEmpty()) _attachments.value = emptyList()
834 // Clear the in-memory draft for the UI immediately, but don't persist the clear
835 // yet — if the send fails and the process dies before we restore, the draft
836 // would be lost forever. The persisted draft is cleared only on success.
837 // suppressDraftPersist prevents the debounced collector from persisting the
838 // empty draft before the send resolves.
840 // Only clear when the text being sent IS the current draft. send() is also
841 // invoked for auto-sent queued follow-ups (SessionIdle) and retryFailed(), where
842 // the user may have typed a NEW draft since — wiping it would lose that text.
843 if (_draft.value.trim() == trimmed) {
844 suppressDraftPersist.set(true)
847 viewModelScope.launch {
848 runCatchingCancellable {
849 conn.repository.sendPrompt(
852 attachments = attachments.map { it.part },
853 model = _selectedModel.value?.ref,
854 agent = _selectedAgent.value,
855 idempotencyKey = key,
858 suppressDraftPersist.set(false)
859 _failedDraft.value = trimmed
860 failedIdempotencyKey = key
861 // Only restore the draft if the user hasn't typed anything new since.
862 if (_draft.value.isBlank()) updateDraft(trimmed)
863 // Restore the staged attachments too, so a failed send doesn't lose them —
864 // but only if the user hasn't staged new ones in the meantime.
865 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
866 // Re-persist so the restored attachments survive process death after a failure.
868 // Retryable: this is the failed send whose prompt Retry re-submits.
869 _errorEvents.trySend(ChatError(container.friendlyError(it), retryable = true))
870 _running.value = false
872 suppressDraftPersist.set(false)
873 // This attempt reached the server, so the stashed retry key is spent — drop it so a
874 // later unrelated send can't reuse it (retryFailed clears it on success too).
875 failedIdempotencyKey = null
876 // Send succeeded — the attachments rode along, so clear their persisted copy
877 // (or keep any the user staged after the optimistic clear).
879 // Send succeeded — persist the cleared draft, but only if the user hasn't
880 // typed a new one while the send was in flight. Otherwise clearing the
881 // store echoes back through the draft observer and wipes the in-progress
882 // text (data loss). Mirrors the guard on the failure path above.
883 if (_draft.value.isBlank()) {
884 lastPersistedDraft = ""
885 container.draftStore.set(sessionId, "")
887 // Don't reset _running here: the agent continues streaming via SSE.
888 // _running is cleared on SessionIdle/SessionError (see event collector)
889 // or when the SSE stream drops (see connection state watcher below).
896 * Queue [trimmed] (+ [attachments]) to the offline outbox, clearing the composer
897 * optimistically like a real send. AppContainer flushes the outbox automatically on
898 * reconnect. The prompt is attributed to the active (or most-recent) server profile so a
899 * reconnect to a different server doesn't misfire it. On any failure the draft/attachments
900 * are restored so nothing is lost.
902 private fun enqueueOffline(trimmed: String, attachments: List<PendingAttachment>): Boolean {
903 _failedDraft.value = null
904 if (attachments.isNotEmpty()) _attachments.value = emptyList()
905 val clearDraft = _draft.value.trim() == trimmed
907 suppressDraftPersist.set(true)
910 viewModelScope.launch {
911 val profileId = connection?.profile?.id
912 ?: runCatchingCancellable { container.profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
913 if (profileId == null) {
914 // No server to attribute the message to — restore the composer and report it
915 // rather than silently dropping the prompt.
916 suppressDraftPersist.set(false)
917 if (_draft.value.isBlank()) updateDraft(trimmed)
918 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
920 _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
923 runCatchingCancellable {
924 container.outboxStore.enqueue(
926 id = java.util.UUID.randomUUID().toString(),
927 profileId = profileId,
928 sessionId = sessionId,
930 attachments = attachments.map { it.toPersisted() },
931 model = _selectedModel.value?.ref,
932 agent = _selectedAgent.value,
933 createdAt = System.currentTimeMillis(),
937 suppressDraftPersist.set(false)
938 // The staged attachments now live in the outbox item; clear their draft copy.
940 if (_draft.value.isBlank()) {
941 lastPersistedDraft = ""
942 container.draftStore.set(sessionId, "")
944 // Nudge a flush in case we're actually online with a live connection.
945 container.flushOutbox()
946 _errorEvents.trySend(ChatError(container.string(R.string.outbox_queued)))
948 suppressDraftPersist.set(false)
949 if (_draft.value.isBlank()) updateDraft(trimmed)
950 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
952 _errorEvents.trySend(ChatError(container.friendlyError(it)))
958 /** Discard a single queued (offline) message. */
959 fun discardQueued(id: String) {
960 viewModelScope.launch { runCatchingCancellable { container.outboxStore.remove(id) } }
963 /** Discard every queued (offline) message for this session. */
964 fun discardAllQueued() {
965 viewModelScope.launch { runCatchingCancellable { container.outboxStore.removeForSession(sessionId) } }
968 /** Attempt to flush the outbox now (e.g. the user tapped "Send now"). */
969 fun flushQueued() { container.flushOutbox() }
971 /** Re-send the last draft whose send failed, if any. */
973 val draft = _failedDraft.value ?: return
974 // Reuse the failed send's idempotency key so a retry after a lost response is
975 // deduplicated server-side rather than starting a duplicate run.
976 val key = failedIdempotencyKey
977 // Don't clear _failedDraft until send() accepts the text — if _running is
978 // already true, send() returns false and the draft would be lost forever.
979 // Attachments aren't re-sent on retry (they were optimistically restored to the
980 // composer on the original failure, so they'll ride the next manual send).
981 if (send(draft, includeAttachments = false, idempotencyKey = key)) {
982 _failedDraft.value = null
983 failedIdempotencyKey = null
988 * Queue [text] to be sent automatically when the current run finishes, or clear any
989 * queued follow-up when [text] is blank. Used when the user taps Send while a run
990 * is already active — the Send button is replaced by Stop during a run, but the
991 * input field stays enabled, so a follow-up typed mid-run would otherwise have
994 fun queueFollowUp(text: String) {
995 val trimmed = text.trim()
996 setQueuedFollowUp(trimmed.takeIf { it.isNotEmpty() })
997 // The text now lives in the queued chip, so clear the input field just as a
998 // normal send would. No-op for the cancel case (blank text), which must leave
999 // whatever the user has since typed untouched.
1000 if (trimmed.isNotEmpty()) {
1002 // Persist the cleared draft IMMEDIATELY, not just in memory: the follow-up was already
1003 // persisted synchronously above, so if the process is killed before the debounced
1004 // draft-persist fires, the same text would remain under BOTH the draft and follow-up
1005 // keys — and cold-start recovery would restore it to the composer AND re-queue it (a
1006 // duplicate). Mirror send()'s lastPersistedDraft update so the drafts observer treats
1007 // this store change as our own echo and doesn't write the cleared value back.
1008 lastPersistedDraft = ""
1009 container.draftStore.flushDraft(sessionId, "")
1013 /** Set or clear the queued follow-up and mirror it to persistence, so a follow-up
1014 * queued mid-run survives process death (recovered on the next open of this session).
1015 * Passing null / blank clears both the in-memory value and the persisted copy. */
1016 private fun setQueuedFollowUp(text: String?) {
1017 _queuedFollowUp.value = text
1018 container.draftStore.flushFollowUp(sessionId, text.orEmpty())
1021 /** Transient flag set by [refreshMessages] so the top-bar refresh icon can show
1022 * a brief spinner as immediate tap feedback. The SSE reconnect triggered by
1023 * refreshMessages may not visibly change [connectionState] (it's already
1024 * Connected), so without this the tap appears to do nothing. Clears after a
1025 * short delay or when the connection state next becomes Connected. */
1026 private val _refreshing = MutableStateFlow(false)
1027 val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
1029 /** Manually re-fetch messages by forcing the SSE stream to reconnect, which
1030 * triggers the repository's re-seed-from-REST logic. Gives the user a recovery
1031 * path when the SSE stream silently drops and the auto-reconnect re-seed is slow
1032 * or fails — a pull-to-refresh or top-bar refresh forces a fresh fetch under the
1033 * existing generation-guarded merge. Also clears [loadError] optimistically and
1034 * sets [refreshing] for immediate tap feedback. */
1035 fun refreshMessages() {
1036 val conn = connection ?: return
1037 _loadError.value = false
1038 _refreshing.value = true
1039 conn.events.triggerReconnect()
1040 viewModelScope.launch {
1041 delay(NetworkConfig.refreshFeedbackMs)
1042 _refreshing.value = false
1046 /** Invoke a slash-command by name via the server's /command endpoint. */
1047 fun runCommand(command: Command) {
1048 val conn = connection ?: return
1049 // A run is already in flight — surface feedback instead of silently dropping the
1050 // command (the picker stays openable during a run and dismisses on select).
1051 if (!_running.compareAndSet(false, true)) {
1052 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1055 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1056 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1057 runEndedByIdle = false
1058 viewModelScope.launch {
1059 runCatchingCancellable {
1060 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
1062 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1063 _running.value = false
1069 val conn = connection ?: return
1070 // Drop any queued follow-up so the SessionIdle that follows the abort doesn't
1071 // auto-send it — the user tapped Stop to halt work, not to trigger the next turn.
1072 setQueuedFollowUp(null)
1073 if (!_aborting.compareAndSet(false, true)) return
1074 viewModelScope.launch {
1076 // Run the abort POST under NonCancellable so it survives this scope being
1077 // cancelled. The "Stop and exit" path calls abort() then navigates back
1078 // immediately, which pops this ViewModel's back-stack entry → onCleared() →
1079 // viewModelScope.cancel(); without this the in-flight abort is cancelled (often
1080 // before the request even reaches the server) and the run keeps burning tokens
1081 // server-side despite the user explicitly stopping it.
1082 runCatchingCancellable { withContext(NonCancellable) { conn.repository.abort(sessionId) } }
1083 .onSuccess { _running.value = false }
1084 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1086 _aborting.value = false
1091 fun addAttachment(attachment: PendingAttachment) {
1092 // Enforce the cumulative size cap across the whole staged set (the per-file size and the
1093 // count caps are checked upstream). The base64 data URLs are held in memory and
1094 // re-serialized on every add/remove and again when the request body is built, so an
1095 // unbounded set OOMs. Reject rather than add, mirroring the other attachment limits'
1096 // snackbar via errorEvents. Size is the raw (pre-base64) byte count, matching the
1097 // per-file check, and includes attachments already staged (incl. restored ones).
1098 val staged = _attachments.value.sumOf { base64DataUrlByteSize(it.part.url) }
1099 if (staged + base64DataUrlByteSize(attachment.part.url) > NetworkConfig.maxTotalAttachmentBytes) {
1100 _errorEvents.trySend(ChatError(container.string(R.string.attachment_limit)))
1103 _attachments.update { it + attachment }
1104 persistAttachments()
1107 fun removeAttachment(id: String) {
1108 _attachments.update { list -> list.filterNot { it.id == id } }
1109 persistAttachments()
1112 fun clearAttachments() {
1113 _attachments.value = emptyList()
1114 persistAttachments()
1117 /** Persist the current staged attachments so an interrupted compose survives process
1118 * death (mirrors draft persistence). Fire-and-forget on [viewModelScope]. */
1119 private fun persistAttachments() {
1120 val snapshot = _attachments.value
1121 viewModelScope.launch {
1122 runCatchingCancellable { container.attachmentDraftStore.save(sessionId, snapshot.map { it.toPersisted() }) }
1123 .onFailure { Log.w("ChatViewModel", "Failed to persist attachments", it) }
1127 /** Revert the conversation to just before [messageId], hiding everything after it. The
1128 * authoritative state also arrives via SessionUpdated; we flag it here for immediate
1130 fun revertTo(messageId: String) {
1131 val conn = connection ?: return
1132 viewModelScope.launch {
1133 runCatchingCancellable { conn.api.revert(sessionId, messageId) }
1134 .onSuccess { _reverted.value = it.isReverted }
1135 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1139 /** Load a previously-sent user prompt back into the composer to edit and re-send.
1140 * Reverts the conversation to just before [messageId] (hiding it and everything after)
1141 * and prefills the draft with [text]. The next Send continues from that checkpoint,
1142 * effectively replacing the edited message; the reverted banner's Undo restores it.
1143 * Overwrites the current draft — this is an explicit edit action on that message. */
1144 fun editMessage(messageId: String, text: String) {
1146 _draft.value = text.take(NetworkConfig.maxDraftLengthChars)
1149 /** Prefill the composer with [text] as a Markdown blockquote so the user can respond to a
1150 * specific message inline. Appends to any existing draft (with a blank line) rather than
1151 * overwriting, so quoting doesn't discard text already being typed. */
1152 fun quoteReply(text: String) {
1153 val quoted = text.trim().lineSequence().joinToString("\n") { "> $it" }
1154 if (quoted.isBlank()) return
1155 val current = _draft.value
1156 val combined = if (current.isBlank()) "$quoted\n\n" else "$current\n\n$quoted\n\n"
1157 // Cap like the composer's typed/pasted/dictated input so quoting onto a near-full draft
1158 // can't push it past the limit (negative remaining counter + an over-cap prompt sent).
1159 _draft.value = combined.take(NetworkConfig.maxDraftLengthChars)
1162 /** Fork the conversation by creating a brand-new session seeded with [text] as its first
1163 * prompt (using the currently-selected model/agent). Emits the new session id via
1164 * [branchEvents] so the UI can navigate to it. There's no server-side "branch" endpoint,
1165 * so this starts a fresh session from the chosen prompt rather than copying history. */
1166 fun branchFrom(text: String) {
1167 val conn = connection ?: return
1168 val prompt = text.trim()
1169 if (prompt.isEmpty()) return
1170 viewModelScope.launch {
1171 runCatchingCancellable {
1172 val session = conn.repository.createSession(title = null)
1173 conn.repository.sendPrompt(
1176 model = _selectedModel.value?.ref,
1177 agent = _selectedAgent.value,
1181 .onSuccess { newId -> _branchEvents.trySend(newId) }
1182 .onFailure { _errorEvents.trySend(ChatError(container.string(R.string.branch_failed))) }
1186 /** Undo the active revert checkpoint, restoring the hidden messages. */
1188 val conn = connection ?: return
1189 viewModelScope.launch {
1190 runCatchingCancellable { conn.api.unrevert(sessionId) }
1191 .onSuccess { _reverted.value = it.isReverted }
1192 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1196 /** Create (or fetch the existing) public share link. The URL is exposed via [shareUrl]
1197 * and also emitted once via [shareLinkEvents] so the UI can copy it to the clipboard. */
1198 fun shareSession() {
1199 val conn = connection ?: return
1200 viewModelScope.launch {
1201 runCatchingCancellable { conn.api.shareSession(sessionId) }
1202 .onSuccess { session ->
1203 val url = session.share?.url?.takeIf { it.isNotBlank() }
1204 _shareUrl.value = url
1205 if (url != null) _shareLinkEvents.trySend(url)
1207 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1211 /** Revoke the session's public share link. */
1212 fun unshareSession() {
1213 val conn = connection ?: return
1214 viewModelScope.launch {
1215 runCatchingCancellable { conn.api.unshareSession(sessionId) }
1216 .onSuccess { _shareUrl.value = it.share?.url?.takeIf { it.isNotBlank() } }
1217 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1221 /** Compact the conversation via the summarize endpoint; the summary streams back via SSE.
1222 * Uses the currently-selected model (required by the endpoint). */
1224 val conn = connection ?: return
1225 val model = _selectedModel.value?.ref ?: run {
1226 _errorEvents.trySend(ChatError(container.string(R.string.needs_model)))
1229 if (!_running.compareAndSet(false, true)) {
1230 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1233 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1234 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1235 runEndedByIdle = false
1236 viewModelScope.launch {
1237 runCatchingCancellable { conn.api.summarize(sessionId, model) }
1239 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1240 _running.value = false
1245 /** Analyze the project and (re)generate its AGENTS.md; the run streams back via SSE. */
1247 val conn = connection ?: return
1248 if (!_running.compareAndSet(false, true)) {
1249 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1252 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1253 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1254 runEndedByIdle = false
1255 viewModelScope.launch {
1256 runCatchingCancellable { conn.api.initSession(sessionId) }
1258 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1259 _running.value = false
1264 /** Run a one-off shell [command] in the session's worktree; output streams back via SSE.
1265 * The server requires an agent to scope the run — use the selected one, else the primary. */
1266 fun runShell(command: String) {
1267 val conn = connection ?: return
1268 val cmd = command.trim()
1269 if (cmd.isEmpty()) return
1270 val agent = _selectedAgent.value
1271 ?: _agents.value.firstOrNull { it.isPrimary }?.name
1272 ?: _agents.value.firstOrNull()?.name
1273 ?: DEFAULT_SHELL_AGENT
1274 if (!_running.compareAndSet(false, true)) {
1275 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1278 // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1279 // reconnect keeps the working indicator lit instead of leaving it stuck off.
1280 runEndedByIdle = false
1281 viewModelScope.launch {
1282 runCatchingCancellable { conn.api.shell(sessionId, cmd, agent, _selectedModel.value?.ref) }
1284 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1285 _running.value = false
1290 /** Rename the current session via PATCH /session/:id. On success updates [sessionTitle]
1291 * so the top bar reflects the new name immediately. A failure surfaces as a snackbar;
1292 * the caller keeps the dialog open so the user can retry without retyping. */
1293 fun renameSession(newTitle: String) {
1294 val conn = connection ?: return
1295 val title = newTitle.trim()
1296 if (title.isEmpty() || title == _sessionTitle.value) return
1297 viewModelScope.launch {
1298 runCatchingCancellable { conn.api.updateSession(sessionId, title) }
1299 .onSuccess { _sessionTitle.value = it.displayTitle }
1300 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1304 /** Delete the current session via DELETE /session/:id. Surfaces a toast (via the UI's
1305 * sessionDeleted flow) and navigates away on success; a failure surfaces as a snackbar.
1306 * Unlike [SessionListViewModel.deleteSession] there's no undo window — the user is
1307 * already viewing the session, so a confirmation dialog guards the action instead. */
1308 fun deleteSession() {
1309 val conn = connection ?: return
1310 viewModelScope.launch {
1311 runCatchingCancellable { conn.repository.deleteSession(sessionId) }
1313 container.draftStore.remove(sessionId)
1314 container.attachmentDraftStore.remove(sessionId)
1315 container.messageCacheStore.remove(conn.profile.id, sessionId)
1316 container.outboxStore.removeForSession(sessionId)
1317 container.clearUnread(sessionId)
1318 _sessionDeleted.value = true
1320 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1324 /** Reconnect to the most recently used server profile (used when the connection is gone). */
1326 if (container.activeConnection.value != null) return
1327 // Guard against concurrent reconnect calls: the user can tap the button
1328 // multiple times while _reconnecting is true, which would launch parallel
1329 // connect() coroutines that race to set the active connection, leaking the
1330 // intermediate connections.
1331 if (!_reconnecting.compareAndSet(false, true)) return
1332 viewModelScope.launch {
1334 val recent = runCatchingCancellable { container.profileStore.profiles.first() }
1335 .getOrDefault(emptyList())
1337 if (recent == null) {
1338 _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
1341 runCatchingCancellable {
1342 val conn = container.connect(recent)
1345 container.disconnect()
1346 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1349 _reconnecting.value = false
1354 fun respondPermission(permission: Permission, response: PermissionResponse) {
1355 val conn = connection ?: return
1356 // Dismiss optimistically (revealing any queued request); permission.replied will confirm.
1357 // If the call fails we re-surface this request so the user can retry instead of being stuck
1358 // with a dismissed dialog and a tool run that's still paused server-side.
1359 resolvePermission(permission.id)
1360 viewModelScope.launch {
1361 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
1363 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1364 enqueuePermission(permission)
1369 private companion object {
1370 /** Fallback agent for the shell endpoint when none is selected and no agent catalog
1371 * loaded. opencode's default primary agent is "build". */
1372 const val DEFAULT_SHELL_AGENT = "build"
1375 override fun onCleared() {
1377 // Flush any pending debounced draft so it survives navigation. Uses an
1378 // asynchronous apply() (not a synchronous commit) so the main thread isn't
1379 // blocked on disk I/O — Android's SharedPreferences framework guarantees
1380 // pending apply() writes are flushed before the process exits.
1381 // Flush the pending draft — including an empty one. Persistence is otherwise debounced,
1382 // so if the user cleared the input and navigated back within the debounce window, the
1383 // debounce coroutine was cancelled with viewModelScope and the prefs still hold the
1384 // previous non-empty draft. flushDraft() removes the key when the text is blank, so
1385 // flushing commits the clear instead of resurrecting the deleted text.
1387 // BUT respect the send-in-flight guard: send() optimistically clears _draft to "" WITHOUT
1388 // persisting the clear so a failed send can restore the text. If the user taps Send then
1389 // immediately navigates back (cancelling the in-flight send), flushing the blank _draft here
1390 // would remove the persisted key and erase the pre-send draft with no recovery. Skip the
1391 // flush while a send is in flight so the pre-send draft stays persisted (recoverable).
1392 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