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
+444 -180

No files matched your search

+1
View File
@@ -27,3 +27,4 @@ ktlint
# direnv # direnv
.direnv/ .direnv/
hermes-chat-redesign.html
@@ -68,9 +68,10 @@ class ChatScreenTest {
composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed() composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed()
composeTestRule.onNodeWithTag("send_button").assertDoesNotExist() composeTestRule.onNodeWithTag("send_button").assertDoesNotExist()
// Verify entering text switches mic button to send button // Verify entering text shows send button in input row
// Mic stays visible in toolbar row (new two-row layout)
composeTestRule.onNodeWithTag("chat_input").performTextInput("Hello Hermes") composeTestRule.onNodeWithTag("chat_input").performTextInput("Hello Hermes")
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed() composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
composeTestRule.onNodeWithTag("mic_button").assertDoesNotExist() composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed()
} }
} }
@@ -536,6 +536,11 @@ fun ChatScreen(
onImageTap = { filePickerLauncher.launch("image/*") }, onImageTap = { filePickerLauncher.launch("image/*") },
onFileTap = { filePickerLauncher.launch("*/*") }, onFileTap = { filePickerLauncher.launch("*/*") },
onRemoveAttachment = viewModel::removeAttachment, 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, val modelPickerLoading: Boolean = false,
// Current session's active model label (provider/model), shown in the chip // Current session's active model label (provider/model), shown in the chip
val currentSessionModel: String? = null, val currentSessionModel: String? = null,
// Reasoning effort level for the current session
val reasoningLevel: String? = null,
// Attachment state // Attachment state
val pendingAttachments: List<Attachment> = emptyList(), val pendingAttachments: List<Attachment> = emptyList(),
// Reaction animation — set when a reaction WS event arrives, auto-clears // Reaction animation — set when a reaction WS event arrives, auto-clears
@@ -231,7 +233,12 @@ class ChatViewModel(
connectWebSocket(setLoading = false) connectWebSocket(setLoading = false)
viewModelScope.launch { viewModelScope.launch {
wsClient.events.collect { event -> wsClient.events.collect { event ->
try {
handleWsEvent(event) 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 // B7 (Jun 30 2026, kanban t_connection_loading): clear loading state on connection failure or status change
@@ -376,6 +383,33 @@ class ChatViewModel(
handleGatewayReady() 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 -> { is WsEvent.MessageToken -> {
streamingController.handleMessageToken(event) streamingController.handleMessageToken(event)
} }
@@ -559,6 +593,12 @@ class ChatViewModel(
(resultMap?.get("resumed") as? String) (resultMap?.get("resumed") as? String)
?: _uiState.value.currentSessionId ?: _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 // 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
// the WS round-trip. Calling loadCachedMessages() here would // the WS round-trip. Calling loadCachedMessages() here would
@@ -568,6 +608,18 @@ class ChatViewModel(
it.copy( it.copy(
isLoading = false, isLoading = false,
currentSessionId = sessionId, 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). // 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) { fun createNewSession(setLoading: Boolean = true) {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -1086,6 +1140,18 @@ class ChatViewModel(
onSent = { id -> trackRequest(id, WsMethods.SESSION_CREATE) }, 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() { fun loadSessions() {
@@ -1244,6 +1310,32 @@ class ChatViewModel(
handleSlashCommand("/model $model --provider $provider --session") 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) { fun switchSession(sessionId: String) {
if (sessionId == _uiState.value.currentSessionId) return 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.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons 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.automirrored.filled.Send
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -68,8 +65,8 @@ import com.m57.hermescontrol.data.ws.CommandCatalog
import com.m57.hermescontrol.ui.chat.ChatInputPolicy import com.m57.hermescontrol.ui.chat.ChatInputPolicy
/** /**
* The chat input bar with attach menu, slash-command dropdown, * The chat input bar with a two-row layout: input+send on top,
* attachment preview chips, and mic/send/stop button swapping. * and a toolbar with attach/model chip/reasoning chip/mic below.
*/ */
@Composable @Composable
fun ChatInputBar( fun ChatInputBar(
@@ -86,6 +83,11 @@ fun ChatInputBar(
onImageTap: () -> Unit = {}, onImageTap: () -> Unit = {},
onFileTap: () -> Unit = {}, onFileTap: () -> Unit = {},
onRemoveAttachment: (Int) -> 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 // 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
@@ -135,7 +137,7 @@ fun ChatInputBar(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
border = border =
androidx.compose.foundation.BorderStroke( BorderStroke(
1.dp, 1.dp,
MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
), ),
@@ -182,6 +184,7 @@ fun ChatInputBar(
} }
} }
// ── TOP ROW: Input field with embedded send button ──
Row( Row(
modifier = modifier =
Modifier Modifier
@@ -189,20 +192,124 @@ fun ChatInputBar(
.padding(horizontal = 8.dp, vertical = 2.dp), .padding(horizontal = 8.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Box { val placeholderText =
// Single attach button that opens a dropdown menu when {
IconButton( !isConnected -> {
onClick = { showAttachmentMenu = true }, stringResource(R.string.chat_input_placeholder_not_connected)
}
ChatInputPolicy.showQueuePlaceholder(inputFieldValue.text, isAgentTyping) -> {
stringResource(R.string.chat_input_placeholder_queue)
}
isAgentTyping -> {
stringResource(R.string.chat_input_placeholder_waiting)
}
else -> {
stringResource(R.string.chat_input_placeholder_type_message)
}
}
BasicTextField(
value = inputFieldValue,
onValueChange = onInputChange,
modifier =
Modifier
.weight(1f)
.heightIn(min = 42.dp, max = 120.dp)
.padding(vertical = 4.dp)
.onFocusChanged { isFocused = it.isFocused }
.testTag("chat_input"),
enabled = isConnected, enabled = isConnected,
modifier = Modifier.size(36.dp), textStyle =
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
),
singleLine = false,
maxLines = 4,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
Surface(
shape = RoundedCornerShape(18.dp),
border =
BorderStroke(
width = if (isFocused) 2.dp else 1.dp,
color =
if (isFocused) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
},
),
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.padding(start = 12.dp, end = 4.dp, top = 9.dp, bottom = 9.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
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( Icon(
imageVector = Icons.Default.AttachFile, imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.chat_attach_desc), contentDescription = stringResource(R.string.chat_send_desc),
tint = MaterialTheme.colorScheme.onSurfaceVariant, )
}
}
}
}
}
},
) )
} }
// ── 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"),
)
// Attachment dropdown (anchored to the attach button in ComposerToolbar)
// Shown overlaid at the toolbar level
DropdownMenu( DropdownMenu(
expanded = showAttachmentMenu, expanded = showAttachmentMenu,
onDismissRequest = { showAttachmentMenu = false }, onDismissRequest = { showAttachmentMenu = false },
@@ -248,191 +355,46 @@ fun ChatInputBar(
) )
} }
} }
val placeholderText =
when {
!isConnected -> {
stringResource(R.string.chat_input_placeholder_not_connected)
}
ChatInputPolicy.showQueuePlaceholder(inputFieldValue.text, isAgentTyping) -> {
stringResource(R.string.chat_input_placeholder_queue)
}
isAgentTyping -> {
stringResource(R.string.chat_input_placeholder_waiting)
}
else -> {
stringResource(R.string.chat_input_placeholder_type_message)
}
}
BasicTextField(
value = inputFieldValue,
onValueChange = onInputChange,
modifier =
Modifier
.weight(1f)
.heightIn(min = 42.dp, max = 120.dp)
.padding(horizontal = 4.dp, vertical = 4.dp)
.onFocusChanged { isFocused = it.isFocused }
.testTag("chat_input"),
enabled = isConnected,
textStyle =
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
),
singleLine = false,
maxLines = 4,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
Surface(
shape = RoundedCornerShape(18.dp),
border =
BorderStroke(
width = if (isFocused) 2.dp else 1.dp,
color =
if (isFocused) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
},
),
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(),
) {
Box(
modifier =
Modifier
.padding(horizontal = 12.dp, vertical = 9.dp)
.fillMaxWidth(),
contentAlignment = Alignment.CenterStart,
) {
if (inputFieldValue.text.isEmpty()) {
Text(
text = placeholderText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
innerTextField()
}
}
},
)
Spacer(modifier = Modifier.width(4.dp))
// 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())
},
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),
)
}
}
}
}
}
}
} }
} }
} }
/** /**
* Compact chip showing a pending attachment with a remove button. * Reusable attachment chip composable for showing a pending attachment
* with a remove button.
*/ */
@Composable @Composable
fun AttachmentChip( fun AttachmentChip(
attachment: Attachment, attachment: Attachment,
onRemove: () -> Unit, onRemove: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
val thumbnail = attachment.uri
Surface( Surface(
shape = RoundedCornerShape(16.dp), modifier = modifier,
color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)), tonalElevation = 2.dp,
) { ) {
Row( Row(
modifier = Modifier.padding(start = 10.dp, end = 6.dp, top = 4.dp, bottom = 4.dp), modifier = Modifier.padding(4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (attachment.isImage) { if (attachment.isImage) {
AsyncImage( AsyncImage(
model = attachment.uri, model = thumbnail,
contentDescription = attachment.name, contentDescription = attachment.name,
modifier = modifier =
Modifier Modifier
.size(20.dp) .size(24.dp)
.clip(RoundedCornerShape(4.dp)), .clip(RoundedCornerShape(4.dp)),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
} else { } else {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.InsertDriveFile, imageVector = Icons.Default.InsertDriveFile,
contentDescription = null, contentDescription = attachment.name,
modifier = Modifier.size(16.dp), modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
@@ -445,15 +407,11 @@ fun AttachmentChip(
modifier = Modifier.widthIn(max = 120.dp), modifier = Modifier.widthIn(max = 120.dp),
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
IconButton( IconButton(onClick = onRemove, modifier = Modifier.size(18.dp)) {
onClick = onRemove,
modifier = Modifier.size(18.dp),
) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.chat_attach_remove_desc), contentDescription = stringResource(R.string.chat_attach_remove_desc),
modifier = Modifier.size(14.dp), 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"
}
}