@@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
24import kotlinx.coroutines.flow.SharingStarted
24import kotlinx.coroutines.flow.SharingStarted
25import kotlinx.coroutines.flow.StateFlow
25import kotlinx.coroutines.flow.StateFlow
26import kotlinx.coroutines.flow.asStateFlow
26import kotlinx.coroutines.flow.asStateFlow
27+import kotlinx.coroutines.flow.collectLatest
27import kotlinx.coroutines.flow.first
28import kotlinx.coroutines.flow.first
28import kotlinx.coroutines.flow.flatMapLatest
29import kotlinx.coroutines.flow.flatMapLatest
29import kotlinx.coroutines.flow.flowOf
30import kotlinx.coroutines.flow.flowOf
@@ -114,8 +115,8 @@ class ChatViewModel(
115 // Watch the bus for run completion / errors / permission asks for this session.
116 // Watch the bus for run completion / errors / permission asks for this session.
116 viewModelScope.launch {
117 viewModelScope.launch {
117− container.activeConnection.collect { conn ->
118+ container.activeConnection.collectLatest { conn ->
118− if (conn == null) return@collect
119+ if (conn == null) return@collectLatest
119 conn.events.events.collect { event ->
120 conn.events.events.collect { event ->
120 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
121 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
121 if (SessionRepository.isError(event, sessionId)) {
122 if (SessionRepository.isError(event, sessionId)) {
@@ -141,8 +142,8 @@ class ChatViewModel(
141 // Load the model catalog; preselect the server default. Failure is non-fatal —
142 // Load the model catalog; preselect the server default. Failure is non-fatal —
142 // sending with no model just uses the server's default agent/model.
143 // sending with no model just uses the server's default agent/model.
143 viewModelScope.launch {
144 viewModelScope.launch {
144− container.activeConnection.collect { conn ->
145+ container.activeConnection.collectLatest { conn ->
145− if (conn == null) return@collect
146+ if (conn == null) return@collectLatest
146 runCatching { conn.api.providers() }.getOrNull()?.let { resp ->
147 runCatching { conn.api.providers() }.getOrNull()?.let { resp ->
147 val options = resp.toOptions()
148 val options = resp.toOptions()
148 _models.value = options
149 _models.value = options
@@ -152,22 +153,22 @@ class ChatViewModel(
153 // Load the agent catalog (non-fatal).
154 // Load the agent catalog (non-fatal).
154 viewModelScope.launch {
155 viewModelScope.launch {
155− container.activeConnection.collect { conn ->
156+ container.activeConnection.collectLatest { conn ->
156− if (conn == null) return@collect
157+ if (conn == null) return@collectLatest
157 runCatching { conn.api.agents() }.getOrNull()?.let { _agents.value = it }
158 runCatching { conn.api.agents() }.getOrNull()?.let { _agents.value = it }
160 // Load the command catalog (non-fatal).
161 // Load the command catalog (non-fatal).
161 viewModelScope.launch {
162 viewModelScope.launch {
162− container.activeConnection.collect { conn ->
163+ container.activeConnection.collectLatest { conn ->
163− if (conn == null) return@collect
164+ if (conn == null) return@collectLatest
164 runCatching { conn.api.commands() }.getOrNull()?.let { _commands.value = it }
165 runCatching { conn.api.commands() }.getOrNull()?.let { _commands.value = it }
167 // Resolve the human-readable session title for the app bar (non-fatal).
168 // Resolve the human-readable session title for the app bar (non-fatal).
168 viewModelScope.launch {
169 viewModelScope.launch {
169− container.activeConnection.collect { conn ->
170+ container.activeConnection.collectLatest { conn ->
170− if (conn == null) return@collect
171+ if (conn == null) return@collectLatest
171 runCatching { conn.repository.listSessions() }
172 runCatching { conn.repository.listSessions() }
172 .getOrNull()
173 .getOrNull()
173 ?.firstOrNull { it.id == sessionId }
174 ?.firstOrNull { it.id == sessionId }
@@ -177,8 +178,8 @@ class ChatViewModel(
177 // If the SSE stream drops mid-run, the run indicator would spin forever;
178 // If the SSE stream drops mid-run, the run indicator would spin forever;
178 // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
179 // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
179 viewModelScope.launch {
180 viewModelScope.launch {
180− container.activeConnection.collect { conn ->
181+ container.activeConnection.collectLatest { conn ->
181− if (conn == null) return@collect
182+ if (conn == null) return@collectLatest
182 conn.events.state.collect { state ->
183 conn.events.state.collect { state ->
183 if (state == EventStreamClient.ConnectionState.Disconnected && _running.value) {
184 if (state == EventStreamClient.ConnectionState.Disconnected && _running.value) {
184 _running.value = false
185 _running.value = false
@@ -196,7 +197,10 @@ class ChatViewModel(
196 _running.value = true
197 _running.value = true
197 _error.value = null
198 _error.value = null
198 _failedDraft.value = null
199 _failedDraft.value = null
199− updateDraft("")
200+ // Clear the in-memory draft for the UI immediately, but don't persist the clear
201+ // yet — if the send fails and the process dies before we restore, the draft
202+ // would be lost forever. The persisted draft is cleared only on success.
200 viewModelScope.launch {
204 viewModelScope.launch {
201 val ok = runCatching {
205 val ok = runCatching {
202 conn.repository.sendPrompt(
206 conn.repository.sendPrompt(
@@ -212,6 +216,9 @@ class ChatViewModel(
212 // Only restore the draft if the user hasn't typed anything new since.
216 // Only restore the draft if the user hasn't typed anything new since.
213 if (_draft.value.isBlank()) updateDraft(trimmed)
217 if (_draft.value.isBlank()) updateDraft(trimmed)
214 _error.value = container.string(R.string.error_failed_to_send)
218 _error.value = container.string(R.string.error_failed_to_send)
220+ // Send succeeded — now it's safe to persist the empty draft.
221+ container.draftStore.set(sessionId, "")
217 return true
224 return true
@@ -267,11 +274,16 @@ class ChatViewModel(
268 fun respondPermission(permission: Permission, response: PermissionResponse) {
275 fun respondPermission(permission: Permission, response: PermissionResponse) {
269 val conn = connection ?: return
276 val conn = connection ?: return
270− // Clear optimistically; permission.replied will confirm.
277+ // Clear optimistically; permission.replied will confirm. If the call fails we
278+ // restore the pending permission so the user can retry instead of being stuck
279+ // with a dismissed dialog and a paused tool run.
271 _pendingPermission.value = null
280 _pendingPermission.value = null
272 viewModelScope.launch {
281 viewModelScope.launch {
273 runCatching { conn.api.respondPermission(sessionId, permission.id, response) }
282 runCatching { conn.api.respondPermission(sessionId, permission.id, response) }
274− .onFailure { _error.value = container.friendlyError(it) }
284+ _error.value = container.friendlyError(it)
285+ _pendingPermission.value = permission