fix(chat): support confirmation dialog when switching to expensive or data-policy models (#1050)

This commit is contained in:
M57 authored and GitHub committed 2026-09-03 16:56:08 +03:00
1 parent fb80be04b4
commit efe6fe81cb
7 files changed
+201 -4

No files matched your search

@@ -38,6 +38,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -911,6 +912,25 @@ fun ChatScreen(
)
}
// Expensive / Data-policy model confirmation dialog (issue #589 follow-up)
if (state.modelSwitchConfirmMessage != null) {
AlertDialog(
onDismissRequest = { viewModel.dismissModelSwitchConfirm() },
title = { Text(stringResource(R.string.model_expensive_title)) },
text = { Text(state.modelSwitchConfirmMessage!!) },
confirmButton = {
Button(onClick = { viewModel.confirmModelSwitchExpensive() }) {
Text(stringResource(R.string.model_confirm_continue))
}
},
dismissButton = {
TextButton(onClick = { viewModel.dismissModelSwitchConfirm() }) {
Text(stringResource(R.string.common_cancel))
}
},
)
}
if (showContextSheet && state.contextBreakdown != null) {
ContextDetailSheet(
breakdown = state.contextBreakdown!!,
@@ -327,6 +327,7 @@ data class ChatUiState(
val modelPickerProviders: List<ModelProvider> = emptyList(),
val modelPickerPinned: List<PinnedModel> = emptyList(),
val modelPickerLoading: Boolean = false,
val modelSwitchConfirmMessage: String? = null,
// Current session's active model label (provider/model), shown in the chip
val currentSessionModel: String? = null,
// Per-model reasoning capabilities for the current session's model
@@ -463,6 +464,15 @@ class ChatViewModel(
private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>()
private var sessionGeneration = 0L
private data class ActiveModelSwitch(
val spec: String,
val previousModel: String?,
)
private val pendingModelSwitchRequests = ConcurrentHashMap<String, ActiveModelSwitch>()
private var activeModelSwitchConfirmation: ActiveModelSwitch? = null
private var optimisticPreviousModel: String? = null
private var resumeRequestSequence = 0L
private var activeResumeRequestSequence = 0L
private var hydrationRequestSequence = 0L
@@ -1208,6 +1218,25 @@ class ChatViewModel(
addSystemMessage("Approval submitted")
}
}
WsMethods.CONFIG_SET -> {
val pending = pendingModelSwitchRequests.remove(id)
val map = result as? Map<*, *> ?: return
val key = map["key"] as? String
if (key == "model") {
val confirmRequired = map["confirm_required"] as? Boolean ?: false
if (confirmRequired) {
val confirmMessage =
(map["confirm_message"] as? String)
?: (map["warning"] as? String)
?: "This model requires confirmation to switch. Continue?"
activeModelSwitchConfirmation = pending
_uiState.update {
it.copy(modelSwitchConfirmMessage = confirmMessage)
}
}
}
}
}
}
@@ -1889,7 +1918,10 @@ class ChatViewModel(
* `<model> --provider <slug> --session`
* (matching the TUI client's `modelValueForConfigSet`).
*/
private fun handleModelSwitch(command: String) {
private fun handleModelSwitch(
command: String,
confirmExpensive: Boolean = false,
) {
val sessionId = runtimeSessionId
if (sessionId == null) {
addAssistantMessage("No active session. Use `/new` to create one.")
@@ -1906,11 +1938,25 @@ class ChatViewModel(
} else {
command.trim()
}
val previousModel = optimisticPreviousModel ?: _uiState.value.currentSessionModel
optimisticPreviousModel = null
val params =
mutableMapOf<String, Any>(
"key" to "model",
"value" to spec,
"session_id" to sessionId,
)
if (confirmExpensive) {
params["confirm_expensive_model"] = true
}
viewModelScope.launch(ioDispatcher) {
wsClient.send(
WsMethods.CONFIG_SET,
mapOf("key" to "model", "value" to spec, "session_id" to sessionId),
onSent = { id -> trackRequest(id, WsMethods.CONFIG_SET) },
params,
onSent = { id ->
trackRequest(id, WsMethods.CONFIG_SET)
pendingModelSwitchRequests[id] = ActiveModelSwitch(spec, previousModel)
},
)
}
}
@@ -2265,6 +2311,7 @@ class ChatViewModel(
provider: String,
model: String,
) {
optimisticPreviousModel = _uiState.value.currentSessionModel
_uiState.update {
it.copy(
showModelPicker = false,
@@ -2280,6 +2327,28 @@ class ChatViewModel(
handleSlashCommand("/model $model --provider $provider --session")
}
fun dismissModelSwitchConfirm() {
val pending = activeModelSwitchConfirmation
activeModelSwitchConfirmation = null
_uiState.update {
it.copy(
modelSwitchConfirmMessage = null,
currentSessionModel = pending?.previousModel ?: it.currentSessionModel,
)
}
syncCurrentModelCapabilities()
fetchContextUsage()
}
fun confirmModelSwitchExpensive() {
val pending = activeModelSwitchConfirmation
activeModelSwitchConfirmation = null
_uiState.update { it.copy(modelSwitchConfirmMessage = null) }
if (pending != null) {
handleModelSwitch(pending.spec, confirmExpensive = true)
}
}
/**
* Set the reasoning effort level for the current session.
*
@@ -2575,6 +2644,7 @@ class ChatViewModel(
showSessionPicker = false,
showModelPicker = false,
modelPickerLoading = false,
modelSwitchConfirmMessage = null,
currentSessionModel = null,
currentModelCapabilities = null,
reasoningLevel = null,