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