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