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)
42 private val container: AppContainer,
43 private val sessionId: String,
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())
58 .onEach { _loading.value = false }
60 _loading.value = false
61 _error.value = container.friendlyError(it)
64 scope = viewModelScope,
65 started = SharingStarted.WhileSubscribed(5_000),
66 initialValue = emptyList(),
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) }
91 scope = viewModelScope,
92 started = SharingStarted.WhileSubscribed(5_000),
93 initialValue = EventStreamClient.ConnectionState.Disconnected,
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()
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)
148 fun clearError() { _error.value = null }
150 fun selectModel(option: ModelOption) { _selectedModel.value = option }
152 fun selectAgent(name: String?) { _selectedAgent.value = name }
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)
167 _modelsLoading.value = false
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
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
197 fun updateDraft(text: String) {
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) }
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
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)
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
232 if (event.properties.info.id == sessionId) _sessionTitle.value = event.properties.info.displayTitle
234 if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
235 _sessionDeleted.value = true
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)
256 _modelsLoading.value = false
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
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
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() }
289 ?.firstOrNull { it.id == sessionId }
290 ?.let { _sessionTitle.value = it.displayTitle }
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
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
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.
319 viewModelScope.launch {
320 runCatchingCancellable {
321 conn.repository.sendPrompt(
324 model = _selectedModel.value?.ref,
325 agent = _selectedAgent.value,
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
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).
344 /** Re-send the last draft whose send failed, if any. */
346 val draft = _failedDraft.value ?: return
347 _failedDraft.value = null
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
356 viewModelScope.launch {
357 runCatchingCancellable {
358 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
360 _error.value = container.friendlyError(it)
361 _running.value = false
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) }
375 /** Reconnect to the most recently used server profile (used when the connection is gone). */
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)
387 _loading.value = false
389 container.disconnect()
390 _error.value = container.friendlyError(it)
391 _loading.value = false
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) }
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
412 override fun 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)
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