feat(chat): integrate fast mode toggle into generation controls dropdown (#1093)

This commit is contained in:
M57 authored and GitHub committed 2026-09-12 15:15:22 +03:00
1 parent 5dc3896237
commit 0bb087c41e
11 files changed
+388 -19

No files matched your search

@@ -24,6 +24,10 @@ private data class ActiveModelSwitch(
val previousModel: String?,
)
private data class ActiveFastSwitch(
val targetFast: Boolean,
)
/**
* Owns model picker loading/caching, in-session model switches, model capabilities,
* pinned model toggling, and expensive model switch confirmation dialogs.
@@ -48,6 +52,8 @@ class ChatModelSwitchDelegate(
private val savePinnedModels: (List<PinnedModel>) -> Unit = { AuthManager.savePinnedModels(it) },
) {
private val pendingModelSwitchRequests = ConcurrentHashMap<String, ActiveModelSwitch>()
private val pendingFastSwitchRequests = ConcurrentHashMap<String, ActiveFastSwitch>()
private val fastRejectedModels = ConcurrentHashMap.newKeySet<String>()
private var activeModelSwitchConfirmation: ActiveModelSwitch? = null
private var optimisticPreviousModel: String? = null
private var cachedModelOptions: List<ModelProvider> = emptyList()
@@ -195,6 +201,7 @@ class ChatModelSwitchDelegate(
result: Any?,
) {
val pending = pendingModelSwitchRequests.remove(id)
val pendingFast = pendingFastSwitchRequests.remove(id)
val map = result as? Map<*, *> ?: return
val key = map["key"] as? String
if (key == "model") {
@@ -209,6 +216,20 @@ class ChatModelSwitchDelegate(
it.copy(modelSwitchConfirmMessage = confirmMessage)
}
}
} else if (key == "fast") {
val rawVal = map["value"] as? String
val confirmedFast =
if (rawVal != null) {
rawVal == "fast" || rawVal == "priority"
} else {
pendingFast?.targetFast ?: uiState.value.fastMode
}
uiState.update {
it.copy(
fastMode = confirmedFast,
isFastModeChanging = false,
)
}
}
}
@@ -234,6 +255,51 @@ class ChatModelSwitchDelegate(
}
}
fun toggleFastMode() {
val state = uiState.value
val sessionId = runtimeSessionId() ?: return
if (state.currentModelCapabilities?.fast != true) return
if (state.isFastModeChanging) return
val target = !state.fastMode
uiState.update { it.copy(isFastModeChanging = true) }
scope.launch(ioDispatcher) {
wsSend(
WsMethods.CONFIG_SET,
mapOf(
"key" to "fast",
"value" to if (target) "fast" else "normal",
"session_id" to sessionId,
),
) { id ->
trackRequest(id, WsMethods.CONFIG_SET)
pendingFastSwitchRequests[id] = ActiveFastSwitch(target)
}
}
}
fun handleConfigSetError(
id: String,
error: Any?,
) {
val pendingFast = pendingFastSwitchRequests.remove(id)
if (pendingFast != null) {
val errorMsg =
when (error) {
is Map<*, *> -> error["message"] as? String ?: error.toString()
else -> error.toString()
}.lowercase()
val currentModel = uiState.value.currentSessionModel
if (currentModel != null &&
(errorMsg.contains("fast mode is not available") || errorMsg.contains("unknown fast mode"))
) {
fastRejectedModels.add(currentModel)
syncCurrentModelCapabilities()
}
uiState.update { it.copy(isFastModeChanging = false) }
}
}
fun setReasoningLevel(level: String?) {
uiState.update { it.copy(reasoningLevel = level) }
val sessionId = runtimeSessionId() ?: return
@@ -276,7 +342,12 @@ class ChatModelSwitchDelegate(
} else {
val provider = label.substring(0, idx)
val model = label.substring(idx + 1)
cachedModelOptions.find { it.slug == provider }?.capabilities?.get(model)
val baseCaps = cachedModelOptions.find { it.slug == provider }?.capabilities?.get(model)
if (fastRejectedModels.contains(label) && baseCaps?.fast == true) {
baseCaps.copy(fast = false)
} else {
baseCaps
}
}
}
uiState.update { state ->
@@ -634,6 +634,10 @@ fun ChatScreen(
onReasoningTap = { level -> viewModel.setReasoningLevel(level) },
canDisableReasoning = state.currentModelCapabilities?.can_disable_reasoning,
supportsReasoning = state.currentModelCapabilities?.reasoning,
fastMode = state.fastMode,
fastSupported = state.currentModelCapabilities?.fast == true,
isFastModeChanging = state.isFastModeChanging,
onToggleFastMode = { viewModel.toggleFastMode() },
)
}
@@ -130,6 +130,9 @@ data class ChatUiState(
val currentModelCapabilities: ModelCapabilities? = null,
// Reasoning effort level for the current session
val reasoningLevel: String? = null,
// Fast mode / Priority processing state for the current session
val fastMode: Boolean = false,
val isFastModeChanging: Boolean = false,
val terminalBackend: String? = null,
// Context-window meter (issue #756): tokens currently used by the session
// prompt (numerator) and the active model's full context window (denominator).
@@ -733,6 +736,10 @@ class ChatViewModel(
val provider = info["provider"] as? String
val reasoningEffort = info["reasoning_effort"] as? String
val terminalBackend = info["terminal_backend"] as? String
val serviceTier = (info["service_tier"] as? String)?.trim()?.lowercase()
val fastFlag =
(info["fast"] as? Boolean)
?: (if (serviceTier != null) serviceTier == "priority" else null)
val newModelLabel =
if (model != null && provider != null) {
"$provider/$model"
@@ -758,6 +765,8 @@ class ChatViewModel(
} else {
reasoningEffort
},
fastMode = fastFlag ?: state.fastMode,
isFastModeChanging = if (fastFlag != null) false else state.isFastModeChanging,
terminalBackend = terminalBackend ?: state.terminalBackend,
fullContextTokens = if (modelSwapped) null else state.fullContextTokens,
)
@@ -1036,6 +1045,10 @@ class ChatViewModel(
val provider = infoMap?.get("provider") as? String
val reasoningEffort = infoMap?.get("reasoning_effort") as? String
val terminalBackend = infoMap?.get("terminal_backend") as? String
val serviceTier = (infoMap?.get("service_tier") as? String)?.trim()?.lowercase()
val fastFlag =
(infoMap?.get("fast") as? Boolean)
?: (if (serviceTier != null) serviceTier == "priority" else null)
// B8 (Jun 20 2026, kanban t_session_resume): do NOT reload
// cached messages here — switchSession() already did so before
@@ -1059,6 +1072,8 @@ class ChatViewModel(
} else {
reasoningEffort
},
fastMode = fastFlag ?: false,
isFastModeChanging = false,
terminalBackend = terminalBackend ?: it.terminalBackend,
)
}
@@ -1198,6 +1213,10 @@ class ChatViewModel(
return
}
if (method == WsMethods.CONFIG_SET) {
modelSwitchDelegate.handleConfigSetError(id, error)
}
if (method == WsMethods.SESSION_CREATE) {
val pending = pendingInitialPrompt
pendingInitialPrompt = null
@@ -2221,6 +2240,8 @@ class ChatViewModel(
fun setReasoningLevel(level: String?) = modelSwitchDelegate.setReasoningLevel(level)
fun toggleFastMode() = modelSwitchDelegate.toggleFastMode()
fun getModelCapabilities(
providerSlug: String,
modelName: String,
@@ -2471,6 +2492,8 @@ class ChatViewModel(
currentSessionModel = null,
currentModelCapabilities = null,
reasoningLevel = null,
fastMode = false,
isFastModeChanging = false,
terminalBackend = null,
usedContextTokens = null,
fullContextTokens = null,
@@ -103,6 +103,10 @@ fun ChatInputBar(
onReasoningTap: (String?) -> Unit = {},
canDisableReasoning: Boolean? = null,
supportsReasoning: Boolean? = null,
fastMode: Boolean = false,
fastSupported: Boolean = false,
isFastModeChanging: Boolean = false,
onToggleFastMode: () -> Unit = {},
) {
// Allow sending while the agent is mid-turn or awaiting approval: the
// gateway's prompt.submit busy-input policy queues it as the next turn
@@ -377,6 +381,10 @@ fun ChatInputBar(
modifier = Modifier.testTag("chat_composer_toolbar"),
canDisableReasoning = canDisableReasoning,
supportsReasoning = supportsReasoning,
fastMode = fastMode,
fastSupported = fastSupported,
isFastModeChanging = isFastModeChanging,
onToggleFastMode = onToggleFastMode,
)
// Attachment dropdown (anchored to the attach button in ComposerToolbar)
@@ -3,16 +3,20 @@ package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilterChip
@@ -21,6 +25,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -29,8 +35,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
@@ -60,6 +70,10 @@ fun ComposerToolbar(
modifier: Modifier = Modifier,
canDisableReasoning: Boolean? = null,
supportsReasoning: Boolean? = null,
fastMode: Boolean = false,
fastSupported: Boolean = false,
isFastModeChanging: Boolean = false,
onToggleFastMode: () -> Unit = {},
) {
var showReasoningMenu by remember { mutableStateOf(false) }
val reasoningDisabledForModel = supportsReasoning == false
@@ -109,24 +123,44 @@ fun ComposerToolbar(
.testTag("model_chip"),
)
// Reasoning chip with dropdown menu (right side, next to mic)
// Reasoning & Generation controls with dropdown menu (Option 3)
Box {
FilterChip(
selected = reasoningLevel != null,
selected = reasoningLevel != null || fastMode,
onClick = { showReasoningMenu = true },
enabled = !reasoningDisabledForModel,
enabled = isConnected,
label = {
Text(
text =
if (reasoningDisabledForModel) {
"No reasoning"
} else {
buildReasoningLabel(reasoningLevel)
},
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (fastMode) {
Icon(
imageVector = Icons.Filled.Bolt,
contentDescription = stringResource(R.string.chat_fast_mode_label),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(14.dp),
)
}
Text(
text =
if (reasoningDisabledForModel) {
if (fastMode) stringResource(R.string.chat_fast_mode_label) else "No reasoning"
} else {
val rLabel = buildReasoningLabel(reasoningLevel)
if (fastMode) {
"${stringResource(
R.string.chat_fast_mode_label,
)} · $rLabel"
} else {
rLabel
}
},
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
modifier =
Modifier
@@ -137,11 +171,74 @@ fun ComposerToolbar(
DropdownMenu(
expanded = showReasoningMenu,
onDismissRequest = { showReasoningMenu = false },
modifier = Modifier.widthIn(min = 220.dp),
) {
// ── Fast Mode Toggle Item ──
val fastAvailable = isConnected && fastSupported
DropdownMenuItem(
leadingIcon = {
Icon(
imageVector = Icons.Filled.Bolt,
contentDescription = null,
tint =
if (fastMode) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(20.dp),
)
},
text = {
Column {
Text(
text = stringResource(R.string.chat_fast_mode_label),
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (fastMode) FontWeight.SemiBold else FontWeight.Normal,
)
if (!fastSupported) {
Text(
text = stringResource(R.string.chat_fast_mode_unavailable),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
}
},
trailingIcon = {
if (isFastModeChanging) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
} else {
Switch(
checked = fastMode,
onCheckedChange = null, // MenuItem click owns the trigger
enabled = fastAvailable && !isFastModeChanging,
modifier =
Modifier
.scale(0.85f)
.testTag("fast_mode_switch"),
)
}
},
onClick = {
if (fastAvailable && !isFastModeChanging) {
onToggleFastMode()
}
},
enabled = fastAvailable && !isFastModeChanging,
)
HorizontalDivider()
Text(
text = "Reasoning",
text = "REASONING",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
)
if (canDisable == false) {
@@ -160,7 +257,6 @@ fun ComposerToolbar(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
)
}
HorizontalDivider()
val allLevels =
listOf(
"none" to "None",