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?,
|
val previousModel: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class ActiveFastSwitch(
|
||||||
|
val targetFast: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns model picker loading/caching, in-session model switches, model capabilities,
|
* Owns model picker loading/caching, in-session model switches, model capabilities,
|
||||||
* pinned model toggling, and expensive model switch confirmation dialogs.
|
* 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 savePinnedModels: (List<PinnedModel>) -> Unit = { AuthManager.savePinnedModels(it) },
|
||||||
) {
|
) {
|
||||||
private val pendingModelSwitchRequests = ConcurrentHashMap<String, ActiveModelSwitch>()
|
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 activeModelSwitchConfirmation: ActiveModelSwitch? = null
|
||||||
private var optimisticPreviousModel: String? = null
|
private var optimisticPreviousModel: String? = null
|
||||||
private var cachedModelOptions: List<ModelProvider> = emptyList()
|
private var cachedModelOptions: List<ModelProvider> = emptyList()
|
||||||
@@ -195,6 +201,7 @@ class ChatModelSwitchDelegate(
|
|||||||
result: Any?,
|
result: Any?,
|
||||||
) {
|
) {
|
||||||
val pending = pendingModelSwitchRequests.remove(id)
|
val pending = pendingModelSwitchRequests.remove(id)
|
||||||
|
val pendingFast = pendingFastSwitchRequests.remove(id)
|
||||||
val map = result as? Map<*, *> ?: return
|
val map = result as? Map<*, *> ?: return
|
||||||
val key = map["key"] as? String
|
val key = map["key"] as? String
|
||||||
if (key == "model") {
|
if (key == "model") {
|
||||||
@@ -209,6 +216,20 @@ class ChatModelSwitchDelegate(
|
|||||||
it.copy(modelSwitchConfirmMessage = confirmMessage)
|
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?) {
|
fun setReasoningLevel(level: String?) {
|
||||||
uiState.update { it.copy(reasoningLevel = level) }
|
uiState.update { it.copy(reasoningLevel = level) }
|
||||||
val sessionId = runtimeSessionId() ?: return
|
val sessionId = runtimeSessionId() ?: return
|
||||||
@@ -276,7 +342,12 @@ class ChatModelSwitchDelegate(
|
|||||||
} else {
|
} else {
|
||||||
val provider = label.substring(0, idx)
|
val provider = label.substring(0, idx)
|
||||||
val model = label.substring(idx + 1)
|
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 ->
|
uiState.update { state ->
|
||||||
|
|||||||
@@ -634,6 +634,10 @@ fun ChatScreen(
|
|||||||
onReasoningTap = { level -> viewModel.setReasoningLevel(level) },
|
onReasoningTap = { level -> viewModel.setReasoningLevel(level) },
|
||||||
canDisableReasoning = state.currentModelCapabilities?.can_disable_reasoning,
|
canDisableReasoning = state.currentModelCapabilities?.can_disable_reasoning,
|
||||||
supportsReasoning = state.currentModelCapabilities?.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,
|
val currentModelCapabilities: ModelCapabilities? = null,
|
||||||
// Reasoning effort level for the current session
|
// Reasoning effort level for the current session
|
||||||
val reasoningLevel: String? = null,
|
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,
|
val terminalBackend: String? = null,
|
||||||
// Context-window meter (issue #756): tokens currently used by the session
|
// Context-window meter (issue #756): tokens currently used by the session
|
||||||
// prompt (numerator) and the active model's full context window (denominator).
|
// prompt (numerator) and the active model's full context window (denominator).
|
||||||
@@ -733,6 +736,10 @@ class ChatViewModel(
|
|||||||
val provider = info["provider"] as? String
|
val provider = info["provider"] as? String
|
||||||
val reasoningEffort = info["reasoning_effort"] as? String
|
val reasoningEffort = info["reasoning_effort"] as? String
|
||||||
val terminalBackend = info["terminal_backend"] 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 =
|
val newModelLabel =
|
||||||
if (model != null && provider != null) {
|
if (model != null && provider != null) {
|
||||||
"$provider/$model"
|
"$provider/$model"
|
||||||
@@ -758,6 +765,8 @@ class ChatViewModel(
|
|||||||
} else {
|
} else {
|
||||||
reasoningEffort
|
reasoningEffort
|
||||||
},
|
},
|
||||||
|
fastMode = fastFlag ?: state.fastMode,
|
||||||
|
isFastModeChanging = if (fastFlag != null) false else state.isFastModeChanging,
|
||||||
terminalBackend = terminalBackend ?: state.terminalBackend,
|
terminalBackend = terminalBackend ?: state.terminalBackend,
|
||||||
fullContextTokens = if (modelSwapped) null else state.fullContextTokens,
|
fullContextTokens = if (modelSwapped) null else state.fullContextTokens,
|
||||||
)
|
)
|
||||||
@@ -1036,6 +1045,10 @@ class ChatViewModel(
|
|||||||
val provider = infoMap?.get("provider") as? String
|
val provider = infoMap?.get("provider") as? String
|
||||||
val reasoningEffort = infoMap?.get("reasoning_effort") as? String
|
val reasoningEffort = infoMap?.get("reasoning_effort") as? String
|
||||||
val terminalBackend = infoMap?.get("terminal_backend") 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
|
// B8 (Jun 20 2026, kanban t_session_resume): do NOT reload
|
||||||
// cached messages here — switchSession() already did so before
|
// cached messages here — switchSession() already did so before
|
||||||
@@ -1059,6 +1072,8 @@ class ChatViewModel(
|
|||||||
} else {
|
} else {
|
||||||
reasoningEffort
|
reasoningEffort
|
||||||
},
|
},
|
||||||
|
fastMode = fastFlag ?: false,
|
||||||
|
isFastModeChanging = false,
|
||||||
terminalBackend = terminalBackend ?: it.terminalBackend,
|
terminalBackend = terminalBackend ?: it.terminalBackend,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1198,6 +1213,10 @@ class ChatViewModel(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (method == WsMethods.CONFIG_SET) {
|
||||||
|
modelSwitchDelegate.handleConfigSetError(id, error)
|
||||||
|
}
|
||||||
|
|
||||||
if (method == WsMethods.SESSION_CREATE) {
|
if (method == WsMethods.SESSION_CREATE) {
|
||||||
val pending = pendingInitialPrompt
|
val pending = pendingInitialPrompt
|
||||||
pendingInitialPrompt = null
|
pendingInitialPrompt = null
|
||||||
@@ -2221,6 +2240,8 @@ class ChatViewModel(
|
|||||||
|
|
||||||
fun setReasoningLevel(level: String?) = modelSwitchDelegate.setReasoningLevel(level)
|
fun setReasoningLevel(level: String?) = modelSwitchDelegate.setReasoningLevel(level)
|
||||||
|
|
||||||
|
fun toggleFastMode() = modelSwitchDelegate.toggleFastMode()
|
||||||
|
|
||||||
fun getModelCapabilities(
|
fun getModelCapabilities(
|
||||||
providerSlug: String,
|
providerSlug: String,
|
||||||
modelName: String,
|
modelName: String,
|
||||||
@@ -2471,6 +2492,8 @@ class ChatViewModel(
|
|||||||
currentSessionModel = null,
|
currentSessionModel = null,
|
||||||
currentModelCapabilities = null,
|
currentModelCapabilities = null,
|
||||||
reasoningLevel = null,
|
reasoningLevel = null,
|
||||||
|
fastMode = false,
|
||||||
|
isFastModeChanging = false,
|
||||||
terminalBackend = null,
|
terminalBackend = null,
|
||||||
usedContextTokens = null,
|
usedContextTokens = null,
|
||||||
fullContextTokens = null,
|
fullContextTokens = null,
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ fun ChatInputBar(
|
|||||||
onReasoningTap: (String?) -> Unit = {},
|
onReasoningTap: (String?) -> Unit = {},
|
||||||
canDisableReasoning: Boolean? = null,
|
canDisableReasoning: Boolean? = null,
|
||||||
supportsReasoning: 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
|
// 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
|
// 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"),
|
modifier = Modifier.testTag("chat_composer_toolbar"),
|
||||||
canDisableReasoning = canDisableReasoning,
|
canDisableReasoning = canDisableReasoning,
|
||||||
supportsReasoning = supportsReasoning,
|
supportsReasoning = supportsReasoning,
|
||||||
|
fastMode = fastMode,
|
||||||
|
fastSupported = fastSupported,
|
||||||
|
isFastModeChanging = isFastModeChanging,
|
||||||
|
onToggleFastMode = onToggleFastMode,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Attachment dropdown (anchored to the attach button in ComposerToolbar)
|
// 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.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.AttachFile
|
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.Mic
|
||||||
import androidx.compose.material.icons.filled.Stop
|
import androidx.compose.material.icons.filled.Stop
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
@@ -21,6 +25,8 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.SwitchDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -29,8 +35,12 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
@@ -60,6 +70,10 @@ fun ComposerToolbar(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
canDisableReasoning: Boolean? = null,
|
canDisableReasoning: Boolean? = null,
|
||||||
supportsReasoning: Boolean? = null,
|
supportsReasoning: Boolean? = null,
|
||||||
|
fastMode: Boolean = false,
|
||||||
|
fastSupported: Boolean = false,
|
||||||
|
isFastModeChanging: Boolean = false,
|
||||||
|
onToggleFastMode: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var showReasoningMenu by remember { mutableStateOf(false) }
|
var showReasoningMenu by remember { mutableStateOf(false) }
|
||||||
val reasoningDisabledForModel = supportsReasoning == false
|
val reasoningDisabledForModel = supportsReasoning == false
|
||||||
@@ -109,24 +123,44 @@ fun ComposerToolbar(
|
|||||||
.testTag("model_chip"),
|
.testTag("model_chip"),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Reasoning chip with dropdown menu (right side, next to mic)
|
// Reasoning & Generation controls with dropdown menu (Option 3)
|
||||||
Box {
|
Box {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = reasoningLevel != null,
|
selected = reasoningLevel != null || fastMode,
|
||||||
onClick = { showReasoningMenu = true },
|
onClick = { showReasoningMenu = true },
|
||||||
enabled = !reasoningDisabledForModel,
|
enabled = isConnected,
|
||||||
label = {
|
label = {
|
||||||
Text(
|
Row(
|
||||||
text =
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
if (reasoningDisabledForModel) {
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
"No reasoning"
|
) {
|
||||||
} else {
|
if (fastMode) {
|
||||||
buildReasoningLabel(reasoningLevel)
|
Icon(
|
||||||
},
|
imageVector = Icons.Filled.Bolt,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
contentDescription = stringResource(R.string.chat_fast_mode_label),
|
||||||
maxLines = 1,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
overflow = TextOverflow.Ellipsis,
|
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 =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -137,11 +171,74 @@ fun ComposerToolbar(
|
|||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = showReasoningMenu,
|
expanded = showReasoningMenu,
|
||||||
onDismissRequest = { showReasoningMenu = false },
|
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(
|
||||||
text = "Reasoning",
|
text = "REASONING",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
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),
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
)
|
)
|
||||||
if (canDisable == false) {
|
if (canDisable == false) {
|
||||||
@@ -160,7 +257,6 @@ fun ComposerToolbar(
|
|||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
HorizontalDivider()
|
|
||||||
val allLevels =
|
val allLevels =
|
||||||
listOf(
|
listOf(
|
||||||
"none" to "None",
|
"none" to "None",
|
||||||
|
|||||||
@@ -1031,6 +1031,9 @@
|
|||||||
<string name="channels_expiry_format">ينتهي في: %1$s</string>
|
<string name="channels_expiry_format">ينتهي في: %1$s</string>
|
||||||
<string name="channels_search_placeholder">البحث في القنوات…</string>
|
<string name="channels_search_placeholder">البحث في القنوات…</string>
|
||||||
<string name="chat_attach_file">إرفاق ملف</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_save_attachment">حفظ %1$s</string>
|
||||||
<string name="chat_switch_model_title">تبديل النموذج (لهذه المحادثة)</string>
|
<string name="chat_switch_model_title">تبديل النموذج (لهذه المحادثة)</string>
|
||||||
<string name="config_clear_value">مسح القيمة</string>
|
<string name="config_clear_value">مسح القيمة</string>
|
||||||
|
|||||||
@@ -883,4 +883,7 @@
|
|||||||
<string name="subagent_status_cancelled">취소됨</string>
|
<string name="subagent_status_cancelled">취소됨</string>
|
||||||
<string name="subagent_status_failed">실패</string>
|
<string name="subagent_status_failed">실패</string>
|
||||||
<string name="subagent_duration">%1$.1f초</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>
|
</resources>
|
||||||
@@ -1022,6 +1022,9 @@
|
|||||||
<string name="channels_expiry_format">过期时间:%1$s</string>
|
<string name="channels_expiry_format">过期时间:%1$s</string>
|
||||||
<string name="channels_search_placeholder">搜索渠道…</string>
|
<string name="channels_search_placeholder">搜索渠道…</string>
|
||||||
<string name="chat_attach_file">附加文件</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_save_attachment">保存 %1$s</string>
|
||||||
<string name="chat_switch_model_title">切换模型(当前对话)</string>
|
<string name="chat_switch_model_title">切换模型(当前对话)</string>
|
||||||
<string name="config_clear_value">清除值</string>
|
<string name="config_clear_value">清除值</string>
|
||||||
|
|||||||
@@ -1158,6 +1158,9 @@
|
|||||||
<string name="channels_expiry_format">Expires: %1$s</string>
|
<string name="channels_expiry_format">Expires: %1$s</string>
|
||||||
<string name="channels_search_placeholder">Search channels…</string>
|
<string name="channels_search_placeholder">Search channels…</string>
|
||||||
<string name="chat_attach_file">Attach file</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_save_attachment">Save %1$s</string>
|
||||||
<string name="chat_switch_model_title">Switch model (this chat)</string>
|
<string name="chat_switch_model_title">Switch model (this chat)</string>
|
||||||
<string name="config_clear_value">Clear value</string>
|
<string name="config_clear_value">Clear value</string>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class ChatModelSwitchDelegateTest {
|
|||||||
slug = "openai",
|
slug = "openai",
|
||||||
name = "OpenAI",
|
name = "OpenAI",
|
||||||
models = listOf("gpt-4o"),
|
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)
|
assertNull(uiState.value.modelSwitchConfirmMessage)
|
||||||
assertEquals("anthropic/claude-3", uiState.value.currentSessionModel)
|
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)
|
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
|
@Test
|
||||||
fun testResumeNotFound_recoversWithNewSession() =
|
fun testResumeNotFound_recoversWithNewSession() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|||||||
Reference in new issue
Block a user