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
+154
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,12 @@
|
|||||||
package com.m57.hermescontrol.ui.chat.components
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.expandVertically
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.scaleIn
|
|
||||||
import androidx.compose.animation.scaleOut
|
|
||||||
import androidx.compose.animation.shrinkVertically
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.animation.togetherWith
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.foundation.text.BasicTextField
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
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.material.icons.filled.Close
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -51,7 +45,6 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
@@ -76,8 +69,8 @@ import com.m57.hermescontrol.ui.common.BotAvatar
|
|||||||
import com.m57.hermescontrol.util.BidiUtils
|
import com.m57.hermescontrol.util.BidiUtils
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The chat input bar with a two-row layout: input+send on top,
|
* The chat input bar: a single rounded card with the input on top and a
|
||||||
* and a toolbar with attach/model chip/reasoning chip/mic below.
|
* controls row (attach, model/reasoning pill, mic, send) inside it below.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatInputBar(
|
fun ChatInputBar(
|
||||||
@@ -116,29 +109,25 @@ fun ChatInputBar(
|
|||||||
|
|
||||||
// Attachment menu state
|
// Attachment menu state
|
||||||
var showAttachmentMenu by remember { mutableStateOf(false) }
|
var showAttachmentMenu by remember { mutableStateOf(false) }
|
||||||
var isFocused by remember { mutableStateOf(false) }
|
val palette = composerPalette()
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = true,
|
visible = true,
|
||||||
enter = slideInVertically(initialOffsetY = { it }),
|
enter = slideInVertically(initialOffsetY = { it }),
|
||||||
exit = slideOutVertically(targetOffsetY = { 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(
|
Surface(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
shape = RoundedCornerShape(20.dp),
|
shape = MaterialTheme.shapes.large,
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = palette.card,
|
||||||
border =
|
border = BorderStroke(width = 1.dp, color = palette.cardBorder),
|
||||||
BorderStroke(
|
|
||||||
width = 1.dp,
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
|
|
||||||
),
|
|
||||||
tonalElevation = 2.dp,
|
|
||||||
shadowElevation = 4.dp,
|
|
||||||
) {
|
) {
|
||||||
Column {
|
Column(modifier = Modifier.padding(top = 8.dp, bottom = 10.dp)) {
|
||||||
// Commands hidden from the suggestion menu — desktop/CLI-only and
|
// Commands hidden from the suggestion menu — desktop/CLI-only and
|
||||||
// TUI-only commands that don't function on mobile (issue #574).
|
// TUI-only commands that don't function on mobile (issue #574).
|
||||||
// Single source of truth: CommandBlocklist.UNSUPPORTED, which is
|
// 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(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
.padding(horizontal = 20.dp, vertical = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
val placeholderText =
|
val placeholderText =
|
||||||
@@ -257,109 +246,43 @@ fun ChatInputBar(
|
|||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.heightIn(min = 42.dp, max = 120.dp)
|
.heightIn(min = 42.dp, max = 200.dp)
|
||||||
.padding(vertical = 4.dp)
|
.padding(vertical = 4.dp)
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
|
||||||
.testTag("chat_input"),
|
.testTag("chat_input"),
|
||||||
enabled = isConnected,
|
enabled = isConnected,
|
||||||
textStyle =
|
textStyle =
|
||||||
MaterialTheme.typography.bodyMedium.copy(
|
MaterialTheme.typography.bodyLarge.copy(
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = palette.text,
|
||||||
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||||
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
|
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||||
),
|
),
|
||||||
singleLine = false,
|
singleLine = false,
|
||||||
maxLines = 4,
|
maxLines = 8,
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
cursorBrush = SolidColor(palette.text),
|
||||||
decorationBox = { innerTextField ->
|
decorationBox = { innerTextField ->
|
||||||
CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) {
|
CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) {
|
||||||
Surface(
|
Box(
|
||||||
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,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment =
|
||||||
|
if (isInputRtl) {
|
||||||
|
Alignment.CenterEnd
|
||||||
|
} else {
|
||||||
|
Alignment.CenterStart
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
Row(
|
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
|
||||||
modifier =
|
if (inputFieldValue.text.isEmpty()) {
|
||||||
Modifier
|
Text(
|
||||||
.padding(start = 12.dp, end = 4.dp, top = 9.dp, bottom = 9.dp)
|
text = placeholderText,
|
||||||
.fillMaxWidth(),
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||||
) {
|
color = palette.placeholder,
|
||||||
Box(
|
maxLines = 1,
|
||||||
modifier = Modifier.weight(1f),
|
overflow = TextOverflow.Ellipsis,
|
||||||
contentAlignment =
|
modifier = Modifier.fillMaxWidth(),
|
||||||
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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
innerTextField()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -374,6 +297,8 @@ fun ChatInputBar(
|
|||||||
currentSessionModel = currentSessionModel,
|
currentSessionModel = currentSessionModel,
|
||||||
reasoningLevel = reasoningLevel,
|
reasoningLevel = reasoningLevel,
|
||||||
isListening = isListening,
|
isListening = isListening,
|
||||||
|
canSend = canSend,
|
||||||
|
onSend = onSend,
|
||||||
onAttachTap = { showAttachmentMenu = true },
|
onAttachTap = { showAttachmentMenu = true },
|
||||||
onModelTap = onModelTap,
|
onModelTap = onModelTap,
|
||||||
onReasoningSelected = onReasoningTap,
|
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
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.widthIn
|
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.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.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.filled.Stop
|
||||||
|
import androidx.compose.material.icons.outlined.Mic
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilledIconButton
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButtonColors
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
@@ -35,6 +46,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.scale
|
import androidx.compose.ui.draw.scale
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -46,16 +58,24 @@ import androidx.compose.ui.unit.dp
|
|||||||
import com.m57.hermescontrol.R
|
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
|
* When [canDisableReasoning] is false the "None" level is disabled with a
|
||||||
* "reasoning always on" hint (issue #946). Absent key (null) means no
|
* "reasoning always on" hint (issue #946). Absent key (null) means no
|
||||||
* restriction is known — full scale offered.
|
* restriction is known — full scale offered.
|
||||||
* When [supportsReasoning] is false the model takes no reasoning parameter
|
* When [supportsReasoning] is false the model takes no reasoning parameter
|
||||||
* and the chip is disabled.
|
* and the level menu is disabled.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ComposerToolbar(
|
fun ComposerToolbar(
|
||||||
@@ -68,6 +88,8 @@ fun ComposerToolbar(
|
|||||||
onReasoningSelected: (String?) -> Unit,
|
onReasoningSelected: (String?) -> Unit,
|
||||||
onMicTap: () -> Unit,
|
onMicTap: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
canSend: Boolean = false,
|
||||||
|
onSend: () -> Unit = {},
|
||||||
canDisableReasoning: Boolean? = null,
|
canDisableReasoning: Boolean? = null,
|
||||||
supportsReasoning: Boolean? = null,
|
supportsReasoning: Boolean? = null,
|
||||||
fastMode: Boolean = false,
|
fastMode: Boolean = false,
|
||||||
@@ -76,6 +98,7 @@ fun ComposerToolbar(
|
|||||||
onToggleFastMode: () -> Unit = {},
|
onToggleFastMode: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var showReasoningMenu by remember { mutableStateOf(false) }
|
var showReasoningMenu by remember { mutableStateOf(false) }
|
||||||
|
val palette = composerPalette()
|
||||||
val reasoningDisabledForModel = supportsReasoning == false
|
val reasoningDisabledForModel = supportsReasoning == false
|
||||||
val canDisable = canDisableReasoning
|
val canDisable = canDisableReasoning
|
||||||
|
|
||||||
@@ -83,62 +106,74 @@ fun ComposerToolbar(
|
|||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
.padding(horizontal = 12.dp)
|
||||||
|
.padding(top = 6.dp)
|
||||||
.testTag("composer_toolbar"),
|
.testTag("composer_toolbar"),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
// Attach button
|
// Attach button
|
||||||
IconButton(
|
FilledIconButton(
|
||||||
onClick = onAttachTap,
|
onClick = onAttachTap,
|
||||||
enabled = isConnected,
|
enabled = isConnected,
|
||||||
modifier = Modifier.size(36.dp),
|
colors = flatIconButtonColors(palette),
|
||||||
|
modifier = Modifier.size(ControlSize),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.AttachFile,
|
imageVector = Icons.Default.Add,
|
||||||
contentDescription = stringResource(R.string.chat_attach_file),
|
contentDescription = stringResource(R.string.chat_attach_file),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Model chip — takes available space, fixed height
|
// Model + reasoning pill — wraps its content inside the free space,
|
||||||
FilterChip(
|
// pushing the mic/action buttons to the far end
|
||||||
selected = currentSessionModel != null,
|
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
|
||||||
onClick = onModelTap,
|
Row(
|
||||||
label = {
|
modifier =
|
||||||
Row(
|
Modifier
|
||||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
.height(ControlSize)
|
||||||
) {
|
.clip(CircleShape)
|
||||||
Text(
|
.background(palette.control),
|
||||||
text = currentSessionModel ?: "Model",
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
) {
|
||||||
maxLines = 1,
|
Text(
|
||||||
)
|
// Keeps the provider so same-named models from different providers
|
||||||
}
|
// stay distinguishable; trims from the middle when tight.
|
||||||
},
|
text = currentSessionModel?.let(::composerModelLabel) ?: "Model",
|
||||||
modifier =
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
Modifier
|
color = palette.onControl,
|
||||||
.weight(1f)
|
maxLines = 1,
|
||||||
.height(28.dp)
|
overflow = TextOverflow.MiddleEllipsis,
|
||||||
.testTag("model_chip"),
|
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 {
|
||||||
Box {
|
|
||||||
FilterChip(
|
|
||||||
selected = reasoningLevel != null || fastMode,
|
|
||||||
onClick = { showReasoningMenu = true },
|
|
||||||
enabled = isConnected,
|
|
||||||
label = {
|
|
||||||
Row(
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.clickable(enabled = isConnected) { showReasoningMenu = true }
|
||||||
|
.padding(start = 6.dp, end = 12.dp)
|
||||||
|
.testTag("reasoning_chip"),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
|
val levelColor =
|
||||||
|
palette.onControlVariant.copy(
|
||||||
|
alpha = if (isConnected) 1f else 0.38f,
|
||||||
|
)
|
||||||
if (fastMode) {
|
if (fastMode) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Bolt,
|
imageVector = Icons.Filled.Bolt,
|
||||||
contentDescription = stringResource(R.string.chat_fast_mode_label),
|
contentDescription = stringResource(R.string.chat_fast_mode_label),
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = levelColor,
|
||||||
modifier = Modifier.size(14.dp),
|
modifier = Modifier.size(14.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -156,175 +191,265 @@ fun ComposerToolbar(
|
|||||||
rLabel
|
rLabel
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = levelColor,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
// Chevron marks the level half of the pill as a menu trigger
|
||||||
},
|
|
||||||
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 = {
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Bolt,
|
imageVector = Icons.Filled.ExpandMore,
|
||||||
contentDescription = null,
|
contentDescription = stringResource(R.string.chat_reasoning_menu_desc),
|
||||||
tint =
|
tint = levelColor,
|
||||||
if (fastMode) {
|
modifier = Modifier.size(16.dp),
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
},
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
text = {
|
|
||||||
Column {
|
DropdownMenu(
|
||||||
Text(
|
expanded = showReasoningMenu,
|
||||||
text = stringResource(R.string.chat_fast_mode_label),
|
onDismissRequest = { showReasoningMenu = false },
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
modifier = Modifier.widthIn(min = 220.dp),
|
||||||
fontWeight = if (fastMode) FontWeight.SemiBold else FontWeight.Normal,
|
) {
|
||||||
)
|
// ── Fast Mode Toggle Item ──
|
||||||
if (!fastSupported) {
|
val fastAvailable = isConnected && fastSupported
|
||||||
Text(
|
DropdownMenuItem(
|
||||||
text = stringResource(R.string.chat_fast_mode_unavailable),
|
leadingIcon = {
|
||||||
style = MaterialTheme.typography.labelSmall,
|
Icon(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
imageVector = Icons.Filled.Bolt,
|
||||||
|
contentDescription = null,
|
||||||
|
tint =
|
||||||
|
if (fastMode) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
}
|
text = {
|
||||||
},
|
Column {
|
||||||
trailingIcon = {
|
Text(
|
||||||
if (isFastModeChanging) {
|
text = stringResource(R.string.chat_fast_mode_label),
|
||||||
CircularProgressIndicator(
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
modifier = Modifier.size(16.dp),
|
fontWeight = if (fastMode) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
strokeWidth = 2.dp,
|
)
|
||||||
color = MaterialTheme.colorScheme.primary,
|
if (!fastSupported) {
|
||||||
)
|
Text(
|
||||||
} else {
|
text = stringResource(R.string.chat_fast_mode_unavailable),
|
||||||
Switch(
|
style = MaterialTheme.typography.labelSmall,
|
||||||
checked = fastMode,
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||||
onCheckedChange = null, // MenuItem click owns the trigger
|
)
|
||||||
enabled = fastAvailable && !isFastModeChanging,
|
}
|
||||||
modifier =
|
}
|
||||||
Modifier
|
},
|
||||||
.scale(0.85f)
|
trailingIcon = {
|
||||||
.testTag("fast_mode_switch"),
|
if (isFastModeChanging) {
|
||||||
)
|
CircularProgressIndicator(
|
||||||
}
|
modifier = Modifier.size(16.dp),
|
||||||
},
|
strokeWidth = 2.dp,
|
||||||
onClick = {
|
color = MaterialTheme.colorScheme.primary,
|
||||||
if (fastAvailable && !isFastModeChanging) {
|
)
|
||||||
onToggleFastMode()
|
} else {
|
||||||
}
|
Switch(
|
||||||
},
|
checked = fastMode,
|
||||||
enabled = fastAvailable && !isFastModeChanging,
|
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(
|
||||||
text = "REASONING",
|
text = "REASONING",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
)
|
)
|
||||||
if (canDisable == false) {
|
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(
|
||||||
text = label,
|
text = "reasoning always on",
|
||||||
fontWeight =
|
style = MaterialTheme.typography.labelSmall,
|
||||||
if (reasoningLevel == level) {
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||||
MaterialTheme.typography.bodyMedium.fontWeight
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
color =
|
|
||||||
when {
|
|
||||||
noneDisabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
|
||||||
reasoningLevel == level -> MaterialTheme.colorScheme.primary
|
|
||||||
else -> MaterialTheme.colorScheme.onSurface
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
onClick = {
|
if (reasoningDisabledForModel) {
|
||||||
showReasoningMenu = false
|
Text(
|
||||||
onReasoningSelected(level)
|
text = "no reasoning parameter for this model",
|
||||||
},
|
style = MaterialTheme.typography.labelSmall,
|
||||||
enabled = !noneDisabled && !reasoningDisabledForModel,
|
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
|
// Flat mic / stop button — only while the action button is in send mode
|
||||||
IconButton(
|
AnimatedVisibility(
|
||||||
onClick = onMicTap,
|
visible = canSend,
|
||||||
enabled = isConnected,
|
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 =
|
colors =
|
||||||
if (isListening) {
|
if (!canSend && isListening) {
|
||||||
IconButtonDefaults.filledTonalIconButtonColors(
|
listeningIconButtonColors()
|
||||||
containerColor = MaterialTheme.colorScheme.error,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onError,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
IconButtonDefaults.filledTonalIconButtonColors()
|
IconButtonDefaults.filledIconButtonColors(
|
||||||
|
containerColor = palette.action,
|
||||||
|
contentColor = palette.onAction,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.size(36.dp)
|
.size(ControlSize)
|
||||||
.testTag(if (isListening) "mic_stop_button" else "mic_button"),
|
.testTag(
|
||||||
|
when {
|
||||||
|
canSend -> "send_button"
|
||||||
|
isListening -> "mic_stop_button"
|
||||||
|
else -> "mic_button"
|
||||||
|
},
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Crossfade(
|
||||||
imageVector = if (isListening) Icons.Default.Stop else Icons.Default.Mic,
|
targetState =
|
||||||
contentDescription = if (isListening) "Stop listening" else "Mic",
|
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.
|
* Build a human-readable label from a reasoning effort level.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1241,6 +1241,7 @@
|
|||||||
<string name="chat_fast_mode_label">Fast</string>
|
<string name="chat_fast_mode_label">Fast</string>
|
||||||
<string name="chat_fast_mode_desc">Fast mode</string>
|
<string name="chat_fast_mode_desc">Fast mode</string>
|
||||||
<string name="chat_fast_mode_unavailable">Fast mode unavailable for this model</string>
|
<string name="chat_fast_mode_unavailable">Fast mode unavailable for this model</string>
|
||||||
|
<string name="chat_reasoning_menu_desc">Reasoning options</string>
|
||||||
<string name="chat_save_attachment">Save %1$s</string>
|
<string name="chat_save_attachment">Save %1$s</string>
|
||||||
<string name="chat_switch_model_title">Switch model (this chat)</string>
|
<string name="chat_switch_model_title">Switch model (this chat)</string>
|
||||||
<string name="config_clear_value">Clear value</string>
|
<string name="config_clear_value">Clear value</string>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user