diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 57e0f5fd..1b5646e1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -5,97 +5,44 @@ import android.app.Activity import android.content.Intent import android.content.pm.PackageManager import android.net.Uri -import android.os.Build import android.provider.OpenableColumns import android.speech.RecognizerIntent import android.speech.SpeechRecognizer import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.* -import androidx.compose.animation.core.* -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.offset -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.LazyListState -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll 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.Add -import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.KeyboardArrowUp -import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Stop -import androidx.compose.material.icons.outlined.Psychology -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.FloatingActionButton 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.Snackbar import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.key -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow @@ -103,43 +50,34 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation 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.dp -import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel -import coil.compose.AsyncImage import com.m57.hermescontrol.NavigationController 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.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.HermesWsClient -import com.m57.hermescontrol.notification.NotificationHelper import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner +import com.m57.hermescontrol.ui.chat.components.ChatInputBar +import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects +import com.m57.hermescontrol.ui.chat.components.ChatLoadingOverlay +import com.m57.hermescontrol.ui.chat.components.ChatMessageList +import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab +import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay +import com.m57.hermescontrol.ui.chat.components.ReloginDialog +import com.m57.hermescontrol.ui.chat.components.scrollToBottom import com.m57.hermescontrol.ui.common.AutoScrollingTitleText import com.m57.hermescontrol.ui.common.CredentialWarningBanner -import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.model.components.ModelPickerDialog -import com.m57.hermescontrol.ui.providers.ProvidersScreen import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged @@ -151,6 +89,17 @@ import java.util.Locale private const val SESSION_SYNC_INTERVAL_MS = 5_000L +/** + * Chat screen β€” the primary conversation surface of Hermes Control. + * + * This file is a thin compositor that delegates all UI rendering to focused + * composable components under `ui/chat/components/`. See issue #621 for the + * rationale behind the split and the full fileβ†’content mapping. + * + * The original 2,267-line god file was split into 11 single-purpose files; + * this entry point handles only state hoisting, scaffold wiring, and the + * remembered launchers that need to be activity-scoped. + */ @OptIn(ExperimentalFoundationApi::class) @Composable fun ChatScreen( @@ -165,6 +114,7 @@ fun ChatScreen( val listState = rememberLazyListState() var isOlderPagingArmed by remember(state.currentSessionId) { mutableStateOf(false) } + // Periodic session sync while connected. LaunchedEffect(state.currentSessionId, state.connectionStatus) { while (state.currentSessionId != null && state.connectionStatus == ConnectionStatus.CONNECTED) { delay(SESSION_SYNC_INTERVAL_MS) @@ -172,6 +122,7 @@ fun ChatScreen( } } + // Arm + trigger older-message paging when the user scrolls near the top. LaunchedEffect(listState, state.currentSessionId, state.hasOlderMessages, state.isLoadingOlder) { if (!state.hasOlderMessages || state.isLoadingOlder) return@LaunchedEffect snapshotFlow { listState.firstVisibleItemIndex } @@ -410,7 +361,7 @@ fun ChatScreen( .background(backgroundGradient) .imePadding(), ) { - ChatTopBanner( + ChatConnectionBanner( connectionStatus = state.connectionStatus, onReconnect = viewModel::reconnect, onReloginClick = { showReloginDialog = true }, @@ -464,7 +415,7 @@ fun ChatScreen( isThinking = streamingState.isThinking, ) - // Reaction hearts animation (purely cosmetic β€” fades out + // Reaction heartsanimation (purely cosmetic β€” fades out // automatically after the ViewModel clears the state) key(state.reactionTriggerId) { ReactionHeartsOverlay( @@ -574,1694 +525,3 @@ fun ChatScreen( } } } - -@Composable -private 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(), - ) - } - } - } -} - -@Composable -private 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), - ) - } - } - } -} - -@Composable -private fun ChatInputBar( - inputFieldValue: TextFieldValue, - onInputChange: (TextFieldValue) -> Unit, - onSend: () -> Unit, - onMicTap: () -> Unit, - isListening: Boolean, - isAgentTyping: Boolean, - isConnected: Boolean, - commandCatalog: CommandCatalog, - pendingAttachments: List = 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 isSlashCommand = inputFieldValue.text.startsWith("/") - 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 = androidx.compose.ui.text.font.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 -private 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, - ) - } - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private 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 -private 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 -private 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)) - } - }, - ) -} - -@Composable -private 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 -private 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), - ) - } - } -} - -/** - * 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 -private 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, - ) -} - -private 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. - */ -private 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()) } - } - } -} - -@Composable -private fun ChatLifecycleEffects( - sessionId: String?, - connectionStatus: ConnectionStatus, - currentSessionId: String?, - messages: List, - streamingMessage: ChatMessage?, - isThinking: Boolean, - errorMessage: String?, - backgroundCompleteMessage: String?, - isSearchActive: Boolean, - currentSearchMatchIndex: Int, - searchMatchIndices: List, - clarifyRequest: ClarifyUi?, - sudoPrompt: SudoPromptUi?, - secretPrompt: SecretPromptUi?, - listState: LazyListState, - snackbarHostState: SnackbarHostState, - viewModel: ChatViewModel, -) { - val context = LocalContext.current - val lifecycleOwner = androidx.lifecycle.compose.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(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 = - androidx.lifecycle.LifecycleEventObserver { _, event -> - when (event) { - androidx.lifecycle.Lifecycle.Event.ON_START -> { - NotificationHelper.setAppForeground(context, true) - NotificationHelper.stop(context) - viewModel.refreshSettings() - viewModel.refreshCurrentSession() - } - - androidx.lifecycle.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 = android.Manifest.permission.POST_NOTIFICATIONS - if (ContextCompat.checkSelfPermission(context, permission) != - android.content.pm.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), - ) - } - } -} - -@Composable -private fun ChatTopBanner( - connectionStatus: ConnectionStatus, - onReconnect: () -> Unit, - onReloginClick: () -> Unit, -) { - val isShown = - connectionStatus != ConnectionStatus.CONNECTED && - connectionStatus != ConnectionStatus.CONNECTING - androidx.compose.animation.AnimatedVisibility( - visible = isShown, - enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(), - exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut(), - ) { - androidx.compose.material3.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 = - androidx.compose.material3.ButtonDefaults.textButtonColors( - contentColor = LocalHermesStatusColors.current.error, - ), - ) { - Text(stringResource(R.string.chat_action_relogin)) - } - TextButton( - onClick = onReconnect, - colors = - androidx.compose.material3.ButtonDefaults.textButtonColors( - contentColor = LocalHermesStatusColors.current.error, - ), - ) { - Text(stringResource(R.string.chat_action_reconnect)) - } - } - } - - ConnectionStatus.NO_NETWORK -> { - TextButton( - onClick = onReconnect, - colors = - androidx.compose.material3.ButtonDefaults.textButtonColors( - contentColor = LocalHermesStatusColors.current.error, - ), - ) { - Text(stringResource(R.string.chat_action_reconnect)) - } - } - - ConnectionStatus.RECONNECTING, - ConnectionStatus.AUTH_EXPIRED, - -> { - TextButton( - onClick = onReloginClick, - colors = - androidx.compose.material3.ButtonDefaults.textButtonColors( - contentColor = LocalHermesStatusColors.current.error, - ), - ) { - Text(stringResource(R.string.chat_action_relogin)) - } - } - - else -> {} - } - } - } - } -} - -@Composable -private fun ChatMessageList( - messages: List, - streamingMessage: ChatMessage?, - isThinking: Boolean, - thinkingText: String, - isSearchActive: Boolean, - searchQuery: String, - currentSearchMatchIndex: Int, - searchMatchIndices: List, - typingEffectEnabled: Boolean, - typingEffectDelayMs: Int, - isLoading: Boolean, - isLoadingOlder: Boolean, - isDark: Boolean, - listState: LazyListState, - lastAnimatedMessageId: String?, - onLastAnimatedMessageIdChange: (String?) -> Unit, - viewModel: ChatViewModel, - subagentIndicators: List = emptyList(), -) { - // 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). - Column(modifier = Modifier.fillMaxSize()) { - // Search bar - androidx.compose.animation.AnimatedVisibility( - visible = isSearchActive, - enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(), - exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut(), - ) { - androidx.compose.material3.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) - } - } - } -} - -@Composable -private fun ChatLoadingOverlay(isLoading: Boolean) { - androidx.compose.animation.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, - ) - } - } - } - } -} - -@Composable -private fun BoxScope.ChatScrollToBottomFab( - showScrollToBottom: Boolean, - scrollScope: kotlinx.coroutines.CoroutineScope, - listState: LazyListState, - messages: List, - streamingMessage: ChatMessage?, - isThinking: Boolean, -) { - androidx.compose.animation.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), - ) - } - } -} - -@Composable -private 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(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)) - } - }, - ) -} - -// ── 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 -private fun ReactionHeartsOverlay( - reactionKind: String?, - modifier: Modifier = Modifier, -) { - val emojis = - remember(reactionKind) { - when (reactionKind) { - "ily", "<3", "good bot" -> { - listOf("πŸ’—", "❀️", "πŸ’•", "πŸ’–", "🩷", "πŸ’˜", "πŸ’") - } - - else -> { - listOf("πŸ’—", "❀️", "πŸ’•", "πŸ’–") - } - } - } - - androidx.compose.animation.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 -private 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), - ) -} - -@Composable -private fun SubagentIndicatorRow( - indicator: SubagentIndicator, - modifier: Modifier = Modifier, -) { - androidx.compose.material3.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, - ) - } - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt new file mode 100644 index 00000000..69f13e35 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt @@ -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 = 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, + ) + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatConnectionBanner.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatConnectionBanner.kt new file mode 100644 index 00000000..69e6fa42 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatConnectionBanner.kt @@ -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 -> {} + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatDialogs.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatDialogs.kt new file mode 100644 index 00000000..85977ef8 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatDialogs.kt @@ -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(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)) + } + }, + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatIndicators.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatIndicators.kt new file mode 100644 index 00000000..a4560204 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatIndicators.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt new file mode 100644 index 00000000..8874b6bd --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt @@ -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, + streamingMessage: ChatMessage?, + isThinking: Boolean, + errorMessage: String?, + backgroundCompleteMessage: String?, + isSearchActive: Boolean, + currentSearchMatchIndex: Int, + searchMatchIndices: List, + 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(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), + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLoadingOverlay.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLoadingOverlay.kt new file mode 100644 index 00000000..4597baf2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLoadingOverlay.kt @@ -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, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt new file mode 100644 index 00000000..501077ee --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt @@ -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, + streamingMessage: ChatMessage?, + isThinking: Boolean, + thinkingText: String, + isSearchActive: Boolean, + searchQuery: String, + currentSearchMatchIndex: Int, + searchMatchIndices: List, + 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 = 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) + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatReactions.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatReactions.kt new file mode 100644 index 00000000..16990d3f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatReactions.kt @@ -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), + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollFab.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollFab.kt new file mode 100644 index 00000000..86513c1b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollFab.kt @@ -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, + 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), + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatSearchBar.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatSearchBar.kt new file mode 100644 index 00000000..801b6147 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatSearchBar.kt @@ -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), + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatStreaming.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatStreaming.kt new file mode 100644 index 00000000..69c7e9f4 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatStreaming.kt @@ -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, + ) +}