From e5adfcf57101bf03bde47484ead8e194cb932281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alfredo=20Orteg=C3=B3n=20Sep=C3=BAlveda?= Date: Tue, 15 Sep 2026 08:37:46 -0500 Subject: [PATCH] feat(chat): restyle the composer as a single card (#1114) --- .../components/ComposerInteractionTest.kt | 154 ++++++ .../ui/chat/components/ChatComposer.kt | 151 ++---- .../ui/chat/components/ComposerPalette.kt | 52 ++ .../ui/chat/components/ComposerToolbar.kt | 505 +++++++++++------- app/src/main/res/values/strings.xml | 1 + .../ui/chat/ComposerPaletteTest.kt | 76 +++ .../ui/chat/ComposerToolbarTest.kt | 35 ++ 7 files changed, 671 insertions(+), 303 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerPalette.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerPaletteTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerToolbarTest.kt 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")) + } +}