perf(#281): fix PERF-13/14/15/16 — main-thread settings, 100Hz polling, leaked scope, WS disconnect (#359)

* perf(#281): fix PERF-13/14/15/16 — main-thread settings load, 100Hz polling, leaked scope, WS disconnect

* fix(#281): fix SettingsViewModelTest failures from async init dispatcher

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-25 20:39:52 +03:00
1 parent 9297413b2a
commit 3399880621
5 files changed
+81 -46

No files matched your search

@@ -797,10 +797,11 @@ private fun StreamingBubbleWithTypingEffect(
// 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.
LaunchedEffect(Unit) {
var wordCount = 0
while (true) {
val words = currentContent.value.split(" ")
if (visibleWordCount < words.size) {
if (visibleWordCount < wordCount) {
delay(currentDelayMs.value.toLong())
visibleWordCount++
} else {
@@ -808,7 +809,11 @@ private fun StreamingBubbleWithTypingEffect(
onAnimationComplete()
break
}
delay(10)
// Only split when we need to check for new content arriving
val words = currentContent.value.split(" ")
wordCount = words.size
if (visibleWordCount < wordCount) continue
delay(100) // was 10 — reduced from 100Hz to 10Hz
}
}
visibleWordCount = Int.MAX_VALUE
@@ -1020,7 +1020,8 @@ class ChatViewModel(
override fun onCleared() {
super.onCleared()
pendingCleanupJob?.cancel()
wsClient.disconnect()
// PERF-16: Don't disconnect the global HermesWsClient singleton when
// leaving the Chat screen — it's used by background notification reply.
}
companion object {
@@ -10,6 +10,7 @@ import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.theme.BottomNavDisplayMode
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -38,47 +39,57 @@ data class SettingsUiState(
val bottomNavDisplayMode: BottomNavDisplayMode = BottomNavDisplayMode.ICON_AND_TEXT,
)
class SettingsViewModel : ViewModel() {
class SettingsViewModel(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
init {
loadSettings()
viewModelScope.launch(ioDispatcher) {
loadSettings()
}
}
private fun loadSettings() {
private suspend fun loadSettings() {
val selectedId = AuthManager.getSelectedProfileId()
val host = AuthManager.getHost()
val port = AuthManager.getPort().toString()
val token = AuthManager.getToken() ?: ""
val autoReconnect = AuthManager.isAutoReconnect()
val themePreference = AuthManager.getThemePreference()
val useDynamicColors = AuthManager.isUseDynamicColors()
val themePreset = AuthManager.getThemePreset()
val selectedNavItems = AuthManager.getBottomNavItems()
val typingEffectEnabled = AuthManager.isTypingEffectEnabled()
val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs()
val profiles = AuthManager.getConnectionProfiles()
val bottomNavDisplayMode = AuthManager.getBottomNavDisplayMode()
val renameProfileName =
profiles.firstOrNull { p -> p.id == selectedId }?.name ?: ""
_uiState.update {
it.copy(
host = AuthManager.getHost(),
port = AuthManager.getPort().toString(),
token = AuthManager.getToken() ?: "",
autoReconnect = AuthManager.isAutoReconnect(),
// B6 (Jun 18 2026, kanban t_86e9be9b): restore user's theme
// choice from persistent storage on init. Previously this slot
// was missing — always defaulted to ThemePreference.SYSTEM.
themePreference = AuthManager.getThemePreference(),
useDynamicColors = AuthManager.isUseDynamicColors(),
themePreset = AuthManager.getThemePreset(),
selectedNavItems = AuthManager.getBottomNavItems(),
typingEffectEnabled = AuthManager.isTypingEffectEnabled(),
typingEffectDelayMs = AuthManager.getTypingEffectDelayMs(),
profiles = AuthManager.getConnectionProfiles(),
host = host,
port = port,
token = token,
autoReconnect = autoReconnect,
themePreference = themePreference,
useDynamicColors = useDynamicColors,
themePreset = themePreset,
selectedNavItems = selectedNavItems,
typingEffectEnabled = typingEffectEnabled,
typingEffectDelayMs = typingEffectDelayMs,
profiles = profiles,
selectedProfileId = selectedId,
renameProfileName =
AuthManager
.getConnectionProfiles()
.firstOrNull { p ->
p.id == selectedId
}?.name ?: "",
bottomNavDisplayMode = AuthManager.getBottomNavDisplayMode(),
renameProfileName = renameProfileName,
bottomNavDisplayMode = bottomNavDisplayMode,
)
}
}
fun selectProfile(profileId: String?) {
AuthManager.setSelectedProfileId(profileId)
loadSettings()
viewModelScope.launch(ioDispatcher) { loadSettings() }
ApiClient.rebuild()
}
@@ -95,7 +106,7 @@ class SettingsViewModel : ViewModel() {
if (it.id == currentId) it.copy(name = newName) else it
}
AuthManager.saveConnectionProfiles(updatedProfiles)
loadSettings()
viewModelScope.launch(ioDispatcher) { loadSettings() }
}
fun deleteProfile(profileId: String) {
@@ -105,7 +116,7 @@ class SettingsViewModel : ViewModel() {
if (AuthManager.getSelectedProfileId() == profileId) {
AuthManager.setSelectedProfileId(null)
}
loadSettings()
viewModelScope.launch(ioDispatcher) { loadSettings() }
ApiClient.rebuild()
}
@@ -207,8 +218,10 @@ class SettingsViewModel : ViewModel() {
AuthManager.setTypingEffectDelayMs(state.typingEffectDelayMs)
ApiClient.rebuild()
loadSettings()
_uiState.update { it.copy(isSaved = true, testResult = null) }
viewModelScope.launch(ioDispatcher) {
loadSettings()
_uiState.update { it.copy(isSaved = true, testResult = null) }
}
}
fun testConnection() {
@@ -225,7 +238,7 @@ class SettingsViewModel : ViewModel() {
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.getStatus() }
}
when (result) {