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 android.util.Log
23import kotlinx.coroutines.ExperimentalCoroutinesApi
24import kotlinx.coroutines.flow.MutableStateFlow
25import kotlinx.coroutines.flow.SharingStarted
26import kotlinx.coroutines.flow.StateFlow
27import kotlinx.coroutines.flow.asStateFlow
28import kotlinx.coroutines.flow.collectLatest
29import kotlinx.coroutines.flow.first
30import kotlinx.coroutines.flow.flatMapLatest
31import kotlinx.coroutines.flow.flowOf
32import kotlinx.coroutines.flow.onEach
33import kotlinx.coroutines.flow.stateIn
34import kotlinx.coroutines.launch
36@OptIn(ExperimentalCoroutinesApi::class)
38 private val container: AppContainer,
39 private val sessionId: String,
42 private val connection get() = container.activeConnection.value
44 val connected: Boolean get() = connection != null
46 private val _loading = MutableStateFlow(true)
47 val loading: StateFlow<Boolean> = _loading.asStateFlow()
49 val messages: StateFlow<List<MessageWithParts>> =
50 container.activeConnection
51 .flatMapLatest { conn ->
52 conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
54 .onEach { _loading.value = false }
56 scope = viewModelScope,
57 started = SharingStarted.WhileSubscribed(5_000),
58 initialValue = emptyList(),
61 private val _running = MutableStateFlow(false)
62 val running: StateFlow<Boolean> = _running.asStateFlow()
64 private val _error = MutableStateFlow<String?>(null)
65 val error: StateFlow<String?> = _error.asStateFlow()
67 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
68 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
70 private val _selectedModel = MutableStateFlow<ModelOption?>(null)
71 val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
73 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
74 container.activeConnection
75 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
77 scope = viewModelScope,
78 started = SharingStarted.WhileSubscribed(5_000),
79 initialValue = EventStreamClient.ConnectionState.Disconnected,
82 private val _pendingPermission = MutableStateFlow<Permission?>(null)
83 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
85 private val _agents = MutableStateFlow<List<Agent>>(emptyList())
86 val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
88 private val _selectedAgent = MutableStateFlow<String?>(null)
89 val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
91 private val _commands = MutableStateFlow<List<Command>>(emptyList())
92 val commands: StateFlow<List<Command>> = _commands.asStateFlow()
94 private val _sessionTitle = MutableStateFlow<String?>(null)
95 val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
97 /** The text of the last send that failed, surfaced so the UI can offer a retry. */
98 private val _failedDraft = MutableStateFlow<String?>(null)
99 val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
101 /** Per-session draft, persisted so it survives navigation/process death. */
102 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
103 val draft: StateFlow<String> = _draft.asStateFlow()
105 fun clearError() { _error.value = null }
107 fun selectModel(option: ModelOption) { _selectedModel.value = option }
109 fun selectAgent(name: String?) { _selectedAgent.value = name }
111 fun updateDraft(text: String) {
113 container.draftStore.set(sessionId, text)
117 // Watch the bus for run completion / errors / permission asks for this session.
118 viewModelScope.launch {
119 container.activeConnection.collectLatest { conn ->
120 if (conn == null) return@collectLatest
121 conn.events.events.collect { event ->
122 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
123 if (SessionRepository.isError(event, sessionId)) {
124 _running.value = false
125 _error.value = container.string(R.string.error_agent_reported)
128 is PermissionUpdated ->
129 if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
130 is PermissionReplied ->
131 if (event.properties.sessionID == sessionId && event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
133 if (event.properties.info.id == sessionId) _sessionTitle.value = event.properties.info.displayTitle
135 if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
136 _sessionTitle.value = event.properties.info?.displayTitle
143 // Load the model catalog; preselect the server default. Failure is non-fatal —
144 // sending with no model just uses the server's default agent/model.
145 viewModelScope.launch {
146 container.activeConnection.collectLatest { conn ->
147 if (conn == null) { _models.value = emptyList(); _selectedModel.value = null; return@collectLatest }
148 runCatching { conn.api.providers() }
149 .onFailure { Log.w("ChatViewModel", "Failed to load model catalog", it) }
150 .getOrNull()?.let { resp ->
151 val options = resp.toOptions()
152 _models.value = options
153 _selectedModel.value = resp.defaultOption(options)
157 // Load the agent catalog (non-fatal).
158 viewModelScope.launch {
159 container.activeConnection.collectLatest { conn ->
160 if (conn == null) { _agents.value = emptyList(); return@collectLatest }
161 runCatching { conn.api.agents() }
162 .onFailure { Log.w("ChatViewModel", "Failed to load agent catalog", it) }
163 .getOrNull()?.let { _agents.value = it }
166 // Load the command catalog (non-fatal).
167 viewModelScope.launch {
168 container.activeConnection.collectLatest { conn ->
169 if (conn == null) { _commands.value = emptyList(); return@collectLatest }
170 runCatching { conn.api.commands() }
171 .onFailure { Log.w("ChatViewModel", "Failed to load command catalog", it) }
172 .getOrNull()?.let { _commands.value = it }
175 // Resolve the human-readable session title for the app bar (non-fatal).
176 viewModelScope.launch {
177 container.activeConnection.collectLatest { conn ->
178 if (conn == null) return@collectLatest
179 runCatching { conn.repository.listSessions() }
181 ?.firstOrNull { it.id == sessionId }
182 ?.let { _sessionTitle.value = it.displayTitle }
185 // If the SSE stream drops mid-run, the run indicator would spin forever;
186 // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
187 viewModelScope.launch {
188 container.activeConnection.collectLatest { conn ->
189 if (conn == null) return@collectLatest
190 conn.events.state.collect { state ->
191 if (state == EventStreamClient.ConnectionState.Disconnected && _running.value) {
192 _running.value = false
199 /** Sends [text]; returns true on success so the caller can clear the draft only then. */
200 fun send(text: String): Boolean {
201 val conn = connection ?: return false
202 val trimmed = text.trim()
203 if (trimmed.isEmpty() || _running.value) return false
204 _running.value = true
206 _failedDraft.value = null
207 // Clear the in-memory draft for the UI immediately, but don't persist the clear
208 // yet — if the send fails and the process dies before we restore, the draft
209 // would be lost forever. The persisted draft is cleared only on success.
211 viewModelScope.launch {
213 conn.repository.sendPrompt(
216 model = _selectedModel.value?.ref,
217 agent = _selectedAgent.value,
220 _failedDraft.value = trimmed
221 // Only restore the draft if the user hasn't typed anything new since.
222 if (_draft.value.isBlank()) updateDraft(trimmed)
223 _error.value = container.friendlyError(it)
225 // Send succeeded — now it's safe to persist the empty draft.
226 container.draftStore.set(sessionId, "")
228 _running.value = false
233 /** Re-send the last draft whose send failed, if any. */
235 val draft = _failedDraft.value ?: return
236 _failedDraft.value = null
240 /** Invoke a slash-command by name via the server's /command endpoint. */
241 fun runCommand(command: Command) {
242 val conn = connection ?: return
243 if (_running.value) return
244 _running.value = true
246 viewModelScope.launch {
248 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
249 }.onFailure { _error.value = container.friendlyError(it) }
250 _running.value = false
255 val conn = connection ?: return
256 viewModelScope.launch {
257 runCatching { conn.repository.abort(sessionId) }
258 .onFailure { _error.value = container.friendlyError(it) }
259 _running.value = false
263 /** Reconnect to the most recently used server profile (used when the connection is gone). */
265 if (container.activeConnection.value != null) return
266 viewModelScope.launch {
267 _loading.value = true
268 val recent = runCatching { container.profileStore.profiles.first() }
269 .getOrDefault(emptyList())
270 .firstOrNull() ?: run { _loading.value = false; return@launch }
272 val conn = container.connect(recent)
275 _loading.value = false
277 container.disconnect()
278 _error.value = container.friendlyError(it)
279 _loading.value = false
284 fun respondPermission(permission: Permission, response: PermissionResponse) {
285 val conn = connection ?: return
286 // Clear optimistically; permission.replied will confirm. If the call fails we
287 // restore the pending permission so the user can retry instead of being stuck
288 // with a dismissed dialog and a paused tool run.
289 _pendingPermission.value = null
290 viewModelScope.launch {
291 runCatching { conn.api.respondPermission(sessionId, permission.id, response) }
293 _error.value = container.friendlyError(it)
294 _pendingPermission.value = permission
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