@@ -33,6 +33,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
3333import androidx.compose.foundation.text.BasicTextField
3434import androidx.compose.foundation.verticalScroll
3535import androidx.compose.material3.Icon
36+ import androidx.compose.material3.HorizontalDivider
3637import androidx.compose.material3.MaterialTheme
3738import androidx.compose.material3.Surface
3839import androidx.compose.material3.Text
@@ -59,6 +60,7 @@ import androidx.compose.ui.platform.LocalDensity
5960import androidx.compose.ui.res.painterResource
6061import androidx.compose.ui.res.stringResource
6162import androidx.compose.ui.semantics.Role
63+ import androidx.compose.ui.semantics.clearAndSetSemantics
6264import androidx.compose.ui.semantics.contentDescription
6365import androidx.compose.ui.semantics.role
6466import androidx.compose.ui.semantics.selected
@@ -80,6 +82,7 @@ import com.openbitfun.mobile.core.feature.session.ChatComposerPolicy
8082import com.openbitfun.mobile.core.feature.session.ComposerImage
8183import com.openbitfun.mobile.core.feature.session.ComposerPrimaryAction
8284import com.openbitfun.mobile.core.feature.session.ModelOption
85+ import com.openbitfun.mobile.core.feature.session.ModelRole
8386
8487internal const val COMPOSER_TEST_TAG : String = " composer"
8588internal const val COMPOSER_INPUT_TEST_TAG : String = " composer-input"
@@ -287,9 +290,6 @@ internal fun ComposerBar(
287290 enter = compactControlEnter,
288291 exit = compactControlExit,
289292 ) {
290- if (capabilities.showVoiceInput && ! busy && (draft.isNotBlank() || images.isNotEmpty())) {
291- PrimaryActionButton (action = ComposerPrimaryAction .VOICE , onVoice = onVoice, onSend = onSend, onStop = onStop, testTag = " composer-voice" )
292- }
293293 PrimaryActionButton (
294294 action = action,
295295 stopEnabled = ChatComposerPolicy .canStop(streaming, capabilities.requiresRemoteConnection, phase),
@@ -338,8 +338,8 @@ internal fun ComposerBar(
338338 )
339339 }
340340 Box (modifier = Modifier .weight(1f ))
341- if (capabilities.showVoiceInput && ! busy && (draft.isNotBlank() || images.isNotEmpty())) {
342- PrimaryActionButton (action = ComposerPrimaryAction .VOICE , onVoice = onVoice, onSend = onSend, onStop = onStop, testTag = " composer-voice" )
341+ if (capabilities.showVoiceInput && (draft.isNotBlank() || images.isNotEmpty())) {
342+ PrimaryActionButton (action = if (busy) ComposerPrimaryAction . VOICE_BLOCKED else ComposerPrimaryAction .VOICE , onVoice = onVoice, onSend = onSend, onStop = onStop, testTag = " composer-voice" )
343343 }
344344 PrimaryActionButton (
345345 action = action,
@@ -402,7 +402,17 @@ private fun ComposerField(
402402 color = colors.onSurfaceVariant,
403403 )
404404 }
405- field()
405+ // Focus expands the same editor. At rest, render a one-line
406+ // preview so overflow cannot scroll sideways or lose the draft.
407+ Box (Modifier .alpha(if (! expanded && draft.isNotEmpty()) 0f else 1f )) { field() }
408+ if (! expanded && draft.isNotEmpty()) Text (
409+ draft,
410+ maxLines = 1 ,
411+ overflow = TextOverflow .Ellipsis ,
412+ style = MaterialTheme .typography.bodyLarge,
413+ color = colors.onSurface,
414+ modifier = Modifier .fillMaxWidth().clearAndSetSemantics {},
415+ )
406416 }
407417 },
408418 )
@@ -446,7 +456,7 @@ private fun ModelControl(
446456) {
447457 val label = stringResource(R .string.models_title)
448458 val displayModel = model ? : modelOptions.firstOrNull { it.selected }
449- val displayLabel = displayModel?.primaryLabel ? : label
459+ val displayLabel = displayModel?.let { modelOptionTitle(it) } ? : label
450460 val wide = composerIsWide(LocalConfiguration .current.screenWidthDp)
451461 Box {
452462 Row (
@@ -530,7 +540,9 @@ private fun ModelSelectorContent(
530540 onDismiss : () -> Unit ,
531541) {
532542 val selectorOptions = remember(options) {
533- options.filter(ModelOption ::selected) + options.filterNot(ModelOption ::selected)
543+ options.filter { it.role != null } +
544+ options.filter { it.role == null && it.selected } +
545+ options.filter { it.role == null && ! it.selected }
534546 }
535547 val visibleRows = selectorOptions.size.coerceAtMost(7 )
536548 val listHeight = if (visibleRows == 0 ) {
@@ -597,7 +609,12 @@ private fun ModelSelectorContent(
597609 .height(listHeight)
598610 .verticalScroll(rememberScrollState()),
599611 ) {
600- selectorOptions.forEach { option ->
612+ selectorOptions.forEachIndexed { index, option ->
613+ if (index > 0 && option.role == null && selectorOptions[index - 1 ].role != null ) {
614+ HorizontalDivider (color = MaterialTheme .colorScheme.outlineVariant)
615+ }
616+ val title = modelOptionTitle(option)
617+ val subtitle = modelOptionSubtitle(option)
601618 Row (
602619 verticalAlignment = Alignment .CenterVertically ,
603620 horizontalArrangement = Arrangement .spacedBy(10 .dp),
@@ -616,7 +633,7 @@ private fun ModelSelectorContent(
616633 .clickable { onSelect(option.id) }
617634 .semantics {
618635 contentDescription =
619- " ${option.primaryLabel} · ${option.secondaryLabel} "
636+ " $title · $subtitle "
620637 role = Role .Button
621638 selected = option.selected
622639 }
@@ -641,24 +658,44 @@ private fun ModelSelectorContent(
641658 modifier = Modifier .weight(1f ),
642659 ) {
643660 Text (
644- option.primaryLabel ,
661+ title ,
645662 fontSize = MaterialTheme .typography.labelLarge.fontSize,
646663 fontWeight = FontWeight .Medium ,
647664 color = MaterialTheme .colorScheme.onSurface,
648665 )
649666 Text (
650- option.secondaryLabel ,
667+ subtitle ,
651668 fontSize = MaterialTheme .typography.bodySmall.fontSize,
652669 color = MaterialTheme .colorScheme.onSurfaceVariant,
653670 )
654671 }
672+ option.roles.forEach { role ->
673+ Text (
674+ stringResource(if (role == ModelRole .PRIMARY ) R .string.model_role_primary else R .string.model_role_fast),
675+ style = MaterialTheme .typography.labelSmall,
676+ color = MaterialTheme .colorScheme.onSurfaceVariant,
677+ modifier = Modifier .background(MaterialTheme .colorScheme.surfaceVariant, RoundedCornerShape (4 .dp))
678+ .padding(horizontal = 6 .dp, vertical = 1 .dp),
679+ )
680+ }
655681 }
656682 }
657683 }
658684 }
659685 }
660686}
661687
688+ @Composable
689+ internal fun modelOptionTitle (option : ModelOption ): String = when (option.role) {
690+ ModelRole .PRIMARY -> stringResource(R .string.model_primary)
691+ ModelRole .FAST -> stringResource(R .string.model_fast)
692+ null -> option.primaryLabel
693+ }
694+
695+ @Composable
696+ internal fun modelOptionSubtitle (option : ModelOption ): String =
697+ if (option.fallsBackToPrimary) stringResource(R .string.model_fast_fallback) else option.secondaryLabel
698+
662699/* * What a control that is offered but not usable right now looks like. */
663700private const val DimmedAlpha : Float = 0.38f
664701
0 commit comments