refactor(#621): split ChatScreen.kt god file into focused composable components (#623)

Split the 2,267-line ChatScreen.kt into 11 single-purpose files under
ui/chat/components/, keeping ChatScreen.kt as a thin (~530 line)
entry compositor that delegates rendering to the new components.

Files extracted:
- ChatConnectionBanner.kt    — top banner (reconnecting/disconnected/auth-expired)
- ChatMessageList.kt         — message list + search bar overlay + empty state
- ChatComposer.kt            — input bar + attachment chips + slash dropdown
- ChatSearchBar.kt           — CompactSearchInput + SearchBarRow
- ChatDialogs.kt             — ClarifyDialog, SudoPromptDialog, SecretPromptDialog, ReloginDialog
- ChatIndicators.kt          — ThinkingIndicator, ReasoningIndicator, SubagentIndicatorRow
- ChatScrollFab.kt           — scrollToBottom utils + FAB
- ChatLifecycleEffects.kt    — session switch, lifecycle observer, auto-scroll, error snackbars
- ChatReactions.kt           — ReactionHeartsOverlay + FloatingHeart animation
- ChatStreaming.kt           — StreamingBubbleWithTypingEffect
- ChatLoadingOverlay.kt      — full-screen semi-transparent loading overlay

No behavioral change. All composables are public where they need to be
called from ChatScreen, otherwise keep their original visibility.
Unblocks #583 and #584 which were previously too risky to attempt in
the 2.2k-line file.

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-18 13:03:04 +03:00
1 parent d484d535ce
commit 0c2b9679b7
12 files changed
+2122 -1764

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,441 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
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.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.ws.CommandBlocklist
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.
*/
@Composable
fun ChatInputBar(
inputFieldValue: TextFieldValue,
onInputChange: (TextFieldValue) -> Unit,
onSend: () -> Unit,
onMicTap: () -> Unit,
isListening: Boolean,
isAgentTyping: Boolean,
isConnected: Boolean,
commandCatalog: CommandCatalog,
pendingAttachments: List<Attachment> = emptyList(),
onCameraTap: () -> Unit = {},
onImageTap: () -> Unit = {},
onFileTap: () -> Unit = {},
onRemoveAttachment: (Int) -> 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
// (tui_gateway/server.py:_handle_busy_submit), so the message is never
// dropped. Slash commands were already allowed; regular prompts now are too.
val canSend = ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected)
// Attachment menu state
var showAttachmentMenu by remember { mutableStateOf(false) }
AnimatedVisibility(
visible = true,
enter = slideInVertically(initialOffsetY = { it }),
exit = slideOutVertically(targetOffsetY = { it }),
) {
Card(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 4.dp),
shape = RoundedCornerShape(24.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
),
border =
BorderStroke(
width = 1.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f),
),
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
) {
Column {
// Commands hidden from the suggestion menu — desktop/CLI-only and
// TUI-only commands that don't function on mobile (issue #574).
// Single source of truth: CommandBlocklist.UNSUPPORTED, which is
// also enforced at dispatch time (issue #576, deliverable #3).
val hiddenSlashDisplay = CommandBlocklist.UNSUPPORTED
val commandNames =
commandCatalog.pairs
.map { it[0] }
.filter { it.lowercase() !in hiddenSlashDisplay }
androidx.compose.animation.AnimatedVisibility(
visible = inputFieldValue.text.startsWith("/") && !inputFieldValue.text.contains(" "),
enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(),
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(),
) {
val filteredCommands =
commandNames.filter { it.startsWith(inputFieldValue.text, ignoreCase = true) }
if (filteredCommands.isNotEmpty()) {
androidx.compose.material3.Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
border =
androidx.compose.foundation.BorderStroke(
1.dp,
MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
),
) {
LazyColumn(
modifier = Modifier.heightIn(max = 200.dp),
) {
items(filteredCommands, key = { it }) { cmd ->
DropdownMenuItem(
text = {
Text(
cmd,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
},
onClick = { onInputChange(ChatInputPolicy.commandFieldValue(cmd)) },
)
}
}
}
}
}
// Attachment preview chips
AnimatedVisibility(
visible = pendingAttachments.isNotEmpty(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
pendingAttachments.forEachIndexed { index, attachment ->
AttachmentChip(
attachment = attachment,
onRemove = { onRemoveAttachment(index) },
)
}
}
}
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.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,
)
},
)
}
}
OutlinedTextField(
value = inputFieldValue,
onValueChange = onInputChange,
modifier =
Modifier
.weight(1f)
.heightIn(min = 36.dp, max = 120.dp)
.padding(horizontal = 4.dp, vertical = 8.dp)
.testTag("chat_input"),
placeholder = {
Text(
if (!isConnected) {
stringResource(R.string.chat_input_placeholder_not_connected)
} else if (ChatInputPolicy.showQueuePlaceholder(inputFieldValue.text, isAgentTyping)) {
stringResource(R.string.chat_input_placeholder_queue)
} else if (isAgentTyping) {
stringResource(R.string.chat_input_placeholder_waiting)
} else {
stringResource(R.string.chat_input_placeholder_type_message)
},
style = MaterialTheme.typography.bodyMedium,
)
},
enabled = isConnected,
singleLine = false,
maxLines = 4,
shape = RoundedCornerShape(20.dp),
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
cursorColor = MaterialTheme.colorScheme.primary,
),
)
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" -> {
FilledTonalButton(
onClick = onMicTap,
enabled = isConnected,
modifier =
Modifier
.size(40.dp)
.testTag("mic_button"),
shape = CircleShape,
contentPadding = PaddingValues(0.dp),
) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = stringResource(R.string.chat_mic_desc),
)
}
}
"listening" -> {
FilledTonalButton(
onClick = onMicTap,
modifier =
Modifier
.size(40.dp)
.testTag("mic_stop_button"),
shape = CircleShape,
contentPadding = PaddingValues(0.dp),
colors =
ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) {
Icon(
imageVector = Icons.Default.Stop,
contentDescription = "Stop listening",
tint = MaterialTheme.colorScheme.onError,
)
}
}
else -> {
FilledTonalButton(
onClick = onSend,
enabled = canSend,
modifier =
Modifier
.size(40.dp)
.testTag("send_button"),
shape = CircleShape,
contentPadding = PaddingValues(0.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.chat_send_desc),
)
}
}
}
}
}
}
}
}
}
/**
* Compact chip showing a pending attachment with a remove button.
*/
@Composable
fun AttachmentChip(
attachment: Attachment,
onRemove: () -> Unit,
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)),
) {
Row(
modifier = Modifier.padding(start = 10.dp, end = 6.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (attachment.isImage) {
AsyncImage(
model = attachment.uri,
contentDescription = attachment.name,
modifier =
Modifier
.size(20.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),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
text = attachment.name,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 120.dp),
)
Spacer(modifier = Modifier.width(4.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,143 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.theme.LocalHermesStatusColors
/**
* Top-anchored banner shown when the WebSocket connection is in a non-healthy
* state (RECONNECTING / DISCONNECTED / NO_NETWORK / AUTH_EXPIRED).
*
* Extracted from the original ChatScreen.kt — see issue #621.
*/
@Composable
fun ChatConnectionBanner(
connectionStatus: ConnectionStatus,
onReconnect: () -> Unit,
onReloginClick: () -> Unit,
) {
val isShown =
connectionStatus != ConnectionStatus.CONNECTED &&
connectionStatus != ConnectionStatus.CONNECTING
AnimatedVisibility(
visible = isShown,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = LocalHermesStatusColors.current.errorContainer,
contentColor = LocalHermesStatusColors.current.error,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
) {
if (connectionStatus == ConnectionStatus.RECONNECTING) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp).padding(end = 8.dp),
strokeWidth = 2.dp,
color = LocalHermesStatusColors.current.error,
)
}
Text(
modifier = Modifier.weight(1f, fill = false),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
text =
when (connectionStatus) {
ConnectionStatus.RECONNECTING -> stringResource(R.string.chat_status_reconnecting)
ConnectionStatus.DISCONNECTED -> stringResource(R.string.chat_status_disconnected)
ConnectionStatus.NO_NETWORK -> stringResource(R.string.chat_status_no_network)
ConnectionStatus.AUTH_EXPIRED -> stringResource(R.string.chat_status_auth_expired)
else -> ""
},
style = MaterialTheme.typography.bodyMedium,
)
}
when (connectionStatus) {
ConnectionStatus.DISCONNECTED -> {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
onClick = onReloginClick,
colors =
ButtonDefaults.textButtonColors(
contentColor = LocalHermesStatusColors.current.error,
),
) {
Text(stringResource(R.string.chat_action_relogin))
}
TextButton(
onClick = onReconnect,
colors =
ButtonDefaults.textButtonColors(
contentColor = LocalHermesStatusColors.current.error,
),
) {
Text(stringResource(R.string.chat_action_reconnect))
}
}
}
ConnectionStatus.NO_NETWORK -> {
TextButton(
onClick = onReconnect,
colors =
ButtonDefaults.textButtonColors(
contentColor = LocalHermesStatusColors.current.error,
),
) {
Text(stringResource(R.string.chat_action_reconnect))
}
}
ConnectionStatus.RECONNECTING,
ConnectionStatus.AUTH_EXPIRED,
-> {
TextButton(
onClick = onReloginClick,
colors =
ButtonDefaults.textButtonColors(
contentColor = LocalHermesStatusColors.current.error,
),
) {
Text(stringResource(R.string.chat_action_relogin))
}
}
else -> {}
}
}
}
}
}
@@ -0,0 +1,330 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
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.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.ClarifyUi
/** Dialog shown when the agent asks the user to clarify between options. */
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ClarifyDialog(
clarify: ClarifyUi,
onOptionSelected: (String) -> Unit,
onDismiss: () -> Unit,
) {
var typedText by rememberSaveable(clarify.text) { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.chat_clarify_title)) },
text = {
Column(modifier = Modifier.fillMaxWidth()) {
Column(
modifier =
Modifier
.weight(1f, fill = false)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = clarify.text)
if (clarify.options.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.chat_clarify_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
clarify.options.forEach { option ->
Surface(
onClick = { typedText = option },
modifier =
Modifier
.fillMaxWidth()
.testTag("clarify_option"),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.secondaryContainer,
) {
Box(
modifier =
Modifier
.combinedClickable(
role = Role.Button,
onClick = { typedText = option },
onLongClick = { onOptionSelected(option) },
).padding(horizontal = 24.dp, vertical = 10.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = option,
color = MaterialTheme.colorScheme.onSecondaryContainer,
style = MaterialTheme.typography.labelLarge,
)
}
}
Spacer(modifier = Modifier.height(4.dp))
}
}
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = typedText,
onValueChange = { typedText = it },
label = { Text(stringResource(R.string.chat_clarify_response)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
},
confirmButton = {
TextButton(
onClick = {
if (typedText.isNotBlank()) {
onOptionSelected(typedText)
}
},
enabled = typedText.isNotBlank(),
) {
Text(stringResource(R.string.chat_send))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.chat_dismiss))
}
},
)
}
/**
* Secure password dialog for a pending `sudo.request` (issue #524).
* The backend blocked the turn waiting for the sudo password — previously
* mobile dropped the event and the agent hung forever.
*/
@Composable
fun SudoPromptDialog(
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
) {
var password by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.chat_sudo_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = stringResource(R.string.chat_sudo_body))
Spacer(modifier = Modifier.height(4.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.chat_sudo_password)) },
modifier = Modifier.fillMaxWidth().testTag("sudo_password_input"),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, keyboardType = KeyboardType.Password),
)
}
},
confirmButton = {
TextButton(
onClick = {
if (password.isNotBlank()) {
onConfirm(password)
}
},
enabled = password.isNotBlank(),
) {
Text(stringResource(R.string.chat_send))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.chat_dismiss))
}
},
)
}
/**
* Secure value dialog for a pending `secret.request` (issue #524).
* The backend blocked the turn waiting for a secret (token/password) —
* previously mobile dropped the event and the agent hung forever.
*/
@Composable
fun SecretPromptDialog(
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
) {
var secret by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.chat_secret_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = stringResource(R.string.chat_secret_body))
Spacer(modifier = Modifier.height(4.dp))
OutlinedTextField(
value = secret,
onValueChange = { secret = it },
label = { Text(stringResource(R.string.chat_secret_value)) },
modifier = Modifier.fillMaxWidth().testTag("secret_value_input"),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, keyboardType = KeyboardType.Password),
)
}
},
confirmButton = {
TextButton(
onClick = {
if (secret.isNotBlank()) {
onConfirm(secret)
}
},
enabled = secret.isNotBlank(),
) {
Text(stringResource(R.string.chat_send))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.chat_dismiss))
}
},
)
}
/** Re-authentication dialog shown when the connection enters AUTH_EXPIRED. */
@Composable
fun ReloginDialog(
onDismiss: () -> Unit,
onRelogin: (String, String, (Boolean, String?) -> Unit) -> Unit,
) {
val emptyCredentialsError = stringResource(R.string.chat_relogin_error_empty)
val statusColors = LocalHermesStatusColors.current
var username by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
AlertDialog(
onDismissRequest = { if (!isLoading) onDismiss() },
title = { Text(stringResource(R.string.chat_relogin_title)) },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = username,
onValueChange = {
username = it
errorMessage = null
},
label = { Text(stringResource(R.string.chat_relogin_username)) },
singleLine = true,
enabled = !isLoading,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = password,
onValueChange = {
password = it
errorMessage = null
},
label = { Text(stringResource(R.string.chat_relogin_password)) },
singleLine = true,
enabled = !isLoading,
visualTransformation =
androidx.compose.ui.text.input
.PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
if (errorMessage != null) {
Text(
text = errorMessage!!,
color = statusColors.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
},
confirmButton = {
TextButton(
enabled = !isLoading,
onClick = {
if (username.isBlank() || password.isBlank()) {
errorMessage = emptyCredentialsError
return@TextButton
}
isLoading = true
errorMessage = null
onRelogin(username, password) { success, error ->
isLoading = false
if (success) {
onDismiss()
} else {
errorMessage = error ?: "Unknown error"
}
}
},
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = statusColors.info,
)
} else {
Text(stringResource(R.string.chat_relogin_submit))
}
}
},
dismissButton = {
TextButton(
enabled = !isLoading,
onClick = onDismiss,
) {
Text(stringResource(R.string.chat_relogin_cancel))
}
},
)
}
@@ -0,0 +1,232 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.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.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.outlined.Psychology
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.graphics.graphicsLayer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.SubagentIndicator
/**
* Animated "🤔 Thinking…" card shown while the assistant is thinking
* but has not yet produced visible text.
*/
@Composable
fun ThinkingIndicator(thinkingText: String) {
val infiniteTransition = rememberInfiniteTransition(label = "thinking")
val alpha by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec =
infiniteRepeatable(
animation = tween(600, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "thinking_alpha",
)
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
contentAlignment = Alignment.CenterStart,
) {
Card(
colors =
CardDefaults.cardColors(
containerColor = LocalHermesStatusColors.current.infoContainer.copy(alpha = 0.5f),
),
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "🤔",
fontSize = 14.sp,
modifier = Modifier.graphicsLayer { this.alpha = alpha },
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text =
if (thinkingText.isNotBlank()) {
val display = thinkingText.takeLast(100)
stringResource(R.string.chat_thinking_param, display)
} else {
stringResource(R.string.chat_thinking)
},
style =
MaterialTheme.typography.bodySmall.copy(
color = LocalHermesStatusColors.current.info,
),
maxLines = 2,
modifier = Modifier.animateContentSize(),
)
}
}
}
}
/**
* Collapsible card showing the assistant's reasoning trace
* (reasoning-model thinking steps) before the final answer.
*/
@Composable
fun ReasoningIndicator(reasoningText: String) {
var expanded by remember { mutableStateOf(false) }
val statusColors = LocalHermesStatusColors.current
Card(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp)
.clickable(
role = Role.Button,
onClick = { expanded = !expanded },
),
colors =
CardDefaults.cardColors(
containerColor = statusColors.infoContainer.copy(alpha = 0.5f),
),
shape = RoundedCornerShape(12.dp),
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Outlined.Psychology,
contentDescription = stringResource(R.string.chat_reasoning),
tint = statusColors.info,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(
text = stringResource(R.string.chat_reasoning),
style = MaterialTheme.typography.labelMedium,
color = statusColors.info,
)
Spacer(Modifier.weight(1f))
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription =
stringResource(
if (expanded) R.string.chat_reasoning_collapse else R.string.chat_reasoning_expand,
),
tint = statusColors.info,
modifier = Modifier.size(18.dp),
)
}
AnimatedVisibility(visible = expanded) {
Text(
text = reasoningText,
style = MaterialTheme.typography.bodySmall,
color = statusColors.info,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
}
/**
* Row showing a subagent task progress chip in the message list.
*/
@Composable
fun SubagentIndicatorRow(
indicator: SubagentIndicator,
modifier: Modifier = Modifier,
) {
Surface(
modifier =
modifier
.padding(horizontal = 12.dp, vertical = 4.dp),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
border =
BorderStroke(
width = 1.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
),
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = "🔀",
style = MaterialTheme.typography.bodySmall,
)
val taskProgressText =
if (indicator.taskIndex != null && indicator.taskCount != null) {
" (${indicator.taskIndex}/${indicator.taskCount})"
} else {
""
}
val goalText = indicator.goal ?: "Subagent task"
val textPreview =
if (!indicator.text.isNullOrEmpty()) {
": ${indicator.text}"
} else if (!indicator.summary.isNullOrEmpty()) {
": ${indicator.summary}"
} else {
""
}
Text(
text = "$goalText$taskProgressText$textPreview",
style =
MaterialTheme.typography.bodySmall.copy(
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -0,0 +1,208 @@
package com.m57.hermescontrol.ui.chat.components
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatViewModel
import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.SecretPromptUi
import com.m57.hermescontrol.ui.chat.SudoPromptUi
import kotlinx.coroutines.flow.collectLatest
@Composable
fun ChatLifecycleEffects(
sessionId: String?,
connectionStatus: ConnectionStatus,
currentSessionId: String?,
messages: List<ChatMessage>,
streamingMessage: ChatMessage?,
isThinking: Boolean,
errorMessage: String?,
backgroundCompleteMessage: String?,
isSearchActive: Boolean,
currentSearchMatchIndex: Int,
searchMatchIndices: List<Int>,
clarifyRequest: ClarifyUi?,
sudoPrompt: SudoPromptUi?,
secretPrompt: SecretPromptUi?,
listState: LazyListState,
snackbarHostState: SnackbarHostState,
viewModel: ChatViewModel,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
// Publish the notification session ID to the ViewModel synchronously
SideEffect {
viewModel.initialSessionId = sessionId
}
// Switch to session from notification/history
var lastSessionId by remember { mutableStateOf<String?>(null) }
val pendingSessionId = NavigationController.pendingSessionId
LaunchedEffect(sessionId, pendingSessionId, connectionStatus) {
if (connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect
val target = if (!sessionId.isNullOrBlank()) sessionId else pendingSessionId
if (!target.isNullOrBlank()) {
viewModel.switchSession(target)
if (target == pendingSessionId) {
NavigationController.pendingSessionId = null
}
}
}
// Lifecycle observer for notification foreground service
DisposableEffect(lifecycleOwner) {
val observer =
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> {
NotificationHelper.setAppForeground(context, true)
NotificationHelper.stop(context)
viewModel.refreshSettings()
viewModel.refreshCurrentSession()
}
Lifecycle.Event.ON_STOP -> {
NotificationHelper.setAppForeground(context, false)
NotificationHelper.start(context)
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
// Request POST_NOTIFICATIONS permission on Android 13+
val requestNotificationPermission =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { /* granted */ }
LaunchedEffect(Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permission = Manifest.permission.POST_NOTIFICATIONS
if (ContextCompat.checkSelfPermission(context, permission) !=
PackageManager.PERMISSION_GRANTED
) {
requestNotificationPermission.launch(permission)
}
}
}
// Auto-scroll to bottom on new messages + session switch (issues #584/#583)
//
// IMPORTANT (m57, #584 follow-up): while an assistant message is STREAMING
// the scroll is left completely FREE — no auto-follow of the streaming
// message — so the user can scroll up/down to read while it generates.
// Auto-scroll only happens on explicit actions (send / FAB / session
// switch, handled elsewhere) and when a discrete non-streaming message
// lands while the user is already pinned to the bottom.
//
// The effect runs once (Unit), so we mirror the params through
// rememberUpdatedState and read them live inside the flow/collect — that
// keeps the lambda bound to the latest values instead of first-composition
// closure captures. streamingMessage is intentionally NOT a flow key, so
// the flow doesn't churn per token; we just read it live to gate scrolling.
val latestMessages by rememberUpdatedState(messages)
val latestStreaming by rememberUpdatedState(streamingMessage)
val latestIsThinking by rememberUpdatedState(isThinking)
val latestSessionId by rememberUpdatedState(currentSessionId)
LaunchedEffect(Unit) {
snapshotFlow {
Pair(latestMessages.size, latestIsThinking)
}.collectLatest { (msgCount, thinking) ->
val totalItems = msgCount + (if (thinking) 1 else 0)
if (totalItems <= 0) return@collectLatest
// While a message is streaming, leave scrolling free (no auto-follow).
if (latestStreaming != null) return@collectLatest
val isSessionSwitch = latestSessionId != lastSessionId
if (isSessionSwitch) {
lastSessionId = latestSessionId
listState.scrollToBottom(animated = false)
return@collectLatest
}
if (!listState.isAtBottom()) return@collectLatest
// Discrete new message while pinned to the bottom — follow it.
listState.scrollToBottom(animated = true)
}
}
// Show error as snackbar
LaunchedEffect(errorMessage) {
errorMessage?.let { error ->
snackbarHostState.showSnackbar(error)
viewModel.clearError()
}
}
// Show background-complete as a non-blocking snackbar (issue #527)
LaunchedEffect(backgroundCompleteMessage) {
backgroundCompleteMessage?.let { message ->
snackbarHostState.showSnackbar(message)
viewModel.clearBackgroundComplete()
}
}
// Clarify dialog
clarifyRequest?.let { clarify ->
ClarifyDialog(
clarify = clarify,
onOptionSelected = viewModel::respondToClarify,
onDismiss = viewModel::dismissClarify,
)
}
// Sudo / secret prompt dialogs (issue #524)
sudoPrompt?.let { prompt ->
SudoPromptDialog(
onConfirm = viewModel::respondToSudo,
onDismiss = viewModel::dismissSudo,
)
}
secretPrompt?.let { prompt ->
SecretPromptDialog(
onConfirm = viewModel::respondToSecret,
onDismiss = viewModel::dismissSecret,
)
}
// Scroll to current search match
LaunchedEffect(isSearchActive, currentSearchMatchIndex, searchMatchIndices) {
if (isSearchActive &&
currentSearchMatchIndex >= 0 &&
currentSearchMatchIndex < searchMatchIndices.size &&
messages.isNotEmpty()
) {
val targetIndex = searchMatchIndices[currentSearchMatchIndex]
listState.animateScrollToItem(
targetIndex.coerceIn(0, messages.lastIndex),
)
}
}
}
@@ -0,0 +1,64 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
/** Full-screen semi-transparent overlay shown during initial session load. */
@Composable
fun ChatLoadingOverlay(isLoading: Boolean) {
AnimatedVisibility(
visible = isLoading,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.fillMaxSize(),
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.7f)),
contentAlignment = Alignment.Center,
) {
Card(
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(4.dp),
) {
Column(
modifier = Modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.chat_status_connecting),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
@@ -0,0 +1,195 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.chat.ChatBubble
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatViewModel
import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.SubagentIndicator
import com.m57.hermescontrol.ui.common.EmptyState
/**
* The chat message list.
*
* Lay children out vertically so the search bar occupies real layout space
* ABOVE the message list. Without this container the call site is a Box,
* which overlays the LazyColumn on top of the search AnimatedVisibility and
* swallows every tap on the bar (bar visible but not clickable).
*/
@Composable
fun ChatMessageList(
messages: List<ChatMessage>,
streamingMessage: ChatMessage?,
isThinking: Boolean,
thinkingText: String,
isSearchActive: Boolean,
searchQuery: String,
currentSearchMatchIndex: Int,
searchMatchIndices: List<Int>,
typingEffectEnabled: Boolean,
typingEffectDelayMs: Int,
isLoading: Boolean,
isLoadingOlder: Boolean,
isDark: Boolean,
listState: androidx.compose.foundation.lazy.LazyListState,
lastAnimatedMessageId: String?,
onLastAnimatedMessageIdChange: (String?) -> Unit,
viewModel: ChatViewModel,
subagentIndicators: List<SubagentIndicator> = emptyList(),
) {
Column(modifier = Modifier.fillMaxSize()) {
// Search bar
AnimatedVisibility(
visible = isSearchActive,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceContainerLow,
tonalElevation = 2.dp,
border =
BorderStroke(
width = 1.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f),
),
) {
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
SearchBarRow(
searchQuery = searchQuery,
onQueryChange = { viewModel.setSearchQuery(it) },
searchMatchCount = searchMatchIndices.size,
currentMatchIndex = currentSearchMatchIndex,
onNavigateUp = { viewModel.navigateSearchMatch(-1) },
onNavigateDown = { viewModel.navigateSearchMatch(1) },
onClose = { viewModel.clearSearch() },
)
}
}
}
if (messages.isEmpty() && !isLoading) {
EmptyState(
title = stringResource(R.string.chat_empty_title),
subtitle = stringResource(R.string.chat_empty_subtitle),
)
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxWidth().weight(1f),
contentPadding = PaddingValues(vertical = 8.dp),
) {
if (isLoadingOlder) {
item(key = "loading-older") {
Box(
modifier = Modifier.fillMaxWidth().padding(12.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
}
itemsIndexed(
items = messages,
key = { _, message -> message.id },
) { index, message ->
val isCurrentMatch =
isSearchActive &&
currentSearchMatchIndex >= 0 &&
currentSearchMatchIndex < searchMatchIndices.size &&
searchMatchIndices[currentSearchMatchIndex] == index
val isLastMessage = index == messages.lastIndex
val isAssistant = message.role == MessageRole.ASSISTANT
if (isAssistant && message.reasoningText.isNotBlank()) {
ReasoningIndicator(message.reasoningText)
}
if (typingEffectEnabled && isLastMessage && isAssistant && message.isStreaming &&
lastAnimatedMessageId != message.id
) {
StreamingBubbleWithTypingEffect(
streaming = message,
typingDelayMs = typingEffectDelayMs,
isDark = isDark,
onAnimationComplete = {
onLastAnimatedMessageIdChange(message.id)
},
)
} else {
ChatBubble(
message = message,
isDarkTheme = isDark,
searchQuery = if (isSearchActive) searchQuery else "",
isCurrentMatch = isCurrentMatch,
onRespondApproval = viewModel::respondToApproval,
)
}
}
// Streaming message
streamingMessage?.let { streaming ->
item(key = "streaming-${streaming.id}") {
if (streaming.reasoningText.isNotBlank()) {
ReasoningIndicator(streaming.reasoningText)
}
if (typingEffectEnabled && streaming.isStreaming) {
StreamingBubbleWithTypingEffect(
streaming = streaming,
typingDelayMs = typingEffectDelayMs,
isDark = isDark,
)
} else {
ChatBubble(
message = streaming,
isDarkTheme = isDark,
searchQuery = "",
isCurrentMatch = false,
)
}
}
}
// Thinking indicator
if (isThinking) {
item(key = "thinking") {
ThinkingIndicator(thinkingText)
}
}
// Subagent indicators
items(
items = subagentIndicators,
key = { indicator -> "subagent-${indicator.subagentId ?: indicator.goal ?: indicator.type}" },
) { indicator ->
SubagentIndicatorRow(indicator = indicator)
}
}
}
}
@@ -0,0 +1,115 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
// ── Reaction hearts animation ──────────────────────────────────────────
/**
* Lightweight floating-hearts animation triggered by a `reaction` WS event.
* Hearts rise from the bottom-center of the chat area, drift slightly
* horizontally, and fade out — purely cosmetic, no persistence.
*/
@Composable
fun ReactionHeartsOverlay(
reactionKind: String?,
modifier: Modifier = Modifier,
) {
val emojis =
remember(reactionKind) {
when (reactionKind) {
"ily", "<3", "good bot" -> {
listOf("💗", "❤️", "💕", "💖", "🩷", "💘", "💝")
}
else -> {
listOf("💗", "❤️", "💕", "💖")
}
}
}
AnimatedVisibility(
visible = reactionKind != null,
enter = fadeIn(animationSpec = tween(200)),
exit = fadeOut(animationSpec = tween(400)),
modifier = modifier.fillMaxSize(),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.BottomCenter,
) {
emojis.forEachIndexed { index, emoji ->
key("heart_$index") {
FloatingHeart(
emoji = emoji,
delayMs = index * 120L + 100L,
horizontalOffset = ((index - emojis.size / 2) * 28).dp,
)
}
}
}
}
}
@Composable
fun FloatingHeart(
emoji: String,
delayMs: Long,
horizontalOffset: Dp,
) {
val alpha = remember { Animatable(0f) }
val offsetY = remember { Animatable(0f) }
LaunchedEffect(Unit) {
delay(delayMs)
// Phase 1: fade in quickly at the bottom
launch {
alpha.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
)
// Phase 2: fade out slowly while rising
alpha.animateTo(
targetValue = 0f,
animationSpec = tween(durationMillis = 1400, easing = LinearEasing),
)
}
// Float upward over the full animation
launch {
offsetY.animateTo(
targetValue = -220f,
animationSpec = tween(durationMillis = 1600, easing = LinearEasing),
)
}
}
Text(
text = emoji,
fontSize = 24.sp,
modifier =
Modifier
.offset(x = horizontalOffset, y = offsetY.value.dp)
.graphicsLayer(alpha = alpha.value),
)
}
@@ -0,0 +1,121 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.chat.ChatMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* Returns true when the list is scrolled near its bottom
* (within [threshold] items of the last index).
*/
fun LazyListState.isAtBottom(threshold: Int = 3): Boolean {
val layoutInfo = this.layoutInfo
val visibleItems = layoutInfo.visibleItemsInfo
if (visibleItems.isEmpty()) return true
val lastVisibleItem = visibleItems.last()
return lastVisibleItem.index >= layoutInfo.totalItemsCount - threshold
}
/**
* Scroll so the bottom edge of the last item is aligned to the bottom of the
* viewport (issue #583).
*
* `animateScrollToItem(lastIndex)` only top-aligns the last item, so when the
* last item is taller than the viewport its bottom is clipped below the fold.
* We first top-align (instant), then scroll the exact remaining gap so the
* bottom is visible. Using the remaining delta (not `scrollOffset = Int.MAX_VALUE`)
* avoids integer overflow in the internal scroll-position clamp, which would
* otherwise wrap the offset to 0 and scroll back to the top.
*/
suspend fun LazyListState.scrollToBottom(animated: Boolean) {
val layoutInfo = this.layoutInfo
if (layoutInfo.totalItemsCount == 0) return
val lastIndex = layoutInfo.totalItemsCount - 1
// Top-align the last item first so layoutInfo reflects the last item's offset.
// When animated, use the animated variant so the FAB / send clicks keep a
// smooth scroll instead of an instant jump followed by a tiny animation.
if (animated) {
animateScrollToItem(lastIndex)
} else {
scrollToItem(lastIndex)
}
val info = this.layoutInfo
val lastItem = info.visibleItemsInfo.lastOrNull { it.index == lastIndex } ?: return
val remaining =
(lastItem.offset + lastItem.size + info.afterContentPadding) - info.viewportEndOffset
if (remaining > 0) {
if (animated) {
animateScrollBy(remaining.toFloat())
} else {
scroll { scrollBy(remaining.toFloat()) }
}
}
}
/**
* Floating action button shown when the message list is not at the bottom;
* tapping scrolls the list back to the bottom.
*/
@Composable
fun BoxScope.ChatScrollToBottomFab(
showScrollToBottom: Boolean,
scrollScope: CoroutineScope,
listState: LazyListState,
messages: List<ChatMessage>,
streamingMessage: ChatMessage?,
isThinking: Boolean,
) {
AnimatedVisibility(
visible = showScrollToBottom,
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut(),
modifier =
Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 16.dp),
) {
FloatingActionButton(
onClick = {
scrollScope.launch {
val totalItems =
messages.size +
(if (streamingMessage != null) 1 else 0) +
(if (isThinking) 1 else 0)
if (totalItems > 0) {
listState.scrollToBottom(animated = true)
}
}
},
modifier = Modifier.size(40.dp),
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = MaterialTheme.colorScheme.onSurface,
) {
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = stringResource(R.string.content_desc_scroll_to_bottom),
)
}
}
}
@@ -0,0 +1,175 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
@Composable
fun CompactSearchInput(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
isError: Boolean = false,
) {
val textColor = MaterialTheme.colorScheme.onSurface
val errorColor = MaterialTheme.colorScheme.error
val outlineColor = if (isError) errorColor else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier =
modifier
.height(40.dp)
.background(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
shape = RoundedCornerShape(20.dp),
).border(
width = 1.dp,
color = outlineColor,
shape = RoundedCornerShape(20.dp),
).padding(horizontal = 12.dp),
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium.copy(color = textColor),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
decorationBox = { innerTextField ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
Box(modifier = Modifier.weight(1f)) {
if (value.isEmpty()) {
Text(
text = stringResource(R.string.chat_search_placeholder),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
innerTextField()
}
if (value.isNotEmpty()) {
IconButton(
onClick = { onValueChange("") },
modifier = Modifier.size(20.dp),
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.chat_clear_search_desc),
modifier = Modifier.size(16.dp),
)
}
}
}
},
)
}
@Composable
fun SearchBarRow(
searchQuery: String,
onQueryChange: (String) -> Unit,
searchMatchCount: Int,
currentMatchIndex: Int,
onNavigateUp: () -> Unit,
onNavigateDown: () -> Unit,
onClose: () -> Unit,
) {
val isError = searchQuery.isNotEmpty() && searchMatchCount == 0
Row(
modifier =
Modifier
.fillMaxWidth()
.height(48.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CompactSearchInput(
value = searchQuery,
onValueChange = onQueryChange,
modifier = Modifier.weight(1f),
isError = isError,
)
if (searchQuery.isNotEmpty()) {
val countText =
if (searchMatchCount > 0) {
"${currentMatchIndex + 1}/$searchMatchCount"
} else {
"0/0"
}
Text(
text = countText,
style =
MaterialTheme.typography.bodySmall.copy(
color =
if (isError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
),
modifier = Modifier.padding(horizontal = 4.dp),
)
}
IconButton(
onClick = onNavigateUp,
enabled = searchMatchCount > 0,
modifier = Modifier.size(36.dp),
) {
Icon(
Icons.Filled.KeyboardArrowUp,
contentDescription = stringResource(R.string.chat_prev_match_desc),
modifier = Modifier.size(20.dp),
)
}
IconButton(
onClick = onNavigateDown,
enabled = searchMatchCount > 0,
modifier = Modifier.size(36.dp),
) {
Icon(
Icons.Filled.KeyboardArrowDown,
contentDescription = stringResource(R.string.chat_next_match_desc),
modifier = Modifier.size(20.dp),
)
}
IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.chat_close_search_desc),
modifier = Modifier.size(20.dp),
)
}
}
}
@@ -0,0 +1,74 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import com.m57.hermescontrol.ui.chat.ChatBubble
import com.m57.hermescontrol.ui.chat.ChatMessage
import kotlinx.coroutines.delay
/**
* Wraps a streaming [ChatBubble] with a word-by-word typing reveal effect.
* Shows words one at a time at [typingDelayMs] intervals while the message is
* still streaming. When streaming completes the full text is shown immediately.
* The underlying [ChatMessage.content] in state is never modified — this is a
* display-only transformation.
*/
@Composable
fun StreamingBubbleWithTypingEffect(
streaming: ChatMessage,
typingDelayMs: Int,
isDark: Boolean,
onAnimationComplete: () -> Unit = {},
) {
var visibleWordCount by remember { mutableIntStateOf(0) }
val currentContent = rememberUpdatedState(streaming.content)
val currentIsStreaming = rememberUpdatedState(streaming.isStreaming)
val currentDelayMs = rememberUpdatedState(typingDelayMs)
// Timer that ticks at the configured delay, incrementing the visible word
// count each tick. Stops ticking when streaming ends, then shows all words.
// Optimized: split is only called when waiting for new content, not per tick.
LaunchedEffect(Unit) {
var wordCount = 0
while (true) {
if (visibleWordCount < wordCount) {
delay(currentDelayMs.value.toLong())
visibleWordCount++
} else {
if (!currentIsStreaming.value) {
onAnimationComplete()
break
}
// Only split when we need to check for new content arriving
val words = currentContent.value.split(" ")
wordCount = words.size
if (visibleWordCount < wordCount) continue
delay(100) // was 10 — reduced from 100Hz to 10Hz
}
}
visibleWordCount = Int.MAX_VALUE
}
// Derive display text from the latest full content at each recomposition
val words = streaming.content.split(" ")
val visibleCount =
if (visibleWordCount >= Int.MAX_VALUE / 2) {
words.size
} else {
visibleWordCount.coerceIn(0, words.size)
}
val displayText = words.take(visibleCount.coerceAtLeast(1)).joinToString(" ")
ChatBubble(
message = streaming.copy(content = displayText),
isDarkTheme = isDark,
searchQuery = "",
isCurrentMatch = false,
)
}