fix(chat): scroll long composer model names (#1129)
This commit is contained in:
2 files changed
+149
-33
No files matched your search
+117
-21
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user