From a59c8c6a4f7ef4fb37453741d9b3242eecc59d75 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:22:07 +0300 Subject: [PATCH] feat(chat): full-bleed agent replies behind a chat style setting (issue #866) (#878) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../chat/fullbleed/FullBleedChatListTest.kt | 135 ++++++++ .../data/config/ServerStoreState.kt | 4 + .../hermescontrol/data/local/AuthManager.kt | 14 + .../com/m57/hermescontrol/theme/ChatStyle.kt | 11 + .../m57/hermescontrol/ui/chat/ChatBubble.kt | 4 +- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 90 ++++-- .../hermescontrol/ui/chat/ChatViewModel.kt | 5 + .../ui/chat/components/ChatMessageList.kt | 9 - .../ui/chat/components/ChatStreaming.kt | 15 +- .../ui/chat/components/MessageCards.kt | 78 ++++- .../chat/fullbleed/FullBleedAgentMessage.kt | 193 ++++++++++++ .../ui/chat/fullbleed/FullBleedChatList.kt | 296 ++++++++++++++++++ .../ui/chat/fullbleed/FullBleedStreaming.kt | 96 ++++++ .../ui/chat/fullbleed/FullBleedToolRow.kt | 57 ++++ .../ui/chat/fullbleed/FullBleedTurns.kt | 90 ++++++ .../ui/settings/SettingsSubPages.kt | 2 + .../ui/settings/SettingsViewModel.kt | 10 + .../settings/components/AppearanceSection.kt | 41 +++ app/src/main/res/values-ko/strings.xml | 8 + app/src/main/res/values/strings.xml | 8 + .../hermescontrol/theme/ThemePaletteTest.kt | 29 ++ .../ui/chat/ChatUpdateCommandTest.kt | 2 + .../ui/chat/ChatViewModelTest.kt | 3 + .../ui/chat/FullBleedTurnsTest.kt | 148 +++++++++ .../ui/chat/SlashCommandDispatchRpcTest.kt | 2 + .../ui/settings/SettingsViewModelTest.kt | 3 + 26 files changed, 1301 insertions(+), 52 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/theme/ChatStyle.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedStreaming.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedToolRow.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedTurnsTest.kt 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 ·