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 9ab82a72..4564a834 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 @@ -35,7 +35,7 @@ object AuthManager { private const val DEFAULT_HOST = "127.0.0.1" private const val DEFAULT_PORT = 9119 private const val DEFAULT_AUTO_RECONNECT = true - private const val DEFAULT_TYPING_EFFECT_ENABLED = false + private const val DEFAULT_TYPING_EFFECT_ENABLED = true private const val DEFAULT_TYPING_EFFECT_DELAY_MS = 30 @Volatile diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index eaebadbc..2f033af7 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -51,7 +51,7 @@ object EventParser { WsEvent.MessageStart(sessionId) } - "message.token" -> { + "message.token", "message.delta" -> { val token = payload?.get("text") as? String ?: "" WsEvent.MessageToken(token, sessionId) } 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 2af6b913..34ad5b68 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 @@ -80,6 +80,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -127,6 +128,7 @@ fun ChatScreen( } val scrollScope = rememberCoroutineScope() var inputText by rememberSaveable { mutableStateOf("") } + var lastAnimatedMessageId by rememberSaveable { mutableStateOf(null) } val snackbarHostState = remember { SnackbarHostState() } val isDark = isSystemInDarkTheme() val context = LocalContext.current @@ -434,12 +436,28 @@ fun ChatScreen( state.currentSearchMatchIndex < state.searchMatchIndices.size && state.searchMatchIndices[state.currentSearchMatchIndex] == index - ChatBubble( - message = message, - isDarkTheme = isDark, - searchQuery = if (state.isSearchActive) state.searchQuery else "", - isCurrentMatch = isCurrentMatch, - ) + val isLastMessage = index == state.messages.lastIndex + val isAssistant = message.role == MessageRole.ASSISTANT + val typingEnabled = AuthManager.isTypingEffectEnabled() + val typingDelayMs = AuthManager.getTypingEffectDelayMs() + + if (typingEnabled && isLastMessage && isAssistant && lastAnimatedMessageId != message.id) { + StreamingBubbleWithTypingEffect( + streaming = message, + typingDelayMs = typingDelayMs, + isDark = isDark, + onAnimationComplete = { + lastAnimatedMessageId = message.id + }, + ) + } else { + ChatBubble( + message = message, + isDarkTheme = isDark, + searchQuery = if (state.isSearchActive) state.searchQuery else "", + isCurrentMatch = isCurrentMatch, + ) + } } // Streaming message — rendered separately for O(1) updates @@ -1041,15 +1059,28 @@ private fun StreamingBubbleWithTypingEffect( streaming: ChatMessage, typingDelayMs: Int, isDark: Boolean, + onAnimationComplete: () -> Unit = {}, ) { var visibleWordCount by remember { mutableIntStateOf(0) } + val currentContent = rememberUpdatedState(streaming.content) + val currentIsStreaming = rememberUpdatedState(streaming.isStreaming) + val currentDelayMs = rememberUpdatedState(typingDelayMs) // Timer that ticks at the configured delay, incrementing the visible word // count each tick. Stops ticking when streaming ends, then shows all words. LaunchedEffect(Unit) { - while (streaming.isStreaming) { - delay(typingDelayMs.toLong()) - visibleWordCount++ + while (true) { + val words = currentContent.value.split(" ") + if (visibleWordCount < words.size) { + delay(currentDelayMs.value.toLong()) + visibleWordCount++ + } else { + if (!currentIsStreaming.value) { + onAnimationComplete() + break + } + delay(10) + } } visibleWordCount = Int.MAX_VALUE }