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",
+3
View File
@@ -1031,6 +1031,9 @@
<string name="channels_expiry_format">ينتهي في: %1$s</string>
<string name="channels_search_placeholder">البحث في القنوات…</string>
<string name="chat_attach_file">إرفاق ملف</string>
<string name="chat_fast_mode_label">سريع</string>
<string name="chat_fast_mode_desc">الوضع السريع</string>
<string name="chat_fast_mode_unavailable">الوضع السريع غير مدعوم لهذا النموذج</string>
<string name="chat_save_attachment">حفظ %1$s</string>
<string name="chat_switch_model_title">تبديل النموذج (لهذه المحادثة)</string>
<string name="config_clear_value">مسح القيمة</string>
+3
View File
@@ -883,4 +883,7 @@
<string name="subagent_status_cancelled">취소됨</string>
<string name="subagent_status_failed">실패</string>
<string name="subagent_duration">%1$.1f초</string>
<string name="chat_fast_mode_label">빠름</string>
<string name="chat_fast_mode_desc">빠른 모드</string>
<string name="chat_fast_mode_unavailable">이 모델에서는 빠른 모드를 사용할 수 없습니다</string>
</resources>
+3
View File
@@ -1022,6 +1022,9 @@
<string name="channels_expiry_format">过期时间:%1$s</string>
<string name="channels_search_placeholder">搜索渠道…</string>
<string name="chat_attach_file">附加文件</string>
<string name="chat_fast_mode_label">快速</string>
<string name="chat_fast_mode_desc">快速模式</string>
<string name="chat_fast_mode_unavailable">此模型不支持快速模式</string>
<string name="chat_save_attachment">保存 %1$s</string>
<string name="chat_switch_model_title">切换模型(当前对话)</string>
<string name="config_clear_value">清除值</string>
+3
View File
@@ -1158,6 +1158,9 @@
<string name="channels_expiry_format">Expires: %1$s</string>
<string name="channels_search_placeholder">Search channels…</string>
<string name="chat_attach_file">Attach file</string>
<string name="chat_fast_mode_label">Fast</string>
<string name="chat_fast_mode_desc">Fast mode</string>
<string name="chat_fast_mode_unavailable">Fast mode unavailable for this model</string>
<string name="chat_save_attachment">Save %1$s</string>
<string name="chat_switch_model_title">Switch model (this chat)</string>
<string name="config_clear_value">Clear value</string>
@@ -40,7 +40,7 @@ class ChatModelSwitchDelegateTest {
slug = "openai",
name = "OpenAI",
models = listOf("gpt-4o"),
capabilities = mapOf("gpt-4o" to ModelCapabilities(reasoning = true)),
capabilities = mapOf("gpt-4o" to ModelCapabilities(fast = true, reasoning = true)),
),
),
)
@@ -138,4 +138,73 @@ class ChatModelSwitchDelegateTest {
assertNull(uiState.value.modelSwitchConfirmMessage)
assertEquals("anthropic/claude-3", uiState.value.currentSessionModel)
}
@Test
fun toggleFastMode_sendsConfigSetFast_andUpdatesOnAck() =
testScope.runTest {
delegate.preloadModelOptions()
advanceUntilIdle()
// Switch to model with fast capability
delegate.sendSlashModel("openai", "gpt-4o")
advanceUntilIdle()
sentMethods.clear()
sentParams.clear()
assertTrue(uiState.value.currentModelCapabilities?.fast == true)
assertFalse(uiState.value.fastMode)
assertFalse(uiState.value.isFastModeChanging)
delegate.toggleFastMode()
advanceUntilIdle()
assertTrue(uiState.value.isFastModeChanging)
assertFalse(uiState.value.fastMode) // Still unconfirmed!
assertEquals(listOf(WsMethods.CONFIG_SET), sentMethods)
assertEquals("fast", sentParams.first()["key"])
assertEquals("fast", sentParams.first()["value"])
assertEquals("runtime-1", sentParams.first()["session_id"])
// Backend acknowledges
delegate.handleConfigSetResult("req-1", mapOf("key" to "fast", "value" to "fast"))
assertTrue(uiState.value.fastMode)
assertFalse(uiState.value.isFastModeChanging)
// Toggle off
delegate.toggleFastMode()
advanceUntilIdle()
assertTrue(uiState.value.isFastModeChanging)
assertEquals("normal", sentParams.last()["value"])
delegate.handleConfigSetResult("req-2", mapOf("key" to "fast", "value" to "normal"))
assertFalse(uiState.value.fastMode)
assertFalse(uiState.value.isFastModeChanging)
}
@Test
fun toggleFastMode_whenRejectedByBackend_clearsChangingAndLatchesUnavailable() =
testScope.runTest {
delegate.preloadModelOptions()
advanceUntilIdle()
delegate.sendSlashModel("openai", "gpt-4o")
advanceUntilIdle()
sentMethods.clear()
sentParams.clear()
assertTrue(uiState.value.currentModelCapabilities?.fast == true)
delegate.toggleFastMode()
advanceUntilIdle()
assertTrue(uiState.value.isFastModeChanging)
// Backend returns error indicating fast mode not available
delegate.handleConfigSetError("req-1", mapOf("message" to "fast mode is not available for this model"))
assertFalse(uiState.value.isFastModeChanging)
assertFalse(uiState.value.fastMode)
assertEquals(false, uiState.value.currentModelCapabilities?.fast)
// Further toggle attempts are no-ops
val sentCount = sentMethods.size
delegate.toggleFastMode()
advanceUntilIdle()
assertEquals(sentCount, sentMethods.size)
}
}
@@ -2951,6 +2951,92 @@ class ChatViewModelTest {
assertEquals("nous/tencent/hy3:free", viewModel.uiState.value.currentSessionModel)
}
@Test
fun testSessionInfo_hydratesFastModeState() =
runTest {
stubEmptySessionRests("session-a")
val (viewModel, _) = createViewModelWithSession()
advanceUntilIdle()
assertFalse(viewModel.uiState.value.fastMode)
// Fast = true in session.info
mockEventsFlow.emit(
WsEvent.SessionInfo(
mapOf("fast" to true, "service_tier" to "priority"),
),
)
advanceUntilIdle()
assertTrue(viewModel.uiState.value.fastMode)
assertFalse(viewModel.uiState.value.isFastModeChanging)
// Fast = false / normal in session.info
mockEventsFlow.emit(
WsEvent.SessionInfo(
mapOf("fast" to false, "service_tier" to "normal"),
),
)
advanceUntilIdle()
assertFalse(viewModel.uiState.value.fastMode)
// Partial session.info without fast/service_tier does NOT clear state
mockEventsFlow.emit(
WsEvent.SessionInfo(
mapOf("fast" to true),
),
)
advanceUntilIdle()
assertTrue(viewModel.uiState.value.fastMode)
mockEventsFlow.emit(
WsEvent.SessionInfo(
mapOf("terminal_backend" to "tmux"),
),
)
advanceUntilIdle()
assertTrue(viewModel.uiState.value.fastMode) // preserved!
}
@Test
fun testSessionResume_hydratesFastModeState() =
runTest {
stubEmptySessionRests("session-b")
val (viewModel, _) = createViewModelWithSession()
var resumeReqId = ""
every { HermesWsClient.send(WsMethods.SESSION_RESUME, any(), any()) } answers {
val reqId = "resume-req-1"
resumeReqId = reqId
arg<((String) -> Unit)?>(2)?.invoke(reqId)
reqId
}
viewModel.switchSession("session-b")
runCurrent()
// Simulate SESSION_RESUME response with fast=true in info
mockEventsFlow.emit(
WsEvent.RpcResult(
id = resumeReqId,
result =
mapOf(
"session_id" to "sess-runtime-1",
"resumed" to "session-b",
"info" to
mapOf(
"model" to "gpt-4o",
"provider" to "openai",
"fast" to true,
"service_tier" to "priority",
),
),
),
)
advanceUntilIdle()
assertTrue(viewModel.uiState.value.fastMode)
assertFalse(viewModel.uiState.value.isFastModeChanging)
}
@Test
fun testResumeNotFound_recoversWithNewSession() =
runTest {