git.iko.soy/opencode-android
on d643c13 help sign in
423 lines 19 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.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.repo.SessionRepository
20import soy.iko.opencode.di.AppContainer
21import soy.iko.opencode.R
22import soy.iko.opencode.util.runCatchingCancellable
23import android.util.Log
24import kotlinx.coroutines.ExperimentalCoroutinesApi
25import kotlinx.coroutines.flow.MutableStateFlow
26import kotlinx.coroutines.flow.SharingStarted
27import kotlinx.coroutines.flow.StateFlow
28import kotlinx.coroutines.flow.asStateFlow
29import kotlinx.coroutines.flow.catch
30import kotlinx.coroutines.flow.collectLatest
31import kotlinx.coroutines.flow.debounce
32import kotlinx.coroutines.flow.drop
33import kotlinx.coroutines.flow.first
34import kotlinx.coroutines.flow.flatMapLatest
35import kotlinx.coroutines.flow.flowOf
36import kotlinx.coroutines.flow.onEach
37import kotlinx.coroutines.flow.stateIn
38import kotlinx.coroutines.launch
40@OptIn(ExperimentalCoroutinesApi::class)
41class ChatViewModel(
42 private val container: AppContainer,
43 private val sessionId: String,
44) : ViewModel() {
46 private val connection get() = container.activeConnection.value
48 val connected: Boolean get() = connection != null
50 private val _loading = MutableStateFlow(true)
51 val loading: StateFlow<Boolean> = _loading.asStateFlow()
53 val messages: StateFlow<List<MessageWithParts>> =
54 container.activeConnection
55 .flatMapLatest { conn ->
56 conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
57 }
58 .onEach { _loading.value = false }
59 .catch {
60 _loading.value = false
61 _error.value = container.friendlyError(it)
62 }
63 .stateIn(
64 scope = viewModelScope,
65 started = SharingStarted.WhileSubscribed(5_000),
66 initialValue = emptyList(),
67 )
69 private val _running = MutableStateFlow(false)
70 val running: StateFlow<Boolean> = _running.asStateFlow()
72 private val _error = MutableStateFlow<String?>(null)
73 val error: StateFlow<String?> = _error.asStateFlow()
75 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
76 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
78 private val _modelsLoading = MutableStateFlow(true)
79 val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
81 private val _modelsError = MutableStateFlow(false)
82 val modelsError: StateFlow<Boolean> = _modelsError.asStateFlow()
84 private val _selectedModel = MutableStateFlow<ModelOption?>(null)
85 val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
87 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
88 container.activeConnection
89 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
90 .stateIn(
91 scope = viewModelScope,
92 started = SharingStarted.WhileSubscribed(5_000),
93 initialValue = EventStreamClient.ConnectionState.Disconnected,
94 )
96 private val _pendingPermission = MutableStateFlow<Permission?>(null)
97 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
99 private val _agents = MutableStateFlow<List<Agent>>(emptyList())
100 val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
102 private val _agentsLoading = MutableStateFlow(true)
103 val agentsLoading: StateFlow<Boolean> = _agentsLoading.asStateFlow()
105 private val _agentsError = MutableStateFlow(false)
106 val agentsError: StateFlow<Boolean> = _agentsError.asStateFlow()
108 private val _selectedAgent = MutableStateFlow<String?>(null)
109 val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
111 private val _commands = MutableStateFlow<List<Command>>(emptyList())
112 val commands: StateFlow<List<Command>> = _commands.asStateFlow()
114 private val _commandsLoading = MutableStateFlow(true)
115 val commandsLoading: StateFlow<Boolean> = _commandsLoading.asStateFlow()
117 private val _commandsError = MutableStateFlow(false)
118 val commandsError: StateFlow<Boolean> = _commandsError.asStateFlow()
120 private val _sessionTitle = MutableStateFlow<String?>(null)
121 val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
123 /** Set when the current session is deleted via SSE, so the UI can navigate away. */
124 private val _sessionDeleted = MutableStateFlow(false)
125 val sessionDeleted: StateFlow<Boolean> = _sessionDeleted.asStateFlow()
127 /** The text of the last send that failed, surfaced so the UI can offer a retry. */
128 private val _failedDraft = MutableStateFlow<String?>(null)
129 val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
131 /** Per-session draft, persisted so it survives navigation/process death. */
132 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
133 val draft: StateFlow<String> = _draft.asStateFlow()
135 init {
136 // DraftStore loads SharedPreferences asynchronously; on a cold start the
137 // synchronous get() above returns "" until the background load completes.
138 // Observe the ready signal and re-seed the draft so it appears in the UI.
139 viewModelScope.launch {
140 container.draftStore.ready.collect { ready ->
141 if (ready && _draft.value.isEmpty()) {
142 _draft.value = container.draftStore.get(sessionId)
143 }
144 }
145 }
146 }
148 fun clearError() { _error.value = null }
150 fun selectModel(option: ModelOption) { _selectedModel.value = option }
152 fun selectAgent(name: String?) { _selectedAgent.value = name }
154 fun reloadModels() {
155 val conn = connection ?: return
156 viewModelScope.launch {
157 conn.api.invalidateCache()
158 _modelsLoading.value = true
159 _modelsError.value = false
160 runCatchingCancellable { conn.api.providers() }
161 .onFailure { Log.w("ChatViewModel", "Failed to reload model catalog", it); _modelsError.value = true }
162 .getOrNull()?.let { resp ->
163 val options = resp.toOptions()
164 _models.value = options
165 _selectedModel.value = resp.defaultOption(options)
166 }
167 _modelsLoading.value = false
168 }
169 }
171 fun reloadAgents() {
172 val conn = connection ?: return
173 viewModelScope.launch {
174 conn.api.invalidateCache()
175 _agentsLoading.value = true
176 _agentsError.value = false
177 runCatchingCancellable { conn.api.agents() }
178 .onFailure { Log.w("ChatViewModel", "Failed to reload agent catalog", it); _agentsError.value = true }
179 .getOrNull()?.let { _agents.value = it }
180 _agentsLoading.value = false
181 }
182 }
184 fun reloadCommands() {
185 val conn = connection ?: return
186 viewModelScope.launch {
187 conn.api.invalidateCache()
188 _commandsLoading.value = true
189 _commandsError.value = false
190 runCatchingCancellable { conn.api.commands() }
191 .onFailure { Log.w("ChatViewModel", "Failed to reload command catalog", it); _commandsError.value = true }
192 .getOrNull()?.let { _commands.value = it }
193 _commandsLoading.value = false
194 }
195 }
197 fun updateDraft(text: String) {
198 _draft.value = text
199 }
201 init {
202 // Debounce draft persistence so we don't write to disk on every keystroke.
203 viewModelScope.launch {
204 _draft.drop(1).debounce(500).collect { text ->
205 runCatchingCancellable { container.draftStore.set(sessionId, text) }
206 .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
207 }
208 }
209 // Reset per-connection state when the active server changes so stale spinners,
210 // permission dialogs, errors, and agent selections from the old server don't
211 // persist into the new one.
212 viewModelScope.launch {
213 container.activeConnection.collectLatest { conn ->
214 if (conn == null) return@collectLatest
215 _running.value = false
216 _pendingPermission.value = null
217 _error.value = null
218 _failedDraft.value = null
219 _selectedAgent.value = null
220 conn.events.events.collect { event ->
221 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
222 if (SessionRepository.isError(event, sessionId)) {
223 _running.value = false
224 _error.value = container.string(R.string.error_agent_reported)
225 }
226 when (event) {
227 is PermissionUpdated ->
228 if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
229 is PermissionReplied ->
230 if (event.properties.sessionID == sessionId && event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
231 is SessionUpdated ->
232 if (event.properties.info.id == sessionId) _sessionTitle.value = event.properties.info.displayTitle
233 is SessionDeleted ->
234 if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
235 _sessionDeleted.value = true
236 }
237 else -> {}
238 }
239 }
240 }
241 }
242 // Load the model catalog; preselect the server default. Failure is non-fatal —
243 // sending with no model just uses the server's default agent/model.
244 viewModelScope.launch {
245 container.activeConnection.collectLatest { conn ->
246 if (conn == null) { _models.value = emptyList(); _selectedModel.value = null; _modelsLoading.value = false; _modelsError.value = false; return@collectLatest }
247 _modelsLoading.value = true
248 _modelsError.value = false
249 runCatchingCancellable { conn.api.providers() }
250 .onFailure { Log.w("ChatViewModel", "Failed to load model catalog", it); _modelsError.value = true }
251 .getOrNull()?.let { resp ->
252 val options = resp.toOptions()
253 _models.value = options
254 _selectedModel.value = resp.defaultOption(options)
255 }
256 _modelsLoading.value = false
257 }
258 }
259 // Load the agent catalog (non-fatal).
260 viewModelScope.launch {
261 container.activeConnection.collectLatest { conn ->
262 if (conn == null) { _agents.value = emptyList(); _agentsLoading.value = false; _agentsError.value = false; return@collectLatest }
263 _agentsLoading.value = true
264 _agentsError.value = false
265 runCatchingCancellable { conn.api.agents() }
266 .onFailure { Log.w("ChatViewModel", "Failed to load agent catalog", it); _agentsError.value = true }
267 .getOrNull()?.let { _agents.value = it }
268 _agentsLoading.value = false
269 }
270 }
271 // Load the command catalog (non-fatal).
272 viewModelScope.launch {
273 container.activeConnection.collectLatest { conn ->
274 if (conn == null) { _commands.value = emptyList(); _commandsLoading.value = false; _commandsError.value = false; return@collectLatest }
275 _commandsLoading.value = true
276 _commandsError.value = false
277 runCatchingCancellable { conn.api.commands() }
278 .onFailure { Log.w("ChatViewModel", "Failed to load command catalog", it); _commandsError.value = true }
279 .getOrNull()?.let { _commands.value = it }
280 _commandsLoading.value = false
281 }
282 }
283 // Resolve the human-readable session title for the app bar (non-fatal).
284 viewModelScope.launch {
285 container.activeConnection.collectLatest { conn ->
286 if (conn == null) return@collectLatest
287 runCatchingCancellable { conn.repository.listSessions() }
288 .getOrNull()
289 ?.firstOrNull { it.id == sessionId }
290 ?.let { _sessionTitle.value = it.displayTitle }
291 }
292 }
293 // If the SSE stream drops mid-run, the run indicator would spin forever;
294 // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
295 viewModelScope.launch {
296 container.activeConnection.collectLatest { conn ->
297 if (conn == null) return@collectLatest
298 conn.events.state.collect { state ->
299 if ((state == EventStreamClient.ConnectionState.Disconnected ||
300 state == EventStreamClient.ConnectionState.Failed) && _running.value) {
301 _running.value = false
302 }
303 }
304 }
305 }
306 }
308 /** Sends [text]; returns true on success so the caller can clear the draft only then. */
309 fun send(text: String): Boolean {
310 val conn = connection ?: return false
311 val trimmed = text.trim()
312 if (trimmed.isEmpty() || !_running.compareAndSet(false, true)) return false
313 _error.value = null
314 _failedDraft.value = null
315 // Clear the in-memory draft for the UI immediately, but don't persist the clear
316 // yet — if the send fails and the process dies before we restore, the draft
317 // would be lost forever. The persisted draft is cleared only on success.
318 _draft.value = ""
319 viewModelScope.launch {
320 runCatchingCancellable {
321 conn.repository.sendPrompt(
322 sessionId,
323 trimmed,
324 model = _selectedModel.value?.ref,
325 agent = _selectedAgent.value,
326 )
327 }.onFailure {
328 _failedDraft.value = trimmed
329 // Only restore the draft if the user hasn't typed anything new since.
330 if (_draft.value.isBlank()) updateDraft(trimmed)
331 _error.value = container.friendlyError(it)
332 _running.value = false
333 }.onSuccess {
334 // Send succeeded — now it's safe to persist the empty draft.
335 container.draftStore.set(sessionId, "")
336 // Don't reset _running here: the agent continues streaming via SSE.
337 // _running is cleared on SessionIdle/SessionError (see event collector)
338 // or when the SSE stream drops (see connection state watcher below).
339 }
340 }
341 return true
342 }
344 /** Re-send the last draft whose send failed, if any. */
345 fun retryFailed() {
346 val draft = _failedDraft.value ?: return
347 _failedDraft.value = null
348 send(draft)
349 }
351 /** Invoke a slash-command by name via the server's /command endpoint. */
352 fun runCommand(command: Command) {
353 val conn = connection ?: return
354 if (!_running.compareAndSet(false, true)) return
355 _error.value = null
356 viewModelScope.launch {
357 runCatchingCancellable {
358 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
359 }.onFailure {
360 _error.value = container.friendlyError(it)
361 _running.value = false
362 }
363 }
364 }
366 fun abort() {
367 val conn = connection ?: return
368 viewModelScope.launch {
369 runCatchingCancellable { conn.repository.abort(sessionId) }
370 .onSuccess { _running.value = false }
371 .onFailure { _error.value = container.friendlyError(it) }
372 }
373 }
375 /** Reconnect to the most recently used server profile (used when the connection is gone). */
376 fun reconnect() {
377 if (container.activeConnection.value != null) return
378 viewModelScope.launch {
379 _loading.value = true
380 val recent = runCatchingCancellable { container.profileStore.profiles.first() }
381 .getOrDefault(emptyList())
382 .firstOrNull() ?: run { _loading.value = false; return@launch }
383 runCatchingCancellable {
384 val conn = container.connect(recent)
385 conn.api.ping()
386 }.onSuccess {
387 _loading.value = false
388 }.onFailure {
389 container.disconnect()
390 _error.value = container.friendlyError(it)
391 _loading.value = false
392 }
393 }
394 }
396 fun respondPermission(permission: Permission, response: PermissionResponse) {
397 val conn = connection ?: return
398 // Clear optimistically; permission.replied will confirm. If the call fails we
399 // restore the pending permission so the user can retry instead of being stuck
400 // with a dismissed dialog and a paused tool run.
401 _pendingPermission.value = null
402 viewModelScope.launch {
403 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
404 .onFailure {
405 _error.value = container.friendlyError(it)
406 // Only restore if no new permission has arrived in the meantime.
407 if (_pendingPermission.value == null) _pendingPermission.value = permission
408 }
409 }
410 }
412 override fun onCleared() {
413 super.onCleared()
414 // Flush any pending debounced draft so it survives navigation. Uses an
415 // asynchronous apply() (not a synchronous commit) so the main thread isn't
416 // blocked on disk I/O — Android's SharedPreferences framework guarantees
417 // pending apply() writes are flushed before the process exits.
418 val pending = _draft.value
419 if (pending.isNotEmpty()) {
420 container.draftStore.flushDraft(sessionId, pending)
421 }
422 }