fix(#205): fix typing effect by checking actual streamed word counts and default it to enabled (#246)

* fix(#205): fix typing effect by checking actual streamed word counts and default it to enabled

* fix: correct import path of rememberSaveable to resolve build error

* fix: make typingDelayMs reactive using rememberUpdatedState in StreamingBubbleWithTypingEffect

* fix(#205): support message.delta event type to enable streaming text

* feat(#205): increase typing effect delay slider maximum to 1000ms

* fix(#205): continue animating completed streaming messages until all words are revealed

* fix(#205): revert slider delay maximum value back to 100ms
This commit is contained in:
M57 authored and GitHub committed 2026-06-21 19:38:51 +03:00
1 parent 2f5a31d3cb
commit 0c192eddad
3 files changed
+42 -11

No files matched your search

@@ -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
@@ -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)
}
@@ -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<String?>(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
}