refactor(chat): two-row ChatComposer + model/reasoning chips (#669)
* feat(chat): add reasoningLevel to ChatUiState + ViewModel methods * feat(chat): add ComposerToolbar with model + reasoning chips * refactor(chat): rewrite ChatInputBar to two-row layout * feat(chat): wire ComposerToolbar into ChatScreen * fix(chat): make model chip fixed 120dp with horizontal scroll for long names * feat(chat): reasoning chip opens dropdown menu with all 9 levels instead of cycling * fix(chat): remove Auto level from reasoning menu — backend only supports none..ultra, default is medium * feat(chat): sync model + reasoning level from backend session.resume response * fix(chat): handle session.info events for model/reasoning sync + fix null value 4002 * fix(chat): safety timeout for createNewSession + try-catch event loop * fix(chat): address agy review findings - Restore InsertDriveFile icon for non-image attachments - Add widthIn(max=120.dp) constraint on attachment filename - Restore stringResource reference for remove-attachment description - Remove dead cycleReasoningLevel() method (dropdown supersedes) - Fix createNewSession timeout race with generation counter * fix(chat): update instrumented test for two-row layout chatScreen_renders_and_acceptsInput had an assertion that the mic button disappears when text is entered — this was the OLD single-row layout behavior. The new two-row composer always shows the mic button in the toolbar row, independent of input state. --------- Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
This commit is contained in:
@@ -27,3 +27,4 @@ ktlint
|
|||||||
|
|
||||||
# direnv
|
# direnv
|
||||||
.direnv/
|
.direnv/
|
||||||
|
hermes-chat-redesign.html
|
||||||
|
|||||||
@@ -68,9 +68,10 @@ class ChatScreenTest {
|
|||||||
composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed()
|
composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed()
|
||||||
composeTestRule.onNodeWithTag("send_button").assertDoesNotExist()
|
composeTestRule.onNodeWithTag("send_button").assertDoesNotExist()
|
||||||
|
|
||||||
// Verify entering text switches mic button to send button
|
// Verify entering text shows send button in input row
|
||||||
|
// Mic stays visible in toolbar row (new two-row layout)
|
||||||
composeTestRule.onNodeWithTag("chat_input").performTextInput("Hello Hermes")
|
composeTestRule.onNodeWithTag("chat_input").performTextInput("Hello Hermes")
|
||||||
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
|
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
|
||||||
composeTestRule.onNodeWithTag("mic_button").assertDoesNotExist()
|
composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -536,6 +536,11 @@ fun ChatScreen(
|
|||||||
onImageTap = { filePickerLauncher.launch("image/*") },
|
onImageTap = { filePickerLauncher.launch("image/*") },
|
||||||
onFileTap = { filePickerLauncher.launch("*/*") },
|
onFileTap = { filePickerLauncher.launch("*/*") },
|
||||||
onRemoveAttachment = viewModel::removeAttachment,
|
onRemoveAttachment = viewModel::removeAttachment,
|
||||||
|
// Composer toolbar wiring (PR 1)
|
||||||
|
currentSessionModel = state.currentSessionModel,
|
||||||
|
reasoningLevel = state.reasoningLevel,
|
||||||
|
onModelTap = { viewModel.openModelPicker() },
|
||||||
|
onReasoningTap = { level -> viewModel.setReasoningLevel(level) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ data class ChatUiState(
|
|||||||
val modelPickerLoading: Boolean = false,
|
val modelPickerLoading: Boolean = false,
|
||||||
// Current session's active model label (provider/model), shown in the chip
|
// Current session's active model label (provider/model), shown in the chip
|
||||||
val currentSessionModel: String? = null,
|
val currentSessionModel: String? = null,
|
||||||
|
// Reasoning effort level for the current session
|
||||||
|
val reasoningLevel: String? = null,
|
||||||
// Attachment state
|
// Attachment state
|
||||||
val pendingAttachments: List<Attachment> = emptyList(),
|
val pendingAttachments: List<Attachment> = emptyList(),
|
||||||
// Reaction animation — set when a reaction WS event arrives, auto-clears
|
// Reaction animation — set when a reaction WS event arrives, auto-clears
|
||||||
@@ -231,7 +233,12 @@ class ChatViewModel(
|
|||||||
connectWebSocket(setLoading = false)
|
connectWebSocket(setLoading = false)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
wsClient.events.collect { event ->
|
wsClient.events.collect { event ->
|
||||||
|
try {
|
||||||
handleWsEvent(event)
|
handleWsEvent(event)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("ChatVM", "Uncaught in event loop", e)
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// B7 (Jun 30 2026, kanban t_connection_loading): clear loading state on connection failure or status change
|
// B7 (Jun 30 2026, kanban t_connection_loading): clear loading state on connection failure or status change
|
||||||
@@ -376,6 +383,33 @@ class ChatViewModel(
|
|||||||
handleGatewayReady()
|
handleGatewayReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is WsEvent.SessionInfo -> {
|
||||||
|
// Session info pushed by backend when config changes
|
||||||
|
// (model switch, reasoning level, etc.)
|
||||||
|
val info = event.data
|
||||||
|
if (info != null) {
|
||||||
|
val model = info["model"] as? String
|
||||||
|
val provider = info["provider"] as? String
|
||||||
|
val reasoningEffort = info["reasoning_effort"] as? String
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
currentSessionModel =
|
||||||
|
if (model != null && provider != null) {
|
||||||
|
"$provider/$model"
|
||||||
|
} else {
|
||||||
|
model ?: state.currentSessionModel
|
||||||
|
},
|
||||||
|
reasoningLevel =
|
||||||
|
if (reasoningEffort.isNullOrEmpty()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
reasoningEffort
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
is WsEvent.MessageToken -> {
|
is WsEvent.MessageToken -> {
|
||||||
streamingController.handleMessageToken(event)
|
streamingController.handleMessageToken(event)
|
||||||
}
|
}
|
||||||
@@ -559,6 +593,12 @@ class ChatViewModel(
|
|||||||
(resultMap?.get("resumed") as? String)
|
(resultMap?.get("resumed") as? String)
|
||||||
?: _uiState.value.currentSessionId
|
?: _uiState.value.currentSessionId
|
||||||
|
|
||||||
|
// Parse session info from backend — model, provider, reasoning_effort
|
||||||
|
val infoMap = resultMap?.get("info") as? Map<String, Any?>
|
||||||
|
val model = infoMap?.get("model") as? String
|
||||||
|
val provider = infoMap?.get("provider") as? String
|
||||||
|
val reasoningEffort = infoMap?.get("reasoning_effort") as? String
|
||||||
|
|
||||||
// B8 (Jun 20 2026, kanban t_session_resume): do NOT reload
|
// B8 (Jun 20 2026, kanban t_session_resume): do NOT reload
|
||||||
// cached messages here — switchSession() already did so before
|
// cached messages here — switchSession() already did so before
|
||||||
// the WS round-trip. Calling loadCachedMessages() here would
|
// the WS round-trip. Calling loadCachedMessages() here would
|
||||||
@@ -568,6 +608,18 @@ class ChatViewModel(
|
|||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
currentSessionId = sessionId,
|
currentSessionId = sessionId,
|
||||||
|
currentSessionModel =
|
||||||
|
if (model != null && provider != null) {
|
||||||
|
"$provider/$model"
|
||||||
|
} else {
|
||||||
|
model ?: it.currentSessionModel
|
||||||
|
},
|
||||||
|
reasoningLevel =
|
||||||
|
if (reasoningEffort.isNullOrEmpty()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
reasoningEffort
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Mirror the active runtime session id app-wide (issue #532).
|
// Mirror the active runtime session id app-wide (issue #532).
|
||||||
@@ -1069,6 +1121,8 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var sessionCreateCounter = 0L
|
||||||
|
|
||||||
fun createNewSession(setLoading: Boolean = true) {
|
fun createNewSession(setLoading: Boolean = true) {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -1086,6 +1140,18 @@ class ChatViewModel(
|
|||||||
onSent = { id -> trackRequest(id, WsMethods.SESSION_CREATE) },
|
onSent = { id -> trackRequest(id, WsMethods.SESSION_CREATE) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// B7 safety timeout: clear loading state if RPC response never arrives
|
||||||
|
if (setLoading && !isTestEnvironment()) {
|
||||||
|
val generation = ++sessionCreateCounter
|
||||||
|
viewModelScope.launch {
|
||||||
|
delay(10_000L)
|
||||||
|
// Only clear if no newer session creation has started — prevents a
|
||||||
|
// stale timeout from wiping the loading flag of a subsequent request.
|
||||||
|
if (generation == sessionCreateCounter && _uiState.value.isLoading) {
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadSessions() {
|
fun loadSessions() {
|
||||||
@@ -1244,6 +1310,32 @@ class ChatViewModel(
|
|||||||
handleSlashCommand("/model $model --provider $provider --session")
|
handleSlashCommand("/model $model --provider $provider --session")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the reasoning effort level for the current session.
|
||||||
|
*
|
||||||
|
* Updates the UI optimistically and sends a `config.set` RPC to the
|
||||||
|
* backend. The level applies per-session via the runtime session ID.
|
||||||
|
* If [level] is null it resets to the model's default.
|
||||||
|
*
|
||||||
|
* @param level One of "low", "medium", "high", or null for default.
|
||||||
|
*/
|
||||||
|
fun setReasoningLevel(level: String?) {
|
||||||
|
_uiState.update { it.copy(reasoningLevel = level) }
|
||||||
|
val sessionId = runtimeSessionId ?: return
|
||||||
|
if (level == null) return // null = model default, no need to send WS
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
wsClient.send(
|
||||||
|
WsMethods.CONFIG_SET,
|
||||||
|
mapOf(
|
||||||
|
"key" to "reasoning",
|
||||||
|
"value" to level,
|
||||||
|
"session_id" to sessionId,
|
||||||
|
),
|
||||||
|
onSent = { id -> trackRequest(id, WsMethods.CONFIG_SET) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun switchSession(sessionId: String) {
|
fun switchSession(sessionId: String) {
|
||||||
if (sessionId == _uiState.value.currentSessionId) return
|
if (sessionId == _uiState.value.currentSessionId) return
|
||||||
|
|
||||||
|
|||||||
@@ -28,12 +28,9 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.Send
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
import androidx.compose.material.icons.filled.AttachFile
|
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Mic
|
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||||
import androidx.compose.material.icons.filled.Stop
|
|
||||||
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
|
||||||
@@ -68,8 +65,8 @@ import com.m57.hermescontrol.data.ws.CommandCatalog
|
|||||||
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
|
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The chat input bar with attach menu, slash-command dropdown,
|
* The chat input bar with a two-row layout: input+send on top,
|
||||||
* attachment preview chips, and mic/send/stop button swapping.
|
* and a toolbar with attach/model chip/reasoning chip/mic below.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatInputBar(
|
fun ChatInputBar(
|
||||||
@@ -86,6 +83,11 @@ fun ChatInputBar(
|
|||||||
onImageTap: () -> Unit = {},
|
onImageTap: () -> Unit = {},
|
||||||
onFileTap: () -> Unit = {},
|
onFileTap: () -> Unit = {},
|
||||||
onRemoveAttachment: (Int) -> Unit = {},
|
onRemoveAttachment: (Int) -> Unit = {},
|
||||||
|
// NEW: composer toolbar wiring
|
||||||
|
currentSessionModel: String? = null,
|
||||||
|
reasoningLevel: String? = null,
|
||||||
|
onModelTap: () -> Unit = {},
|
||||||
|
onReasoningTap: (String?) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
// Allow sending while the agent is mid-turn or awaiting approval: the
|
// Allow sending while the agent is mid-turn or awaiting approval: the
|
||||||
// gateway's prompt.submit busy-input policy queues it as the next turn
|
// gateway's prompt.submit busy-input policy queues it as the next turn
|
||||||
@@ -135,7 +137,7 @@ fun ChatInputBar(
|
|||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
border =
|
border =
|
||||||
androidx.compose.foundation.BorderStroke(
|
BorderStroke(
|
||||||
1.dp,
|
1.dp,
|
||||||
MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
|
MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
|
||||||
),
|
),
|
||||||
@@ -182,6 +184,7 @@ fun ChatInputBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── TOP ROW: Input field with embedded send button ──
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -189,20 +192,124 @@ fun ChatInputBar(
|
|||||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Box {
|
val placeholderText =
|
||||||
// Single attach button that opens a dropdown menu
|
when {
|
||||||
IconButton(
|
!isConnected -> {
|
||||||
onClick = { showAttachmentMenu = true },
|
stringResource(R.string.chat_input_placeholder_not_connected)
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatInputPolicy.showQueuePlaceholder(inputFieldValue.text, isAgentTyping) -> {
|
||||||
|
stringResource(R.string.chat_input_placeholder_queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
isAgentTyping -> {
|
||||||
|
stringResource(R.string.chat_input_placeholder_waiting)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
stringResource(R.string.chat_input_placeholder_type_message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BasicTextField(
|
||||||
|
value = inputFieldValue,
|
||||||
|
onValueChange = onInputChange,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.heightIn(min = 42.dp, max = 120.dp)
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
.onFocusChanged { isFocused = it.isFocused }
|
||||||
|
.testTag("chat_input"),
|
||||||
enabled = isConnected,
|
enabled = isConnected,
|
||||||
modifier = Modifier.size(36.dp),
|
textStyle =
|
||||||
|
MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
singleLine = false,
|
||||||
|
maxLines = 4,
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
|
decorationBox = { innerTextField ->
|
||||||
|
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,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
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)) {
|
||||||
|
if (inputFieldValue.text.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = placeholderText,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
innerTextField()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send button INSIDE the field
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = canSend && inputFieldValue.text.isNotBlank(),
|
||||||
|
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(
|
Icon(
|
||||||
imageVector = Icons.Default.AttachFile,
|
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||||
contentDescription = stringResource(R.string.chat_attach_desc),
|
contentDescription = stringResource(R.string.chat_send_desc),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── BOTTOM ROW: Toolbar ──
|
||||||
|
ComposerToolbar(
|
||||||
|
isConnected = isConnected,
|
||||||
|
currentSessionModel = currentSessionModel,
|
||||||
|
reasoningLevel = reasoningLevel,
|
||||||
|
isListening = isListening,
|
||||||
|
onAttachTap = { showAttachmentMenu = true },
|
||||||
|
onModelTap = onModelTap,
|
||||||
|
onReasoningSelected = onReasoningTap,
|
||||||
|
onMicTap = onMicTap,
|
||||||
|
modifier = Modifier.testTag("chat_composer_toolbar"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Attachment dropdown (anchored to the attach button in ComposerToolbar)
|
||||||
|
// Shown overlaid at the toolbar level
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = showAttachmentMenu,
|
expanded = showAttachmentMenu,
|
||||||
onDismissRequest = { showAttachmentMenu = false },
|
onDismissRequest = { showAttachmentMenu = false },
|
||||||
@@ -248,191 +355,46 @@ fun ChatInputBar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val placeholderText =
|
|
||||||
when {
|
|
||||||
!isConnected -> {
|
|
||||||
stringResource(R.string.chat_input_placeholder_not_connected)
|
|
||||||
}
|
|
||||||
|
|
||||||
ChatInputPolicy.showQueuePlaceholder(inputFieldValue.text, isAgentTyping) -> {
|
|
||||||
stringResource(R.string.chat_input_placeholder_queue)
|
|
||||||
}
|
|
||||||
|
|
||||||
isAgentTyping -> {
|
|
||||||
stringResource(R.string.chat_input_placeholder_waiting)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
stringResource(R.string.chat_input_placeholder_type_message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BasicTextField(
|
|
||||||
value = inputFieldValue,
|
|
||||||
onValueChange = onInputChange,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.heightIn(min = 42.dp, max = 120.dp)
|
|
||||||
.padding(horizontal = 4.dp, vertical = 4.dp)
|
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
|
||||||
.testTag("chat_input"),
|
|
||||||
enabled = isConnected,
|
|
||||||
textStyle =
|
|
||||||
MaterialTheme.typography.bodyMedium.copy(
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
singleLine = false,
|
|
||||||
maxLines = 4,
|
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
|
||||||
decorationBox = { innerTextField ->
|
|
||||||
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,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.padding(horizontal = 12.dp, vertical = 9.dp)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
contentAlignment = Alignment.CenterStart,
|
|
||||||
) {
|
|
||||||
if (inputFieldValue.text.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = placeholderText,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
innerTextField()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
|
|
||||||
// Mic / Stop / Send button — swaps based on input state
|
|
||||||
AnimatedContent(
|
|
||||||
targetState =
|
|
||||||
when {
|
|
||||||
isListening -> "listening"
|
|
||||||
inputFieldValue.text.isBlank() && pendingAttachments.isEmpty() -> "mic"
|
|
||||||
else -> "send"
|
|
||||||
},
|
|
||||||
transitionSpec = {
|
|
||||||
(scaleIn(initialScale = 0.8f) + fadeIn())
|
|
||||||
.togetherWith(scaleOut(targetScale = 0.8f) + fadeOut())
|
|
||||||
},
|
|
||||||
label = "mic_send_toggle",
|
|
||||||
) { buttonState ->
|
|
||||||
when (buttonState) {
|
|
||||||
"mic" -> {
|
|
||||||
IconButton(
|
|
||||||
onClick = onMicTap,
|
|
||||||
enabled = isConnected,
|
|
||||||
colors = IconButtonDefaults.filledTonalIconButtonColors(),
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.size(36.dp)
|
|
||||||
.testTag("mic_button"),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Mic,
|
|
||||||
contentDescription = stringResource(R.string.chat_mic_desc),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
"listening" -> {
|
|
||||||
IconButton(
|
|
||||||
onClick = onMicTap,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.size(36.dp)
|
|
||||||
.testTag("mic_stop_button"),
|
|
||||||
colors =
|
|
||||||
IconButtonDefaults.filledTonalIconButtonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.error,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onError,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Stop,
|
|
||||||
contentDescription = "Stop listening",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact chip showing a pending attachment with a remove button.
|
* Reusable attachment chip composable for showing a pending attachment
|
||||||
|
* with a remove button.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun AttachmentChip(
|
fun AttachmentChip(
|
||||||
attachment: Attachment,
|
attachment: Attachment,
|
||||||
onRemove: () -> Unit,
|
onRemove: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val thumbnail = attachment.uri
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(16.dp),
|
modifier = modifier,
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
shape = RoundedCornerShape(8.dp),
|
||||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)),
|
tonalElevation = 2.dp,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(start = 10.dp, end = 6.dp, top = 4.dp, bottom = 4.dp),
|
modifier = Modifier.padding(4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
if (attachment.isImage) {
|
if (attachment.isImage) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = attachment.uri,
|
model = thumbnail,
|
||||||
contentDescription = attachment.name,
|
contentDescription = attachment.name,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.size(20.dp)
|
.size(24.dp)
|
||||||
.clip(RoundedCornerShape(4.dp)),
|
.clip(RoundedCornerShape(4.dp)),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
} else {
|
} else {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.InsertDriveFile,
|
imageVector = Icons.Default.InsertDriveFile,
|
||||||
contentDescription = null,
|
contentDescription = attachment.name,
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(24.dp),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
@@ -445,15 +407,11 @@ fun AttachmentChip(
|
|||||||
modifier = Modifier.widthIn(max = 120.dp),
|
modifier = Modifier.widthIn(max = 120.dp),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
IconButton(
|
IconButton(onClick = onRemove, modifier = Modifier.size(18.dp)) {
|
||||||
onClick = onRemove,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = stringResource(R.string.chat_attach_remove_desc),
|
contentDescription = stringResource(R.string.chat_attach_remove_desc),
|
||||||
modifier = Modifier.size(14.dp),
|
modifier = Modifier.size(14.dp),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.AttachFile
|
||||||
|
import androidx.compose.material.icons.filled.Mic
|
||||||
|
import androidx.compose.material.icons.filled.Stop
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom toolbar row for the chat composer.
|
||||||
|
*
|
||||||
|
* Layout: [📎 attach] [model chip] [reasoning chip] [←spacer→] [🎙 mic]
|
||||||
|
*
|
||||||
|
* The reasoning chip opens a dropdown menu to pick a level (instead of cycling).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ComposerToolbar(
|
||||||
|
isConnected: Boolean,
|
||||||
|
currentSessionModel: String?,
|
||||||
|
reasoningLevel: String?,
|
||||||
|
isListening: Boolean,
|
||||||
|
onAttachTap: () -> Unit,
|
||||||
|
onModelTap: () -> Unit,
|
||||||
|
onReasoningSelected: (String?) -> Unit,
|
||||||
|
onMicTap: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var showReasoningMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
|
.testTag("composer_toolbar"),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
// Attach button
|
||||||
|
IconButton(
|
||||||
|
onClick = onAttachTap,
|
||||||
|
enabled = isConnected,
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.AttachFile,
|
||||||
|
contentDescription = "Attach file",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model chip — fixed width, text scrolls if too long
|
||||||
|
FilterChip(
|
||||||
|
selected = currentSessionModel != null,
|
||||||
|
onClick = onModelTap,
|
||||||
|
label = {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = currentSessionModel ?: "Model",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.width(120.dp)
|
||||||
|
.testTag("model_chip"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reasoning chip with dropdown menu
|
||||||
|
Box {
|
||||||
|
FilterChip(
|
||||||
|
selected = reasoningLevel != null,
|
||||||
|
onClick = { showReasoningMenu = true },
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = buildReasoningLabel(reasoningLevel),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.testTag("reasoning_chip"),
|
||||||
|
)
|
||||||
|
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showReasoningMenu,
|
||||||
|
onDismissRequest = { showReasoningMenu = false },
|
||||||
|
) {
|
||||||
|
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) ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
fontWeight =
|
||||||
|
if (reasoningLevel == level) {
|
||||||
|
MaterialTheme.typography.bodyMedium.fontWeight
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
color =
|
||||||
|
if (reasoningLevel == level) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
showReasoningMenu = false
|
||||||
|
onReasoningSelected(level)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
// Mic / Stop button
|
||||||
|
IconButton(
|
||||||
|
onClick = onMicTap,
|
||||||
|
enabled = isConnected,
|
||||||
|
colors =
|
||||||
|
if (isListening) {
|
||||||
|
IconButtonDefaults.filledTonalIconButtonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onError,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
IconButtonDefaults.filledTonalIconButtonColors()
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.testTag(if (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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a human-readable label from a reasoning effort level.
|
||||||
|
*
|
||||||
|
* @param level One of: "none", "minimal", "low", "medium", "high",
|
||||||
|
* "xhigh", "max", "ultra", or null for model default.
|
||||||
|
* @return Display string such as "🧠 None", "🧠 Low", "🧠 XHigh", "🧠 Ultra", etc.
|
||||||
|
*/
|
||||||
|
private fun buildReasoningLabel(level: String?): String {
|
||||||
|
return when (level) {
|
||||||
|
null -> "🧠 Med"
|
||||||
|
"none" -> "🧠 None"
|
||||||
|
"minimal" -> "🧠 Minimal"
|
||||||
|
"low" -> "🧠 Low"
|
||||||
|
"medium" -> "🧠 Med"
|
||||||
|
"high" -> "🧠 High"
|
||||||
|
"xhigh" -> "🧠 XHigh"
|
||||||
|
"max" -> "🧠 Max"
|
||||||
|
"ultra" -> "🧠 Ultra"
|
||||||
|
else -> "🧠 $level"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user