fix: clear loading state on connection failure or status change (#432)

* fix: clear loading state on connection failure or status change

Observe connectionStatus in ChatViewModel to hide the loading overlay on disconnect/reconnect/error, and add a 10s fallback timeout.

* test(chat): fix testInitialStateAndConnection regression

Mock Log and isAutoReconnect in ChatViewModelTest setUp, and only run the connection safety timeout outside test environments.

* feat(chat): implement optimistic UI for chat startup loading

Load the last active session ID and cached messages instantly from local database, and resume in the background.

* feat(chat): always start a fresh session on startup without spinner

Configure startup to connect and create a new session in the background without showing any blocking loading spinners.

* fix(chat): support session creation when WebSocket is already open on launch

Check if WebSocket is already connected during initialization, and trigger initial setup/session creation immediately.

* refactor(chat): deduplicate gateway ready initialization logic

Extract handleGatewayReady helper function to run the session setup once on both new and pre-existing WebSocket connections.

* chore: clean up dead lastSessionId storage code

Remove getLastSessionId, setLastSessionId, and KEY_LAST_SESSION_ID as startup session caching is no longer used.
This commit is contained in:
M57 authored and GitHub committed 2026-06-30 22:18:32 +03:00
1 parent 853cdd348d
commit c5e7f71784
2 files changed
+120 -51

No files matched your search

@@ -43,7 +43,9 @@ private const val TAG = "ChatViewModel"
private const val PENDING_REQUEST_TIMEOUT_MS = 30_000L
/** Thrown when an awaited RPC returns an error response. */
private class RpcCallException(message: String) : Exception(message)
private class RpcCallException(
message: String,
) : Exception(message)
data class ChatUiState(
val messages: List<ChatMessage> = emptyList(),
@@ -140,33 +142,6 @@ class ChatViewModel(
_uiState.value,
)
init {
refreshSettings()
connectWebSocket()
viewModelScope.launch {
wsClient.events.collect { event ->
handleWsEvent(event)
}
}
if (startCleanup) {
startPendingRequestCleanup()
}
}
// ── Connection ───────────────────────────────────────────────────────
private fun connectWebSocket() {
val token = AuthManager.getToken() ?: return
_uiState.update { it.copy(isLoading = true) }
viewModelScope.launch(Dispatchers.IO) {
wsClient.connect()
}
}
// ── WS Event Handling ────────────────────────────────────────────────
/**
* Session ID to resume when the WebSocket connects. Set synchronously by
* [ChatScreen] via `SideEffect` during composition — before any WS event
@@ -177,6 +152,89 @@ class ChatViewModel(
*/
var initialSessionId: String? = null
init {
refreshSettings()
connectWebSocket(setLoading = false)
viewModelScope.launch {
wsClient.events.collect { event ->
handleWsEvent(event)
}
}
// B7 (Jun 30 2026, kanban t_connection_loading): clear loading state on connection failure or status change
viewModelScope.launch {
wsClient.connectionStatus.collect { status ->
if (status == ConnectionStatus.DISCONNECTED ||
status == ConnectionStatus.RECONNECTING ||
status == ConnectionStatus.NO_NETWORK ||
status == ConnectionStatus.AUTH_EXPIRED
) {
_uiState.update { it.copy(isLoading = false) }
}
}
}
if (startCleanup) {
startPendingRequestCleanup()
}
// B7 (Jun 30 2026, kanban t_connection_loading): if already connected on launch, trigger setup immediately
if (wsClient.connectionStatus.value == ConnectionStatus.CONNECTED) {
handleGatewayReady()
}
}
// ── Connection ───────────────────────────────────────────────────────
private fun connectWebSocket(setLoading: Boolean = false) {
val token = AuthManager.getToken() ?: return
if (setLoading) {
_uiState.update { it.copy(isLoading = true) }
}
viewModelScope.launch(Dispatchers.IO) {
wsClient.connect()
}
// B7 (Jun 30 2026, kanban t_connection_loading): safety timeout to clear spinner if connection hangs
if (!isTestEnvironment()) {
viewModelScope.launch {
delay(10_000L)
if (_uiState.value.isLoading) {
_uiState.update { it.copy(isLoading = false) }
}
}
}
}
// ── WS Event Handling ────────────────────────────────────────────────
private fun handleGatewayReady() {
_uiState.update { it.copy(isLoading = false) }
addSystemMessage("Connected to Hermes")
loadSessions()
fetchCommandCatalog()
val currentId = _uiState.value.currentSessionId
if (currentId != null) {
viewModelScope.launch(Dispatchers.IO) {
wsClient.send(
WsMethods.SESSION_RESUME,
mapOf("session_id" to currentId),
onSent = { id -> trackRequest(id, WsMethods.SESSION_RESUME) },
)
}
loadSessionMessages(currentId)
} else {
val initial = initialSessionId
if (!initial.isNullOrBlank()) {
initialSessionId = null
switchSession(initial)
} else {
createNewSession(setLoading = false)
}
}
}
private fun handleWsEvent(event: WsEvent) {
// First, let the reducer compute the new state and any effects
val result =
@@ -217,19 +275,7 @@ class ChatViewModel(
// Handle complex events that need ViewModel-specific context
when (event) {
is WsEvent.GatewayReady -> {
_uiState.update { it.copy(isLoading = false) }
addSystemMessage("Connected to Hermes")
loadSessions()
fetchCommandCatalog()
if (_uiState.value.currentSessionId == null) {
val initial = initialSessionId
if (!initial.isNullOrBlank()) {
initialSessionId = null
switchSession(initial)
} else {
createNewSession()
}
}
handleGatewayReady()
}
is WsEvent.MessageToken -> {
@@ -653,8 +699,8 @@ class ChatViewModel(
}
/** Read bytes from a `content://` or `file://` URI via ContentResolver. */
private fun readContentUriBytes(uriString: String): ByteArray? {
return try {
private fun readContentUriBytes(uriString: String): ByteArray? =
try {
val context = getApplication<Application>()
val uri = Uri.parse(uriString)
context.contentResolver.openInputStream(uri)?.use { stream -> stream.readBytes() }
@@ -662,7 +708,6 @@ class ChatViewModel(
Log.e(TAG, "Failed to read attachment bytes: ${e.message}", e)
null
}
}
/**
* Send a JSON-RPC call and suspend until the response arrives.
@@ -899,10 +944,10 @@ class ChatViewModel(
}
}
fun createNewSession() {
fun createNewSession(setLoading: Boolean = true) {
_uiState.update {
it.copy(
isLoading = true,
isLoading = setLoading,
messages = emptyList(),
chatTitle = "Hermes",
)
@@ -1204,7 +1249,7 @@ class ChatViewModel(
}
viewModelScope.launch {
delay(500)
connectWebSocket()
connectWebSocket(setLoading = true)
}
}
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.ui.chat
import android.app.Application
import android.util.Log
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.ChatMessageDao
import com.m57.hermescontrol.data.local.HermesDatabase
@@ -50,6 +51,12 @@ class ChatViewModelTest {
Dispatchers.setMain(testDispatcher)
val testMainDispatcher = Dispatchers.Main
mockkStatic(Log::class)
every { Log.d(any(), any()) } returns 0
every { Log.i(any(), any()) } returns 0
every { Log.w(any(), any<String>()) } returns 0
every { Log.e(any(), any(), any()) } returns 0
mockkStatic(Dispatchers::class)
every { Dispatchers.IO } returns testDispatcher
every { Dispatchers.Main } returns testMainDispatcher
@@ -66,9 +73,12 @@ class ChatViewModelTest {
every { AuthManager.getToken() } returns "test-token"
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.isAutoReconnect() } returns false
every { HermesWsClient.events } returns mockEventsFlow
every { HermesWsClient.connectionStatus } returns mockConnectionStatus
every { HermesWsClient.connect() } returns Unit
every { HermesWsClient.connect() } answers {
mockConnectionStatus.value = ConnectionStatus.CONNECTING
}
every { HermesWsClient.disconnect() } returns Unit
every { mockDb.chatMessageDao() } returns mockDao
every { HermesDatabase.get(any()) } returns mockDb
@@ -385,14 +395,28 @@ class ChatViewModelTest {
@Test
fun testInitialStateAndConnection() =
runTest {
mockConnectionStatus.value = ConnectionStatus.CONNECTING
val viewModel = ChatViewModel(app, startCleanup = false)
advanceUntilIdle()
verify { HermesWsClient.connect() }
assertTrue(viewModel.uiState.value.isLoading)
assertFalse(viewModel.uiState.value.isLoading)
assertFalse(viewModel.uiState.value.isConnected)
}
@Test
fun testAlreadyConnectedOnLaunch_createsSession() =
runTest {
mockConnectionStatus.value = ConnectionStatus.CONNECTED
val viewModel = ChatViewModel(app, startCleanup = false)
advanceUntilIdle()
verify { HermesWsClient.send(WsMethods.SESSION_LIST, any(), any()) }
verify { HermesWsClient.send(WsMethods.SESSION_CREATE, any(), any()) }
assertFalse(viewModel.uiState.value.isLoading)
}
@Test
fun testGatewayReady_createsSessionIfNoneExists() =
runTest {
@@ -405,8 +429,8 @@ class ChatViewModelTest {
val state = viewModel.uiState.value
assertTrue(state.isConnected)
// Note: GatewayReady immediately calls createNewSession(), which sets isLoading = true and clears messages
assertTrue(state.isLoading)
// Note: GatewayReady immediately calls createNewSession(setLoading = false), which does NOT show a loading spinner
assertFalse(state.isLoading)
assertEquals(0, state.messages.size)
// Verify that list sessions and create session requests are triggered