git.iko.soy/opencode-android
on 66d239c help sign in
337 lines 15 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.collectLatest
30import kotlinx.coroutines.flow.debounce
31import kotlinx.coroutines.flow.drop
32import kotlinx.coroutines.flow.first
33import kotlinx.coroutines.flow.flatMapLatest
34import kotlinx.coroutines.flow.flowOf
35import kotlinx.coroutines.flow.onEach
36import kotlinx.coroutines.flow.stateIn
37import kotlinx.coroutines.launch
39@OptIn(ExperimentalCoroutinesApi::class)
40class ChatViewModel(
41 private val container: AppContainer,
42 private val sessionId: String,
43) : ViewModel() {
45 private val connection get() = container.activeConnection.value
47 val connected: Boolean get() = connection != null
49 private val _loading = MutableStateFlow(true)
50 val loading: StateFlow<Boolean> = _loading.asStateFlow()
52 val messages: StateFlow<List<MessageWithParts>> =
53 container.activeConnection
54 .flatMapLatest { conn ->
55 conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
56 }
57 .onEach { _loading.value = false }
58 .stateIn(
59 scope = viewModelScope,
60 started = SharingStarted.WhileSubscribed(5_000),
61 initialValue = emptyList(),
62 )
64 private val _running = MutableStateFlow(false)
65 val running: StateFlow<Boolean> = _running.asStateFlow()
67 private val _error = MutableStateFlow<String?>(null)
68 val error: StateFlow<String?> = _error.asStateFlow()
70 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
71 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
73 private val _modelsLoading = MutableStateFlow(true)
74 val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
76 private val _selectedModel = MutableStateFlow<ModelOption?>(null)
77 val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
79 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
80 container.activeConnection
81 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
82 .stateIn(
83 scope = viewModelScope,
84 started = SharingStarted.WhileSubscribed(5_000),
85 initialValue = EventStreamClient.ConnectionState.Disconnected,
86 )
88 private val _pendingPermission = MutableStateFlow<Permission?>(null)
89 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
91 private val _agents = MutableStateFlow<List<Agent>>(emptyList())
92 val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
94 private val _agentsLoading = MutableStateFlow(true)
95 val agentsLoading: StateFlow<Boolean> = _agentsLoading.asStateFlow()
97 private val _selectedAgent = MutableStateFlow<String?>(null)
98 val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
100 private val _commands = MutableStateFlow<List<Command>>(emptyList())
101 val commands: StateFlow<List<Command>> = _commands.asStateFlow()
103 private val _commandsLoading = MutableStateFlow(true)
104 val commandsLoading: StateFlow<Boolean> = _commandsLoading.asStateFlow()
106 private val _sessionTitle = MutableStateFlow<String?>(null)
107 val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
109 /** The text of the last send that failed, surfaced so the UI can offer a retry. */
110 private val _failedDraft = MutableStateFlow<String?>(null)
111 val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
113 /** Per-session draft, persisted so it survives navigation/process death. */
114 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
115 val draft: StateFlow<String> = _draft.asStateFlow()
117 fun clearError() { _error.value = null }
119 fun selectModel(option: ModelOption) { _selectedModel.value = option }
121 fun selectAgent(name: String?) { _selectedAgent.value = name }
123 fun updateDraft(text: String) {
124 _draft.value = text
125 }
127 init {
128 // Debounce draft persistence so we don't write to disk on every keystroke.
129 viewModelScope.launch {
130 _draft.drop(1).debounce(500).collect { text ->
131 container.draftStore.set(sessionId, text)
132 }
133 }
134 // Reset per-connection state when the active server changes so stale spinners,
135 // permission dialogs, errors, and agent selections from the old server don't
136 // persist into the new one.
137 viewModelScope.launch {
138 container.activeConnection.collectLatest { conn ->
139 if (conn == null) return@collectLatest
140 _running.value = false
141 _pendingPermission.value = null
142 _error.value = null
143 _failedDraft.value = null
144 _selectedAgent.value = null
145 conn.events.events.collect { event ->
146 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
147 if (SessionRepository.isError(event, sessionId)) {
148 _running.value = false
149 _error.value = container.string(R.string.error_agent_reported)
150 }
151 when (event) {
152 is PermissionUpdated ->
153 if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
154 is PermissionReplied ->
155 if (event.properties.sessionID == sessionId && event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
156 is SessionUpdated ->
157 if (event.properties.info.id == sessionId) _sessionTitle.value = event.properties.info.displayTitle
158 is SessionDeleted ->
159 if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
160 _sessionTitle.value = event.properties.info?.displayTitle
161 }
162 else -> {}
163 }
164 }
165 }
166 }
167 // Load the model catalog; preselect the server default. Failure is non-fatal —
168 // sending with no model just uses the server's default agent/model.
169 viewModelScope.launch {
170 container.activeConnection.collectLatest { conn ->
171 if (conn == null) { _models.value = emptyList(); _selectedModel.value = null; _modelsLoading.value = false; return@collectLatest }
172 _modelsLoading.value = true
173 runCatchingCancellable { conn.api.providers() }
174 .onFailure { Log.w("ChatViewModel", "Failed to load model catalog", it) }
175 .getOrNull()?.let { resp ->
176 val options = resp.toOptions()
177 _models.value = options
178 _selectedModel.value = resp.defaultOption(options)
179 }
180 _modelsLoading.value = false
181 }
182 }
183 // Load the agent catalog (non-fatal).
184 viewModelScope.launch {
185 container.activeConnection.collectLatest { conn ->
186 if (conn == null) { _agents.value = emptyList(); _agentsLoading.value = false; return@collectLatest }
187 _agentsLoading.value = true
188 runCatchingCancellable { conn.api.agents() }
189 .onFailure { Log.w("ChatViewModel", "Failed to load agent catalog", it) }
190 .getOrNull()?.let { _agents.value = it }
191 _agentsLoading.value = false
192 }
193 }
194 // Load the command catalog (non-fatal).
195 viewModelScope.launch {
196 container.activeConnection.collectLatest { conn ->
197 if (conn == null) { _commands.value = emptyList(); _commandsLoading.value = false; return@collectLatest }
198 _commandsLoading.value = true
199 runCatchingCancellable { conn.api.commands() }
200 .onFailure { Log.w("ChatViewModel", "Failed to load command catalog", it) }
201 .getOrNull()?.let { _commands.value = it }
202 _commandsLoading.value = false
203 }
204 }
205 // Resolve the human-readable session title for the app bar (non-fatal).
206 viewModelScope.launch {
207 container.activeConnection.collectLatest { conn ->
208 if (conn == null) return@collectLatest
209 runCatchingCancellable { conn.repository.listSessions() }
210 .getOrNull()
211 ?.firstOrNull { it.id == sessionId }
212 ?.let { _sessionTitle.value = it.displayTitle }
213 }
214 }
215 // If the SSE stream drops mid-run, the run indicator would spin forever;
216 // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
217 viewModelScope.launch {
218 container.activeConnection.collectLatest { conn ->
219 if (conn == null) return@collectLatest
220 conn.events.state.collect { state ->
221 if (state == EventStreamClient.ConnectionState.Disconnected && _running.value) {
222 _running.value = false
223 }
224 }
225 }
226 }
227 }
229 /** Sends [text]; returns true on success so the caller can clear the draft only then. */
230 fun send(text: String): Boolean {
231 val conn = connection ?: return false
232 val trimmed = text.trim()
233 if (trimmed.isEmpty() || !_running.compareAndSet(false, true)) return false
234 _error.value = null
235 _failedDraft.value = null
236 // Clear the in-memory draft for the UI immediately, but don't persist the clear
237 // yet — if the send fails and the process dies before we restore, the draft
238 // would be lost forever. The persisted draft is cleared only on success.
239 _draft.value = ""
240 viewModelScope.launch {
241 runCatchingCancellable {
242 conn.repository.sendPrompt(
243 sessionId,
244 trimmed,
245 model = _selectedModel.value?.ref,
246 agent = _selectedAgent.value,
247 )
248 }.onFailure {
249 _failedDraft.value = trimmed
250 // Only restore the draft if the user hasn't typed anything new since.
251 if (_draft.value.isBlank()) updateDraft(trimmed)
252 _error.value = container.friendlyError(it)
253 }.onSuccess {
254 // Send succeeded — now it's safe to persist the empty draft.
255 container.draftStore.set(sessionId, "")
256 }
257 _running.value = false
258 }
259 return true
260 }
262 /** Re-send the last draft whose send failed, if any. */
263 fun retryFailed() {
264 val draft = _failedDraft.value ?: return
265 _failedDraft.value = null
266 send(draft)
267 }
269 /** Invoke a slash-command by name via the server's /command endpoint. */
270 fun runCommand(command: Command) {
271 val conn = connection ?: return
272 if (!_running.compareAndSet(false, true)) return
273 _error.value = null
274 viewModelScope.launch {
275 runCatchingCancellable {
276 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
277 }.onFailure { _error.value = container.friendlyError(it) }
278 _running.value = false
279 }
280 }
282 fun abort() {
283 val conn = connection ?: return
284 viewModelScope.launch {
285 runCatchingCancellable { conn.repository.abort(sessionId) }
286 .onSuccess { _running.value = false }
287 .onFailure { _error.value = container.friendlyError(it) }
288 }
289 }
291 /** Reconnect to the most recently used server profile (used when the connection is gone). */
292 fun reconnect() {
293 if (container.activeConnection.value != null) return
294 viewModelScope.launch {
295 _loading.value = true
296 val recent = runCatching { container.profileStore.profiles.first() }
297 .getOrDefault(emptyList())
298 .firstOrNull() ?: run { _loading.value = false; return@launch }
299 runCatchingCancellable {
300 val conn = container.connect(recent)
301 conn.api.ping()
302 }.onSuccess {
303 _loading.value = false
304 }.onFailure {
305 container.disconnect()
306 _error.value = container.friendlyError(it)
307 _loading.value = false
308 }
309 }
310 }
312 fun respondPermission(permission: Permission, response: PermissionResponse) {
313 val conn = connection ?: return
314 // Clear optimistically; permission.replied will confirm. If the call fails we
315 // restore the pending permission so the user can retry instead of being stuck
316 // with a dismissed dialog and a paused tool run.
317 _pendingPermission.value = null
318 viewModelScope.launch {
319 runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
320 .onFailure {
321 _error.value = container.friendlyError(it)
322 // Only restore if no new permission has arrived in the meantime.
323 if (_pendingPermission.value == null) _pendingPermission.value = permission
324 }
325 }
326 }
328 override fun onCleared() {
329 super.onCleared()
330 // Flush any pending debounced draft so it survives navigation. Use a synchronous
331 // commit instead of runBlocking so the main thread isn't held hostage by disk I/O.
332 val pending = _draft.value
333 if (pending.isNotEmpty()) {
334 container.draftStore.flushDraft(sessionId, pending)
335 }
336 }