diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt
new file mode 100644
index 00000000..751abfbb
--- /dev/null
+++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt
@@ -0,0 +1,154 @@
+package com.m57.hermescontrol.ui.chat.components
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTextInput
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import com.m57.hermescontrol.data.ws.CommandCatalog
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Interaction coverage for the chat composer controls: the morphing action
+ * button (dictate / send), stopping dictation from either mic position, and
+ * both halves of the combined model/reasoning pill.
+ */
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class ComposerInteractionTest {
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ private var micTaps = 0
+ private var sends = 0
+ private var modelTaps = 0
+ private var selectedLevel: String? = null
+
+ /** Renders the real input bar with live text and a mic that toggles like ChatMediaLaunchers. */
+ private fun setComposer(reasoningLevel: String? = "medium") {
+ composeTestRule.setContent {
+ var input by remember { mutableStateOf(TextFieldValue("")) }
+ var listening by remember { mutableStateOf(false) }
+ ChatInputBar(
+ inputFieldValue = input,
+ onInputChange = { input = it },
+ onSend = {
+ sends++
+ input = TextFieldValue("")
+ },
+ onMicTap = {
+ micTaps++
+ listening = !listening
+ },
+ isListening = listening,
+ isAgentTyping = false,
+ isConnected = true,
+ commandCatalog = CommandCatalog(),
+ currentSessionModel = "openai/gpt-5.5",
+ reasoningLevel = reasoningLevel,
+ onModelTap = { modelTaps++ },
+ onReasoningTap = { selectedLevel = it },
+ )
+ }
+ }
+
+ @Test
+ fun emptyInput_actionButtonStartsDictation() {
+ setComposer()
+
+ composeTestRule.onNodeWithTag("send_button").assertDoesNotExist()
+ composeTestRule.onNodeWithTag("mic_button").performClick()
+
+ composeTestRule.runOnIdle {
+ assertEquals("action button must start dictation", 1, micTaps)
+ assertEquals("action button must not send without input", 0, sends)
+ }
+ }
+
+ @Test
+ fun typedInput_actionButtonSends_andMicStillDictates() {
+ setComposer()
+
+ composeTestRule.onNodeWithTag("chat_input").performTextInput("Hello Hermes")
+ composeTestRule.onNodeWithTag("send_button").performClick()
+ composeTestRule.runOnIdle {
+ assertEquals("send button must send", 1, sends)
+ assertEquals("send must not trigger dictation", 0, micTaps)
+ }
+
+ composeTestRule.onNodeWithTag("chat_input").performTextInput("Again")
+ composeTestRule.onNodeWithTag("mic_button").performClick()
+ composeTestRule.runOnIdle {
+ assertEquals("mic next to send must start dictation", 1, micTaps)
+ assertEquals("mic must not send", 1, sends)
+ }
+ }
+
+ @Test
+ fun emptyInput_stopButtonStopsDictation() {
+ setComposer()
+
+ composeTestRule.onNodeWithTag("mic_button").performClick()
+ composeTestRule.onNodeWithTag("mic_button").assertDoesNotExist()
+ composeTestRule.onNodeWithTag("mic_stop_button").performClick()
+
+ composeTestRule.onNodeWithTag("mic_stop_button").assertDoesNotExist()
+ composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed()
+ composeTestRule.runOnIdle { assertEquals("start + stop must both reach onMicTap", 2, micTaps) }
+ }
+
+ @Test
+ fun typedInput_stopButtonStopsDictation_withoutSending() {
+ setComposer()
+
+ composeTestRule.onNodeWithTag("chat_input").performTextInput("Hello Hermes")
+ composeTestRule.onNodeWithTag("mic_button").performClick()
+ composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
+ composeTestRule.onNodeWithTag("mic_stop_button").performClick()
+
+ composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed()
+ composeTestRule.runOnIdle {
+ assertEquals("start + stop must both reach onMicTap", 2, micTaps)
+ assertEquals("stopping dictation must not send", 0, sends)
+ }
+ }
+
+ @Test
+ fun modelSideOfPill_opensModelPicker_only() {
+ setComposer()
+
+ composeTestRule.onNodeWithTag("model_chip").performClick()
+
+ composeTestRule.onNodeWithText("REASONING").assertDoesNotExist()
+ composeTestRule.runOnIdle {
+ assertEquals("model side must open the picker", 1, modelTaps)
+ assertEquals("model side must not change reasoning", null, selectedLevel)
+ }
+ }
+
+ @Test
+ fun reasoningSideOfPill_opensMenu_andSelectsLevel() {
+ setComposer(reasoningLevel = "medium")
+
+ composeTestRule.onNodeWithTag("reasoning_chip").performClick()
+ composeTestRule.onNodeWithText("REASONING").assertIsDisplayed()
+ composeTestRule.onNodeWithText("High").performClick()
+
+ composeTestRule.onNodeWithText("REASONING").assertDoesNotExist()
+ composeTestRule.runOnIdle {
+ assertEquals("reasoning menu must report the picked level", "high", selectedLevel)
+ assertEquals("reasoning side must not open the model picker", 0, modelTaps)
+ }
+ }
+}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt
index 0f6c139a..03e9589f 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt
@@ -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,
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerPalette.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerPalette.kt
new file mode 100644
index 00000000..531ba3f5
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerPalette.kt
@@ -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)
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt
index 5a0828ae..483bce86 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt
@@ -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.
*
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index ecea621e..3820722c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1241,6 +1241,7 @@
Fast
Fast mode
Fast mode unavailable for this model
+ Reasoning options
Save %1$s
Switch model (this chat)
Clear value
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerPaletteTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerPaletteTest.kt
new file mode 100644
index 00000000..93a76b07
--- /dev/null
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerPaletteTest.kt
@@ -0,0 +1,76 @@
+package com.m57.hermescontrol.ui.chat.components
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.luminance
+import com.m57.hermescontrol.theme.ThemePreset
+import com.m57.hermescontrol.theme.resolveColorScheme
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Contrast gate for the chat composer across every preset in both modes.
+ *
+ * - Primary text and control labels (onSurface) >= 4.5:1 — WCAG AA text.
+ * - Secondary text (placeholder, reasoning level) >= 3:1 — same bar as the
+ * full-bleed header gate in ThemePaletteTest.
+ * - Action button against the card, and its glyph against the button >= 3:1.
+ * - Flat controls and the card edge must stay visibly distinct (>= 1.15:1).
+ * Their labels and icons carry the WCAG 1.4.11 contrast; this floor catches
+ * presets whose surface tiers collapse (Nord's High == Highest rendered the
+ * controls invisible).
+ */
+class ComposerPaletteTest {
+ private fun contrast(
+ a: Color,
+ b: Color,
+ ): Float {
+ val l1 = a.luminance()
+ val l2 = b.luminance()
+ return (maxOf(l1, l2) + 0.05f) / (minOf(l1, l2) + 0.05f)
+ }
+
+ private fun forEveryPresetMode(block: (String, ComposerPalette, Color) -> Unit) {
+ ThemePreset.entries.forEach { preset ->
+ listOf(true, false).forEach { dark ->
+ val scheme = resolveColorScheme(preset, darkTheme = dark)
+ block("$preset dark=$dark", composerPalette(scheme), scheme.background)
+ }
+ }
+ }
+
+ private fun assertContrast(
+ label: String,
+ a: Color,
+ b: Color,
+ min: Float,
+ ) {
+ val ratio = contrast(a, b)
+ assertTrue("$label contrast %.2f must be >= $min".format(ratio), ratio >= min)
+ }
+
+ @Test
+ fun textMeetsContrastInEveryPresetMode() {
+ forEveryPresetMode { mode, p, _ ->
+ assertContrast("$mode input text/card", p.text, p.card, 4.5f)
+ assertContrast("$mode placeholder/card", p.placeholder, p.card, 3f)
+ assertContrast("$mode control label/control", p.onControl, p.control, 4.5f)
+ assertContrast("$mode reasoning level/control", p.onControlVariant, p.control, 3f)
+ }
+ }
+
+ @Test
+ fun actionButtonMeetsContrastInEveryPresetMode() {
+ forEveryPresetMode { mode, p, _ ->
+ assertContrast("$mode action/card", p.action, p.card, 3f)
+ assertContrast("$mode action glyph/action", p.onAction, p.action, 3f)
+ }
+ }
+
+ @Test
+ fun controlsAndCardEdgeStayVisibleInEveryPresetMode() {
+ forEveryPresetMode { mode, p, background ->
+ assertContrast("$mode control/card", p.control, p.card, 1.15f)
+ assertContrast("$mode card border/background", p.cardBorder, background, 1.15f)
+ }
+ }
+}
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerToolbarTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerToolbarTest.kt
new file mode 100644
index 00000000..c0a90495
--- /dev/null
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerToolbarTest.kt
@@ -0,0 +1,35 @@
+package com.m57.hermescontrol.ui.chat.components
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/**
+ * Unit tests for [composerModelLabel] — the label shown in the composer's
+ * model/reasoning pill. Only the "custom:" provider marker is dropped; the
+ * provider stays so the same model from different providers never collides.
+ */
+class ComposerToolbarTest {
+ @Test
+ fun composerModelLabel_keepsProviderToDisambiguateSameModel() {
+ assertEquals("openai/gpt-5.5", composerModelLabel("openai/gpt-5.5"))
+ assertEquals("copilot/gpt-5.5", composerModelLabel("copilot/gpt-5.5"))
+ }
+
+ @Test
+ fun composerModelLabel_dropsOnlyTheCustomMarker() {
+ assertEquals("my-provider/glm-5.3", composerModelLabel("custom:my-provider/glm-5.3"))
+ assertEquals("openrouter/openai/gpt-5.5", composerModelLabel("openrouter/openai/gpt-5.5"))
+ }
+
+ @Test
+ fun composerModelLabel_leavesModelTagsAndPlainIdsAlone() {
+ assertEquals("ollama/llama3:8b", composerModelLabel("ollama/llama3:8b"))
+ assertEquals("llama3:8b", composerModelLabel("llama3:8b"))
+ assertEquals("gpt-5", composerModelLabel("gpt-5"))
+ }
+
+ @Test
+ fun composerModelLabel_bareCustomMarker_fallsBackToFullId() {
+ assertEquals("custom:/glm-5.3", composerModelLabel("custom:/glm-5.3"))
+ }
+}