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:
1 parent
c16f6e3e0b
commit
bb4149eb0d
6 files changed
+430
-166
No files matched your search
@@ -536,6 +536,11 @@ fun ChatScreen(
|
||||
onImageTap = { filePickerLauncher.launch("image/*") },
|
||||
onFileTap = { filePickerLauncher.launch("*/*") },
|
||||
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,
|
||||
// Current session's active model label (provider/model), shown in the chip
|
||||
val currentSessionModel: String? = null,
|
||||
// Reasoning effort level for the current session
|
||||
val reasoningLevel: String? = null,
|
||||
// Attachment state
|
||||
val pendingAttachments: List<Attachment> = emptyList(),
|
||||
// Reaction animation — set when a reaction WS event arrives, auto-clears
|
||||
@@ -231,7 +233,12 @@ class ChatViewModel(
|
||||
connectWebSocket(setLoading = false)
|
||||
viewModelScope.launch {
|
||||
wsClient.events.collect { event ->
|
||||
handleWsEvent(event)
|
||||
try {
|
||||
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
|
||||
@@ -376,6 +383,33 @@ class ChatViewModel(
|
||||
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 -> {
|
||||
streamingController.handleMessageToken(event)
|
||||
}
|
||||
@@ -559,6 +593,12 @@ class ChatViewModel(
|
||||
(resultMap?.get("resumed") as? String)
|
||||
?: _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
|
||||
// cached messages here — switchSession() already did so before
|
||||
// the WS round-trip. Calling loadCachedMessages() here would
|
||||
@@ -568,6 +608,18 @@ class ChatViewModel(
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
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).
|
||||
@@ -1069,6 +1121,8 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private var sessionCreateCounter = 0L
|
||||
|
||||
fun createNewSession(setLoading: Boolean = true) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
@@ -1086,6 +1140,18 @@ class ChatViewModel(
|
||||
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() {
|
||||
@@ -1244,6 +1310,32 @@ class ChatViewModel(
|
||||
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) {
|
||||
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.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.AttachFile
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -68,8 +65,8 @@ import com.m57.hermescontrol.data.ws.CommandCatalog
|
||||
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
|
||||
|
||||
/**
|
||||
* The chat input bar with attach menu, slash-command dropdown,
|
||||
* attachment preview chips, and mic/send/stop button swapping.
|
||||
* The chat input bar with a two-row layout: input+send on top,
|
||||
* and a toolbar with attach/model chip/reasoning chip/mic below.
|
||||
*/
|
||||
@Composable
|
||||
fun ChatInputBar(
|
||||
@@ -86,6 +83,11 @@ fun ChatInputBar(
|
||||
onImageTap: () -> Unit = {},
|
||||
onFileTap: () -> 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
|
||||
// gateway's prompt.submit busy-input policy queues it as the next turn
|
||||
@@ -135,7 +137,7 @@ fun ChatInputBar(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
border =
|
||||
androidx.compose.foundation.BorderStroke(
|
||||
BorderStroke(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
|
||||
),
|
||||
@@ -182,6 +184,7 @@ fun ChatInputBar(
|
||||
}
|
||||
}
|
||||
|
||||
// ── TOP ROW: Input field with embedded send button ──
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -189,66 +192,6 @@ fun ChatInputBar(
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box {
|
||||
// Single attach button that opens a dropdown menu
|
||||
IconButton(
|
||||
onClick = { showAttachmentMenu = true },
|
||||
enabled = isConnected,
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.AttachFile,
|
||||
contentDescription = stringResource(R.string.chat_attach_desc),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showAttachmentMenu,
|
||||
onDismissRequest = { showAttachmentMenu = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Camera") },
|
||||
onClick = {
|
||||
showAttachmentMenu = false
|
||||
onCameraTap()
|
||||
},
|
||||
leadingIcon = {
|
||||
Text(
|
||||
text = "📷",
|
||||
fontSize = 18.sp,
|
||||
)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Image") },
|
||||
onClick = {
|
||||
showAttachmentMenu = false
|
||||
onImageTap()
|
||||
},
|
||||
leadingIcon = {
|
||||
Text(
|
||||
text = "🖼️",
|
||||
fontSize = 18.sp,
|
||||
)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("File") },
|
||||
onClick = {
|
||||
showAttachmentMenu = false
|
||||
onFileTap()
|
||||
},
|
||||
leadingIcon = {
|
||||
Text(
|
||||
text = "📄",
|
||||
fontSize = 18.sp,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val placeholderText =
|
||||
when {
|
||||
!isConnected -> {
|
||||
@@ -275,7 +218,7 @@ fun ChatInputBar(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.heightIn(min = 42.dp, max = 120.dp)
|
||||
.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||
.padding(vertical = 4.dp)
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.testTag("chat_input"),
|
||||
enabled = isConnected,
|
||||
@@ -302,98 +245,114 @@ fun ChatInputBar(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Box(
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = 12.dp, vertical = 9.dp)
|
||||
.padding(start = 12.dp, end = 4.dp, top = 9.dp, bottom = 9.dp)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (inputFieldValue.text.isEmpty()) {
|
||||
Text(
|
||||
text = placeholderText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
)
|
||||
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(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = stringResource(R.string.chat_send_desc),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
// ── 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"),
|
||||
)
|
||||
|
||||
// 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())
|
||||
// Attachment dropdown (anchored to the attach button in ComposerToolbar)
|
||||
// Shown overlaid at the toolbar level
|
||||
DropdownMenu(
|
||||
expanded = showAttachmentMenu,
|
||||
onDismissRequest = { showAttachmentMenu = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Camera") },
|
||||
onClick = {
|
||||
showAttachmentMenu = false
|
||||
onCameraTap()
|
||||
},
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
leadingIcon = {
|
||||
Text(
|
||||
text = "📷",
|
||||
fontSize = 18.sp,
|
||||
)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Image") },
|
||||
onClick = {
|
||||
showAttachmentMenu = false
|
||||
onImageTap()
|
||||
},
|
||||
leadingIcon = {
|
||||
Text(
|
||||
text = "🖼️",
|
||||
fontSize = 18.sp,
|
||||
)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("File") },
|
||||
onClick = {
|
||||
showAttachmentMenu = false
|
||||
onFileTap()
|
||||
},
|
||||
leadingIcon = {
|
||||
Text(
|
||||
text = "📄",
|
||||
fontSize = 18.sp,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -401,38 +360,41 @@ fun ChatInputBar(
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact chip showing a pending attachment with a remove button.
|
||||
* Reusable attachment chip composable for showing a pending attachment
|
||||
* with a remove button.
|
||||
*/
|
||||
@Composable
|
||||
fun AttachmentChip(
|
||||
attachment: Attachment,
|
||||
onRemove: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val thumbnail = attachment.uri
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)),
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
tonalElevation = 2.dp,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 10.dp, end = 6.dp, top = 4.dp, bottom = 4.dp),
|
||||
modifier = Modifier.padding(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (attachment.isImage) {
|
||||
AsyncImage(
|
||||
model = attachment.uri,
|
||||
model = thumbnail,
|
||||
contentDescription = attachment.name,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(20.dp)
|
||||
.size(24.dp)
|
||||
.clip(RoundedCornerShape(4.dp)),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
imageVector = Icons.Default.InsertDriveFile,
|
||||
contentDescription = attachment.name,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
@@ -445,15 +407,11 @@ fun AttachmentChip(
|
||||
modifier = Modifier.widthIn(max = 120.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
IconButton(
|
||||
onClick = onRemove,
|
||||
modifier = Modifier.size(18.dp),
|
||||
) {
|
||||
IconButton(onClick = onRemove, modifier = Modifier.size(18.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.chat_attach_remove_desc),
|
||||
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