git.iko.soy/opencode-android
on 5d56632 help sign in
1248 lines 64 kB history raw
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.map
49import kotlinx.coroutines.flow.merge
50import kotlinx.coroutines.flow.onEach
51import kotlinx.coroutines.flow.retryWhen
52import kotlinx.coroutines.flow.stateIn
53import kotlinx.coroutines.flow.update
54import kotlinx.coroutines.delay
55import kotlinx.coroutines.launch
57/**
58 * A user-facing error to surface as a snackbar. [retryable] is true only for a failed
59 * message *send* (whose draft is held in [ChatViewModel.failedDraft]); the snackbar attaches
60 * a Retry action solely for those so an unrelated error (e.g. a message-load failure) can't
61 * inherit a Retry that silently re-submits the last prompt.
62 */
63data class ChatError(val message: String, val retryable: Boolean = false)
65/**
66 * An attachment staged to send with the next prompt. [previewModel] is a Coil-loadable model
67 * for the thumbnail (the source content Uri, as a string) for images, or null for non-image
68 * files (rendered with a generic icon). [part] is the wire form (a base64 data URL) sent to
69 * the server.
70 */
71@androidx.compose.runtime.Immutable
72data class PendingAttachment(
73 val id: String,
74 val name: String,
75 val mime: String,
76 val previewModel: Any?,
77 val part: FilePromptPart,
78) {
79 val isImage: Boolean get() = mime.startsWith("image/")
82/** Reduce a staged attachment to its persistable form (the self-contained data URL). */
83private fun PendingAttachment.toPersisted() =
84 soy.iko.opencode.data.repo.PersistedAttachment(id, name, mime, part.url, part.filename)
86/** Rebuild a staged attachment from persistence. The base64 data URL doubles as the Coil
87 * preview model for images (the original content Uri didn't survive the process restart). */
88private fun soy.iko.opencode.data.repo.PersistedAttachment.toPending() = PendingAttachment(
89 id = id,
90 name = name,
91 mime = mime,
92 previewModel = if (mime.startsWith("image/")) url else null,
93 part = FilePromptPart(mime = mime, url = url, filename = filename),
96// Large by design: this VM owns the whole chat surface (streaming, drafts, attachments,
97// catalogs, permissions, revert/edit, sharing, summarize/init/shell, TTS wiring). It's
98// already grandfathered for TooManyFunctions in the detekt baseline; the class-size rule is
99// suppressed for the same reason. A future split into sub-controllers is tracked separately.
100@Suppress("LargeClass")
101@OptIn(ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
102class ChatViewModel(
103 private val container: AppContainer,
104 private val sessionId: String,
105) : ViewModel() {
107 private val connection get() = container.activeConnection.value
109 val connected: Boolean get() = connection != null
111 private val _loading = MutableStateFlow(true)
112 val loading: StateFlow<Boolean> = _loading.asStateFlow()
114 /** Set when the messages flow failed to load (and the list is empty), so the UI
115 * can render a distinct error state instead of masquerading as an empty
116 * conversation. Cleared on a successful non-empty emission. */
117 private val _loadError = MutableStateFlow(false)
118 val loadError: StateFlow<Boolean> = _loadError.asStateFlow()
120 /** Tracks whether the messages flow has ever emitted a non-empty list, so
121 * [messages]' retryWhen can decide whether a re-fetch failure should set
122 * [loadError] (nothing shown yet → error screen) or just emit a snackbar
123 * (already showing messages → don't replace the conversation with an error). */
124 private var hasShownMessages = false
126 /** True once we've surfaced a snackbar for the current message-load failure streak.
127 * Reset on any successful emission so a persistent failure doesn't spam a fresh
128 * snackbar every retry cycle (every few seconds). */
129 private var loadErrorSnackbarShown = false
131 /** Separate from [loading]: tracks the manual reconnect() flow so its spinner
132 * doesn't conflict with the messages flow's loading state. */
133 private val _reconnecting = MutableStateFlow(false)
134 val reconnecting: StateFlow<Boolean> = _reconnecting.asStateFlow()
136 /** When true, the debounced draft collector skips persisting an empty draft so
137 * [send]'s deliberate non-persistence of the cleared draft isn't undone by the
138 * debounce timer firing before the send completes. */
139 private val suppressDraftPersist = java.util.concurrent.atomic.AtomicBoolean(false)
141 /** The draft value this VM last persisted, so the drafts observer can tell its own
142 * (debounced, possibly stale) echo apart from a genuine external write. Without this
143 * a debounced persist of an older value could echo back and overwrite newer text the
144 * user has since typed. */
145 @Volatile private var lastPersistedDraft: String? = null
147 /** Per-catalog reload triggers: incrementing one causes [observeCatalog]'s
148 * collectLatest to cancel any in-flight fetch and start a fresh one, so a
149 * manual reload supersedes a stale observeCatalog fetch. */
150 private val _modelsReload = MutableStateFlow(0)
151 private val _agentsReload = MutableStateFlow(0)
152 private val _commandsReload = MutableStateFlow(0)
154 val messages: StateFlow<List<MessageWithParts>> =
155 container.activeConnection
156 .flatMapLatest { conn ->
157 conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
158 }
159 .onEach {
160 _loading.value = false
161 // A value flowed through: the stream recovered, so allow the next distinct
162 // failure to surface a fresh snackbar again.
163 loadErrorSnackbarShown = false
164 // Clear the error on ANY successful emission, including an empty list. An empty
165 // session that hit one transient error would otherwise stay latched on the
166 // "Failed to load / Retry" screen forever, because the later successful EMPTY
167 // emission couldn't clear the flag. hasShownMessages stays gated on non-empty
168 // (an empty session has genuinely shown nothing yet).
169 _loadError.value = false
170 if (it.isNotEmpty()) {
171 hasShownMessages = true
172 }
173 }
174 .retryWhen { cause, attempt ->
175 _loading.value = false
176 // Only surface the persistent error state when there's nothing to
177 // show — if we already have messages, a transient re-fetch failure
178 // is better surfaced as a snackbar (via errorEvents) than by
179 // replacing the visible conversation with an error screen.
180 if (!hasShownMessages) _loadError.value = true
181 // Surface the snackbar only once per failure streak — retryWhen loops on a
182 // backoff, so emitting here unconditionally would spam a new snackbar every
183 // retry on a persistent load failure.
184 if (!loadErrorSnackbarShown) {
185 _errorEvents.trySend(ChatError(container.friendlyError(cause)))
186 loadErrorSnackbarShown = true
187 }
188 // Exponential backoff capped at retryMaxDelayMs so a persistently failing
189 // server is retried with growing delays (retryInitialDelayMs, then doubling)
190 // instead of hammered at a fixed interval forever. Clamp the shift so a long
191 // failure streak can't overflow the Long delay.
192 val backoffMs = (NetworkConfig.retryInitialDelayMs shl attempt.coerceAtMost(16L).toInt())
193 .coerceAtMost(NetworkConfig.retryMaxDelayMs)
194 delay(backoffMs)
195 true
196 }
197 .stateIn(
198 scope = viewModelScope,
199 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
200 initialValue = emptyList(),
201 )
203 /** Whether the conversation has any messages. Derived separately so the top bar
204 * (share button enabled state) can observe this cheap boolean instead of the
205 * full messages list, avoiding per-token recomposition of the app bar during
206 * streaming. */
207 val hasMessages: StateFlow<Boolean> = messages
208 .map { it.isNotEmpty() }
209 .stateIn(
210 scope = viewModelScope,
211 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
212 initialValue = false,
213 )
215 /** The agent's current task plan (the latest `todowrite`), surfaced so the chat can pin a
216 * live progress checklist above the composer. distinctUntilChanged so a per-token messages
217 * emission that doesn't change the plan doesn't recompose the bar. */
218 val todoPlan: StateFlow<List<TodoItem>> = messages
219 .map { currentTodoPlan(it) }
220 .distinctUntilChanged()
221 .stateIn(
222 scope = viewModelScope,
223 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
224 initialValue = emptyList(),
225 )
227 private val _running = MutableStateFlow(false)
228 val running: StateFlow<Boolean> = _running.asStateFlow()
230 /** True while an abort REST call is in flight, so the Stop button can show a
231 * spinner and prevent double-taps from firing a second abort. */
232 private val _aborting = MutableStateFlow(false)
233 val aborting: StateFlow<Boolean> = _aborting.asStateFlow()
235 /** A follow-up the user typed while a run was active. send() queues it here and
236 * auto-sends once the run completes (SessionIdle), so the user isn't blocked with
237 * no way to send and no indication why the Send button is gone. */
238 private val _queuedFollowUp = MutableStateFlow<String?>(null)
239 val queuedFollowUp: StateFlow<String?> = _queuedFollowUp.asStateFlow()
241 /** One-shot error events surfaced as snackbars. A Channel (not SharedFlow) so an event
242 * emitted before the UI subscribes is buffered and still delivered — a SharedFlow with
243 * replay=0 would drop it (e.g. a VM-init catalog fetch failing before first
244 * composition). Each event is delivered exactly once and not replayed to a
245 * re-subscribing collector, so rotation doesn't re-show a stale snackbar. */
246 private val _errorEvents = Channel<ChatError>(
247 capacity = NetworkConfig.snackbarEventBufferCapacity,
248 onBufferOverflow = BufferOverflow.DROP_OLDEST,
249 )
250 val errorEvents: Flow<ChatError> = _errorEvents.receiveAsFlow()
252 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
253 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
255 private val _modelsLoading = MutableStateFlow(true)
256 val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
258 private val _modelsError = MutableStateFlow(false)
259 val modelsError: StateFlow<Boolean> = _modelsError.asStateFlow()
261 private val _selectedModel = MutableStateFlow<ModelOption?>(null)
262 val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
264 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
265 container.activeConnection
266 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
267 .stateIn(
268 scope = viewModelScope,
269 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
270 initialValue = EventStreamClient.ConnectionState.Disconnected,
271 )
273 private val _pendingPermission = MutableStateFlow<Permission?>(null)
274 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
276 // Unanswered permission requests keyed by id, insertion-ordered. The dialog shows the most
277 // recent; when one is answered/replied — or a response fails — we fall back to the next so a
278 // request that arrived while another was in flight isn't silently dropped (its tool run would
279 // otherwise stay paused server-side with no UI affordance). Only touched on the VM's main
280 // dispatcher (event collector + viewModelScope launches), so no extra synchronization is needed.
281 private val pendingPermissions = LinkedHashMap<String, Permission>()
283 private fun enqueuePermission(permission: Permission) {
284 pendingPermissions[permission.id] = permission
285 _pendingPermission.value = pendingPermissions.values.lastOrNull()
286 }
288 private fun resolvePermission(id: String) {
289 pendingPermissions.remove(id)
290 _pendingPermission.value = pendingPermissions.values.lastOrNull()
291 }
293 private fun clearPermissions() {
294 pendingPermissions.clear()
295 _pendingPermission.value = null
296 }
298 private val _agents = MutableStateFlow<List<Agent>>(emptyList())
299 val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
301 private val _agentsLoading = MutableStateFlow(true)
302 val agentsLoading: StateFlow<Boolean> = _agentsLoading.asStateFlow()
304 private val _agentsError = MutableStateFlow(false)
305 val agentsError: StateFlow<Boolean> = _agentsError.asStateFlow()
307 private val _selectedAgent = MutableStateFlow<String?>(null)
308 val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
310 private val _commands = MutableStateFlow<List<Command>>(emptyList())
311 val commands: StateFlow<List<Command>> = _commands.asStateFlow()
313 private val _commandsLoading = MutableStateFlow(true)
314 val commandsLoading: StateFlow<Boolean> = _commandsLoading.asStateFlow()
316 private val _commandsError = MutableStateFlow(false)
317 val commandsError: StateFlow<Boolean> = _commandsError.asStateFlow()
319 private val _sessionTitle = MutableStateFlow<String?>(null)
320 val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
322 /** Set when the current session is deleted via SSE, so the UI can navigate away. */
323 private val _sessionDeleted = MutableStateFlow(false)
324 val sessionDeleted: StateFlow<Boolean> = _sessionDeleted.asStateFlow()
326 /** The text of the last send that failed, surfaced so the UI can offer a retry. */
327 private val _failedDraft = MutableStateFlow<String?>(null)
328 val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
330 /** The idempotency key of the last failed online send, stashed alongside [_failedDraft] so
331 * [retryFailed] re-submits with the SAME key. A retry after a lost response is then
332 * deduplicated server-side instead of starting a duplicate agent run. */
333 @Volatile private var failedIdempotencyKey: String? = null
335 /** Per-session draft, persisted so it survives navigation/process death. */
336 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
337 val draft: StateFlow<String> = _draft.asStateFlow()
339 /** Messages composed for this session while offline/disconnected, queued in the outbox and
340 * awaiting an automatic flush on reconnect. Surfaced so the composer can show a "queued"
341 * chip with a discard/send-now affordance. */
342 val outbox: StateFlow<List<OutboxMessage>> =
343 container.outboxStore.messages
344 .map { list -> list.filter { it.sessionId == sessionId } }
345 .stateIn(
346 scope = viewModelScope,
347 started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
348 initialValue = emptyList(),
349 )
351 /** Attachments staged for the next prompt (images/files). Persisted per-session (as their
352 * self-contained base64 data URLs) via [AttachmentDraftStore] so an interrupted compose
353 * survives process death — the source content Uris wouldn't survive, but the data URLs
354 * do. Cleared on a successful send. */
355 private val _attachments = MutableStateFlow<List<PendingAttachment>>(emptyList())
356 val attachments: StateFlow<List<PendingAttachment>> = _attachments.asStateFlow()
358 /** True while a revert checkpoint is active for this session (messages after it are hidden
359 * server-side). Drives the "reverted" banner with its Undo. */
360 private val _reverted = MutableStateFlow(false)
361 val reverted: StateFlow<Boolean> = _reverted.asStateFlow()
363 /** The active public share URL for this session, or null when not shared. */
364 private val _shareUrl = MutableStateFlow<String?>(null)
365 val shareUrl: StateFlow<String?> = _shareUrl.asStateFlow()
367 /** One-shot events carrying a freshly-created share URL so the UI can copy/share it. */
368 private val _shareLinkEvents = Channel<String>(
369 capacity = NetworkConfig.snackbarEventBufferCapacity,
370 onBufferOverflow = BufferOverflow.DROP_OLDEST,
371 )
372 val shareLinkEvents: Flow<String> = _shareLinkEvents.receiveAsFlow()
374 /** One-shot events carrying the id of a session freshly branched from a message, so the
375 * UI can navigate to it. */
376 private val _branchEvents = Channel<String>(
377 capacity = NetworkConfig.snackbarEventBufferCapacity,
378 onBufferOverflow = BufferOverflow.DROP_OLDEST,
379 )
380 val branchEvents: Flow<String> = _branchEvents.receiveAsFlow()
382 init {
383 // DraftStore loads SharedPreferences asynchronously; on a cold start the
384 // synchronous get() above returns "" until the background load completes.
385 // Observe the ready signal and re-seed the draft so it appears in the UI.
386 viewModelScope.launch {
387 container.draftStore.ready.collect { ready ->
388 if (!ready) return@collect
389 if (_draft.value.isEmpty()) {
390 _draft.value = container.draftStore.get(sessionId)
391 }
392 // Recover a follow-up queued before process death (its in-memory StateFlow
393 // is gone). If the input is now free, drop it back there for review/send and
394 // consume the stored copy; otherwise re-queue it so it auto-sends when the
395 // current run next idles. Guarded on == null so it never clobbers a
396 // follow-up the user queued after opening this session.
397 val savedFollowUp = container.draftStore.getFollowUp(sessionId)
398 if (savedFollowUp.isNotEmpty() && _queuedFollowUp.value == null) {
399 if (_draft.value.isEmpty()) {
400 _draft.value = savedFollowUp
401 container.draftStore.flushFollowUp(sessionId, "")
402 } else {
403 _queuedFollowUp.value = savedFollowUp
404 }
405 }
406 }
407 }
408 // Observe the drafts store for external mutations to this session's draft
409 // (e.g. a share injected via draftStore.set in two-pane mode, or setImmediate
410 // before navigation in single-pane mode). Without this, a share injected into
411 // an already-open session never appears in the input field — the init-time
412 // read and the ready-only re-seed miss it when the draft is non-empty.
413 // The VM's own debounced persist writes the same value _draft already holds,
414 // so those updates are no-ops here (storeValue == _draft.value). The
415 // suppressDraftPersist guard prevents a stale store value from clobbering
416 // the deliberate draft clear during a send (the store still holds the
417 // pre-send draft until the send resolves).
418 viewModelScope.launch {
419 container.draftStore.drafts.collect { drafts ->
420 // Ignore emissions until the initial disk load completes: the load itself
421 // emits the persisted snapshot, and applying it here would clobber text the
422 // user typed during the async load (the `ready` observer above seeds an
423 // empty draft). After ready, emissions are external writes.
424 if (!container.draftStore.ready.value) return@collect
425 val storeValue = drafts[sessionId].orEmpty()
426 // Ignore this VM's own (debounced, possibly stale) persistence echo — only a
427 // genuine external write (e.g. a two-pane share injection) should overwrite
428 // the live draft.
429 if (storeValue == lastPersistedDraft) return@collect
430 if (storeValue != _draft.value && !suppressDraftPersist.get()) {
431 _draft.value = storeValue
432 }
433 }
434 }
435 }
437 init {
438 // Restore attachments staged before process death. They were persisted as
439 // self-contained data URLs, so they re-send and (for images) re-preview intact.
440 // Guarded on empty so it never clobbers attachments the user staged after opening.
441 viewModelScope.launch {
442 val restored = runCatchingCancellable { container.attachmentDraftStore.load(sessionId) }
443 .getOrDefault(emptyList())
444 if (restored.isNotEmpty() && _attachments.value.isEmpty()) {
445 _attachments.value = restored.map { it.toPending() }
446 }
447 }
448 }
450 fun selectModel(option: ModelOption) { _selectedModel.value = option }
452 fun selectAgent(name: String?) { _selectedAgent.value = name }
454 fun reloadModels() {
455 val conn = connection ?: return
456 viewModelScope.launch {
457 conn.api.invalidateProvidersCache()
458 _modelsReload.value++
459 }
460 }
462 fun reloadAgents() {
463 val conn = connection ?: return
464 viewModelScope.launch {
465 conn.api.invalidateAgentsCache()
466 _agentsReload.value++
467 }
468 }
470 fun reloadCommands() {
471 val conn = connection ?: return
472 viewModelScope.launch {
473 conn.api.invalidateCommandsCache()
474 _commandsReload.value++
475 }
476 }
478 /**
479 * Shared helper for init-block catalog observers: re-fetches whenever the active
480 * connection or the catalog's reload trigger changes, invoking [onNull] to clear
481 * state on null so stale data from the old server doesn't persist. The
482 * [reloadTrigger] is merged with [activeConnection] so a manual reload (via
483 * reloadModels/reloadAgents/reloadCommands) cancels any in-flight observe fetch
484 * via collectLatest, preventing a stale observe result from overwriting a fresh
485 * reload result. Failure is non-fatal — the error flag is surfaced to the UI.
486 */
487 private fun <T> observeCatalog(
488 tag: String,
489 loading: MutableStateFlow<Boolean>,
490 error: MutableStateFlow<Boolean>,
491 fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
492 onSuccess: (T) -> Unit,
493 onNull: () -> Unit,
494 reloadTrigger: StateFlow<Int>,
495 ) {
496 viewModelScope.launch {
497 merge(container.activeConnection, reloadTrigger).collectLatest { _ ->
498 val conn = container.activeConnection.value
499 if (conn == null) { onNull(); loading.value = false; error.value = false; return@collectLatest }
500 loading.value = true
501 error.value = false
502 runCatchingCancellable { fetch(conn.api) }
503 // fetch() does HTTP via withRetry, so the failure can be a
504 // ClientRequestException whose message embeds the full request URL
505 // (may contain auth/paths). Log only a scrubbed summary.
506 .onFailure { Log.w("ChatViewModel", "Failed to load $tag: ${safeExceptionSummary(it)}"); error.value = true }
507 .getOrNull()?.let(onSuccess)
508 loading.value = false
509 }
510 }
511 }
513 fun updateDraft(text: String) {
514 _draft.value = text
515 }
517 init {
518 // Debounce draft persistence so we don't write to disk on every keystroke.
519 // Skip persisting empty drafts that were set by send() (suppressed via
520 // suppressDraftPersist) — send() clears the in-memory draft immediately for
521 // UI feedback but deliberately doesn't persist the clear until the send
522 // succeeds, so a failed send can restore the draft.
523 viewModelScope.launch {
524 _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
525 if (text.isBlank() && suppressDraftPersist.get()) return@collect
526 lastPersistedDraft = text
527 runCatchingCancellable { container.draftStore.set(sessionId, text) }
528 .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
529 }
530 }
531 // Reset per-connection state when the active server changes so stale spinners,
532 // permission dialogs, errors, and agent selections from the old server don't
533 // persist into the new one.
534 viewModelScope.launch {
535 container.activeConnection.collectLatest { conn ->
536 if (conn == null) {
537 // The connection dropped (e.g. disconnect() mid-run). No SSE stream will
538 // arrive to deliver SessionIdle/SessionError, so reset the run state here —
539 // otherwise the working spinner sticks on, ChatScreen keeps keepScreenOn +
540 // the RunForegroundService alive, and the Stop button no-ops (abort() early-
541 // returns with no connection) until a later reconnect happens to clear it.
542 _running.value = false
543 _aborting.value = false
544 clearPermissions()
545 return@collectLatest
546 }
547 _running.value = false
548 clearPermissions()
549 _failedDraft.value = null
550 // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
551 // user intent that is now persisted; wiping it on every (re)connect — which
552 // this collector does, including transient SSE drops and the cold-start
553 // restore path — would drop a legitimately queued/recovered follow-up.
554 _selectedAgent.value = null
555 _sessionTitle.value = null
556 try {
557 conn.events.events.collect { event ->
558 if (SessionRepository.isIdle(event, sessionId)) {
559 _running.value = false
560 // Auto-send a queued follow-up once the previous run finishes,
561 // so the user's drafted-while-running message isn't lost.
562 val queued = _queuedFollowUp.value
563 if (queued != null) {
564 setQueuedFollowUp(null)
565 send(queued, includeAttachments = false)
566 }
567 }
568 if (SessionRepository.isError(event, sessionId)) {
569 _running.value = false
570 // The run errored, so the queued follow-up won't auto-send.
571 // Restore it to the input (if free) so the user's typed text
572 // isn't silently lost; otherwise just clear the queue.
573 val queued = _queuedFollowUp.value
574 if (!queued.isNullOrEmpty() && _draft.value.isEmpty()) {
575 _draft.value = queued
576 }
577 setQueuedFollowUp(null)
578 _errorEvents.trySend(ChatError(container.string(R.string.error_agent_reported)))
579 }
580 // Restore the run indicator if a live streaming event arrives while it's
581 // cleared — e.g. an SSE reconnect mid-run reset _running to false, but the
582 // agent is still working. Don't revive it once the user has hit Stop.
583 if (SessionRepository.isRunActivity(event, sessionId) && !_aborting.value && !_running.value) {
584 _running.value = true
585 }
586 when (event) {
587 is PermissionUpdated ->
588 if (event.properties.sessionID == sessionId) enqueuePermission(event.properties)
589 is PermissionReplied ->
590 if (event.properties.sessionID == sessionId) event.properties.permissionID?.let { resolvePermission(it) }
591 is SessionUpdated ->
592 if (event.properties.info.id == sessionId) {
593 val info = event.properties.info
594 _sessionTitle.value = info.displayTitle
595 _reverted.value = info.isReverted
596 _shareUrl.value = info.share?.url?.takeIf { it.isNotBlank() }
597 }
598 is SessionDeleted ->
599 if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
600 _sessionDeleted.value = true
601 }
602 else -> {}
603 }
604 }
605 } catch (e: CancellationException) {
606 throw e
607 } catch (e: Exception) {
608 Log.w("ChatViewModel", "SSE event collector error", e)
609 }
610 }
611 }
612 // Load the model catalog; preselect the server default. Failure is non-fatal —
613 // sending with no model just uses the server's default agent/model.
614 observeCatalog(
615 tag = "model catalog",
616 loading = _modelsLoading,
617 error = _modelsError,
618 fetch = { it.providers() },
619 onSuccess = { resp ->
620 val options = resp.toOptions()
621 _models.value = options
622 // Only (re)apply the server default when there's no valid current
623 // selection. On re-fetch/reconnect/Retry the user's chosen model must
624 // survive — keep it as long as it's still present in the refreshed list.
625 val current = _selectedModel.value
626 if (current == null || options.none { it.ref == current.ref }) {
627 _selectedModel.value = resp.defaultOption(options)
628 }
629 },
630 onNull = { _models.value = emptyList(); _selectedModel.value = null },
631 reloadTrigger = _modelsReload,
632 )
633 // Load the agent catalog (non-fatal).
634 observeCatalog(
635 tag = "agent catalog",
636 loading = _agentsLoading,
637 error = _agentsError,
638 fetch = { it.agents() },
639 onSuccess = { _agents.value = it },
640 onNull = { _agents.value = emptyList() },
641 reloadTrigger = _agentsReload,
642 )
643 // Load the command catalog (non-fatal).
644 observeCatalog(
645 tag = "command catalog",
646 loading = _commandsLoading,
647 error = _commandsError,
648 fetch = { it.commands() },
649 onSuccess = { _commands.value = it },
650 onNull = { _commands.value = emptyList() },
651 reloadTrigger = _commandsReload,
652 )
653 // Resolve the human-readable session title for the app bar (non-fatal).
654 // Skips the REST call when the SSE event collector already delivered the title
655 // (SessionUpdated fires shortly after connect), avoiding a redundant full
656 // session-list download.
657 viewModelScope.launch {
658 container.activeConnection.collectLatest { conn ->
659 if (conn == null) return@collectLatest
660 if (_sessionTitle.value != null) return@collectLatest
661 runCatchingCancellable { conn.repository.listSessions() }
662 .getOrNull()
663 ?.firstOrNull { it.id == sessionId }
664 ?.let { session ->
665 _sessionTitle.value = session.displayTitle
666 _reverted.value = session.isReverted
667 _shareUrl.value = session.share?.url?.takeIf { it.isNotBlank() }
668 }
669 }
670 }
671 // If the SSE stream drops mid-run, the run indicator would spin forever;
672 // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
673 viewModelScope.launch {
674 container.activeConnection.collectLatest { conn ->
675 if (conn == null) return@collectLatest
676 try {
677 conn.events.state.collect { state ->
678 if ((state == EventStreamClient.ConnectionState.Disconnected ||
679 state == EventStreamClient.ConnectionState.Failed) && _running.value) {
680 _running.value = false
681 }
682 }
683 } catch (e: CancellationException) {
684 throw e
685 } catch (e: Exception) {
686 Log.w("ChatViewModel", "SSE state collector error", e)
687 }
688 }
689 }
690 }
692 /** Sends [text] together with any staged attachments; returns true on success so the
693 * caller can clear the draft only then. */
694 fun send(text: String): Boolean = send(text, includeAttachments = true)
696 /**
697 * Core send. [includeAttachments] is false for the internal auto-send paths (a queued
698 * follow-up, a retry) so staged attachments aren't silently re-sent with unrelated text.
699 */
700 private fun send(text: String, includeAttachments: Boolean, idempotencyKey: String? = null): Boolean {
701 val trimmed = text.trim()
702 val attachments = if (includeAttachments) _attachments.value else emptyList()
703 // An image-only prompt (attachments, no text) is valid; a blank prompt with nothing
704 // attached is not.
705 if (trimmed.isEmpty() && attachments.isEmpty()) return false
706 // Offline, or no active connection: queue the prompt to the outbox (flushed on
707 // reconnect) instead of failing the send. Device-offline is caught even when a stale
708 // connection object lingers, so a phone that lost signal mid-conversation still
709 // captures the message rather than bouncing it off a dead socket.
710 val conn = connection
711 if (conn == null || !container.isOnline.value) {
712 return enqueueOffline(trimmed, attachments)
713 }
714 if (!_running.compareAndSet(false, true)) return false
715 _failedDraft.value = null
716 // Stable idempotency key for THIS online attempt: a brand-new message gets a fresh key,
717 // while retryFailed() re-submits with the key it stashed on the original failure — so a
718 // retry after a lost response is deduplicated server-side (no duplicate agent run).
719 val key = idempotencyKey ?: java.util.UUID.randomUUID().toString()
720 // Clear the staged attachments optimistically so the composer empties immediately;
721 // restore them if the send fails (mirrors the draft handling below).
722 if (attachments.isNotEmpty()) _attachments.value = emptyList()
723 // Clear the in-memory draft for the UI immediately, but don't persist the clear
724 // yet — if the send fails and the process dies before we restore, the draft
725 // would be lost forever. The persisted draft is cleared only on success.
726 // suppressDraftPersist prevents the debounced collector from persisting the
727 // empty draft before the send resolves.
728 //
729 // Only clear when the text being sent IS the current draft. send() is also
730 // invoked for auto-sent queued follow-ups (SessionIdle) and retryFailed(), where
731 // the user may have typed a NEW draft since — wiping it would lose that text.
732 if (_draft.value.trim() == trimmed) {
733 suppressDraftPersist.set(true)
734 _draft.value = ""
735 }
736 viewModelScope.launch {
737 runCatchingCancellable {
738 conn.repository.sendPrompt(
739 sessionId,
740 trimmed,
741 attachments = attachments.map { it.part },
742 model = _selectedModel.value?.ref,
743 agent = _selectedAgent.value,
744 idempotencyKey = key,
745 )
746 }.onFailure {
747 suppressDraftPersist.set(false)
748 _failedDraft.value = trimmed
749 failedIdempotencyKey = key
750 // Only restore the draft if the user hasn't typed anything new since.
751 if (_draft.value.isBlank()) updateDraft(trimmed)
752 // Restore the staged attachments too, so a failed send doesn't lose them —
753 // but only if the user hasn't staged new ones in the meantime.
754 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
755 // Re-persist so the restored attachments survive process death after a failure.
756 persistAttachments()
757 // Retryable: this is the failed send whose prompt Retry re-submits.
758 _errorEvents.trySend(ChatError(container.friendlyError(it), retryable = true))
759 _running.value = false
760 }.onSuccess {
761 suppressDraftPersist.set(false)
762 // Send succeeded — the attachments rode along, so clear their persisted copy
763 // (or keep any the user staged after the optimistic clear).
764 persistAttachments()
765 // Send succeeded — persist the cleared draft, but only if the user hasn't
766 // typed a new one while the send was in flight. Otherwise clearing the
767 // store echoes back through the draft observer and wipes the in-progress
768 // text (data loss). Mirrors the guard on the failure path above.
769 if (_draft.value.isBlank()) {
770 lastPersistedDraft = ""
771 container.draftStore.set(sessionId, "")
772 }
773 // Don't reset _running here: the agent continues streaming via SSE.
774 // _running is cleared on SessionIdle/SessionError (see event collector)
775 // or when the SSE stream drops (see connection state watcher below).
776 }
777 }
778 return true
779 }
781 /**
782 * Queue [trimmed] (+ [attachments]) to the offline outbox, clearing the composer
783 * optimistically like a real send. AppContainer flushes the outbox automatically on
784 * reconnect. The prompt is attributed to the active (or most-recent) server profile so a
785 * reconnect to a different server doesn't misfire it. On any failure the draft/attachments
786 * are restored so nothing is lost.
787 */
788 private fun enqueueOffline(trimmed: String, attachments: List<PendingAttachment>): Boolean {
789 _failedDraft.value = null
790 if (attachments.isNotEmpty()) _attachments.value = emptyList()
791 val clearDraft = _draft.value.trim() == trimmed
792 if (clearDraft) {
793 suppressDraftPersist.set(true)
794 _draft.value = ""
795 }
796 viewModelScope.launch {
797 val profileId = connection?.profile?.id
798 ?: runCatchingCancellable { container.profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
799 if (profileId == null) {
800 // No server to attribute the message to — restore the composer and report it
801 // rather than silently dropping the prompt.
802 suppressDraftPersist.set(false)
803 if (_draft.value.isBlank()) updateDraft(trimmed)
804 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
805 persistAttachments()
806 _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
807 return@launch
808 }
809 runCatchingCancellable {
810 container.outboxStore.enqueue(
811 OutboxMessage(
812 id = java.util.UUID.randomUUID().toString(),
813 profileId = profileId,
814 sessionId = sessionId,
815 text = trimmed,
816 attachments = attachments.map { it.toPersisted() },
817 model = _selectedModel.value?.ref,
818 agent = _selectedAgent.value,
819 createdAt = System.currentTimeMillis(),
820 ),
821 )
822 }.onSuccess {
823 suppressDraftPersist.set(false)
824 // The staged attachments now live in the outbox item; clear their draft copy.
825 persistAttachments()
826 if (_draft.value.isBlank()) {
827 lastPersistedDraft = ""
828 container.draftStore.set(sessionId, "")
829 }
830 // Nudge a flush in case we're actually online with a live connection.
831 container.flushOutbox()
832 _errorEvents.trySend(ChatError(container.string(R.string.outbox_queued)))
833 }.onFailure {
834 suppressDraftPersist.set(false)
835 if (_draft.value.isBlank()) updateDraft(trimmed)
836 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
837 persistAttachments()
838 _errorEvents.trySend(ChatError(container.friendlyError(it)))
839 }
840 }
841 return true
842 }
844 /** Discard a single queued (offline) message. */
845 fun discardQueued(id: String) {
846 viewModelScope.launch { runCatchingCancellable { container.outboxStore.remove(id) } }
847 }
849 /** Discard every queued (offline) message for this session. */
850 fun discardAllQueued() {
851 viewModelScope.launch { runCatchingCancellable { container.outboxStore.removeForSession(sessionId) } }
852 }
854 /** Attempt to flush the outbox now (e.g. the user tapped "Send now"). */
855 fun flushQueued() { container.flushOutbox() }
857 /** Re-send the last draft whose send failed, if any. */
858 fun retryFailed() {
859 val draft = _failedDraft.value ?: return
860 // Reuse the failed send's idempotency key so a retry after a lost response is
861 // deduplicated server-side rather than starting a duplicate run.
862 val key = failedIdempotencyKey
863 // Don't clear _failedDraft until send() accepts the text — if _running is
864 // already true, send() returns false and the draft would be lost forever.
865 // Attachments aren't re-sent on retry (they were optimistically restored to the
866 // composer on the original failure, so they'll ride the next manual send).
867 if (send(draft, includeAttachments = false, idempotencyKey = key)) {
868 _failedDraft.value = null
869 failedIdempotencyKey = null
870 }
871 }
873 /**
874 * Queue [text] to be sent automatically when the current run finishes, or clear any
875 * queued follow-up when [text] is blank. Used when the user taps Send while a run
876 * is already active — the Send button is replaced by Stop during a run, but the
877 * input field stays enabled, so a follow-up typed mid-run would otherwise have
878 * nowhere to go.
879 */
880 fun queueFollowUp(text: String) {
881 val trimmed = text.trim()
882 setQueuedFollowUp(trimmed.takeIf { it.isNotEmpty() })
883 // The text now lives in the queued chip, so clear the input field just as a
884 // normal send would. No-op for the cancel case (blank text), which must leave
885 // whatever the user has since typed untouched.
886 if (trimmed.isNotEmpty()) _draft.value = ""
887 }
889 /** Set or clear the queued follow-up and mirror it to persistence, so a follow-up
890 * queued mid-run survives process death (recovered on the next open of this session).
891 * Passing null / blank clears both the in-memory value and the persisted copy. */
892 private fun setQueuedFollowUp(text: String?) {
893 _queuedFollowUp.value = text
894 container.draftStore.flushFollowUp(sessionId, text.orEmpty())
895 }
897 /** Transient flag set by [refreshMessages] so the top-bar refresh icon can show
898 * a brief spinner as immediate tap feedback. The SSE reconnect triggered by
899 * refreshMessages may not visibly change [connectionState] (it's already
900 * Connected), so without this the tap appears to do nothing. Clears after a
901 * short delay or when the connection state next becomes Connected. */
902 private val _refreshing = MutableStateFlow(false)
903 val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
905 /** Manually re-fetch messages by forcing the SSE stream to reconnect, which
906 * triggers the repository's re-seed-from-REST logic. Gives the user a recovery
907 * path when the SSE stream silently drops and the auto-reconnect re-seed is slow
908 * or fails — a pull-to-refresh or top-bar refresh forces a fresh fetch under the
909 * existing generation-guarded merge. Also clears [loadError] optimistically and
910 * sets [refreshing] for immediate tap feedback. */
911 fun refreshMessages() {
912 val conn = connection ?: return
913 _loadError.value = false
914 _refreshing.value = true
915 conn.events.triggerReconnect()
916 viewModelScope.launch {
917 delay(NetworkConfig.refreshFeedbackMs)
918 _refreshing.value = false
919 }
920 }
922 /** Invoke a slash-command by name via the server's /command endpoint. */
923 fun runCommand(command: Command) {
924 val conn = connection ?: return
925 // A run is already in flight — surface feedback instead of silently dropping the
926 // command (the picker stays openable during a run and dismisses on select).
927 if (!_running.compareAndSet(false, true)) {
928 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
929 return
930 }
931 viewModelScope.launch {
932 runCatchingCancellable {
933 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
934 }.onFailure {
935 _errorEvents.trySend(ChatError(container.friendlyError(it)))
936 _running.value = false
937 }
938 }
939 }
941 fun abort() {
942 val conn = connection ?: return
943 // Drop any queued follow-up so the SessionIdle that follows the abort doesn't
944 // auto-send it — the user tapped Stop to halt work, not to trigger the next turn.
945 setQueuedFollowUp(null)
946 if (!_aborting.compareAndSet(false, true)) return
947 viewModelScope.launch {
948 try {
949 runCatchingCancellable { conn.repository.abort(sessionId) }
950 .onSuccess { _running.value = false }
951 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
952 } finally {
953 _aborting.value = false
954 }
955 }
956 }
958 fun addAttachment(attachment: PendingAttachment) {
959 // Enforce the cumulative size cap across the whole staged set (the per-file size and the
960 // count caps are checked upstream). The base64 data URLs are held in memory and
961 // re-serialized on every add/remove and again when the request body is built, so an
962 // unbounded set OOMs. Reject rather than add, mirroring the other attachment limits'
963 // snackbar via errorEvents. Size is the raw (pre-base64) byte count, matching the
964 // per-file check, and includes attachments already staged (incl. restored ones).
965 val staged = _attachments.value.sumOf { base64DataUrlByteSize(it.part.url) }
966 if (staged + base64DataUrlByteSize(attachment.part.url) > NetworkConfig.maxTotalAttachmentBytes) {
967 _errorEvents.trySend(ChatError(container.string(R.string.attachment_limit)))
968 return
969 }
970 _attachments.update { it + attachment }
971 persistAttachments()
972 }
974 fun removeAttachment(id: String) {
975 _attachments.update { list -> list.filterNot { it.id == id } }
976 persistAttachments()
977 }
979 fun clearAttachments() {
980 _attachments.value = emptyList()
981 persistAttachments()
982 }
984 /** Persist the current staged attachments so an interrupted compose survives process
985 * death (mirrors draft persistence). Fire-and-forget on [viewModelScope]. */
986 private fun persistAttachments() {
987 val snapshot = _attachments.value
988 viewModelScope.launch {
989 runCatchingCancellable { container.attachmentDraftStore.save(sessionId, snapshot.map { it.toPersisted() }) }
990 .onFailure { Log.w("ChatViewModel", "Failed to persist attachments", it) }
991 }
992 }
994 /** Revert the conversation to just before [messageId], hiding everything after it. The
995 * authoritative state also arrives via SessionUpdated; we flag it here for immediate
996 * feedback. */
997 fun revertTo(messageId: String) {
998 val conn = connection ?: return
999 viewModelScope.launch {
1000 runCatchingCancellable { conn.api.revert(sessionId, messageId) }
1001 .onSuccess { _reverted.value = it.isReverted }
1002 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1006 /** Load a previously-sent user prompt back into the composer to edit and re-send.
1007 * Reverts the conversation to just before [messageId] (hiding it and everything after)
1008 * and prefills the draft with [text]. The next Send continues from that checkpoint,
1009 * effectively replacing the edited message; the reverted banner's Undo restores it.
1010 * Overwrites the current draft — this is an explicit edit action on that message. */
1011 fun editMessage(messageId: String, text: String) {
1012 revertTo(messageId)
1013 _draft.value = text
1016 /** Prefill the composer with [text] as a Markdown blockquote so the user can respond to a
1017 * specific message inline. Appends to any existing draft (with a blank line) rather than
1018 * overwriting, so quoting doesn't discard text already being typed. */
1019 fun quoteReply(text: String) {
1020 val quoted = text.trim().lineSequence().joinToString("\n") { "> $it" }
1021 if (quoted.isBlank()) return
1022 val current = _draft.value
1023 _draft.value = if (current.isBlank()) "$quoted\n\n" else "$current\n\n$quoted\n\n"
1026 /** Fork the conversation by creating a brand-new session seeded with [text] as its first
1027 * prompt (using the currently-selected model/agent). Emits the new session id via
1028 * [branchEvents] so the UI can navigate to it. There's no server-side "branch" endpoint,
1029 * so this starts a fresh session from the chosen prompt rather than copying history. */
1030 fun branchFrom(text: String) {
1031 val conn = connection ?: return
1032 val prompt = text.trim()
1033 if (prompt.isEmpty()) return
1034 viewModelScope.launch {
1035 runCatchingCancellable {
1036 val session = conn.repository.createSession(title = null)
1037 conn.repository.sendPrompt(
1038 session.id,
1039 prompt,
1040 model = _selectedModel.value?.ref,
1041 agent = _selectedAgent.value,
1043 session.id
1045 .onSuccess { newId -> _branchEvents.trySend(newId) }
1046 .onFailure { _errorEvents.trySend(ChatError(container.string(R.string.branch_failed))) }
1050 /** Undo the active revert checkpoint, restoring the hidden messages. */
1051 fun unrevert() {
1052 val conn = connection ?: return
1053 viewModelScope.launch {
1054 runCatchingCancellable { conn.api.unrevert(sessionId) }
1055 .onSuccess { _reverted.value = it.isReverted }
1056 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1060 /** Create (or fetch the existing) public share link. The URL is exposed via [shareUrl]
1061 * and also emitted once via [shareLinkEvents] so the UI can copy it to the clipboard. */
1062 fun shareSession() {
1063 val conn = connection ?: return
1064 viewModelScope.launch {
1065 runCatchingCancellable { conn.api.shareSession(sessionId) }
1066 .onSuccess { session ->
1067 val url = session.share?.url?.takeIf { it.isNotBlank() }
1068 _shareUrl.value = url
1069 if (url != null) _shareLinkEvents.trySend(url)
1071 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1075 /** Revoke the session's public share link. */
1076 fun unshareSession() {
1077 val conn = connection ?: return
1078 viewModelScope.launch {
1079 runCatchingCancellable { conn.api.unshareSession(sessionId) }
1080 .onSuccess { _shareUrl.value = it.share?.url?.takeIf { it.isNotBlank() } }
1081 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1085 /** Compact the conversation via the summarize endpoint; the summary streams back via SSE.
1086 * Uses the currently-selected model (required by the endpoint). */
1087 fun summarize() {
1088 val conn = connection ?: return
1089 val model = _selectedModel.value?.ref ?: run {
1090 _errorEvents.trySend(ChatError(container.string(R.string.needs_model)))
1091 return
1093 if (!_running.compareAndSet(false, true)) {
1094 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1095 return
1097 viewModelScope.launch {
1098 runCatchingCancellable { conn.api.summarize(sessionId, model) }
1099 .onFailure {
1100 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1101 _running.value = false
1106 /** Analyze the project and (re)generate its AGENTS.md; the run streams back via SSE. */
1107 fun initProject() {
1108 val conn = connection ?: return
1109 if (!_running.compareAndSet(false, true)) {
1110 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1111 return
1113 viewModelScope.launch {
1114 runCatchingCancellable { conn.api.initSession(sessionId) }
1115 .onFailure {
1116 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1117 _running.value = false
1122 /** Run a one-off shell [command] in the session's worktree; output streams back via SSE.
1123 * The server requires an agent to scope the run — use the selected one, else the primary. */
1124 fun runShell(command: String) {
1125 val conn = connection ?: return
1126 val cmd = command.trim()
1127 if (cmd.isEmpty()) return
1128 val agent = _selectedAgent.value
1129 ?: _agents.value.firstOrNull { it.isPrimary }?.name
1130 ?: _agents.value.firstOrNull()?.name
1131 ?: DEFAULT_SHELL_AGENT
1132 if (!_running.compareAndSet(false, true)) {
1133 _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1134 return
1136 viewModelScope.launch {
1137 runCatchingCancellable { conn.api.shell(sessionId, cmd, agent, _selectedModel.value?.ref) }
1138 .onFailure {
1139 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1140 _running.value = false
1145 /** Rename the current session via PATCH /session/:id. On success updates [sessionTitle]
1146 * so the top bar reflects the new name immediately. A failure surfaces as a snackbar;
1147 * the caller keeps the dialog open so the user can retry without retyping. */
1148 fun renameSession(newTitle: String) {
1149 val conn = connection ?: return
1150 val title = newTitle.trim()
1151 if (title.isEmpty() || title == _sessionTitle.value) return
1152 viewModelScope.launch {
1153 runCatchingCancellable { conn.api.updateSession(sessionId, title) }
1154 .onSuccess { _sessionTitle.value = it.displayTitle }
1155 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1159 /** Delete the current session via DELETE /session/:id. Surfaces a toast (via the UI's
1160 * sessionDeleted flow) and navigates away on success; a failure surfaces as a snackbar.
1161 * Unlike [SessionListViewModel.deleteSession] there's no undo window — the user is
1162 * already viewing the session, so a confirmation dialog guards the action instead. */
1163 fun deleteSession() {
1164 val conn = connection ?: return
1165 viewModelScope.launch {
1166 runCatchingCancellable { conn.repository.deleteSession(sessionId) }
1167 .onSuccess {
1168 container.draftStore.remove(sessionId)
1169 container.attachmentDraftStore.remove(sessionId)
1170 container.messageCacheStore.remove(sessionId)
1171 container.outboxStore.removeForSession(sessionId)
1172 _sessionDeleted.value = true
1174 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1178 /** Reconnect to the most recently used server profile (used when the connection is gone). */
1179 fun reconnect() {
1180 if (container.activeConnection.value != null) return
1181 // Guard against concurrent reconnect calls: the user can tap the button
1182 // multiple times while _reconnecting is true, which would launch parallel
1183 // connect() coroutines that race to set the active connection, leaking the
1184 // intermediate connections.
1185 if (!_reconnecting.compareAndSet(false, true)) return
1186 viewModelScope.launch {
1187 try {
1188 val recent = runCatchingCancellable { container.profileStore.profiles.first() }
1189 .getOrDefault(emptyList())
1190 .firstOrNull()
1191 if (recent == null) {
1192 _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
1193 return@launch
1195 runCatchingCancellable {
1196 val conn = container.connect(recent)
1197 conn.api.ping()
1198 }.onFailure {
1199 container.disconnect()
1200 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1202 } finally {
1203 _reconnecting.value = false
1208 fun respondPermission(permission: Permission, response: PermissionResponse) {
1209 val conn = connection ?: return
1210 // Dismiss optimistically (revealing any queued request); permission.replied will confirm.
1211 // If the call fails we re-surface this request so the user can retry instead of being stuck
1212 // with a dismissed dialog and a tool run that's still paused server-side.
1213 resolvePermission(permission.id)
1214 viewModelScope.launch {
1215 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
1216 .onFailure {
1217 _errorEvents.trySend(ChatError(container.friendlyError(it)))
1218 enqueuePermission(permission)
1223 private companion object {
1224 /** Fallback agent for the shell endpoint when none is selected and no agent catalog
1225 * loaded. opencode's default primary agent is "build". */
1226 const val DEFAULT_SHELL_AGENT = "build"
1229 override fun onCleared() {
1230 super.onCleared()
1231 // Flush any pending debounced draft so it survives navigation. Uses an
1232 // asynchronous apply() (not a synchronous commit) so the main thread isn't
1233 // blocked on disk I/O — Android's SharedPreferences framework guarantees
1234 // pending apply() writes are flushed before the process exits.
1235 // Flush the pending draft — including an empty one. Persistence is otherwise debounced,
1236 // so if the user cleared the input and navigated back within the debounce window, the
1237 // debounce coroutine was cancelled with viewModelScope and the prefs still hold the
1238 // previous non-empty draft. flushDraft() removes the key when the text is blank, so
1239 // flushing commits the clear instead of resurrecting the deleted text.
1240 //
1241 // BUT respect the send-in-flight guard: send() optimistically clears _draft to "" WITHOUT
1242 // persisting the clear so a failed send can restore the text. If the user taps Send then
1243 // immediately navigates back (cancelling the in-flight send), flushing the blank _draft here
1244 // would remove the persisted key and erase the pre-send draft with no recovery. Skip the
1245 // flush while a send is in flight so the pre-send draft stays persisted (recoverable).
1246 if (!suppressDraftPersist.get()) container.draftStore.flushDraft(sessionId, _draft.value)