feat(chat): restyle the composer as a single card (#1114)
This commit is contained in:
1 parent
528ec4f28f
commit
e5adfcf571
7 files changed
+671
-303
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
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.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user