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 index 751abfbb..546b767d 100644 --- 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 @@ -1,16 +1,26 @@ package com.m57.hermescontrol.ui.chat.components +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getUnclippedBoundsInRoot 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.test.performTouchInput +import androidx.compose.ui.test.swipeLeft import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.m57.hermescontrol.data.ws.CommandCatalog @@ -36,30 +46,46 @@ class ComposerInteractionTest { 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") { + private fun setComposer( + reasoningLevel: String? = "medium", + model: String = "openai/gpt-5.5", + composerWidth: Dp? = null, + modelAfterTap: String? = null, + ) { 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 }, - ) + var currentModel by remember { mutableStateOf(model) } + val composer: @Composable () -> Unit = { + ChatInputBar( + inputFieldValue = input, + onInputChange = { input = it }, + onSend = { + sends++ + input = TextFieldValue("") + }, + onMicTap = { + micTaps++ + listening = !listening + }, + isListening = listening, + isAgentTyping = false, + isConnected = true, + commandCatalog = CommandCatalog(), + currentSessionModel = currentModel, + reasoningLevel = reasoningLevel, + onModelTap = { + modelTaps++ + modelAfterTap?.let { currentModel = it } + }, + onReasoningTap = { selectedLevel = it }, + ) + } + if (composerWidth == null) { + composer() + } else { + Box(Modifier.width(composerWidth).testTag("composer_toolbar")) { composer() } + } } } @@ -151,4 +177,74 @@ class ComposerInteractionTest { assertEquals("reasoning side must not open the model picker", 0, modelTaps) } } + + @Test + fun longModelName_narrowComposer_scrollsWithoutOpeningPicker_orHidingControls() { + val longModel = "openrouter/some-extremely-long-model-name-preview-2026-with-extra-characters" + setComposer(model = longModel, composerWidth = 280.dp) + + composeTestRule.onNodeWithTag("reasoning_chip").assertIsDisplayed() + composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed() + val modelText = composeTestRule.onNodeWithText(longModel) + val leftBefore = modelText.getUnclippedBoundsInRoot().left + + composeTestRule.onNodeWithTag("model_chip").performTouchInput { swipeLeft() } + val leftAfter = modelText.getUnclippedBoundsInRoot().left + + composeTestRule.runOnIdle { + assertEquals("swiping the model must not open the picker", 0, modelTaps) + } + check(leftAfter < leftBefore) { "model text did not move after horizontal swipe" } + composeTestRule.onNodeWithTag("reasoning_chip").assertIsDisplayed() + composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed() + } + + @Test + fun shortModelName_wideComposerKeepsPillCompact() { + setComposer(composerWidth = 420.dp) + + val toolbarBounds = + composeTestRule + .onNodeWithTag("composer_toolbar", useUnmergedTree = true) + .getUnclippedBoundsInRoot() + val toolbarWidth = toolbarBounds.right - toolbarBounds.left + val modelChip = composeTestRule.onNodeWithTag("model_chip").getUnclippedBoundsInRoot() + val reasoningChip = composeTestRule.onNodeWithTag("reasoning_chip").getUnclippedBoundsInRoot() + val actionButton = composeTestRule.onNodeWithTag("mic_button").getUnclippedBoundsInRoot() + val pillWidth = maxOf(modelChip.right, reasoningChip.right) - minOf(modelChip.left, reasoningChip.left) + + check(pillWidth < toolbarWidth * 0.8f) { + "short model pill should remain content-sized, width=$pillWidth toolbar=$toolbarWidth" + } + check(actionButton.right > toolbarBounds.right - 32.dp) { + "trailing action should stay near the toolbar edge, right=${actionButton.right} toolbarRight=${toolbarBounds.right}" + } + } + + @Test + fun changingModel_resetsScrolledLabelToTheStart() { + val firstModel = "openrouter/some-extremely-long-model-name-preview-2026-with-extra-characters" + val secondModel = "anthropic/another-extremely-long-model-name-preview-2026-with-extra-characters" + setComposer(model = firstModel, modelAfterTap = secondModel, composerWidth = 280.dp) + + val firstText = composeTestRule.onNodeWithText(firstModel) + val leftBefore = firstText.getUnclippedBoundsInRoot().left + composeTestRule.onNodeWithTag("model_chip").performTouchInput { swipeLeft() } + check(firstText.getUnclippedBoundsInRoot().left < leftBefore) { + "first model text did not move after horizontal swipe" + } + + composeTestRule.onNodeWithTag("model_chip").performClick() + val secondText = composeTestRule.onNodeWithText(secondModel) + val leftAfterModelChange = secondText.getUnclippedBoundsInRoot().left + + check(leftAfterModelChange >= leftBefore - 1.dp) { + "new model label should reset to its initial left position" + } + composeTestRule.onNodeWithTag("reasoning_chip").assertIsDisplayed() + composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed() + composeTestRule.runOnIdle { + assertEquals("model selection should still invoke the picker", 1, modelTaps) + } + } } 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 483bce86..1d0a88a6 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 @@ -8,17 +8,21 @@ import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize 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.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send @@ -40,6 +44,7 @@ import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -47,6 +52,7 @@ 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.clipToBounds import androidx.compose.ui.draw.scale import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -127,32 +133,46 @@ fun ComposerToolbar( // Model + reasoning pill — wraps its content inside the free space, // pushing the mic/action buttons to the far end + val modelScrollState = rememberScrollState() + LaunchedEffect(currentSessionModel) { + modelScrollState.scrollTo(0) + } Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { Row( modifier = Modifier + .width(IntrinsicSize.Max) .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, + Box( modifier = Modifier - .weight(1f, fill = false) + .weight(1f) .fillMaxHeight() + .clipToBounds() + .horizontalScroll(modelScrollState) .clickable(onClick = onModelTap) - .wrapContentHeight() - .padding(start = 16.dp, end = 6.dp) .testTag("model_chip"), - ) + ) { + Text( + // Keeps the provider so same-named models from different providers + // stay distinguishable while allowing the full label to be revealed. + text = currentSessionModel?.let(::composerModelLabel) ?: "Model", + style = MaterialTheme.typography.bodyMedium, + color = palette.onControl, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip, + modifier = + Modifier + .fillMaxHeight() + .wrapContentHeight() + .padding(start = 16.dp, end = 6.dp), + ) + } Box { Row(