refactor(chat): two-row ChatComposer + model/reasoning chips (#669)

* feat(chat): add reasoningLevel to ChatUiState + ViewModel methods

* feat(chat): add ComposerToolbar with model + reasoning chips

* refactor(chat): rewrite ChatInputBar to two-row layout

* feat(chat): wire ComposerToolbar into ChatScreen

* fix(chat): make model chip fixed 120dp with horizontal scroll for long names

* feat(chat): reasoning chip opens dropdown menu with all 9 levels instead of cycling

* fix(chat): remove Auto level from reasoning menu — backend only supports none..ultra, default is medium

* feat(chat): sync model + reasoning level from backend session.resume response

* fix(chat): handle session.info events for model/reasoning sync + fix null value 4002

* fix(chat): safety timeout for createNewSession + try-catch event loop

* fix(chat): address agy review findings

- Restore InsertDriveFile icon for non-image attachments
- Add widthIn(max=120.dp) constraint on attachment filename
- Restore stringResource reference for remove-attachment description
- Remove dead cycleReasoningLevel() method (dropdown supersedes)
- Fix createNewSession timeout race with generation counter

* fix(chat): update instrumented test for two-row layout

chatScreen_renders_and_acceptsInput had an assertion that the mic
button disappears when text is entered — this was the OLD single-row
layout behavior. The new two-row composer always shows the mic button
in the toolbar row, independent of input state.

---------

Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-21 18:18:19 +03:00
1 parent c16f6e3e0b
commit bb4149eb0d
6 files changed
+430 -166

No files matched your search

@@ -536,6 +536,11 @@ fun ChatScreen(
onImageTap = { filePickerLauncher.launch("image/*") },
onFileTap = { filePickerLauncher.launch("*/*") },
onRemoveAttachment = viewModel::removeAttachment,
// Composer toolbar wiring (PR 1)
currentSessionModel = state.currentSessionModel,
reasoningLevel = state.reasoningLevel,
onModelTap = { viewModel.openModelPicker() },
onReasoningTap = { level -> viewModel.setReasoningLevel(level) },
)
}
@@ -90,6 +90,8 @@ data class ChatUiState(
val modelPickerLoading: Boolean = false,
// Current session's active model label (provider/model), shown in the chip
val currentSessionModel: String? = null,
// Reasoning effort level for the current session
val reasoningLevel: String? = null,
// Attachment state
val pendingAttachments: List<Attachment> = emptyList(),
// Reaction animation — set when a reaction WS event arrives, auto-clears
@@ -231,7 +233,12 @@ class ChatViewModel(
connectWebSocket(setLoading = false)
viewModelScope.launch {
wsClient.events.collect { event ->
handleWsEvent(event)
try {
handleWsEvent(event)
} catch (e: Exception) {
android.util.Log.e("ChatVM", "Uncaught in event loop", e)
_uiState.update { it.copy(isLoading = false) }
}
}
}
// B7 (Jun 30 2026, kanban t_connection_loading): clear loading state on connection failure or status change
@@ -376,6 +383,33 @@ class ChatViewModel(
handleGatewayReady()
}
is WsEvent.SessionInfo -> {
// Session info pushed by backend when config changes
// (model switch, reasoning level, etc.)
val info = event.data
if (info != null) {
val model = info["model"] as? String
val provider = info["provider"] as? String
val reasoningEffort = info["reasoning_effort"] as? String
_uiState.update { state ->
state.copy(
currentSessionModel =
if (model != null && provider != null) {
"$provider/$model"
} else {
model ?: state.currentSessionModel
},
reasoningLevel =
if (reasoningEffort.isNullOrEmpty()) {
null
} else {
reasoningEffort
},
)
}
}
}
is WsEvent.MessageToken -> {
streamingController.handleMessageToken(event)
}
@@ -559,6 +593,12 @@ class ChatViewModel(
(resultMap?.get("resumed") as? String)
?: _uiState.value.currentSessionId
// Parse session info from backend — model, provider, reasoning_effort
val infoMap = resultMap?.get("info") as? Map<String, Any?>
val model = infoMap?.get("model") as? String
val provider = infoMap?.get("provider") as? String
val reasoningEffort = infoMap?.get("reasoning_effort") as? String
// B8 (Jun 20 2026, kanban t_session_resume): do NOT reload
// cached messages here — switchSession() already did so before
// the WS round-trip. Calling loadCachedMessages() here would
@@ -568,6 +608,18 @@ class ChatViewModel(
it.copy(
isLoading = false,
currentSessionId = sessionId,
currentSessionModel =
if (model != null && provider != null) {
"$provider/$model"
} else {
model ?: it.currentSessionModel
},
reasoningLevel =
if (reasoningEffort.isNullOrEmpty()) {
null
} else {
reasoningEffort
},
)
}
// Mirror the active runtime session id app-wide (issue #532).
@@ -1069,6 +1121,8 @@ class ChatViewModel(
}
}
private var sessionCreateCounter = 0L
fun createNewSession(setLoading: Boolean = true) {
_uiState.update {
it.copy(
@@ -1086,6 +1140,18 @@ class ChatViewModel(
onSent = { id -> trackRequest(id, WsMethods.SESSION_CREATE) },
)
}
// B7 safety timeout: clear loading state if RPC response never arrives
if (setLoading && !isTestEnvironment()) {
val generation = ++sessionCreateCounter
viewModelScope.launch {
delay(10_000L)
// Only clear if no newer session creation has started — prevents a
// stale timeout from wiping the loading flag of a subsequent request.
if (generation == sessionCreateCounter && _uiState.value.isLoading) {
_uiState.update { it.copy(isLoading = false) }
}
}
}
}
fun loadSessions() {
@@ -1244,6 +1310,32 @@ class ChatViewModel(
handleSlashCommand("/model $model --provider $provider --session")
}
/**
* Set the reasoning effort level for the current session.
*
* Updates the UI optimistically and sends a `config.set` RPC to the
* backend. The level applies per-session via the runtime session ID.
* If [level] is null it resets to the model's default.
*
* @param level One of "low", "medium", "high", or null for default.
*/
fun setReasoningLevel(level: String?) {
_uiState.update { it.copy(reasoningLevel = level) }
val sessionId = runtimeSessionId ?: return
if (level == null) return // null = model default, no need to send WS
viewModelScope.launch(Dispatchers.IO) {
wsClient.send(
WsMethods.CONFIG_SET,
mapOf(
"key" to "reasoning",
"value" to level,
"session_id" to sessionId,
),
onSent = { id -> trackRequest(id, WsMethods.CONFIG_SET) },
)
}
}
fun switchSession(sessionId: String) {
if (sessionId == _uiState.value.currentSessionId) return
@@ -28,12 +28,9 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
@@ -68,8 +65,8 @@ import com.m57.hermescontrol.data.ws.CommandCatalog
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
/**
* The chat input bar with attach menu, slash-command dropdown,
* attachment preview chips, and mic/send/stop button swapping.
* The chat input bar with a two-row layout: input+send on top,
* and a toolbar with attach/model chip/reasoning chip/mic below.
*/
@Composable
fun ChatInputBar(
@@ -86,6 +83,11 @@ fun ChatInputBar(
onImageTap: () -> Unit = {},
onFileTap: () -> Unit = {},
onRemoveAttachment: (Int) -> Unit = {},
// NEW: composer toolbar wiring
currentSessionModel: String? = null,
reasoningLevel: String? = null,
onModelTap: () -> Unit = {},
onReasoningTap: (String?) -> 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
@@ -135,7 +137,7 @@ fun ChatInputBar(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
border =
androidx.compose.foundation.BorderStroke(
BorderStroke(
1.dp,
MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
),
@@ -182,6 +184,7 @@ fun ChatInputBar(
}
}
// ── TOP ROW: Input field with embedded send button ──
Row(
modifier =
Modifier
@@ -189,66 +192,6 @@ fun ChatInputBar(
.padding(horizontal = 8.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box {
// Single attach button that opens a dropdown menu
IconButton(
onClick = { showAttachmentMenu = true },
enabled = isConnected,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Default.AttachFile,
contentDescription = stringResource(R.string.chat_attach_desc),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
DropdownMenu(
expanded = showAttachmentMenu,
onDismissRequest = { showAttachmentMenu = false },
) {
DropdownMenuItem(
text = { Text("Camera") },
onClick = {
showAttachmentMenu = false
onCameraTap()
},
leadingIcon = {
Text(
text = "📷",
fontSize = 18.sp,
)
},
)
DropdownMenuItem(
text = { Text("Image") },
onClick = {
showAttachmentMenu = false
onImageTap()
},
leadingIcon = {
Text(
text = "🖼️",
fontSize = 18.sp,
)
},
)
DropdownMenuItem(
text = { Text("File") },
onClick = {
showAttachmentMenu = false
onFileTap()
},
leadingIcon = {
Text(
text = "📄",
fontSize = 18.sp,
)
},
)
}
}
val placeholderText =
when {
!isConnected -> {
@@ -275,7 +218,7 @@ fun ChatInputBar(
Modifier
.weight(1f)
.heightIn(min = 42.dp, max = 120.dp)
.padding(horizontal = 4.dp, vertical = 4.dp)
.padding(vertical = 4.dp)
.onFocusChanged { isFocused = it.isFocused }
.testTag("chat_input"),
enabled = isConnected,
@@ -302,98 +245,114 @@ fun ChatInputBar(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(),
) {
Box(
Row(
modifier =
Modifier
.padding(horizontal = 12.dp, vertical = 9.dp)
.padding(start = 12.dp, end = 4.dp, top = 9.dp, bottom = 9.dp)
.fillMaxWidth(),
contentAlignment = Alignment.CenterStart,
verticalAlignment = Alignment.CenterVertically,
) {
if (inputFieldValue.text.isEmpty()) {
Text(
text = placeholderText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
Box(modifier = Modifier.weight(1f)) {
if (inputFieldValue.text.isEmpty()) {
Text(
text = placeholderText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
innerTextField()
}
// Send button INSIDE the field
AnimatedContent(
targetState = canSend && inputFieldValue.text.isNotBlank(),
transitionSpec = {
(scaleIn(initialScale = 0.8f) + fadeIn())
.togetherWith(scaleOut(targetScale = 0.8f) + fadeOut())
},
label = "send_toggle",
) { showSend ->
if (showSend) {
IconButton(
onClick = onSend,
enabled = canSend,
colors = IconButtonDefaults.filledTonalIconButtonColors(),
modifier =
Modifier
.size(36.dp)
.testTag("send_button"),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.chat_send_desc),
)
}
}
}
innerTextField()
}
}
},
)
}
Spacer(modifier = Modifier.width(4.dp))
// ── BOTTOM ROW: Toolbar ──
ComposerToolbar(
isConnected = isConnected,
currentSessionModel = currentSessionModel,
reasoningLevel = reasoningLevel,
isListening = isListening,
onAttachTap = { showAttachmentMenu = true },
onModelTap = onModelTap,
onReasoningSelected = onReasoningTap,
onMicTap = onMicTap,
modifier = Modifier.testTag("chat_composer_toolbar"),
)
// Mic / Stop / Send button — swaps based on input state
AnimatedContent(
targetState =
when {
isListening -> "listening"
inputFieldValue.text.isBlank() && pendingAttachments.isEmpty() -> "mic"
else -> "send"
},
transitionSpec = {
(scaleIn(initialScale = 0.8f) + fadeIn())
.togetherWith(scaleOut(targetScale = 0.8f) + fadeOut())
// Attachment dropdown (anchored to the attach button in ComposerToolbar)
// Shown overlaid at the toolbar level
DropdownMenu(
expanded = showAttachmentMenu,
onDismissRequest = { showAttachmentMenu = false },
) {
DropdownMenuItem(
text = { Text("Camera") },
onClick = {
showAttachmentMenu = false
onCameraTap()
},
label = "mic_send_toggle",
) { buttonState ->
when (buttonState) {
"mic" -> {
IconButton(
onClick = onMicTap,
enabled = isConnected,
colors = IconButtonDefaults.filledTonalIconButtonColors(),
modifier =
Modifier
.size(36.dp)
.testTag("mic_button"),
) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = stringResource(R.string.chat_mic_desc),
)
}
}
"listening" -> {
IconButton(
onClick = onMicTap,
modifier =
Modifier
.size(36.dp)
.testTag("mic_stop_button"),
colors =
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
) {
Icon(
imageVector = Icons.Default.Stop,
contentDescription = "Stop listening",
)
}
}
else -> {
IconButton(
onClick = onSend,
enabled = canSend,
colors = IconButtonDefaults.filledTonalIconButtonColors(),
modifier =
Modifier
.size(36.dp)
.testTag("send_button"),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.chat_send_desc),
)
}
}
}
}
leadingIcon = {
Text(
text = "📷",
fontSize = 18.sp,
)
},
)
DropdownMenuItem(
text = { Text("Image") },
onClick = {
showAttachmentMenu = false
onImageTap()
},
leadingIcon = {
Text(
text = "🖼️",
fontSize = 18.sp,
)
},
)
DropdownMenuItem(
text = { Text("File") },
onClick = {
showAttachmentMenu = false
onFileTap()
},
leadingIcon = {
Text(
text = "📄",
fontSize = 18.sp,
)
},
)
}
}
}
@@ -401,38 +360,41 @@ fun ChatInputBar(
}
/**
* Compact chip showing a pending attachment with a remove button.
* Reusable attachment chip composable for showing a pending attachment
* with a remove button.
*/
@Composable
fun AttachmentChip(
attachment: Attachment,
onRemove: () -> Unit,
modifier: Modifier = Modifier,
) {
val thumbnail = attachment.uri
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)),
modifier = modifier,
shape = RoundedCornerShape(8.dp),
tonalElevation = 2.dp,
) {
Row(
modifier = Modifier.padding(start = 10.dp, end = 6.dp, top = 4.dp, bottom = 4.dp),
modifier = Modifier.padding(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (attachment.isImage) {
AsyncImage(
model = attachment.uri,
model = thumbnail,
contentDescription = attachment.name,
modifier =
Modifier
.size(20.dp)
.size(24.dp)
.clip(RoundedCornerShape(4.dp)),
contentScale = ContentScale.Crop,
)
Spacer(modifier = Modifier.width(4.dp))
} else {
Icon(
imageVector = Icons.AutoMirrored.Filled.InsertDriveFile,
contentDescription = null,
modifier = Modifier.size(16.dp),
imageVector = Icons.Default.InsertDriveFile,
contentDescription = attachment.name,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(4.dp))
@@ -445,15 +407,11 @@ fun AttachmentChip(
modifier = Modifier.widthIn(max = 120.dp),
)
Spacer(modifier = Modifier.width(4.dp))
IconButton(
onClick = onRemove,
modifier = Modifier.size(18.dp),
) {
IconButton(onClick = onRemove, modifier = Modifier.size(18.dp)) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.chat_attach_remove_desc),
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -0,0 +1,207 @@
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
* Bottom toolbar row for the chat composer.
*
* Layout: [📎 attach] [model chip] [reasoning chip] [←spacer→] [🎙 mic]
*
* The reasoning chip opens a dropdown menu to pick a level (instead of cycling).
*/
@Composable
fun ComposerToolbar(
isConnected: Boolean,
currentSessionModel: String?,
reasoningLevel: String?,
isListening: Boolean,
onAttachTap: () -> Unit,
onModelTap: () -> Unit,
onReasoningSelected: (String?) -> Unit,
onMicTap: () -> Unit,
modifier: Modifier = Modifier,
) {
var showReasoningMenu by remember { mutableStateOf(false) }
Row(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.testTag("composer_toolbar"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
// Attach button
IconButton(
onClick = onAttachTap,
enabled = isConnected,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Default.AttachFile,
contentDescription = "Attach file",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Model chip — fixed width, text scrolls if too long
FilterChip(
selected = currentSessionModel != null,
onClick = onModelTap,
label = {
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
) {
Text(
text = currentSessionModel ?: "Model",
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
)
}
},
modifier =
Modifier
.width(120.dp)
.testTag("model_chip"),
)
// Reasoning chip with dropdown menu
Box {
FilterChip(
selected = reasoningLevel != null,
onClick = { showReasoningMenu = true },
label = {
Text(
text = buildReasoningLabel(reasoningLevel),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
modifier = Modifier.testTag("reasoning_chip"),
)
DropdownMenu(
expanded = showReasoningMenu,
onDismissRequest = { showReasoningMenu = false },
) {
val allLevels =
listOf(
"none" to "🧠 None",
"minimal" to "🧠 Minimal",
"low" to "🧠 Low",
"medium" to "🧠 Med",
"high" to "🧠 High",
"xhigh" to "🧠 XHigh",
"max" to "🧠 Max",
"ultra" to "🧠 Ultra",
)
allLevels.forEach { (level, label) ->
DropdownMenuItem(
text = {
Text(
text = label,
fontWeight =
if (reasoningLevel == level) {
MaterialTheme.typography.bodyMedium.fontWeight
} else {
null
},
color =
if (reasoningLevel == level) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
)
},
onClick = {
showReasoningMenu = false
onReasoningSelected(level)
},
)
}
}
}
Spacer(modifier = Modifier.weight(1f))
// Mic / Stop button
IconButton(
onClick = onMicTap,
enabled = isConnected,
colors =
if (isListening) {
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
)
} else {
IconButtonDefaults.filledTonalIconButtonColors()
},
modifier =
Modifier
.size(36.dp)
.testTag(if (isListening) "mic_stop_button" else "mic_button"),
) {
Icon(
imageVector = if (isListening) Icons.Default.Stop else Icons.Default.Mic,
contentDescription = if (isListening) "Stop listening" else "Mic",
)
}
}
}
/**
* Build a human-readable label from a reasoning effort level.
*
* @param level One of: "none", "minimal", "low", "medium", "high",
* "xhigh", "max", "ultra", or null for model default.
* @return Display string such as "🧠 None", "🧠 Low", "🧠 XHigh", "🧠 Ultra", etc.
*/
private fun buildReasoningLabel(level: String?): String {
return when (level) {
null -> "🧠 Med"
"none" -> "🧠 None"
"minimal" -> "🧠 Minimal"
"low" -> "🧠 Low"
"medium" -> "🧠 Med"
"high" -> "🧠 High"
"xhigh" -> "🧠 XHigh"
"max" -> "🧠 Max"
"ultra" -> "🧠 Ultra"
else -> "🧠 $level"
}
}