diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt new file mode 100644 index 00000000..30514278 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt @@ -0,0 +1,135 @@ +package com.m57.hermescontrol.ui.chat.fullbleed + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +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.ToolStatus +import io.mockk.mockk +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented Compose UI tests for the full-bleed chat renderer (issue #866). + * + * Validates the parallel renderer's hierarchy contract: + * - user messages KEEP the bubble (universal anchor) + * - assistant prose renders full-bleed (no bubble), one turn header per turn + * - tool rows render as distinct compact rows, NOT full-bleed prose + * - streaming + clarify still render + */ +@RunWith(AndroidJUnit4::class) +@MediumTest +class FullBleedChatListTest { + @get:Rule + val composeTestRule = createComposeRule() + + private fun msg( + id: String, + role: MessageRole, + content: String = "content-$id", + toolStatus: ToolStatus? = null, + isStreaming: Boolean = false, + ) = ChatMessage(id = id, role = role, content = content, toolStatus = toolStatus, isStreaming = isStreaming) + + private fun render( + messages: List, + streamingMessage: ChatMessage? = null, + clarify: Boolean = false, + ) { + composeTestRule.setContent { + FullBleedChatList( + messages = messages, + streamingMessage = streamingMessage, + isSearchActive = false, + searchQuery = "", + currentSearchMatchIndex = -1, + searchMatchIndices = emptyList(), + typingEffectEnabled = false, + typingEffectDelayMs = 30, + isLoading = false, + isLoadingOlder = false, + isDark = false, + listState = LazyListState(), + lastAnimatedMessageId = null, + onLastAnimatedMessageIdChange = {}, + viewModel = mockk(relaxed = true), + clarifyRequest = + if (clarify) { + com.m57.hermescontrol.ui.chat.ClarifyUi( + text = "pick", + options = listOf("a"), + ) + } else { + null + }, + ) + } + } + + @Test + fun userMessages_keepBubble_agentProse_isFullBleed() { + render( + listOf( + msg("u1", MessageRole.USER), + msg("a1", MessageRole.ASSISTANT), + ), + ) + composeTestRule.onNodeWithTag("chat_bubble_user").assertIsDisplayed() + composeTestRule.onNodeWithTag("fullbleed_agent_message").assertIsDisplayed() + } + + @Test + fun agentProse_hasNoBubbleContainer() { + render(listOf(msg("a1", MessageRole.ASSISTANT))) + composeTestRule.onNodeWithTag("chat_bubble_assistant").assertDoesNotExist() + composeTestRule.onNodeWithTag("fullbleed_agent_message").assertIsDisplayed() + } + + @Test + fun toolRows_renderCompact_notFullBleed() { + render( + listOf( + msg("a1", MessageRole.ASSISTANT), + msg("t1", MessageRole.TOOL, toolStatus = ToolStatus.COMPLETED), + ), + ) + composeTestRule.onNodeWithTag("fullbleed_tool_row").assertIsDisplayed() + // exactly one full-bleed message (the prose), not the tool row + composeTestRule.onAllNodesWithTag("fullbleed_agent_message").assertCountEquals(1) + } + + @Test + fun turnHeader_renderedOncePerAgentTurn() { + render( + listOf( + msg("a1", MessageRole.ASSISTANT), + msg("t1", MessageRole.TOOL, toolStatus = ToolStatus.COMPLETED), + msg("a2", MessageRole.ASSISTANT), + msg("u2", MessageRole.USER), + msg("a3", MessageRole.ASSISTANT), + ), + ) + // Two agent turns (a1..a2, a3) -> two turn headers (one per turn). + composeTestRule.onAllNodesWithTag("fullbleed_agent_header").assertCountEquals(2) + } + + @Test + fun streaming_and_clarify_stillRender() { + render( + messages = listOf(msg("u1", MessageRole.USER)), + streamingMessage = msg("s1", MessageRole.ASSISTANT, content = "streaming", isStreaming = true), + clarify = true, + ) + composeTestRule.onNodeWithTag("fullbleed_agent_message").assertIsDisplayed() + composeTestRule.onNodeWithTag("clarify_bubble").assertIsDisplayed() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt index 8616b737..5dd785a7 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt @@ -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", diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index 23d28756..e1ca8a8e 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -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.DEFAULT) val themePresetFlow: StateFlow = _themePresetFlow.asStateFlow() + private val _chatStyleFlow = MutableStateFlow(ChatStyle.FULL_BLEED) + val chatStyleFlow: StateFlow = _chatStyleFlow.asStateFlow() + private val _tokenFlow = MutableStateFlow(null) val tokenFlow: StateFlow = _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) } } diff --git a/app/src/main/java/com/m57/hermescontrol/theme/ChatStyle.kt b/app/src/main/java/com/m57/hermescontrol/theme/ChatStyle.kt new file mode 100644 index 00000000..5fdec866 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/theme/ChatStyle.kt @@ -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 } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index 70599936..e851c89b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 1034ff1d..69091295 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -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) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 0aa8d291..ee4c92f3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -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(), ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt index d2baaf53..98198c3f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatMessageList.kt @@ -34,8 +34,6 @@ import com.m57.hermescontrol.ui.common.EmptyState fun ChatMessageList( messages: List, 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") { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatStreaming.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatStreaming.kt index 69c7e9f4..c5002918 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatStreaming.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatStreaming.kt @@ -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), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt index f161eb6b..31b28909 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt @@ -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)) + } + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt new file mode 100644 index 00000000..5207acc0 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt @@ -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 ·