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
+528 -160

No files matched your search

@@ -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)
}
}
}
@@ -1,16 +1,12 @@
package com.m57.hermescontrol.ui.chat.components package com.m57.hermescontrol.ui.chat.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement 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.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.InsertDriveFile
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
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
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -51,7 +45,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
@@ -76,8 +69,8 @@ import com.m57.hermescontrol.ui.common.BotAvatar
import com.m57.hermescontrol.util.BidiUtils import com.m57.hermescontrol.util.BidiUtils
/** /**
* The chat input bar with a two-row layout: input+send on top, * The chat input bar: a single rounded card with the input on top and a
* and a toolbar with attach/model chip/reasoning chip/mic below. * controls row (attach, model/reasoning pill, mic, send) inside it below.
*/ */
@Composable @Composable
fun ChatInputBar( fun ChatInputBar(
@@ -116,29 +109,25 @@ fun ChatInputBar(
// Attachment menu state // Attachment menu state
var showAttachmentMenu by remember { mutableStateOf(false) } var showAttachmentMenu by remember { mutableStateOf(false) }
var isFocused by remember { mutableStateOf(false) } val palette = composerPalette()
AnimatedVisibility( AnimatedVisibility(
visible = true, visible = true,
enter = slideInVertically(initialOffsetY = { it }), enter = slideInVertically(initialOffsetY = { it }),
exit = slideOutVertically(targetOffsetY = { 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( Surface(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
shape = RoundedCornerShape(20.dp), shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surface, color = palette.card,
border = border = BorderStroke(width = 1.dp, color = palette.cardBorder),
BorderStroke(
width = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
),
tonalElevation = 2.dp,
shadowElevation = 4.dp,
) { ) {
Column { Column(modifier = Modifier.padding(top = 8.dp, bottom = 10.dp)) {
// Commands hidden from the suggestion menu — desktop/CLI-only and // Commands hidden from the suggestion menu — desktop/CLI-only and
// TUI-only commands that don't function on mobile (issue #574). // TUI-only commands that don't function on mobile (issue #574).
// Single source of truth: CommandBlocklist.UNSUPPORTED, which is // 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( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp), .padding(horizontal = 20.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
val placeholderText = val placeholderText =
@@ -257,46 +246,23 @@ fun ChatInputBar(
modifier = modifier =
Modifier Modifier
.weight(1f) .weight(1f)
.heightIn(min = 42.dp, max = 120.dp) .heightIn(min = 42.dp, max = 200.dp)
.padding(vertical = 4.dp) .padding(vertical = 4.dp)
.onFocusChanged { isFocused = it.isFocused }
.testTag("chat_input"), .testTag("chat_input"),
enabled = isConnected, enabled = isConnected,
textStyle = textStyle =
MaterialTheme.typography.bodyMedium.copy( MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface, color = palette.text,
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr, textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
), ),
singleLine = false, singleLine = false,
maxLines = 4, maxLines = 8,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), cursorBrush = SolidColor(palette.text),
decorationBox = { innerTextField -> decorationBox = { innerTextField ->
CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) { 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,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.padding(start = 12.dp, end = 4.dp, top = 9.dp, bottom = 9.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Box( Box(
modifier = Modifier.weight(1f), modifier = Modifier.fillMaxWidth(),
contentAlignment = contentAlignment =
if (isInputRtl) { if (isInputRtl) {
Alignment.CenterEnd Alignment.CenterEnd
@@ -304,64 +270,21 @@ fun ChatInputBar(
Alignment.CenterStart Alignment.CenterStart
}, },
) { ) {
CompositionLocalProvider( CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
LocalLayoutDirection provides inputLayoutDirection,
) {
if (inputFieldValue.text.isEmpty()) { if (inputFieldValue.text.isEmpty()) {
Text( Text(
text = placeholderText, text = placeholderText,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyLarge,
textAlign = textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
if (isInputRtl) { color = palette.placeholder,
TextAlign.Right maxLines = 1,
} else { overflow = TextOverflow.Ellipsis,
TextAlign.Left
},
color =
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.6f,
),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} }
innerTextField() 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,
),
)
}
}
}
}
}
} }
}, },
) )
@@ -374,6 +297,8 @@ fun ChatInputBar(
currentSessionModel = currentSessionModel, currentSessionModel = currentSessionModel,
reasoningLevel = reasoningLevel, reasoningLevel = reasoningLevel,
isListening = isListening, isListening = isListening,
canSend = canSend,
onSend = onSend,
onAttachTap = { showAttachmentMenu = true }, onAttachTap = { showAttachmentMenu = true },
onModelTap = onModelTap, onModelTap = onModelTap,
onReasoningSelected = onReasoningTap, 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 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.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.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.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.filled.Stop
import androidx.compose.material.icons.outlined.Mic
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonColors
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
@@ -35,6 +46,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -46,16 +58,24 @@ import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R 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 * When [canDisableReasoning] is false the "None" level is disabled with a
* "reasoning always on" hint (issue #946). Absent key (null) means no * "reasoning always on" hint (issue #946). Absent key (null) means no
* restriction is known — full scale offered. * restriction is known — full scale offered.
* When [supportsReasoning] is false the model takes no reasoning parameter * When [supportsReasoning] is false the model takes no reasoning parameter
* and the chip is disabled. * and the level menu is disabled.
*/ */
@Composable @Composable
fun ComposerToolbar( fun ComposerToolbar(
@@ -68,6 +88,8 @@ fun ComposerToolbar(
onReasoningSelected: (String?) -> Unit, onReasoningSelected: (String?) -> Unit,
onMicTap: () -> Unit, onMicTap: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
canSend: Boolean = false,
onSend: () -> Unit = {},
canDisableReasoning: Boolean? = null, canDisableReasoning: Boolean? = null,
supportsReasoning: Boolean? = null, supportsReasoning: Boolean? = null,
fastMode: Boolean = false, fastMode: Boolean = false,
@@ -76,6 +98,7 @@ fun ComposerToolbar(
onToggleFastMode: () -> Unit = {}, onToggleFastMode: () -> Unit = {},
) { ) {
var showReasoningMenu by remember { mutableStateOf(false) } var showReasoningMenu by remember { mutableStateOf(false) }
val palette = composerPalette()
val reasoningDisabledForModel = supportsReasoning == false val reasoningDisabledForModel = supportsReasoning == false
val canDisable = canDisableReasoning val canDisable = canDisableReasoning
@@ -83,62 +106,74 @@ fun ComposerToolbar(
modifier = modifier =
modifier modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp) .padding(horizontal = 12.dp)
.padding(top = 6.dp)
.testTag("composer_toolbar"), .testTag("composer_toolbar"),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
// Attach button // Attach button
IconButton( FilledIconButton(
onClick = onAttachTap, onClick = onAttachTap,
enabled = isConnected, enabled = isConnected,
modifier = Modifier.size(36.dp), colors = flatIconButtonColors(palette),
modifier = Modifier.size(ControlSize),
) { ) {
Icon( Icon(
imageVector = Icons.Default.AttachFile, imageVector = Icons.Default.Add,
contentDescription = stringResource(R.string.chat_attach_file), contentDescription = stringResource(R.string.chat_attach_file),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
// Model chip — takes available space, fixed height // Model + reasoning pill — wraps its content inside the free space,
FilterChip( // pushing the mic/action buttons to the far end
selected = currentSessionModel != null, Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
onClick = onModelTap,
label = {
Row( Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
) {
Text(
text = currentSessionModel ?: "Model",
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
)
}
},
modifier = modifier =
Modifier Modifier
.weight(1f) .height(ControlSize)
.height(28.dp) .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"), .testTag("model_chip"),
) )
// Reasoning & Generation controls with dropdown menu (Option 3)
Box { Box {
FilterChip(
selected = reasoningLevel != null || fastMode,
onClick = { showReasoningMenu = true },
enabled = isConnected,
label = {
Row( Row(
modifier =
Modifier
.fillMaxHeight()
.clickable(enabled = isConnected) { showReasoningMenu = true }
.padding(start = 6.dp, end = 12.dp)
.testTag("reasoning_chip"),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
val levelColor =
palette.onControlVariant.copy(
alpha = if (isConnected) 1f else 0.38f,
)
if (fastMode) { if (fastMode) {
Icon( Icon(
imageVector = Icons.Filled.Bolt, imageVector = Icons.Filled.Bolt,
contentDescription = stringResource(R.string.chat_fast_mode_label), contentDescription = stringResource(R.string.chat_fast_mode_label),
tint = MaterialTheme.colorScheme.primary, tint = levelColor,
modifier = Modifier.size(14.dp), modifier = Modifier.size(14.dp),
) )
} }
@@ -156,17 +191,19 @@ fun ComposerToolbar(
rLabel rLabel
} }
}, },
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodyMedium,
color = levelColor,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} // Chevron marks the level half of the pill as a menu trigger
}, Icon(
modifier = imageVector = Icons.Filled.ExpandMore,
Modifier contentDescription = stringResource(R.string.chat_reasoning_menu_desc),
.height(28.dp) tint = levelColor,
.testTag("reasoning_chip"), modifier = Modifier.size(16.dp),
) )
}
DropdownMenu( DropdownMenu(
expanded = showReasoningMenu, expanded = showReasoningMenu,
@@ -298,33 +335,121 @@ fun ComposerToolbar(
} }
} }
} }
}
}
// Mic / Stop button // Flat mic / stop button — only while the action button is in send mode
IconButton( AnimatedVisibility(
visible = canSend,
enter = fadeIn() + scaleIn(initialScale = 0.8f),
exit = fadeOut() + scaleOut(targetScale = 0.8f),
) {
FilledIconButton(
onClick = onMicTap, onClick = onMicTap,
enabled = isConnected, enabled = isConnected,
colors = colors = if (isListening) listeningIconButtonColors() else flatIconButtonColors(palette),
if (isListening) {
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
)
} else {
IconButtonDefaults.filledTonalIconButtonColors()
},
modifier = modifier =
Modifier Modifier
.size(36.dp) .size(ControlSize)
.testTag(if (isListening) "mic_stop_button" else "mic_button"), .testTag(if (isListening) "mic_stop_button" else "mic_button"),
) { ) {
Icon( Icon(
imageVector = if (isListening) Icons.Default.Stop else Icons.Default.Mic, imageVector = if (isListening) Icons.Default.Stop else Icons.Outlined.Mic,
contentDescription = if (isListening) "Stop listening" else "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 (!canSend && isListening) {
listeningIconButtonColors()
} else {
IconButtonDefaults.filledIconButtonColors(
containerColor = palette.action,
contentColor = palette.onAction,
)
},
modifier =
Modifier
.size(ControlSize)
.testTag(
when {
canSend -> "send_button"
isListening -> "mic_stop_button"
else -> "mic_button"
},
),
) {
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. * Build a human-readable label from a reasoning effort level.
* *
+1
View File
@@ -1241,6 +1241,7 @@
<string name="chat_fast_mode_label">Fast</string> <string name="chat_fast_mode_label">Fast</string>
<string name="chat_fast_mode_desc">Fast mode</string> <string name="chat_fast_mode_desc">Fast mode</string>
<string name="chat_fast_mode_unavailable">Fast mode unavailable for this model</string> <string name="chat_fast_mode_unavailable">Fast mode unavailable for this model</string>
<string name="chat_reasoning_menu_desc">Reasoning options</string>
<string name="chat_save_attachment">Save %1$s</string> <string name="chat_save_attachment">Save %1$s</string>
<string name="chat_switch_model_title">Switch model (this chat)</string> <string name="chat_switch_model_title">Switch model (this chat)</string>
<string name="config_clear_value">Clear value</string> <string name="config_clear_value">Clear value</string>
@@ -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)
}
}
}
@@ -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"))
}
}