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