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 index d94af3c5..e78ac7f1 100644 --- 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 @@ -6,12 +6,14 @@ 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.compose.ui.test.onNodeWithText 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.ChatSearchState import com.m57.hermescontrol.ui.chat.ChatViewModel import com.m57.hermescontrol.ui.chat.MessageRole +import com.m57.hermescontrol.ui.chat.StreamingState import com.m57.hermescontrol.ui.chat.ToolStatus import com.m57.hermescontrol.ui.chat.components.ChatScrollController import io.mockk.mockk @@ -47,19 +49,21 @@ class FullBleedChatListTest { private fun render( messages: List, streamingMessage: ChatMessage? = null, + streamingState: StreamingState = StreamingState(streamingMessage = streamingMessage), + isAgentTyping: Boolean = streamingMessage?.isStreaming == true, clarify: Boolean = false, ) { composeTestRule.setContent { val listState = LazyListState() FullBleedChatList( messages = messages, - streamingMessage = streamingMessage, + streamingState = streamingState, + isAgentTyping = isAgentTyping, searchState = ChatSearchState(), typingEffectEnabled = false, typingEffectDelayMs = 30, isLoading = false, isLoadingOlder = false, - isDark = false, listState = listState, scrollController = ChatScrollController( @@ -115,7 +119,7 @@ class FullBleedChatListTest { } @Test - fun turnHeader_renderedOncePerAgentTurn() { + fun assistantHeader_isRemovedFromEveryAgentTurn() { render( listOf( msg("a1", MessageRole.ASSISTANT), @@ -125,8 +129,8 @@ class FullBleedChatListTest { msg("a3", MessageRole.ASSISTANT), ), ) - // Two agent turns (a1..a2, a3) -> two turn headers (one per turn). - composeTestRule.onAllNodesWithTag("fullbleed_agent_header").assertCountEquals(2) + composeTestRule.onAllNodesWithTag("fullbleed_agent_header").assertCountEquals(0) + composeTestRule.onNodeWithTag("fullbleed_finish_time").assertDoesNotExist() } @Test @@ -139,4 +143,55 @@ class FullBleedChatListTest { composeTestRule.onNodeWithTag("fullbleed_agent_message").assertIsDisplayed() composeTestRule.onNodeWithTag("clarify_bubble").assertIsDisplayed() } + + @Test + fun emptyStreamingMessage_showsTypingStatus_withoutEmptyAssistantProse() { + render( + messages = listOf(msg("u1", MessageRole.USER)), + streamingMessage = msg("s1", MessageRole.ASSISTANT, content = "", isStreaming = true), + isAgentTyping = true, + ) + composeTestRule.onNodeWithTag("typing_indicator").assertIsDisplayed() + composeTestRule.onNodeWithTag("fullbleed_agent_message").assertDoesNotExist() + } + + @Test + fun reasoningStreaming_showsThinkingStatus_andKeepsReasoningCard() { + val streaming = + msg( + "s1", + MessageRole.ASSISTANT, + content = "", + isStreaming = true, + ).copy(reasoningText = "step") + render( + messages = listOf(msg("u1", MessageRole.USER)), + streamingMessage = streaming, + streamingState = + StreamingState( + streamingMessage = streaming, + isReasoning = true, + reasoningText = "step", + ), + isAgentTyping = true, + ) + composeTestRule.onNodeWithTag("agent_status_thinking").assertIsDisplayed() + composeTestRule.onNodeWithTag("reasoning_card").assertIsDisplayed() + composeTestRule.onNodeWithTag("typing_indicator").assertDoesNotExist() + } + + @Test + fun runningTool_showsToolStatus_insteadOfTypingDots() { + render( + messages = + listOf( + msg("u1", MessageRole.USER), + msg("t1", MessageRole.TOOL, toolStatus = ToolStatus.RUNNING).copy(toolName = "web_search"), + ), + isAgentTyping = true, + ) + composeTestRule.onNodeWithTag("agent_status_tool").assertIsDisplayed() + composeTestRule.onNodeWithText("Searching…").assertIsDisplayed() + composeTestRule.onNodeWithTag("typing_indicator").assertDoesNotExist() + } } 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 db8bfc30..9dd91ea3 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 @@ -15,7 +15,6 @@ import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -359,7 +358,6 @@ fun ChatScreen( } var viewingImage by rememberSaveable { mutableStateOf(null) } val snackbarHostState = remember { SnackbarHostState() } - val isDark = isSystemInDarkTheme() val launchExternalActivity: (() -> Unit) -> Unit = { launch -> ExternalActivityLifecycleGuard.launchExternalActivity( acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease, @@ -668,14 +666,14 @@ fun ChatScreen( // surface since the bubble renderer was removed. FullBleedChatList( messages = state.messages, - streamingMessage = streamingState.streamingMessage, + streamingState = streamingState, + isAgentTyping = state.isAgentTyping, searchState = searchState, typingEffectEnabled = state.typingEffectEnabled, typingEffectDelayMs = state.typingEffectDelayMs, maxToolCallsPerTurn = state.maxToolCallsPerTurn, isLoading = state.isLoading, isLoadingOlder = state.isLoadingOlder, - isDark = isDark, listState = listState, scrollController = scrollController, lastAnimatedMessageId = lastAnimatedMessageId, 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 75824201..f164cad4 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 @@ -1518,6 +1518,16 @@ class ChatViewModel( else -> error.toString() } + if (method == WsMethods.PROMPT_SUBMIT || method == WsMethods.SESSION_REDIRECT) { + // A prompt rejection can arrive before message.start, leaving the + // UI with only the optimistic typing state. Clear the live tail so + // a failed generation cannot leave stale dots or reasoning behind. + sealStreamingMessageIfAny() + _uiState.update { it.copy(isAgentTyping = false) } + _streamingState.update { StreamingState() } + streamingController.resetStreaming() + } + // Session resume failures go through the bounded retry (desktop // parity) instead of a one-shot snackbar — the session may be // mid-flush on the gateway or the WS may have just rebound, and a diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index f79882f0..f368f80c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -456,10 +456,11 @@ object ChatWsEventReducer { streamingState: StreamingState, ): ReducerResult { val streaming = - streamingState.streamingMessage ?: return ReducerResult( - state = state, - streamingState = streamingState, - ) + streamingState.streamingMessage + ?: return ReducerResult( + state = state.copy(isAgentTyping = false), + streamingState = streamingState, + ) val reasoning = if (streamingState.reasoningText.isNotBlank()) { streamingState.reasoningText @@ -578,7 +579,12 @@ object ChatWsEventReducer { val nextTodos = parsedTodos ?: newState.todos return ReducerResult( - state = newState.copy(messages = newState.messages + toolMessage, todos = nextTodos), + state = + newState.copy( + messages = newState.messages + toolMessage, + isAgentTyping = true, + todos = nextTodos, + ), streamingState = finalStreamingState, effects = effects, ) @@ -810,6 +816,7 @@ object ChatWsEventReducer { ReducerResult( state = state.copy( + isAgentTyping = false, isLoading = false, errorMessage = "Backend error: ${event.message ?: "Unknown gateway error"}", ), 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 dd0477ab..c4bf9e2e 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 @@ -68,6 +68,8 @@ 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.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -746,13 +748,19 @@ fun SubagentCard( // ── TypingIndicator ─────────────────────────────────────────────────────── /** - * Three bouncing dots shown while the assistant is typing / thinking. - * Staggered animation: 0ms, 150ms, 300ms delay per dot. + * Three subtle dots shown while the assistant is waiting to produce visible + * content. Staggered opacity/scale animation keeps the indicator lightweight + * without the distracting vertical bounce used by the old chat renderer. */ @Composable fun TypingIndicator(modifier: Modifier = Modifier) { + val description = stringResource(R.string.chat_agent_status_typing) Row( - modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp).testTag("typing_indicator"), + modifier = + modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .semantics { contentDescription = description } + .testTag("typing_indicator"), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { @@ -768,18 +776,18 @@ private fun TypingDot(delayMs: Int) { val typingSpec: InfiniteRepeatableSpec = remember(delayMs) { infiniteRepeatable( - animation = tween(400, delayMillis = delayMs, easing = LinearEasing), + animation = tween(700, delayMillis = delayMs, easing = LinearEasing), repeatMode = RepeatMode.Reverse, ) } val offset by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = -6f, + initialValue = 0.85f, + targetValue = 1f, animationSpec = typingSpec, - label = "typing_dot_offset_$delayMs", + label = "typing_dot_scale_$delayMs", ) val alpha by infiniteTransition.animateFloat( - initialValue = 0.4f, + initialValue = 0.45f, targetValue = 1f, animationSpec = typingSpec, label = "typing_dot_alpha_$delayMs", @@ -790,7 +798,8 @@ private fun TypingDot(delayMs: Int) { .size(8.dp) .clip(CircleShape) .graphicsLayer { - this.translationY = offset + this.scaleX = offset + this.scaleY = offset this.alpha = alpha }, ) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/AgentStatusIndicator.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/AgentStatusIndicator.kt new file mode 100644 index 00000000..1f6e1cc2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/AgentStatusIndicator.kt @@ -0,0 +1,172 @@ +package com.m57.hermescontrol.ui.chat.fullbleed + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.chat.ChatMessage +import com.m57.hermescontrol.ui.chat.MessageRole +import com.m57.hermescontrol.ui.chat.StreamingState +import com.m57.hermescontrol.ui.chat.ToolSchemaRegistry +import com.m57.hermescontrol.ui.chat.ToolStatus +import com.m57.hermescontrol.ui.chat.components.TypingIndicator +import com.m57.hermescontrol.ui.chat.isUserTurnBoundary + +/** Live status for an agent turn before visible assistant prose is available. */ +internal sealed interface AgentStatus { + data object Typing : AgentStatus + + data object Thinking : AgentStatus + + data class Tool( + val name: String?, + ) : AgentStatus +} + +/** + * Derives the one status that should occupy the agent turn's live tail. + * + * Tool execution wins over generic states, and visible streaming prose wins + * over waiting/reasoning states. This keeps one indicator on screen at a time + * without introducing a second generation state store. + */ +internal fun deriveAgentStatus( + isAgentTyping: Boolean, + streamingState: StreamingState, + messages: List, +): AgentStatus? { + if (!isAgentTyping) return null + + val currentTurnStart = messages.indexOfLast { it.isUserTurnBoundary() } + val currentTurnMessages = + if (currentTurnStart >= 0) { + messages.drop(currentTurnStart + 1) + } else { + emptyList() + } + val activeTool = + currentTurnMessages + .asReversed() + .firstOrNull { + it.role == MessageRole.TOOL && + it.toolStatus == ToolStatus.RUNNING + } + val hasVisibleStreamingContent = streamingState.streamingMessage?.content?.isNotBlank() == true + + return when { + activeTool != null -> AgentStatus.Tool(activeTool.toolName) + hasVisibleStreamingContent -> null + streamingState.isThinking || streamingState.isReasoning -> AgentStatus.Thinking + isAgentTyping -> AgentStatus.Typing + else -> null + } +} + +/** Maps existing canonical tool names to compact, user-facing activity text. */ +internal fun toolStatusLabelRes(toolName: String?): Int = + when (toolName?.lowercase()) { + "web_search", + "search_files", + "session_search", + "tool_search", + "x_search", + -> R.string.chat_agent_status_searching + + "read_file", + -> R.string.chat_agent_status_reading + + "web_extract", + "browser_navigate", + "browser_click", + "browser_type", + "browser_scroll", + "browser_back", + "browser_press", + "browser_snapshot", + "browser_get_images", + "browser_console", + "browser_cdp", + "browser_dialog", + "browser_vision", + -> R.string.chat_agent_status_browsing + + "terminal", + "execute_code", + "process", + "process_manage", + -> R.string.chat_agent_status_running_command + + else -> R.string.chat_agent_status_using_tool + } + +@Composable +internal fun AgentStatusIndicator( + status: AgentStatus, + modifier: Modifier = Modifier, +) { + when (status) { + AgentStatus.Typing -> { + TypingIndicator(modifier = modifier) + } + + AgentStatus.Thinking -> { + StatusText( + text = stringResource(R.string.chat_agent_status_thinking), + icon = null, + modifier = modifier.testTag("agent_status_thinking"), + ) + } + + is AgentStatus.Tool -> { + val config = ToolSchemaRegistry.getDisplayConfig(status.name) + val labelRes = toolStatusLabelRes(status.name) + StatusText( + text = stringResource(labelRes), + icon = config.icon, + modifier = modifier.testTag("agent_status_tool"), + ) + } + } +} + +@Composable +private fun StatusText( + text: String, + icon: ImageVector?, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} 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 index f34a0fa5..b3df7430 100644 --- 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 @@ -1,7 +1,6 @@ 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 @@ -9,7 +8,6 @@ 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 @@ -29,10 +27,8 @@ 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 @@ -40,7 +36,6 @@ 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.TokenEstimator import com.m57.hermescontrol.ui.chat.components.ReasoningCard import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -50,17 +45,12 @@ import kotlinx.coroutines.launch * * 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. + * trailing copy affordance. User messages keep their bubbles; this composable + * is only used for ASSISTANT messages. */ @Composable internal fun FullBleedAgentMessage( message: ChatMessage, - showTurnHeader: Boolean, - isDarkTheme: Boolean, searchQuery: String = "", isCurrentMatch: Boolean = false, showReasoning: Boolean = true, @@ -92,10 +82,6 @@ internal fun FullBleedAgentMessage( .padding(horizontal = 16.dp) .testTag("fullbleed_agent_message"), ) { - if (showTurnHeader) { - AssistantTurnHeader(message.timestamp) - } - if (showReasoning && message.reasoningText.isNotBlank()) { ReasoningCard( reasoningText = message.reasoningText, @@ -105,10 +91,10 @@ internal fun FullBleedAgentMessage( } // Defense-in-depth: never render an empty prose block (blank bubble + - // lone Copy button). Blank settled rows are tool-call placeholders that - // slipped through upstream mapping; streaming keeps rendering so the - // live cursor survives until the first delta lands. - if (message.content.isNotBlank() || message.isStreaming) { + // lone Copy button). Blank rows are tool-call placeholders that slipped + // through upstream mapping; the parent list renders the live status + // indicator until the first visible delta lands. + if (message.content.isNotBlank()) { SelectionContainer { MarkdownText( text = message.content, @@ -140,7 +126,7 @@ internal fun FullBleedAgentMessage( } } - if (!message.isStreaming) { + if (!message.isStreaming && message.content.isNotBlank()) { Row( verticalAlignment = Alignment.CenterVertically, ) { @@ -160,97 +146,7 @@ internal fun FullBleedAgentMessage( tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } - Spacer(modifier = Modifier.width(4.dp)) - val finishTime = message.finishTimestamp ?: message.timestamp - Text( - text = - com.m57.hermescontrol.ui.chat.formatTimestamp( - finishTime, - DateFormat.is24HourFormat(LocalContext.current), - ), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - modifier = Modifier.testTag("fullbleed_finish_time"), - ) - if (message.tokenCount != null && message.tokenCount > 0) { - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = "•", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = - stringResource( - R.string.chat_msg_tokens, - TokenEstimator.formatTokenCount(message.tokenCount), - ), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - modifier = Modifier.testTag("fullbleed_token_count"), - ) - } - if (message.tps != null && message.tps > 0.0) { - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = "•", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = - stringResource( - R.string.chat_msg_tps, - TokenEstimator.formatTps(message.tps), - ), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - modifier = Modifier.testTag("fullbleed_tps"), - ) - } } } } } - -/** - * Agent turn header — "Agent ·