feat(chat): restyle the composer as a single card (#1114)

This commit is contained in:
Alfredo Ortegón Sepúlveda authored and GitHub committed 2026-09-15 16:37:46 +03:00
1 parent 528ec4f28f
commit e5adfcf571
7 files changed
+671 -303

No files matched your search

@@ -1,16 +1,12 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -32,13 +28,11 @@ 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.Close
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -51,7 +45,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalLayoutDirection
@@ -76,8 +69,8 @@ import com.m57.hermescontrol.ui.common.BotAvatar
import com.m57.hermescontrol.util.BidiUtils
/**
* The chat input bar with a two-row layout: input+send on top,
* and a toolbar with attach/model chip/reasoning chip/mic below.
* The chat input bar: a single rounded card with the input on top and a
* controls row (attach, model/reasoning pill, mic, send) inside it below.
*/
@Composable
fun ChatInputBar(
@@ -116,29 +109,25 @@ fun ChatInputBar(
// Attachment menu state
var showAttachmentMenu by remember { mutableStateOf(false) }
var isFocused by remember { mutableStateOf(false) }
val palette = composerPalette()
AnimatedVisibility(
visible = true,
enter = slideInVertically(initialOffsetY = { it }),
exit = slideOutVertically(targetOffsetY = { it }),
) {
// One floating card holds the whole composer: suggestions, attachments,
// the input and the controls row. Flat fill, hairline edge, no shadow.
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
border =
BorderStroke(
width = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
),
tonalElevation = 2.dp,
shadowElevation = 4.dp,
.padding(horizontal = 12.dp, vertical = 8.dp),
shape = MaterialTheme.shapes.large,
color = palette.card,
border = BorderStroke(width = 1.dp, color = palette.cardBorder),
) {
Column {
Column(modifier = Modifier.padding(top = 8.dp, bottom = 10.dp)) {
// Commands hidden from the suggestion menu — desktop/CLI-only and
// TUI-only commands that don't function on mobile (issue #574).
// Single source of truth: CommandBlocklist.UNSUPPORTED, which is
@@ -216,12 +205,12 @@ fun ChatInputBar(
}
}
// ── TOP ROW: Input field with embedded send button ──
// ── TOP ROW: Borderless input field ──
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp),
.padding(horizontal = 20.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val placeholderText =
@@ -257,109 +246,43 @@ fun ChatInputBar(
modifier =
Modifier
.weight(1f)
.heightIn(min = 42.dp, max = 120.dp)
.heightIn(min = 42.dp, max = 200.dp)
.padding(vertical = 4.dp)
.onFocusChanged { isFocused = it.isFocused }
.testTag("chat_input"),
enabled = isConnected,
textStyle =
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
MaterialTheme.typography.bodyLarge.copy(
color = palette.text,
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
),
singleLine = false,
maxLines = 4,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
maxLines = 8,
cursorBrush = SolidColor(palette.text),
decorationBox = { innerTextField ->
CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) {
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,
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment =
if (isInputRtl) {
Alignment.CenterEnd
} else {
Alignment.CenterStart
},
) {
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),
contentAlignment =
if (isInputRtl) {
Alignment.CenterEnd
} else {
Alignment.CenterStart
},
) {
CompositionLocalProvider(
LocalLayoutDirection provides inputLayoutDirection,
) {
if (inputFieldValue.text.isEmpty()) {
Text(
text = placeholderText,
style = MaterialTheme.typography.bodyMedium,
textAlign =
if (isInputRtl) {
TextAlign.Right
} else {
TextAlign.Left
},
color =
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.6f,
),
modifier = Modifier.fillMaxWidth(),
)
}
innerTextField()
}
}
// Send button INSIDE the field. Shown whenever a send is
// possible — text typed OR an attachment pending (issue
// #956): the old text-only gate hid the button entirely
// for attachment-only sends.
AnimatedContent(
targetState = canSend,
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,
),
)
}
}
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
if (inputFieldValue.text.isEmpty()) {
Text(
text = placeholderText,
style = MaterialTheme.typography.bodyLarge,
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
color = palette.placeholder,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
}
innerTextField()
}
}
}
@@ -374,6 +297,8 @@ fun ChatInputBar(
currentSessionModel = currentSessionModel,
reasoningLevel = reasoningLevel,
isListening = isListening,
canSend = canSend,
onSend = onSend,
onAttachTap = { showAttachmentMenu = true },
onModelTap = onModelTap,
onReasoningSelected = onReasoningTap,
@@ -0,0 +1,52 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
/**
* Resolved colors for the chat composer card and its controls.
*
* Control fills are derived from [ColorScheme.onSurface] instead of the
* surfaceContainer tiers: several presets ship nearly identical tiers
* (Nord's High and Highest are the same color), which made the flat controls
* vanish into the card. A fixed onSurface tint keeps them visible in every
* preset and under dynamic color. Text and icon contrast is gated per preset
* in ComposerPaletteTest.
*/
internal data class ComposerPalette(
val card: Color,
val cardBorder: Color,
val text: Color,
val placeholder: Color,
val control: Color,
val onControl: Color,
val onControlVariant: Color,
val action: Color,
val onAction: Color,
)
private const val CONTROL_TINT_ALPHA = 0.12f
internal fun composerPalette(scheme: ColorScheme): ComposerPalette {
val card = scheme.surfaceContainer
val tint = scheme.onSurface.copy(alpha = CONTROL_TINT_ALPHA)
return ComposerPalette(
card = card,
cardBorder = tint.compositeOver(scheme.background),
text = scheme.onSurface,
placeholder = scheme.onSurfaceVariant,
control = tint.compositeOver(card),
onControl = scheme.onSurface,
onControlVariant = scheme.onSurfaceVariant,
action = scheme.onSurface,
onAction = scheme.surface,
)
}
@Composable
@ReadOnlyComposable
internal fun composerPalette(): ComposerPalette = composerPalette(MaterialTheme.colorScheme)
@@ -1,28 +1,39 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.horizontalScroll
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.fillMaxHeight
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.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.outlined.Mic
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonColors
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
@@ -35,6 +46,7 @@ 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.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
@@ -46,16 +58,24 @@ import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
/**
* Bottom toolbar row for the chat composer.
* Bottom controls row for the chat composer, rendered inside the composer card.
*
* Layout: [📎 attach] [model chip] [←spacer→] [🧠 reasoning] [🎙 mic]
* Layout: [+ attach] [model · reasoning pill ──free space──] [mic] [action]
*
* The reasoning chip opens a dropdown menu to pick a level (instead of cycling).
* All controls are flat and borderless; hierarchy comes from fill brightness.
* The pill shows the model and the reasoning level side by side — tapping the
* model half opens the model picker, tapping the level half opens the level menu.
*
* The trailing action button morphs: while a send is possible it sends,
* otherwise it carries the mic action. The flat mic button only appears next
* to it while it is in send mode, so dictation stays reachable at all times.
*
* The reasoning menu picks a level (instead of cycling).
* When [canDisableReasoning] is false the "None" level is disabled with a
* "reasoning always on" hint (issue #946). Absent key (null) means no
* restriction is known — full scale offered.
* When [supportsReasoning] is false the model takes no reasoning parameter
* and the chip is disabled.
* and the level menu is disabled.
*/
@Composable
fun ComposerToolbar(
@@ -68,6 +88,8 @@ fun ComposerToolbar(
onReasoningSelected: (String?) -> Unit,
onMicTap: () -> Unit,
modifier: Modifier = Modifier,
canSend: Boolean = false,
onSend: () -> Unit = {},
canDisableReasoning: Boolean? = null,
supportsReasoning: Boolean? = null,
fastMode: Boolean = false,
@@ -76,6 +98,7 @@ fun ComposerToolbar(
onToggleFastMode: () -> Unit = {},
) {
var showReasoningMenu by remember { mutableStateOf(false) }
val palette = composerPalette()
val reasoningDisabledForModel = supportsReasoning == false
val canDisable = canDisableReasoning
@@ -83,62 +106,74 @@ fun ComposerToolbar(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.padding(horizontal = 12.dp)
.padding(top = 6.dp)
.testTag("composer_toolbar"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// Attach button
IconButton(
FilledIconButton(
onClick = onAttachTap,
enabled = isConnected,
modifier = Modifier.size(36.dp),
colors = flatIconButtonColors(palette),
modifier = Modifier.size(ControlSize),
) {
Icon(
imageVector = Icons.Default.AttachFile,
imageVector = Icons.Default.Add,
contentDescription = stringResource(R.string.chat_attach_file),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Model chip — takes available space, fixed height
FilterChip(
selected = currentSessionModel != null,
onClick = onModelTap,
label = {
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
) {
Text(
text = currentSessionModel ?: "Model",
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
)
}
},
modifier =
Modifier
.weight(1f)
.height(28.dp)
.testTag("model_chip"),
)
// Model + reasoning pill — wraps its content inside the free space,
// pushing the mic/action buttons to the far end
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
Row(
modifier =
Modifier
.height(ControlSize)
.clip(CircleShape)
.background(palette.control),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
// Keeps the provider so same-named models from different providers
// stay distinguishable; trims from the middle when tight.
text = currentSessionModel?.let(::composerModelLabel) ?: "Model",
style = MaterialTheme.typography.bodyMedium,
color = palette.onControl,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
modifier =
Modifier
.weight(1f, fill = false)
.fillMaxHeight()
.clickable(onClick = onModelTap)
.wrapContentHeight()
.padding(start = 16.dp, end = 6.dp)
.testTag("model_chip"),
)
// Reasoning & Generation controls with dropdown menu (Option 3)
Box {
FilterChip(
selected = reasoningLevel != null || fastMode,
onClick = { showReasoningMenu = true },
enabled = isConnected,
label = {
Box {
Row(
modifier =
Modifier
.fillMaxHeight()
.clickable(enabled = isConnected) { showReasoningMenu = true }
.padding(start = 6.dp, end = 12.dp)
.testTag("reasoning_chip"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
val levelColor =
palette.onControlVariant.copy(
alpha = if (isConnected) 1f else 0.38f,
)
if (fastMode) {
Icon(
imageVector = Icons.Filled.Bolt,
contentDescription = stringResource(R.string.chat_fast_mode_label),
tint = MaterialTheme.colorScheme.primary,
tint = levelColor,
modifier = Modifier.size(14.dp),
)
}
@@ -156,175 +191,265 @@ fun ComposerToolbar(
rLabel
}
},
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodyMedium,
color = levelColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
modifier =
Modifier
.height(28.dp)
.testTag("reasoning_chip"),
)
DropdownMenu(
expanded = showReasoningMenu,
onDismissRequest = { showReasoningMenu = false },
modifier = Modifier.widthIn(min = 220.dp),
) {
// ── Fast Mode Toggle Item ──
val fastAvailable = isConnected && fastSupported
DropdownMenuItem(
leadingIcon = {
// Chevron marks the level half of the pill as a menu trigger
Icon(
imageVector = Icons.Filled.Bolt,
contentDescription = null,
tint =
if (fastMode) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(20.dp),
imageVector = Icons.Filled.ExpandMore,
contentDescription = stringResource(R.string.chat_reasoning_menu_desc),
tint = levelColor,
modifier = Modifier.size(16.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),
}
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),
)
}
}
},
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,
)
},
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()
HorizontalDivider()
Text(
text = "REASONING",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
)
if (canDisable == false) {
Text(
text = "reasoning always on",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
)
}
if (reasoningDisabledForModel) {
Text(
text = "no reasoning parameter for this model",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
)
}
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) ->
val isNone = level == "none"
val noneDisabled = isNone && (canDisable == false || reasoningDisabledForModel)
DropdownMenuItem(
text = {
Text(
text = "REASONING",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
)
if (canDisable == false) {
Text(
text = label,
fontWeight =
if (reasoningLevel == level) {
MaterialTheme.typography.bodyMedium.fontWeight
} else {
null
},
color =
when {
noneDisabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
reasoningLevel == level -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurface
},
text = "reasoning always on",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
)
},
onClick = {
showReasoningMenu = false
onReasoningSelected(level)
},
enabled = !noneDisabled && !reasoningDisabledForModel,
)
}
if (reasoningDisabledForModel) {
Text(
text = "no reasoning parameter for this model",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
)
}
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) ->
val isNone = level == "none"
val noneDisabled = isNone && (canDisable == false || reasoningDisabledForModel)
DropdownMenuItem(
text = {
Text(
text = label,
fontWeight =
if (reasoningLevel == level) {
MaterialTheme.typography.bodyMedium.fontWeight
} else {
null
},
color =
when {
noneDisabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
reasoningLevel == level -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurface
},
)
},
onClick = {
showReasoningMenu = false
onReasoningSelected(level)
},
enabled = !noneDisabled && !reasoningDisabledForModel,
)
}
}
}
}
}
// Mic / Stop button
IconButton(
onClick = onMicTap,
enabled = isConnected,
// Flat mic / stop button — only while the action button is in send mode
AnimatedVisibility(
visible = canSend,
enter = fadeIn() + scaleIn(initialScale = 0.8f),
exit = fadeOut() + scaleOut(targetScale = 0.8f),
) {
FilledIconButton(
onClick = onMicTap,
enabled = isConnected,
colors = if (isListening) listeningIconButtonColors() else flatIconButtonColors(palette),
modifier =
Modifier
.size(ControlSize)
.testTag(if (isListening) "mic_stop_button" else "mic_button"),
) {
Icon(
imageVector = if (isListening) Icons.Default.Stop else Icons.Outlined.Mic,
contentDescription = if (isListening) "Stop listening" else "Mic",
)
}
}
// Action button — send when a send is possible, mic / stop otherwise
FilledIconButton(
onClick = if (canSend) onSend else onMicTap,
enabled = canSend || isConnected,
colors =
if (isListening) {
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
)
if (!canSend && isListening) {
listeningIconButtonColors()
} else {
IconButtonDefaults.filledTonalIconButtonColors()
IconButtonDefaults.filledIconButtonColors(
containerColor = palette.action,
contentColor = palette.onAction,
)
},
modifier =
Modifier
.size(36.dp)
.testTag(if (isListening) "mic_stop_button" else "mic_button"),
.size(ControlSize)
.testTag(
when {
canSend -> "send_button"
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",
)
Crossfade(
targetState =
when {
canSend -> ActionGlyph.SEND
isListening -> ActionGlyph.STOP
else -> ActionGlyph.VOICE
},
label = "composer_action_glyph",
) { glyph ->
when (glyph) {
ActionGlyph.SEND -> {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.chat_send_desc),
)
}
ActionGlyph.STOP -> {
Icon(imageVector = Icons.Default.Stop, contentDescription = "Stop listening")
}
ActionGlyph.VOICE -> {
Icon(imageVector = Icons.Outlined.Mic, contentDescription = "Mic")
}
}
}
}
}
}
private val ControlSize = 36.dp
private enum class ActionGlyph { SEND, STOP, VOICE }
@Composable
private fun flatIconButtonColors(palette: ComposerPalette): IconButtonColors =
IconButtonDefaults.filledIconButtonColors(
containerColor = palette.control,
contentColor = palette.onControl,
)
@Composable
private fun listeningIconButtonColors(): IconButtonColors =
IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
)
/**
* Model label for the composer pill: the session's "provider/model" id with
* only the "custom:" marker removed from the provider ("custom:acme/glm-5.3"
* → "acme/glm-5.3"). The provider itself is kept so the same model served by
* different providers never renders identically.
*/
internal fun composerModelLabel(sessionModel: String): String {
val slash = sessionModel.indexOf('/')
if (slash <= 0) return sessionModel
val provider = sessionModel.substring(0, slash)
if (!provider.startsWith(CUSTOM_PROVIDER_PREFIX) || provider.length == CUSTOM_PROVIDER_PREFIX.length) {
return sessionModel
}
return provider.removePrefix(CUSTOM_PROVIDER_PREFIX) + sessionModel.substring(slash)
}
private const val CUSTOM_PROVIDER_PREFIX = "custom:"
/**
* Build a human-readable label from a reasoning effort level.
*