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.MessageWithParts
8import soy.iko.opencode.data.model.ModelOption
9import soy.iko.opencode.data.model.Permission
10import soy.iko.opencode.data.model.PermissionReplied
11import soy.iko.opencode.data.model.PermissionResponse
12import soy.iko.opencode.data.model.PermissionUpdated
13import soy.iko.opencode.data.model.SessionDeleted
14import soy.iko.opencode.data.model.SessionUpdated
15import soy.iko.opencode.data.model.TextPart
16import soy.iko.opencode.data.model.defaultOption
17import soy.iko.opencode.data.model.toOptions
18import soy.iko.opencode.data.network.EventStreamClient
19import soy.iko.opencode.data.network.NetworkConfig
20import soy.iko.opencode.data.repo.SessionRepository
21import soy.iko.opencode.di.AppContainer
22import soy.iko.opencode.R
23import soy.iko.opencode.util.runCatchingCancellable
24import soy.iko.opencode.util.safeExceptionSummary
25import android.util.Log
26import kotlinx.coroutines.ExperimentalCoroutinesApi
27import kotlinx.coroutines.channels.BufferOverflow
28import kotlinx.coroutines.channels.Channel
29import kotlinx.coroutines.flow.Flow
30import kotlinx.coroutines.flow.MutableStateFlow
31import kotlinx.coroutines.flow.SharingStarted
32import kotlinx.coroutines.flow.StateFlow
33import kotlinx.coroutines.flow.asStateFlow
34import kotlinx.coroutines.flow.receiveAsFlow
35import kotlinx.coroutines.CancellationException
36import kotlinx.coroutines.flow.collectLatest
37import kotlinx.coroutines.flow.debounce
38import kotlinx.coroutines.flow.drop
39import kotlinx.coroutines.flow.first
40import kotlinx.coroutines.flow.filterIsInstance
41import kotlinx.coroutines.flow.flatMapLatest
42import kotlinx.coroutines.flow.flowOf
43import kotlinx.coroutines.flow.map
44import kotlinx.coroutines.flow.merge
45import kotlinx.coroutines.flow.onEach
46import kotlinx.coroutines.flow.retryWhen
47import kotlinx.coroutines.flow.stateIn
48import kotlinx.coroutines.delay
49import kotlinx.coroutines.launch
52 * A user-facing error to surface as a snackbar. [retryable] is true only for a failed
53 * message *send* (whose draft is held in [ChatViewModel.failedDraft]); the snackbar attaches
54 * a Retry action solely for those so an unrelated error (e.g. a message-load failure) can't
55 * inherit a Retry that silently re-submits the last prompt.
57data class ChatError(val message: String, val retryable: Boolean = false)
59@OptIn(ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
61 private val container: AppContainer,
62 private val sessionId: String,
65 private val connection get() = container.activeConnection.value
67 val connected: Boolean get() = connection != null
69 private val _loading = MutableStateFlow(true)
70 val loading: StateFlow<Boolean> = _loading.asStateFlow()
72 /** Set when the messages flow failed to load (and the list is empty), so the UI
73 * can render a distinct error state instead of masquerading as an empty
74 * conversation. Cleared on a successful non-empty emission. */
75 private val _loadError = MutableStateFlow(false)
76 val loadError: StateFlow<Boolean> = _loadError.asStateFlow()
78 /** Tracks whether the messages flow has ever emitted a non-empty list, so
79 * [messages]' retryWhen can decide whether a re-fetch failure should set
80 * [loadError] (nothing shown yet → error screen) or just emit a snackbar
81 * (already showing messages → don't replace the conversation with an error). */
82 private var hasShownMessages = false
84 /** True once we've surfaced a snackbar for the current message-load failure streak.
85 * Reset on any successful emission so a persistent failure doesn't spam a fresh
86 * snackbar every retry cycle (every few seconds). */
87 private var loadErrorSnackbarShown = false
89 /** Separate from [loading]: tracks the manual reconnect() flow so its spinner
90 * doesn't conflict with the messages flow's loading state. */
91 private val _reconnecting = MutableStateFlow(false)
92 val reconnecting: StateFlow<Boolean> = _reconnecting.asStateFlow()
94 /** When true, the debounced draft collector skips persisting an empty draft so
95 * [send]'s deliberate non-persistence of the cleared draft isn't undone by the
96 * debounce timer firing before the send completes. */
97 private val suppressDraftPersist = java.util.concurrent.atomic.AtomicBoolean(false)
99 /** The draft value this VM last persisted, so the drafts observer can tell its own
100 * (debounced, possibly stale) echo apart from a genuine external write. Without this
101 * a debounced persist of an older value could echo back and overwrite newer text the
102 * user has since typed. */
103 @Volatile private var lastPersistedDraft: String? = null
105 /** Per-catalog reload triggers: incrementing one causes [observeCatalog]'s
106 * collectLatest to cancel any in-flight fetch and start a fresh one, so a
107 * manual reload supersedes a stale observeCatalog fetch. */
108 private val _modelsReload = MutableStateFlow(0)
109 private val _agentsReload = MutableStateFlow(0)
110 private val _commandsReload = MutableStateFlow(0)
112 val messages: StateFlow<List<MessageWithParts>> =
113 container.activeConnection
114 .flatMapLatest { conn ->
115 conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
118 _loading.value = false
119 // A value flowed through: the stream recovered, so allow the next distinct
120 // failure to surface a fresh snackbar again.
121 loadErrorSnackbarShown = false
122 if (it.isNotEmpty()) {
123 hasShownMessages = true
124 _loadError.value = false
127 .retryWhen { cause, _ ->
128 _loading.value = false
129 // Only surface the persistent error state when there's nothing to
130 // show — if we already have messages, a transient re-fetch failure
131 // is better surfaced as a snackbar (via errorEvents) than by
132 // replacing the visible conversation with an error screen.
133 if (!hasShownMessages) _loadError.value = true
134 // Surface the snackbar only once per failure streak — retryWhen loops
135 // every retryInitialDelayMs, so emitting here unconditionally would spam a
136 // new snackbar every few seconds on a persistent load failure.
137 if (!loadErrorSnackbarShown) {
138 _errorEvents.trySend(ChatError(container.friendlyError(cause)))
139 loadErrorSnackbarShown = true
141 // Delay before retrying to avoid a tight loop on persistent errors.
142 delay(NetworkConfig.retryInitialDelayMs)
146 scope = viewModelScope,
147 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
148 initialValue = emptyList(),
151 /** Whether the conversation has any messages. Derived separately so the top bar
152 * (share button enabled state) can observe this cheap boolean instead of the
153 * full messages list, avoiding per-token recomposition of the app bar during
155 val hasMessages: StateFlow<Boolean> = messages
156 .map { it.isNotEmpty() }
158 scope = viewModelScope,
159 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
160 initialValue = false,
163 private val _running = MutableStateFlow(false)
164 val running: StateFlow<Boolean> = _running.asStateFlow()
166 /** True while an abort REST call is in flight, so the Stop button can show a
167 * spinner and prevent double-taps from firing a second abort. */
168 private val _aborting = MutableStateFlow(false)
169 val aborting: StateFlow<Boolean> = _aborting.asStateFlow()
171 /** A follow-up the user typed while a run was active. send() queues it here and
172 * auto-sends once the run completes (SessionIdle), so the user isn't blocked with
173 * no way to send and no indication why the Send button is gone. */
174 private val _queuedFollowUp = MutableStateFlow<String?>(null)
175 val queuedFollowUp: StateFlow<String?> = _queuedFollowUp.asStateFlow()
177 /** One-shot error events surfaced as snackbars. A Channel (not SharedFlow) so an event
178 * emitted before the UI subscribes is buffered and still delivered — a SharedFlow with
179 * replay=0 would drop it (e.g. a VM-init catalog fetch failing before first
180 * composition). Each event is delivered exactly once and not replayed to a
181 * re-subscribing collector, so rotation doesn't re-show a stale snackbar. */
182 private val _errorEvents = Channel<ChatError>(
183 capacity = NetworkConfig.snackbarEventBufferCapacity,
184 onBufferOverflow = BufferOverflow.DROP_OLDEST,
186 val errorEvents: Flow<ChatError> = _errorEvents.receiveAsFlow()
188 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
189 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
191 private val _modelsLoading = MutableStateFlow(true)
192 val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
194 private val _modelsError = MutableStateFlow(false)
195 val modelsError: StateFlow<Boolean> = _modelsError.asStateFlow()
197 private val _selectedModel = MutableStateFlow<ModelOption?>(null)
198 val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
200 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
201 container.activeConnection
202 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
204 scope = viewModelScope,
205 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
206 initialValue = EventStreamClient.ConnectionState.Disconnected,
209 private val _pendingPermission = MutableStateFlow<Permission?>(null)
210 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
212 private val _agents = MutableStateFlow<List<Agent>>(emptyList())
213 val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
215 private val _agentsLoading = MutableStateFlow(true)
216 val agentsLoading: StateFlow<Boolean> = _agentsLoading.asStateFlow()
218 private val _agentsError = MutableStateFlow(false)
219 val agentsError: StateFlow<Boolean> = _agentsError.asStateFlow()
221 private val _selectedAgent = MutableStateFlow<String?>(null)
222 val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
224 private val _commands = MutableStateFlow<List<Command>>(emptyList())
225 val commands: StateFlow<List<Command>> = _commands.asStateFlow()
227 private val _commandsLoading = MutableStateFlow(true)
228 val commandsLoading: StateFlow<Boolean> = _commandsLoading.asStateFlow()
230 private val _commandsError = MutableStateFlow(false)
231 val commandsError: StateFlow<Boolean> = _commandsError.asStateFlow()
233 private val _sessionTitle = MutableStateFlow<String?>(null)
234 val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
236 /** Set when the current session is deleted via SSE, so the UI can navigate away. */
237 private val _sessionDeleted = MutableStateFlow(false)
238 val sessionDeleted: StateFlow<Boolean> = _sessionDeleted.asStateFlow()
240 /** The text of the last send that failed, surfaced so the UI can offer a retry. */
241 private val _failedDraft = MutableStateFlow<String?>(null)
242 val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
244 /** Per-session draft, persisted so it survives navigation/process death. */
245 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
246 val draft: StateFlow<String> = _draft.asStateFlow()
249 // DraftStore loads SharedPreferences asynchronously; on a cold start the
250 // synchronous get() above returns "" until the background load completes.
251 // Observe the ready signal and re-seed the draft so it appears in the UI.
252 viewModelScope.launch {
253 container.draftStore.ready.collect { ready ->
254 if (!ready) return@collect
255 if (_draft.value.isEmpty()) {
256 _draft.value = container.draftStore.get(sessionId)
258 // Recover a follow-up queued before process death (its in-memory StateFlow
259 // is gone). If the input is now free, drop it back there for review/send and
260 // consume the stored copy; otherwise re-queue it so it auto-sends when the
261 // current run next idles. Guarded on == null so it never clobbers a
262 // follow-up the user queued after opening this session.
263 val savedFollowUp = container.draftStore.getFollowUp(sessionId)
264 if (savedFollowUp.isNotEmpty() && _queuedFollowUp.value == null) {
265 if (_draft.value.isEmpty()) {
266 _draft.value = savedFollowUp
267 container.draftStore.flushFollowUp(sessionId, "")
269 _queuedFollowUp.value = savedFollowUp
274 // Observe the drafts store for external mutations to this session's draft
275 // (e.g. a share injected via draftStore.set in two-pane mode, or setImmediate
276 // before navigation in single-pane mode). Without this, a share injected into
277 // an already-open session never appears in the input field — the init-time
278 // read and the ready-only re-seed miss it when the draft is non-empty.
279 // The VM's own debounced persist writes the same value _draft already holds,
280 // so those updates are no-ops here (storeValue == _draft.value). The
281 // suppressDraftPersist guard prevents a stale store value from clobbering
282 // the deliberate draft clear during a send (the store still holds the
283 // pre-send draft until the send resolves).
284 viewModelScope.launch {
285 container.draftStore.drafts.collect { drafts ->
286 // Ignore emissions until the initial disk load completes: the load itself
287 // emits the persisted snapshot, and applying it here would clobber text the
288 // user typed during the async load (the `ready` observer above seeds an
289 // empty draft). After ready, emissions are external writes.
290 if (!container.draftStore.ready.value) return@collect
291 val storeValue = drafts[sessionId].orEmpty()
292 // Ignore this VM's own (debounced, possibly stale) persistence echo — only a
293 // genuine external write (e.g. a two-pane share injection) should overwrite
295 if (storeValue == lastPersistedDraft) return@collect
296 if (storeValue != _draft.value && !suppressDraftPersist.get()) {
297 _draft.value = storeValue
303 fun selectModel(option: ModelOption) { _selectedModel.value = option }
305 fun selectAgent(name: String?) { _selectedAgent.value = name }
308 val conn = connection ?: return
309 viewModelScope.launch {
310 conn.api.invalidateProvidersCache()
311 _modelsReload.value++
316 val conn = connection ?: return
317 viewModelScope.launch {
318 conn.api.invalidateAgentsCache()
319 _agentsReload.value++
323 fun reloadCommands() {
324 val conn = connection ?: return
325 viewModelScope.launch {
326 conn.api.invalidateCommandsCache()
327 _commandsReload.value++
332 * Shared helper for init-block catalog observers: re-fetches whenever the active
333 * connection or the catalog's reload trigger changes, invoking [onNull] to clear
334 * state on null so stale data from the old server doesn't persist. The
335 * [reloadTrigger] is merged with [activeConnection] so a manual reload (via
336 * reloadModels/reloadAgents/reloadCommands) cancels any in-flight observe fetch
337 * via collectLatest, preventing a stale observe result from overwriting a fresh
338 * reload result. Failure is non-fatal — the error flag is surfaced to the UI.
340 private fun <T> observeCatalog(
342 loading: MutableStateFlow<Boolean>,
343 error: MutableStateFlow<Boolean>,
344 fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
345 onSuccess: (T) -> Unit,
347 reloadTrigger: StateFlow<Int>,
349 viewModelScope.launch {
350 merge(container.activeConnection, reloadTrigger).collectLatest { _ ->
351 val conn = container.activeConnection.value
352 if (conn == null) { onNull(); loading.value = false; error.value = false; return@collectLatest }
355 runCatchingCancellable { fetch(conn.api) }
356 // fetch() does HTTP via withRetry, so the failure can be a
357 // ClientRequestException whose message embeds the full request URL
358 // (may contain auth/paths). Log only a scrubbed summary.
359 .onFailure { Log.w("ChatViewModel", "Failed to load $tag: ${safeExceptionSummary(it)}"); error.value = true }
360 .getOrNull()?.let(onSuccess)
361 loading.value = false
366 fun updateDraft(text: String) {
371 // Debounce draft persistence so we don't write to disk on every keystroke.
372 // Skip persisting empty drafts that were set by send() (suppressed via
373 // suppressDraftPersist) — send() clears the in-memory draft immediately for
374 // UI feedback but deliberately doesn't persist the clear until the send
375 // succeeds, so a failed send can restore the draft.
376 viewModelScope.launch {
377 _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
378 if (text.isBlank() && suppressDraftPersist.get()) return@collect
379 lastPersistedDraft = text
380 runCatchingCancellable { container.draftStore.set(sessionId, text) }
381 .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
384 // Reset per-connection state when the active server changes so stale spinners,
385 // permission dialogs, errors, and agent selections from the old server don't
386 // persist into the new one.
387 viewModelScope.launch {
388 container.activeConnection.collectLatest { conn ->
390 // The connection dropped (e.g. disconnect() mid-run). No SSE stream will
391 // arrive to deliver SessionIdle/SessionError, so reset the run state here —
392 // otherwise the working spinner sticks on, ChatScreen keeps keepScreenOn +
393 // the RunForegroundService alive, and the Stop button no-ops (abort() early-
394 // returns with no connection) until a later reconnect happens to clear it.
395 _running.value = false
396 _aborting.value = false
397 _pendingPermission.value = null
400 _running.value = false
401 _pendingPermission.value = null
402 _failedDraft.value = null
403 // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
404 // user intent that is now persisted; wiping it on every (re)connect — which
405 // this collector does, including transient SSE drops and the cold-start
406 // restore path — would drop a legitimately queued/recovered follow-up.
407 _selectedAgent.value = null
408 _sessionTitle.value = null
410 conn.events.events.collect { event ->
411 if (SessionRepository.isIdle(event, sessionId)) {
412 _running.value = false
413 // Auto-send a queued follow-up once the previous run finishes,
414 // so the user's drafted-while-running message isn't lost.
415 val queued = _queuedFollowUp.value
416 if (queued != null) {
417 setQueuedFollowUp(null)
421 if (SessionRepository.isError(event, sessionId)) {
422 _running.value = false
423 // The run errored, so the queued follow-up won't auto-send.
424 // Restore it to the input (if free) so the user's typed text
425 // isn't silently lost; otherwise just clear the queue.
426 val queued = _queuedFollowUp.value
427 if (!queued.isNullOrEmpty() && _draft.value.isEmpty()) {
428 _draft.value = queued
430 setQueuedFollowUp(null)
431 _errorEvents.trySend(ChatError(container.string(R.string.error_agent_reported)))
434 is PermissionUpdated ->
435 if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
436 is PermissionReplied ->
437 if (event.properties.sessionID == sessionId && event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
439 if (event.properties.info.id == sessionId) _sessionTitle.value = event.properties.info.displayTitle
441 if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
442 _sessionDeleted.value = true
447 } catch (e: CancellationException) {
449 } catch (e: Exception) {
450 Log.w("ChatViewModel", "SSE event collector error", e)
454 // Load the model catalog; preselect the server default. Failure is non-fatal —
455 // sending with no model just uses the server's default agent/model.
457 tag = "model catalog",
458 loading = _modelsLoading,
459 error = _modelsError,
460 fetch = { it.providers() },
461 onSuccess = { resp ->
462 val options = resp.toOptions()
463 _models.value = options
464 // Only (re)apply the server default when there's no valid current
465 // selection. On re-fetch/reconnect/Retry the user's chosen model must
466 // survive — keep it as long as it's still present in the refreshed list.
467 val current = _selectedModel.value
468 if (current == null || options.none { it.ref == current.ref }) {
469 _selectedModel.value = resp.defaultOption(options)
472 onNull = { _models.value = emptyList(); _selectedModel.value = null },
473 reloadTrigger = _modelsReload,
475 // Load the agent catalog (non-fatal).
477 tag = "agent catalog",
478 loading = _agentsLoading,
479 error = _agentsError,
480 fetch = { it.agents() },
481 onSuccess = { _agents.value = it },
482 onNull = { _agents.value = emptyList() },
483 reloadTrigger = _agentsReload,
485 // Load the command catalog (non-fatal).
487 tag = "command catalog",
488 loading = _commandsLoading,
489 error = _commandsError,
490 fetch = { it.commands() },
491 onSuccess = { _commands.value = it },
492 onNull = { _commands.value = emptyList() },
493 reloadTrigger = _commandsReload,
495 // Resolve the human-readable session title for the app bar (non-fatal).
496 // Skips the REST call when the SSE event collector already delivered the title
497 // (SessionUpdated fires shortly after connect), avoiding a redundant full
498 // session-list download.
499 viewModelScope.launch {
500 container.activeConnection.collectLatest { conn ->
501 if (conn == null) return@collectLatest
502 if (_sessionTitle.value != null) return@collectLatest
503 runCatchingCancellable { conn.repository.listSessions() }
505 ?.firstOrNull { it.id == sessionId }
506 ?.let { _sessionTitle.value = it.displayTitle }
509 // If the SSE stream drops mid-run, the run indicator would spin forever;
510 // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
511 viewModelScope.launch {
512 container.activeConnection.collectLatest { conn ->
513 if (conn == null) return@collectLatest
515 conn.events.state.collect { state ->
516 if ((state == EventStreamClient.ConnectionState.Disconnected ||
517 state == EventStreamClient.ConnectionState.Failed) && _running.value) {
518 _running.value = false
521 } catch (e: CancellationException) {
523 } catch (e: Exception) {
524 Log.w("ChatViewModel", "SSE state collector error", e)
530 /** Sends [text]; returns true on success so the caller can clear the draft only then. */
531 fun send(text: String): Boolean {
532 val conn = connection ?: return false
533 val trimmed = text.trim()
534 if (trimmed.isEmpty() || !_running.compareAndSet(false, true)) return false
535 _failedDraft.value = null
536 // Clear the in-memory draft for the UI immediately, but don't persist the clear
537 // yet — if the send fails and the process dies before we restore, the draft
538 // would be lost forever. The persisted draft is cleared only on success.
539 // suppressDraftPersist prevents the debounced collector from persisting the
540 // empty draft before the send resolves.
542 // Only clear when the text being sent IS the current draft. send() is also
543 // invoked for auto-sent queued follow-ups (SessionIdle) and retryFailed(), where
544 // the user may have typed a NEW draft since — wiping it would lose that text.
545 if (_draft.value.trim() == trimmed) {
546 suppressDraftPersist.set(true)
549 viewModelScope.launch {
550 runCatchingCancellable {
551 conn.repository.sendPrompt(
554 model = _selectedModel.value?.ref,
555 agent = _selectedAgent.value,
558 suppressDraftPersist.set(false)
559 _failedDraft.value = trimmed
560 // Only restore the draft if the user hasn't typed anything new since.
561 if (_draft.value.isBlank()) updateDraft(trimmed)
562 // Retryable: this is the failed send whose prompt Retry re-submits.
563 _errorEvents.trySend(ChatError(container.friendlyError(it), retryable = true))
564 _running.value = false
566 suppressDraftPersist.set(false)
567 // Send succeeded — persist the cleared draft, but only if the user hasn't
568 // typed a new one while the send was in flight. Otherwise clearing the
569 // store echoes back through the draft observer and wipes the in-progress
570 // text (data loss). Mirrors the guard on the failure path above.
571 if (_draft.value.isBlank()) {
572 lastPersistedDraft = ""
573 container.draftStore.set(sessionId, "")
575 // Don't reset _running here: the agent continues streaming via SSE.
576 // _running is cleared on SessionIdle/SessionError (see event collector)
577 // or when the SSE stream drops (see connection state watcher below).
583 /** Re-send the last draft whose send failed, if any. */
585 val draft = _failedDraft.value ?: return
586 // Don't clear _failedDraft until send() accepts the text — if _running is
587 // already true, send() returns false and the draft would be lost forever.
589 _failedDraft.value = null
594 * Queue [text] to be sent automatically when the current run finishes, or clear any
595 * queued follow-up when [text] is blank. Used when the user taps Send while a run
596 * is already active — the Send button is replaced by Stop during a run, but the
597 * input field stays enabled, so a follow-up typed mid-run would otherwise have
600 fun queueFollowUp(text: String) {
601 val trimmed = text.trim()
602 setQueuedFollowUp(trimmed.takeIf { it.isNotEmpty() })
603 // The text now lives in the queued chip, so clear the input field just as a
604 // normal send would. No-op for the cancel case (blank text), which must leave
605 // whatever the user has since typed untouched.
606 if (trimmed.isNotEmpty()) _draft.value = ""
609 /** Set or clear the queued follow-up and mirror it to persistence, so a follow-up
610 * queued mid-run survives process death (recovered on the next open of this session).
611 * Passing null / blank clears both the in-memory value and the persisted copy. */
612 private fun setQueuedFollowUp(text: String?) {
613 _queuedFollowUp.value = text
614 container.draftStore.flushFollowUp(sessionId, text.orEmpty())
617 /** Transient flag set by [refreshMessages] so the top-bar refresh icon can show
618 * a brief spinner as immediate tap feedback. The SSE reconnect triggered by
619 * refreshMessages may not visibly change [connectionState] (it's already
620 * Connected), so without this the tap appears to do nothing. Clears after a
621 * short delay or when the connection state next becomes Connected. */
622 private val _refreshing = MutableStateFlow(false)
623 val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
625 /** Manually re-fetch messages by forcing the SSE stream to reconnect, which
626 * triggers the repository's re-seed-from-REST logic. Gives the user a recovery
627 * path when the SSE stream silently drops and the auto-reconnect re-seed is slow
628 * or fails — a pull-to-refresh or top-bar refresh forces a fresh fetch under the
629 * existing generation-guarded merge. Also clears [loadError] optimistically and
630 * sets [refreshing] for immediate tap feedback. */
631 fun refreshMessages() {
632 val conn = connection ?: return
633 _loadError.value = false
634 _refreshing.value = true
635 conn.events.triggerReconnect()
636 viewModelScope.launch {
637 delay(NetworkConfig.refreshFeedbackMs)
638 _refreshing.value = false
642 /** Invoke a slash-command by name via the server's /command endpoint. */
643 fun runCommand(command: Command) {
644 val conn = connection ?: return
645 // A run is already in flight — surface feedback instead of silently dropping the
646 // command (the picker stays openable during a run and dismisses on select).
647 if (!_running.compareAndSet(false, true)) {
648 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
651 viewModelScope.launch {
652 runCatchingCancellable {
653 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
655 _errorEvents.trySend(ChatError(container.friendlyError(it)))
656 _running.value = false
662 val conn = connection ?: return
663 // Drop any queued follow-up so the SessionIdle that follows the abort doesn't
664 // auto-send it — the user tapped Stop to halt work, not to trigger the next turn.
665 setQueuedFollowUp(null)
666 if (!_aborting.compareAndSet(false, true)) return
667 viewModelScope.launch {
669 runCatchingCancellable { conn.repository.abort(sessionId) }
670 .onSuccess { _running.value = false }
671 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
673 _aborting.value = false
678 /** Reconnect to the most recently used server profile (used when the connection is gone). */
680 if (container.activeConnection.value != null) return
681 // Guard against concurrent reconnect calls: the user can tap the button
682 // multiple times while _reconnecting is true, which would launch parallel
683 // connect() coroutines that race to set the active connection, leaking the
684 // intermediate connections.
685 if (!_reconnecting.compareAndSet(false, true)) return
686 viewModelScope.launch {
688 val recent = runCatchingCancellable { container.profileStore.profiles.first() }
689 .getOrDefault(emptyList())
691 if (recent == null) {
692 _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
695 runCatchingCancellable {
696 val conn = container.connect(recent)
699 container.disconnect()
700 _errorEvents.trySend(ChatError(container.friendlyError(it)))
703 _reconnecting.value = false
708 fun respondPermission(permission: Permission, response: PermissionResponse) {
709 val conn = connection ?: return
710 // Clear optimistically; permission.replied will confirm. If the call fails we
711 // restore the pending permission so the user can retry instead of being stuck
712 // with a dismissed dialog and a paused tool run.
713 _pendingPermission.value = null
714 viewModelScope.launch {
715 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
717 _errorEvents.trySend(ChatError(container.friendlyError(it)))
718 // Only restore if no new permission has arrived in the meantime.
719 if (_pendingPermission.value == null) _pendingPermission.value = permission
724 override fun onCleared() {
726 // Flush any pending debounced draft so it survives navigation. Uses an
727 // asynchronous apply() (not a synchronous commit) so the main thread isn't
728 // blocked on disk I/O — Android's SharedPreferences framework guarantees
729 // pending apply() writes are flushed before the process exits.
730 // Always flush — including an empty draft. Persistence is otherwise debounced, so if
731 // the user cleared the input and navigated back within the debounce window, the
732 // debounce coroutine was cancelled with viewModelScope and the prefs still hold the
733 // previous non-empty draft. flushDraft() removes the key when the text is blank, so an
734 // unconditional flush commits the clear instead of resurrecting the deleted text.
735 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