* feat(chat): ChatStyle preference type + store field + AuthManager (issue #866) * feat(settings): chat style toggle in Appearance (issue #866) * feat(chat): plumb ChatStyle into ChatUiState + refreshSettings (issue #866) * feat(chat): full-bleed turn grouping (issue #866) * feat(chat): full-bleed agent message renderer (issue #866) * feat(chat): full-bleed compact tool rows + system events (issue #866) * feat(chat): full-bleed chat list renderer + streaming (issue #866) * feat(chat): wire full-bleed renderer behind chat style setting (issue #866) * test(theme): full-bleed contrast gate across all presets (issue #866) Gate caught NORD light primary/background < 3:1 — header role label switched to onSurfaceVariant (meta token, correct for header + passes in every preset; NORD's pastel Frost accent can't reach 3:1 on light). * test(chat): full-bleed renderer coverage (issue #866) * style(chat): ktlint formatting pass on full-bleed renderer (issue #866) * fix(chat): eagerly capture per-item state in full-bleed list (issue #866) LazyColumn item{} lambdas run at composition time, so loop locals read inside them saw final values: turn header never rendered (firstProseSeen always true at compose) and milestones misindexed. Capture showTurnHeader + milestone as immutable vals during the DSL loop. Caught by fresh-context review (decompiled Compose foundation to confirm item composition timing). * test(chat): stub AuthManager.getChatStyle in mockkObject setups (issue #866) refreshSettings()/loadSettings() now read AuthManager.getChatStyle(); the four test files that mockkObject(AuthManager) with explicit stubs had no stub for the new method, so mockk fell through to the real object and threw 'AuthManager not initialized'. Full suite: 953/953 passing, same shape as pristine origin/main. * feat(settings): move chat style toggle from Appearance to Chat section * feat(chat): hoist reasoning above tool rows in full-bleed turns Tool rows stream in before the answer message, so the turn rendered tools above the reasoning card. The turn's reasoning now renders at the top of the agent turn, with tool rows and prose underneath. Streaming is folded into the turn model (groupIntoTurnsWithStreaming) so the hoist works live while the answer is still streaming, and the detached streaming item is gone (which also fixed the duplicate turn-header quirk for streaming continuations). * feat(chat): cap reasoning card height + long-press to copy Expanded trace caps at 40% of the screen with internal scroll (Show full lifts the cap); long-press anywhere copies the reasoning. * feat(chat): blinking typing caret while streaming * feat(chat): make full-bleed the default chat style * feat(chat): remove thinking dots, show agent header above reasoning --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
758de04917
commit
a59c8c6a4f
26 files changed
+1301
-52
No files matched your search
@@ -1,6 +1,7 @@
|
||||
package com.m57.hermescontrol.data.config
|
||||
|
||||
import com.m57.hermescontrol.data.model.PinnedModel
|
||||
import com.m57.hermescontrol.theme.ChatStyle
|
||||
import com.m57.hermescontrol.theme.ThemePreference
|
||||
import com.m57.hermescontrol.theme.ThemePreset
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -21,6 +22,9 @@ data class ServerStoreState(
|
||||
val wsAuthParam: String = "token",
|
||||
val typingEffectEnabled: Boolean = true,
|
||||
val typingEffectDelayMs: Int = 30,
|
||||
// Chat rendering style (issue #866): full-bleed renderer (default) or the
|
||||
// legacy bubble renderer.
|
||||
val chatStyle: ChatStyle = ChatStyle.FULL_BLEED,
|
||||
// App display language. "system" = follow device locale; otherwise a BCP-47
|
||||
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
|
||||
val appLanguage: String = "system",
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.m57.hermescontrol.data.remote.CleartextPolicy
|
||||
import com.m57.hermescontrol.data.remote.CookieManager
|
||||
import com.m57.hermescontrol.data.remote.ServerEndpoint
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.theme.ChatStyle
|
||||
import com.m57.hermescontrol.theme.ThemePreference
|
||||
import com.m57.hermescontrol.theme.ThemePreset
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -74,6 +75,9 @@ object AuthManager {
|
||||
private val _themePresetFlow = MutableStateFlow<ThemePreset>(ThemePreset.DEFAULT)
|
||||
val themePresetFlow: StateFlow<ThemePreset> = _themePresetFlow.asStateFlow()
|
||||
|
||||
private val _chatStyleFlow = MutableStateFlow<ChatStyle>(ChatStyle.FULL_BLEED)
|
||||
val chatStyleFlow: StateFlow<ChatStyle> = _chatStyleFlow.asStateFlow()
|
||||
|
||||
private val _tokenFlow = MutableStateFlow<String?>(null)
|
||||
val tokenFlow: StateFlow<String?> = _tokenFlow.asStateFlow()
|
||||
|
||||
@@ -180,6 +184,7 @@ object AuthManager {
|
||||
_themePreferenceFlow.value = state.themePreference
|
||||
_useDynamicColorsFlow.value = state.useDynamicColors
|
||||
_themePresetFlow.value = state.themePreset
|
||||
_chatStyleFlow.value = state.chatStyle
|
||||
// B7 (Jul 08 2026, kanban t_470): keep cookie scope aligned with active profile.
|
||||
appScope?.launch { syncCookieStoreForProfile(state.selectedProfileId) }
|
||||
}
|
||||
@@ -584,6 +589,15 @@ object AuthManager {
|
||||
|
||||
fun getThemePreset(): ThemePreset = serverStore.getLatestState().themePreset
|
||||
|
||||
// ── Chat style (issue #866) ──────────────────────────────────────────
|
||||
|
||||
fun getChatStyle(): ChatStyle = serverStore.getLatestState().chatStyle
|
||||
|
||||
fun setChatStyle(style: ChatStyle) {
|
||||
_chatStyleFlow.value = style
|
||||
serverStore.update { it.copy(chatStyle = style) }
|
||||
}
|
||||
|
||||
fun setThemePreset(preset: ThemePreset) {
|
||||
serverStore.update { it.copy(themePreset = preset) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.m57.hermescontrol.theme
|
||||
|
||||
/**
|
||||
* Chat message rendering style (issue #866). App-local preference,
|
||||
* persisted in [com.m57.hermescontrol.data.config.ServerStoreState].
|
||||
*
|
||||
* BUBBLES — legacy bubble renderer (default, unchanged behavior).
|
||||
* FULL_BLEED — agent prose renders directly on the background with turn
|
||||
* headers; user messages keep bubbles (parallel renderer).
|
||||
*/
|
||||
enum class ChatStyle { BUBBLES, FULL_BLEED }
|
||||
@@ -486,7 +486,7 @@ private fun SelfImprovementReviewCard(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SystemBubble(
|
||||
internal fun SystemBubble(
|
||||
message: ChatMessage,
|
||||
onRespondApproval: (String) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -616,7 +616,7 @@ private fun buildHighlightedString(
|
||||
* Images are displayed as thumbnails; other files show a compact card.
|
||||
*/
|
||||
@Composable
|
||||
private fun InlineAttachment(
|
||||
internal fun InlineAttachment(
|
||||
attachment: Attachment,
|
||||
textColor: Color,
|
||||
onOpen: (Attachment) -> Unit,
|
||||
|
||||
@@ -81,6 +81,7 @@ import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.theme.ChatStyle
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatInputBar
|
||||
@@ -97,6 +98,7 @@ import com.m57.hermescontrol.ui.chat.components.SearchBarRow
|
||||
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
||||
import com.m57.hermescontrol.ui.chat.components.tailContentKey
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
|
||||
import com.m57.hermescontrol.ui.common.ActionProgressDialog
|
||||
import com.m57.hermescontrol.ui.common.AutoScrollingTitleText
|
||||
import com.m57.hermescontrol.ui.common.CredentialWarningBanner
|
||||
@@ -553,32 +555,8 @@ fun ChatScreen(
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
ChatMessageList(
|
||||
messages = state.messages,
|
||||
streamingMessage = streamingState.streamingMessage,
|
||||
isThinking = streamingState.isThinking,
|
||||
thinkingText = streamingState.thinkingText,
|
||||
isSearchActive = state.isSearchActive,
|
||||
searchQuery = state.searchQuery,
|
||||
currentSearchMatchIndex = state.currentSearchMatchIndex,
|
||||
searchMatchIndices = state.searchMatchIndices,
|
||||
typingEffectEnabled = state.typingEffectEnabled,
|
||||
typingEffectDelayMs = state.typingEffectDelayMs,
|
||||
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
|
||||
isLoading = state.isLoading,
|
||||
isLoadingOlder = state.isLoadingOlder,
|
||||
isDark = isDark,
|
||||
listState = listState,
|
||||
lastAnimatedMessageId = lastAnimatedMessageId,
|
||||
onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
|
||||
viewModel = viewModel,
|
||||
clarifyRequest = state.clarifyRequest,
|
||||
onRespondClarify = viewModel::respondToClarify,
|
||||
onDismissClarify = viewModel::dismissClarify,
|
||||
onSaveAttachment = { attachment ->
|
||||
if (!canStartAttachmentSave(pendingSavePath, state.savingAttachmentPath)) {
|
||||
return@ChatMessageList
|
||||
}
|
||||
val onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit = { attachment ->
|
||||
if (canStartAttachmentSave(pendingSavePath, state.savingAttachmentPath)) {
|
||||
pendingSavePath = viewModel.gatewayPathFor(attachment)
|
||||
pendingSaveName = attachment.name
|
||||
pendingSaveMimeType = attachment.mimeType
|
||||
@@ -599,10 +577,62 @@ fun ChatScreen(
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
|
||||
onImageClick = { viewingImage = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when (state.chatStyle) {
|
||||
ChatStyle.BUBBLES ->
|
||||
ChatMessageList(
|
||||
messages = state.messages,
|
||||
streamingMessage = streamingState.streamingMessage,
|
||||
isSearchActive = state.isSearchActive,
|
||||
searchQuery = state.searchQuery,
|
||||
currentSearchMatchIndex = state.currentSearchMatchIndex,
|
||||
searchMatchIndices = state.searchMatchIndices,
|
||||
typingEffectEnabled = state.typingEffectEnabled,
|
||||
typingEffectDelayMs = state.typingEffectDelayMs,
|
||||
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
|
||||
isLoading = state.isLoading,
|
||||
isLoadingOlder = state.isLoadingOlder,
|
||||
isDark = isDark,
|
||||
listState = listState,
|
||||
lastAnimatedMessageId = lastAnimatedMessageId,
|
||||
onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
|
||||
viewModel = viewModel,
|
||||
clarifyRequest = state.clarifyRequest,
|
||||
onRespondClarify = viewModel::respondToClarify,
|
||||
onDismissClarify = viewModel::dismissClarify,
|
||||
onSaveAttachment = onSaveAttachment,
|
||||
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
|
||||
onImageClick = { viewingImage = it },
|
||||
)
|
||||
|
||||
ChatStyle.FULL_BLEED ->
|
||||
FullBleedChatList(
|
||||
messages = state.messages,
|
||||
streamingMessage = streamingState.streamingMessage,
|
||||
isSearchActive = state.isSearchActive,
|
||||
searchQuery = state.searchQuery,
|
||||
currentSearchMatchIndex = state.currentSearchMatchIndex,
|
||||
searchMatchIndices = state.searchMatchIndices,
|
||||
typingEffectEnabled = state.typingEffectEnabled,
|
||||
typingEffectDelayMs = state.typingEffectDelayMs,
|
||||
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
|
||||
isLoading = state.isLoading,
|
||||
isLoadingOlder = state.isLoadingOlder,
|
||||
isDark = isDark,
|
||||
listState = listState,
|
||||
lastAnimatedMessageId = lastAnimatedMessageId,
|
||||
onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
|
||||
viewModel = viewModel,
|
||||
clarifyRequest = state.clarifyRequest,
|
||||
onRespondClarify = viewModel::respondToClarify,
|
||||
onDismissClarify = viewModel::dismissClarify,
|
||||
onSaveAttachment = onSaveAttachment,
|
||||
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
|
||||
onImageClick = { viewingImage = it },
|
||||
)
|
||||
}
|
||||
|
||||
// Loading overlay
|
||||
ChatLoadingOverlay(isLoading = state.isLoading && state.resumeError == null)
|
||||
|
||||
@@ -33,6 +33,7 @@ import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import com.m57.hermescontrol.data.ws.toJsonElement
|
||||
import com.m57.hermescontrol.theme.ChatStyle
|
||||
import com.m57.hermescontrol.ui.common.ActionProgressController
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -273,6 +274,9 @@ data class ChatUiState(
|
||||
// Cached settings
|
||||
val typingEffectEnabled: Boolean = true,
|
||||
val typingEffectDelayMs: Int = 30,
|
||||
// Chat rendering style (issue #866), cached like the typing-effect
|
||||
// settings so ChatScreen can pick the renderer without a store read.
|
||||
val chatStyle: ChatStyle = ChatStyle.FULL_BLEED,
|
||||
// Commands catalog
|
||||
val commandCatalog: CommandCatalog = CommandCatalog(),
|
||||
// Per-command usage counts for the slash-autocomplete ranking (issue
|
||||
@@ -1719,6 +1723,7 @@ class ChatViewModel(
|
||||
state.copy(
|
||||
typingEffectEnabled = AuthManager.isTypingEffectEnabled(),
|
||||
typingEffectDelayMs = AuthManager.getTypingEffectDelayMs(),
|
||||
chatStyle = AuthManager.getChatStyle(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +34,6 @@ import com.m57.hermescontrol.ui.common.EmptyState
|
||||
fun ChatMessageList(
|
||||
messages: List<ChatMessage>,
|
||||
streamingMessage: ChatMessage?,
|
||||
isThinking: Boolean,
|
||||
thinkingText: String,
|
||||
isSearchActive: Boolean,
|
||||
searchQuery: String,
|
||||
currentSearchMatchIndex: Int,
|
||||
@@ -155,13 +153,6 @@ fun ChatMessageList(
|
||||
}
|
||||
}
|
||||
|
||||
// Typing indicator — bouncing dots
|
||||
if (isThinking) {
|
||||
item(key = "typing_indicator") {
|
||||
TypingIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
// Clarify bubble — rendered at the very bottom
|
||||
if (clarifyRequest != null) {
|
||||
item(key = "clarify_bubble") {
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -27,10 +28,20 @@ fun StreamingBubbleWithTypingEffect(
|
||||
onAnimationComplete: () -> Unit = {},
|
||||
) {
|
||||
var visibleWordCount by remember { mutableIntStateOf(0) }
|
||||
var caretVisible by remember { mutableStateOf(true) }
|
||||
val currentContent = rememberUpdatedState(streaming.content)
|
||||
val currentIsStreaming = rememberUpdatedState(streaming.isStreaming)
|
||||
val currentDelayMs = rememberUpdatedState(typingDelayMs)
|
||||
|
||||
// Blinking typing caret while the message is still streaming.
|
||||
LaunchedEffect(Unit) {
|
||||
while (currentIsStreaming.value) {
|
||||
delay(400)
|
||||
caretVisible = !caretVisible
|
||||
}
|
||||
caretVisible = false
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -63,7 +74,9 @@ fun StreamingBubbleWithTypingEffect(
|
||||
} else {
|
||||
visibleWordCount.coerceIn(0, words.size)
|
||||
}
|
||||
val displayText = words.take(visibleCount.coerceAtLeast(1)).joinToString(" ")
|
||||
val displayText =
|
||||
words.take(visibleCount.coerceAtLeast(1)).joinToString(" ") +
|
||||
if (caretVisible && currentIsStreaming.value) "▍" else ""
|
||||
|
||||
ChatBubble(
|
||||
message = streaming.copy(content = displayText),
|
||||
|
||||
@@ -13,6 +13,8 @@ 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.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -23,12 +25,14 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
@@ -58,8 +62,10 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
@@ -67,8 +73,10 @@ import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.CodeComment
|
||||
import com.m57.hermescontrol.theme.CodeKeyword
|
||||
import com.m57.hermescontrol.theme.CodeNumber
|
||||
@@ -79,38 +87,64 @@ import com.m57.hermescontrol.theme.CodeTerminalBorder
|
||||
import com.m57.hermescontrol.theme.CodeTerminalMuted
|
||||
import com.m57.hermescontrol.theme.CodeTerminalText
|
||||
import com.m57.hermescontrol.ui.chat.SubagentIndicator
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
// ── ReasoningCard ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Dark collapsible card showing the assistant's reasoning trace
|
||||
* Collapsible card showing the assistant's reasoning trace
|
||||
* (reasoning-model thinking steps) before the final answer.
|
||||
*
|
||||
* Collapsed: "🧠 Reasoning · {N} steps" with chevron.
|
||||
* Expanded: full reasoning text in monospace bodySmall.
|
||||
* Streaming: pulsing indicator at bottom while [isStreaming].
|
||||
* Expanded: reasoning text in monospace bodySmall, capped at ~40% of the
|
||||
* screen height with internal scroll; "Show full" lifts the cap.
|
||||
* Long-press anywhere: copies the reasoning trace to the clipboard.
|
||||
* Streaming: pulsing indicator at the bottom while [isStreaming].
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ReasoningCard(
|
||||
reasoningText: String,
|
||||
isStreaming: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var fullHeight by remember { mutableStateOf(false) }
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
val scrollState = rememberScrollState()
|
||||
val stepCount = remember(reasoningText) { reasoningText.count { it == '\n' } + 1 }
|
||||
val capHeight = with(LocalConfiguration.current) { (screenHeightDp * 0.4f).dp }
|
||||
|
||||
// Copy feedback: briefly show "Copied" then revert
|
||||
LaunchedEffect(copied) {
|
||||
if (copied) {
|
||||
delay(1500)
|
||||
copied = false
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (!isStreaming) Modifier.animateContentSize() else Modifier)
|
||||
.combinedClickable(
|
||||
onClick = { expanded = !expanded },
|
||||
onLongClick = {
|
||||
val clipboard =
|
||||
context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
|
||||
?: return@combinedClickable
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(null, reasoningText))
|
||||
copied = true
|
||||
},
|
||||
)
|
||||
.testTag("reasoning_card"),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
onClick = { expanded = !expanded },
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -125,6 +159,14 @@ fun ReasoningCard(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (copied) {
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.reasoning_copied),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (expanded) "Collapse reasoning" else "Expand reasoning",
|
||||
@@ -134,15 +176,31 @@ fun ReasoningCard(
|
||||
}
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Column {
|
||||
Text(
|
||||
text = reasoningText,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.heightIn(max = if (fullHeight) Dp.Unspecified else capHeight)
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
Text(
|
||||
text = reasoningText,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
if (isStreaming) {
|
||||
ReasoningPulsingDot(modifier = Modifier.padding(top = 6.dp))
|
||||
}
|
||||
if (fullHeight) {
|
||||
TextButton(onClick = { fullHeight = false }) {
|
||||
Text(stringResource(R.string.reasoning_show_less))
|
||||
}
|
||||
} else if (scrollState.maxValue > 0) {
|
||||
TextButton(onClick = { fullHeight = true }) {
|
||||
Text(stringResource(R.string.reasoning_show_full))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package com.m57.hermescontrol.ui.chat.fullbleed
|
||||
|
||||
import android.content.ClipData
|
||||
import android.text.format.DateFormat
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
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.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
||||
import com.m57.hermescontrol.ui.chat.InlineAttachment
|
||||
import com.m57.hermescontrol.ui.chat.MarkdownText
|
||||
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Full-bleed renderer for ONE agent (assistant) message (issue #866).
|
||||
*
|
||||
* Unlike [com.m57.hermescontrol.ui.chat.ChatBubble], agent prose renders
|
||||
* directly on the background — no bubble container, no width cap — with a
|
||||
* turn header (role + time) and a trailing copy affordance. User messages
|
||||
* keep their bubbles; this composable is only used for ASSISTANT messages.
|
||||
*
|
||||
* [showTurnHeader] renders the role/time header — true only for the first
|
||||
* prose entry of an agent turn so multi-message turns don't repeat it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun FullBleedAgentMessage(
|
||||
message: ChatMessage,
|
||||
showTurnHeader: Boolean,
|
||||
isDarkTheme: Boolean,
|
||||
searchQuery: String = "",
|
||||
isCurrentMatch: Boolean = false,
|
||||
showReasoning: Boolean = true,
|
||||
onOpenAttachment: (Attachment) -> Unit = {},
|
||||
onSaveAttachment: (Attachment) -> Unit = {},
|
||||
savingAttachmentPath: String? = null,
|
||||
canSaveAttachment: Boolean = true,
|
||||
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val textColor = MaterialTheme.colorScheme.onSurface
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
|
||||
// Copy feedback: briefly show ✓ then revert
|
||||
LaunchedEffect(copied) {
|
||||
if (copied) {
|
||||
delay(1500)
|
||||
copied = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.testTag("fullbleed_agent_message"),
|
||||
) {
|
||||
if (showTurnHeader) {
|
||||
AssistantTurnHeader(message.timestamp)
|
||||
}
|
||||
|
||||
if (showReasoning && message.reasoningText.isNotBlank()) {
|
||||
ReasoningCard(
|
||||
reasoningText = message.reasoningText,
|
||||
isStreaming = message.isStreaming,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
}
|
||||
|
||||
SelectionContainer {
|
||||
MarkdownText(
|
||||
text = message.content,
|
||||
textColor = textColor,
|
||||
isStreaming = message.isStreaming,
|
||||
searchQuery = searchQuery,
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
onImageClick = onImageClick,
|
||||
)
|
||||
}
|
||||
|
||||
// Render inline attachments (mirrors ChatBubble so agent-delivered
|
||||
// media — images, files — shows in full-bleed mode too).
|
||||
if (!message.attachments.isNullOrEmpty()) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
message.attachments.forEach { attachment ->
|
||||
InlineAttachment(
|
||||
attachment = attachment,
|
||||
textColor = textColor,
|
||||
onOpen = { onOpenAttachment(it) },
|
||||
onSave = { onSaveAttachment(it) },
|
||||
savingPath = savingAttachmentPath,
|
||||
canSave = canSaveAttachment,
|
||||
onImageClick = onImageClick,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (!message.isStreaming) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, message.content)))
|
||||
}
|
||||
copied = true
|
||||
},
|
||||
modifier = Modifier.size(28.dp).testTag("fullbleed_copy"),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
|
||||
contentDescription = stringResource(R.string.content_desc_copy),
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent turn header — "Agent · <time>". Shared by the prose message and the
|
||||
* hoisted reasoning block so both can lead with the assistant identity and
|
||||
* timestamp.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AssistantTurnHeader(
|
||||
timestamp: Long,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.padding(bottom = 4.dp).testTag("fullbleed_agent_header"),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.fullbleed_role_agent),
|
||||
style =
|
||||
MaterialTheme.typography.labelMedium.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
// Meta color (not primary): Nord's light mode reuses its
|
||||
// pastel Frost accent as primary, which fails >= 3:1 on a
|
||||
// light background. The header is metadata, so the dimmed
|
||||
// meta token is both more correct and gate-compliant in
|
||||
// every preset.
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text =
|
||||
com.m57.hermescontrol.ui.chat.formatTimestamp(
|
||||
timestamp,
|
||||
DateFormat.is24HourFormat(LocalContext.current),
|
||||
),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package com.m57.hermescontrol.ui.chat.fullbleed
|
||||
|
||||
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.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
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.ClarifyUi
|
||||
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
||||
import com.m57.hermescontrol.ui.chat.ToolCallDivider
|
||||
import com.m57.hermescontrol.ui.chat.components.ClarifyBubble
|
||||
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
||||
import com.m57.hermescontrol.ui.chat.toolCallMilestones
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
|
||||
/**
|
||||
* The chat message list for FULL-BLEED style (issue #866).
|
||||
*
|
||||
* Parallel renderer to [com.m57.hermescontrol.ui.chat.components.ChatMessageList]:
|
||||
* user messages keep their bubble (the universal anchor), agent turns render
|
||||
* full-bleed with a turn header, and tool rows / system events render as
|
||||
* distinct compact cards. Spacing contract:
|
||||
* - intra-turn: entries separated by 6.dp (Column padding on agent turn items)
|
||||
* - inter-turn: 12.dp bottom padding after each turn's last item
|
||||
*
|
||||
* COMPOSE GOTCHA (verified): LazyColumn `item {}` content lambdas execute
|
||||
* LAZILY at item-composition time, not during this DSL-building loop. Loop
|
||||
* locals that are read inside item lambdas must be captured as immutable
|
||||
* vals FIRST (eagerly), or every item sees the loop's final value — the
|
||||
* turn header would never render and milestones would be misindexed.
|
||||
*/
|
||||
@Composable
|
||||
fun FullBleedChatList(
|
||||
messages: List<ChatMessage>,
|
||||
streamingMessage: ChatMessage?,
|
||||
isSearchActive: Boolean,
|
||||
searchQuery: String,
|
||||
currentSearchMatchIndex: Int,
|
||||
searchMatchIndices: List<Int>,
|
||||
typingEffectEnabled: Boolean,
|
||||
typingEffectDelayMs: Int,
|
||||
maxToolCallsPerTurn: Int? = null,
|
||||
isLoading: Boolean,
|
||||
isLoadingOlder: Boolean,
|
||||
isDark: Boolean,
|
||||
listState: androidx.compose.foundation.lazy.LazyListState,
|
||||
lastAnimatedMessageId: String?,
|
||||
onLastAnimatedMessageIdChange: (String?) -> Unit,
|
||||
viewModel: ChatViewModel,
|
||||
clarifyRequest: ClarifyUi? = null,
|
||||
onRespondClarify: ((String) -> Unit)? = null,
|
||||
onDismissClarify: (() -> Unit)? = null,
|
||||
onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit = {},
|
||||
savingAttachmentPath: String? = null,
|
||||
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||
) {
|
||||
if (messages.isEmpty() && !isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
EmptyState(
|
||||
title = stringResource(R.string.chat_empty_title),
|
||||
subtitle = stringResource(R.string.chat_empty_subtitle),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val toolMilestones = toolCallMilestones(messages)
|
||||
val turns =
|
||||
remember(messages, streamingMessage) {
|
||||
groupIntoTurnsWithStreaming(messages, streamingMessage)
|
||||
}
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var entryIndex = 0
|
||||
turns.forEach { turn ->
|
||||
when (turn) {
|
||||
is ChatTurn.User -> {
|
||||
// Eager captures: item lambda reads these at
|
||||
// composition time (lazy), so capture now.
|
||||
val userMessage = turn.message
|
||||
val milestone = toolMilestones[entryIndex]
|
||||
item(key = "user-${userMessage.id}") {
|
||||
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
||||
renderChatBubble(
|
||||
message = userMessage,
|
||||
isDark = isDark,
|
||||
searchQuery = searchQuery,
|
||||
isCurrentMatch =
|
||||
isCurrentMatchFor(
|
||||
messages,
|
||||
userMessage.id,
|
||||
isSearchActive,
|
||||
currentSearchMatchIndex,
|
||||
searchMatchIndices,
|
||||
),
|
||||
onOpenAttachment = viewModel::openAttachment,
|
||||
onSaveAttachment = onSaveAttachment,
|
||||
savingAttachmentPath = savingAttachmentPath,
|
||||
onImageClick = onImageClick,
|
||||
)
|
||||
milestone?.let { count ->
|
||||
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
|
||||
}
|
||||
}
|
||||
}
|
||||
entryIndex++
|
||||
}
|
||||
|
||||
is ChatTurn.Agent -> {
|
||||
var firstProseSeen = false
|
||||
// Reasoning hoist: the turn's reasoning renders at the
|
||||
// TOP of the turn — above tool rows — so thinking
|
||||
// leads, then the tool work, then the answer. The
|
||||
// matching prose entry renders without its own card.
|
||||
val turnReasoning =
|
||||
turn.entries
|
||||
.filterIsInstance<AgentEntry.Prose>()
|
||||
.firstOrNull { it.message.reasoningText.isNotBlank() }
|
||||
if (turnReasoning != null) {
|
||||
val reasoning = turnReasoning.message
|
||||
item(key = "reasoning-${reasoning.id}") {
|
||||
Column(modifier = Modifier.padding(bottom = 6.dp)) {
|
||||
// Lead the turn with the assistant identity
|
||||
// and timestamp, then the thinking block.
|
||||
AssistantTurnHeader(timestamp = reasoning.timestamp)
|
||||
ReasoningCard(
|
||||
reasoningText = reasoning.reasoningText,
|
||||
isStreaming = reasoning.isStreaming,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
turn.entries.forEach { entry ->
|
||||
when (entry) {
|
||||
is AgentEntry.Prose -> {
|
||||
val proseMessage = entry.message
|
||||
val showTurnHeader = !firstProseSeen && turnReasoning == null
|
||||
val hoistedReasoning =
|
||||
turnReasoning != null &&
|
||||
proseMessage.id == turnReasoning.message.id
|
||||
val milestone = toolMilestones[entryIndex]
|
||||
item(key = "prose-${proseMessage.id}") {
|
||||
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
||||
if (proseMessage.isStreaming && typingEffectEnabled) {
|
||||
StreamingFullBleedWithTypingEffect(
|
||||
streaming = proseMessage,
|
||||
typingDelayMs = typingEffectDelayMs,
|
||||
isDark = isDark,
|
||||
showTurnHeader = showTurnHeader,
|
||||
showReasoning = !hoistedReasoning,
|
||||
)
|
||||
} else {
|
||||
FullBleedAgentMessage(
|
||||
message = proseMessage,
|
||||
showTurnHeader = showTurnHeader,
|
||||
isDarkTheme = isDark,
|
||||
searchQuery = if (isSearchActive) searchQuery else "",
|
||||
isCurrentMatch =
|
||||
isCurrentMatchFor(
|
||||
messages,
|
||||
proseMessage.id,
|
||||
isSearchActive,
|
||||
currentSearchMatchIndex,
|
||||
searchMatchIndices,
|
||||
),
|
||||
showReasoning = !hoistedReasoning,
|
||||
onOpenAttachment = viewModel::openAttachment,
|
||||
onSaveAttachment = onSaveAttachment,
|
||||
savingAttachmentPath = savingAttachmentPath,
|
||||
canSaveAttachment = savingAttachmentPath == null,
|
||||
onImageClick = onImageClick,
|
||||
)
|
||||
}
|
||||
milestone?.let { count ->
|
||||
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
|
||||
}
|
||||
}
|
||||
}
|
||||
firstProseSeen = true
|
||||
entryIndex++
|
||||
}
|
||||
|
||||
is AgentEntry.ToolRow -> {
|
||||
val toolMessage = entry.message
|
||||
val milestone = toolMilestones[entryIndex]
|
||||
item(key = "tool-${toolMessage.id}") {
|
||||
Column(modifier = Modifier.padding(bottom = 6.dp)) {
|
||||
FullBleedToolRow(toolMessage)
|
||||
milestone?.let { count ->
|
||||
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
|
||||
}
|
||||
}
|
||||
}
|
||||
entryIndex++
|
||||
}
|
||||
|
||||
is AgentEntry.SystemEvent -> {
|
||||
val sysMessage = entry.message
|
||||
item(key = "sys-${sysMessage.id}") {
|
||||
Column(modifier = Modifier.padding(bottom = 6.dp)) {
|
||||
FullBleedSystemEvent(
|
||||
message = sysMessage,
|
||||
onRespondApproval = viewModel::respondToApproval,
|
||||
)
|
||||
}
|
||||
}
|
||||
entryIndex++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clarify bubble — rendered at the very bottom
|
||||
if (clarifyRequest != null) {
|
||||
item(key = "clarify_bubble") {
|
||||
ClarifyBubble(
|
||||
text = clarifyRequest.text,
|
||||
options = clarifyRequest.options,
|
||||
onOptionSelected = { option -> onRespondClarify?.invoke(option) },
|
||||
onDismiss = { onDismissClarify?.invoke() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun renderChatBubble(
|
||||
message: ChatMessage,
|
||||
isDark: Boolean,
|
||||
searchQuery: String,
|
||||
isCurrentMatch: Boolean,
|
||||
onOpenAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit,
|
||||
onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit,
|
||||
savingAttachmentPath: String?,
|
||||
onImageClick: (ImageViewerModel) -> Unit,
|
||||
) {
|
||||
ChatBubble(
|
||||
message = message,
|
||||
isDarkTheme = isDark,
|
||||
searchQuery = searchQuery,
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
onOpenAttachment = onOpenAttachment,
|
||||
onSaveAttachment = onSaveAttachment,
|
||||
savingAttachmentPath = savingAttachmentPath,
|
||||
canSaveAttachment = savingAttachmentPath == null,
|
||||
onImageClick = onImageClick,
|
||||
)
|
||||
}
|
||||
|
||||
private fun isCurrentMatchFor(
|
||||
messages: List<ChatMessage>,
|
||||
messageId: String,
|
||||
isSearchActive: Boolean,
|
||||
currentSearchMatchIndex: Int,
|
||||
searchMatchIndices: List<Int>,
|
||||
): Boolean {
|
||||
if (!isSearchActive || currentSearchMatchIndex < 0 || currentSearchMatchIndex >= searchMatchIndices.size) {
|
||||
return false
|
||||
}
|
||||
val index = messages.indexOfFirst { it.id == messageId }
|
||||
if (index < 0) return false
|
||||
return searchMatchIndices[currentSearchMatchIndex] == index
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.m57.hermescontrol.ui.chat.fullbleed
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* Wraps a streaming [FullBleedAgentMessage] with a word-by-word typing reveal
|
||||
* effect (full-bleed counterpart to
|
||||
* [com.m57.hermescontrol.ui.chat.components.StreamingBubbleWithTypingEffect]).
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Deliberately a separate wrapper (not a genericized version of the bubble
|
||||
* one): the two renderers are parallel code paths by design (issue #866),
|
||||
* and the wrappers die together when the bubble path is removed.
|
||||
*/
|
||||
@Composable
|
||||
internal fun StreamingFullBleedWithTypingEffect(
|
||||
streaming: ChatMessage,
|
||||
typingDelayMs: Int,
|
||||
isDark: Boolean,
|
||||
showTurnHeader: Boolean = true,
|
||||
showReasoning: Boolean = true,
|
||||
onAnimationComplete: () -> Unit = {},
|
||||
) {
|
||||
var visibleWordCount by remember { mutableIntStateOf(0) }
|
||||
var caretVisible by remember { mutableStateOf(true) }
|
||||
val currentContent = rememberUpdatedState(streaming.content)
|
||||
val currentIsStreaming = rememberUpdatedState(streaming.isStreaming)
|
||||
val currentDelayMs = rememberUpdatedState(typingDelayMs)
|
||||
|
||||
// Blinking typing caret while the message is still streaming.
|
||||
LaunchedEffect(Unit) {
|
||||
while (currentIsStreaming.value) {
|
||||
delay(400)
|
||||
caretVisible = !caretVisible
|
||||
}
|
||||
caretVisible = false
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
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(" ") +
|
||||
if (caretVisible && currentIsStreaming.value) "▍" else ""
|
||||
|
||||
FullBleedAgentMessage(
|
||||
message = streaming.copy(content = displayText),
|
||||
showTurnHeader = showTurnHeader,
|
||||
showReasoning = showReasoning,
|
||||
isDarkTheme = isDark,
|
||||
searchQuery = "",
|
||||
isCurrentMatch = false,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.m57.hermescontrol.ui.chat.fullbleed
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||
import com.m57.hermescontrol.ui.chat.SystemBubble
|
||||
import com.m57.hermescontrol.ui.chat.ToolBubble
|
||||
|
||||
/**
|
||||
* Tool-row treatment inside the full-bleed renderer (issue #866).
|
||||
*
|
||||
* ToolBubble is ALREADY a compact dimmed card (surfaceContainerHigh, 8dp
|
||||
* radius, collapsed summary with expand/copy/raw-json/risk-chip) — distinct
|
||||
* from agent prose by design. In full-bleed mode we reuse it verbatim so the
|
||||
* verified tool-rendering logic is never duplicated, adding only a 16dp
|
||||
* start indent so tool rows align with the full-bleed prose margin.
|
||||
*/
|
||||
@Composable
|
||||
internal fun FullBleedToolRow(
|
||||
message: ChatMessage,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp)
|
||||
.testTag("fullbleed_tool_row"),
|
||||
) {
|
||||
ToolBubble(message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* System-event treatment inside the full-bleed renderer (issue #866).
|
||||
*
|
||||
* Reuses [SystemBubble] verbatim — it already renders as a centered, dimmed,
|
||||
* italic caption (with approval buttons and the Self-improvement review card
|
||||
* handling intact), which keeps system events visually distinct from prose.
|
||||
*/
|
||||
@Composable
|
||||
internal fun FullBleedSystemEvent(
|
||||
message: ChatMessage,
|
||||
onRespondApproval: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
SystemBubble(
|
||||
message = message,
|
||||
onRespondApproval = onRespondApproval,
|
||||
modifier = modifier.testTag("fullbleed_system_event"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.m57.hermescontrol.ui.chat.fullbleed
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||
|
||||
/**
|
||||
* Turn model for the full-bleed chat renderer (issue #866).
|
||||
*
|
||||
* A turn is a unit of conversation for spacing + header purposes:
|
||||
* - [ChatTurn.User]: one user message — always its own turn (bubble anchor).
|
||||
* - [ChatTurn.Agent]: everything between user messages — assistant prose,
|
||||
* tool rows, and system events, in original order.
|
||||
*/
|
||||
sealed interface ChatTurn {
|
||||
/** User message — always its own turn (bubble anchor). */
|
||||
data class User(val message: ChatMessage) : ChatTurn
|
||||
|
||||
/** One agent turn: prose, tool rows, and system events in order. */
|
||||
data class Agent(val entries: List<AgentEntry>) : ChatTurn
|
||||
}
|
||||
|
||||
sealed interface AgentEntry {
|
||||
data class Prose(val message: ChatMessage) : AgentEntry
|
||||
|
||||
data class ToolRow(val message: ChatMessage) : AgentEntry
|
||||
|
||||
data class SystemEvent(val message: ChatMessage) : AgentEntry
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a flat message list into turns for the full-bleed renderer.
|
||||
*
|
||||
* Each USER message closes the current agent turn (if any) and opens a User
|
||||
* turn; all non-user messages belong to the surrounding agent turn. A new
|
||||
* agent turn starts after each user turn or at the start of the list.
|
||||
*/
|
||||
fun groupIntoTurns(messages: List<ChatMessage>): List<ChatTurn> {
|
||||
val turns = mutableListOf<ChatTurn>()
|
||||
val agentEntries = mutableListOf<AgentEntry>()
|
||||
|
||||
fun flushAgent() {
|
||||
if (agentEntries.isNotEmpty()) {
|
||||
turns += ChatTurn.Agent(agentEntries.toList())
|
||||
agentEntries.clear()
|
||||
}
|
||||
}
|
||||
|
||||
messages.forEach { message ->
|
||||
when (message.role) {
|
||||
MessageRole.USER -> {
|
||||
flushAgent()
|
||||
turns += ChatTurn.User(message)
|
||||
}
|
||||
|
||||
MessageRole.ASSISTANT -> agentEntries += AgentEntry.Prose(message)
|
||||
MessageRole.TOOL -> agentEntries += AgentEntry.ToolRow(message)
|
||||
MessageRole.SYSTEM -> agentEntries += AgentEntry.SystemEvent(message)
|
||||
}
|
||||
}
|
||||
flushAgent()
|
||||
return turns
|
||||
}
|
||||
|
||||
/**
|
||||
* Like [groupIntoTurns] but folds the in-flight streaming assistant message
|
||||
* into the current agent turn, so it renders as part of the turn (reasoning
|
||||
* hoist, turn headers, spacing) instead of as a detached tail item.
|
||||
*
|
||||
* Defensive: if the streaming message's id is already present in [messages]
|
||||
* (commit race — the message landed while the UI still held the streaming
|
||||
* copy), it is not appended again; a duplicate prose entry would produce a
|
||||
* LazyColumn duplicate-key crash.
|
||||
*/
|
||||
fun groupIntoTurnsWithStreaming(
|
||||
messages: List<ChatMessage>,
|
||||
streamingMessage: ChatMessage?,
|
||||
): List<ChatTurn> {
|
||||
if (streamingMessage == null || messages.any { it.id == streamingMessage.id }) {
|
||||
return groupIntoTurns(messages)
|
||||
}
|
||||
val turns = groupIntoTurns(messages).toMutableList()
|
||||
val prose = AgentEntry.Prose(streamingMessage)
|
||||
val last = turns.lastOrNull()
|
||||
if (last is ChatTurn.Agent) {
|
||||
turns[turns.lastIndex] = ChatTurn.Agent(last.entries + prose)
|
||||
} else {
|
||||
turns += ChatTurn.Agent(listOf(prose))
|
||||
}
|
||||
return turns
|
||||
}
|
||||
@@ -163,6 +163,8 @@ internal fun SettingsChatPage(
|
||||
onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange,
|
||||
typingEffectDelayMs = state.typingEffectDelayMs,
|
||||
onTypingEffectDelayMsChange = viewModel::onTypingEffectDelayMsChange,
|
||||
chatStyle = state.chatStyle,
|
||||
onChatStyleChange = viewModel::onChatStyleChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.ServerEndpoint
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.theme.ChatStyle
|
||||
import com.m57.hermescontrol.theme.ThemePreference
|
||||
import com.m57.hermescontrol.theme.ThemePreset
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -35,6 +36,7 @@ data class SettingsUiState(
|
||||
val isSaved: Boolean = false,
|
||||
val typingEffectEnabled: Boolean = false,
|
||||
val typingEffectDelayMs: Int = 30,
|
||||
val chatStyle: ChatStyle = ChatStyle.FULL_BLEED,
|
||||
val profiles: List<ConnectionProfile> = emptyList(),
|
||||
val selectedProfileId: String? = null,
|
||||
val renameProfileName: String = "",
|
||||
@@ -75,6 +77,7 @@ class SettingsViewModel(
|
||||
val themePreset = AuthManager.getThemePreset()
|
||||
val typingEffectEnabled = AuthManager.isTypingEffectEnabled()
|
||||
val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs()
|
||||
val chatStyle = AuthManager.getChatStyle()
|
||||
val profiles = AuthManager.getConnectionProfiles()
|
||||
val appLanguage = AuthManager.getAppLanguage()
|
||||
val renameProfileName =
|
||||
@@ -94,6 +97,7 @@ class SettingsViewModel(
|
||||
themePreset = themePreset,
|
||||
typingEffectEnabled = typingEffectEnabled,
|
||||
typingEffectDelayMs = typingEffectDelayMs,
|
||||
chatStyle = chatStyle,
|
||||
profiles = profiles,
|
||||
selectedProfileId = selectedId,
|
||||
renameProfileName = renameProfileName,
|
||||
@@ -294,6 +298,11 @@ class SettingsViewModel(
|
||||
AuthManager.setTypingEffectDelayMs(delayMs)
|
||||
}
|
||||
|
||||
fun onChatStyleChange(style: ChatStyle) {
|
||||
_uiState.update { it.copy(chatStyle = style, isSaved = false) }
|
||||
AuthManager.setChatStyle(style)
|
||||
}
|
||||
|
||||
/** Clear all auth credentials — logs out and returns to landing screen. */
|
||||
fun logout() {
|
||||
AuthManager.setToken(null)
|
||||
@@ -320,6 +329,7 @@ class SettingsViewModel(
|
||||
AuthManager.setThemePreset(state.themePreset)
|
||||
AuthManager.setTypingEffectEnabled(state.typingEffectEnabled)
|
||||
AuthManager.setTypingEffectDelayMs(state.typingEffectDelayMs)
|
||||
AuthManager.setChatStyle(state.chatStyle)
|
||||
ApiClient.rebuild()
|
||||
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
|
||||
@@ -32,6 +32,7 @@ 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.theme.ChatStyle
|
||||
import com.m57.hermescontrol.theme.ThemePreference
|
||||
import com.m57.hermescontrol.theme.ThemePreset
|
||||
import com.m57.hermescontrol.ui.settings.SectionCard
|
||||
@@ -234,8 +235,48 @@ internal fun ChatSection(
|
||||
onTypingEffectEnabledChange: (Boolean) -> Unit,
|
||||
typingEffectDelayMs: Int,
|
||||
onTypingEffectDelayMsChange: (Int) -> Unit,
|
||||
chatStyle: ChatStyle,
|
||||
onChatStyleChange: (ChatStyle) -> Unit,
|
||||
) {
|
||||
SectionCard {
|
||||
// Chat rendering style (issue #866): bubble renderer (default) or the
|
||||
// parallel full-bleed renderer. Kept at the top of the section — it's
|
||||
// the primary chat-behavior choice.
|
||||
Text(
|
||||
text = stringResource(R.string.settings_item_chat_style),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.settings_desc_chat_style),
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
ChatStyle.entries.forEachIndexed { index, style ->
|
||||
SegmentedButton(
|
||||
selected = chatStyle == style,
|
||||
onClick = { onChatStyleChange(style) },
|
||||
shape =
|
||||
SegmentedButtonDefaults.itemShape(
|
||||
index = index,
|
||||
count = ChatStyle.entries.size,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
when (style) {
|
||||
ChatStyle.BUBBLES -> stringResource(R.string.chat_style_bubbles)
|
||||
ChatStyle.FULL_BLEED -> stringResource(R.string.chat_style_full_bleed)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
|
||||
Reference in new issue
Block a user