feat(chat): integrate fast mode toggle into generation controls dropdown (#1093)
This commit is contained in:
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",
|
||||
|
||||
Reference in new issue
Block a user