diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index 3644ed92..ba79c8f6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -72,10 +72,10 @@ import com.m57.hermescontrol.theme.SystemMessageColor import com.m57.hermescontrol.theme.ToolChipColor import com.m57.hermescontrol.theme.ToolChipColorLight import com.m57.hermescontrol.theme.UserBubble +import kotlinx.coroutines.delay import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter -import kotlinx.coroutines.delay @Composable fun ChatBubble( @@ -811,8 +811,9 @@ private fun RichText( ) } -private val TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern("HH:mm") - .withZone(ZoneId.systemDefault()) +private val TIMESTAMP_FORMATTER = + DateTimeFormatter.ofPattern("HH:mm") + .withZone(ZoneId.systemDefault()) private fun formatTimestamp(timestamp: Long): String { return TIMESTAMP_FORMATTER.format(Instant.ofEpochMilli(timestamp)) 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 d8b39867..868e3099 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 @@ -171,9 +171,10 @@ fun ChatScreen( // Auto-scroll to bottom on new messages LaunchedEffect(state.messages.size, state.streamingMessage?.content?.length, state.isThinking) { - val totalItems = state.messages.size + - (if (state.streamingMessage != null) 1 else 0) + - (if (state.isThinking) 1 else 0) + val totalItems = + state.messages.size + + (if (state.streamingMessage != null) 1 else 0) + + (if (state.isThinking) 1 else 0) if (totalItems > 0) { listState.animateScrollToItem(totalItems - 1) } @@ -354,8 +355,9 @@ fun ChatScreen( .imePadding(), ) { androidx.compose.animation.AnimatedVisibility( - visible = state.connectionStatus == ConnectionStatus.RECONNECTING || - state.connectionStatus == ConnectionStatus.DISCONNECTED, + visible = + state.connectionStatus == ConnectionStatus.RECONNECTING || + state.connectionStatus == ConnectionStatus.DISCONNECTED, enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(), exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut(), ) { @@ -365,26 +367,29 @@ fun ChatScreen( contentColor = MaterialTheme.colorScheme.onErrorContainer, ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - text = if (state.connectionStatus == ConnectionStatus.RECONNECTING) { - "Reconnecting to Hermes…" - } else { - "Disconnected from Hermes" - }, + text = + if (state.connectionStatus == ConnectionStatus.RECONNECTING) { + "Reconnecting to Hermes…" + } else { + "Disconnected from Hermes" + }, style = MaterialTheme.typography.bodyMedium, ) if (state.connectionStatus == ConnectionStatus.DISCONNECTED) { TextButton( onClick = { viewModel.reconnect() }, - colors = androidx.compose.material3.ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ), + colors = + androidx.compose.material3.ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), ) { Text("Reconnect") } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index ac5e5faa..2328e2a4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -254,13 +254,12 @@ class ChatViewModel( private fun handleMessageComplete(event: WsEvent.MessageComplete) { if (!isCurrentSession(event.sessionId)) return - // Finalize: move the streaming message into the main list - val finalizedMsg: ChatMessage - val sessionId: String? + var finalizedMsg: ChatMessage? = null + var sessionId: String? = null _uiState.update { state -> val streaming = state.streamingMessage - finalizedMsg = if (streaming != null) { + val msg = if (streaming != null) { streaming.copy( content = event.text, isStreaming = false, @@ -271,9 +270,10 @@ class ChatViewModel( content = event.text, ) } + finalizedMsg = msg sessionId = state.currentSessionId state.copy( - messages = state.messages + finalizedMsg, + messages = state.messages + msg, streamingMessage = null, isAgentTyping = false, isThinking = false, @@ -283,10 +283,11 @@ class ChatViewModel( streamingMessageId = null // Persist finalized message — OUTSIDE update{} to avoid CAS retry + val msgToPersist = finalizedMsg val sid = sessionId ?: _uiState.value.currentSessionId - if (sid != null) { + if (msgToPersist != null && sid != null) { viewModelScope.launch(Dispatchers.IO) { - dao.upsert(finalizedMsg.toEntity(sid)) + dao.upsert(msgToPersist.toEntity(sid)) } } } @@ -296,16 +297,17 @@ class ChatViewModel( // If we still have an un-finalized streaming message (no MessageComplete // was sent, which happens on interrupts), fold it into the list. - val orphan: ChatMessage? - val sessionId: String? + var orphan: ChatMessage? = null + var sessionId: String? = null _uiState.update { state -> val streaming = state.streamingMessage - orphan = streaming?.copy(isStreaming = false) + val msg = streaming?.copy(isStreaming = false) + orphan = msg sessionId = state.currentSessionId - if (orphan != null) { + if (msg != null) { state.copy( - messages = state.messages + orphan, + messages = state.messages + msg, streamingMessage = null, isAgentTyping = false, isThinking = false, @@ -322,10 +324,11 @@ class ChatViewModel( streamingMessageId = null // Persist orphaned streaming message + val msgToPersist = orphan val sid = sessionId ?: _uiState.value.currentSessionId - if (orphan != null && sid != null) { + if (msgToPersist != null && sid != null) { viewModelScope.launch(Dispatchers.IO) { - dao.upsert(orphan.toEntity(sid)) + dao.upsert(msgToPersist.toEntity(sid)) } } } @@ -358,16 +361,18 @@ class ChatViewModel( _uiState.update { state -> val messages = state.messages.toMutableList() - val toolIdx = messages.indexOfLast { - it.role == MessageRole.TOOL && - it.toolName == event.name && - it.toolStatus == ToolStatus.RUNNING - } + val toolIdx = + messages.indexOfLast { + it.role == MessageRole.TOOL && + it.toolName == event.name && + it.toolStatus == ToolStatus.RUNNING + } if (toolIdx >= 0) { - val updated = messages[toolIdx].copy( - toolStatus = ToolStatus.COMPLETED, - content = event.data.toString(), - ) + val updated = + messages[toolIdx].copy( + toolStatus = ToolStatus.COMPLETED, + content = event.data.toString(), + ) messages[toolIdx] = updated completedTool = updated } @@ -494,10 +499,11 @@ class ChatViewModel( return } - val userMessage = ChatMessage( - role = MessageRole.USER, - content = text, - ) + val userMessage = + ChatMessage( + role = MessageRole.USER, + content = text, + ) // Update UI — no DB writes inside update{} _uiState.update { state -> @@ -892,13 +898,15 @@ class ChatViewModel( * responded to. Surfaces a timeout error for the user. */ private fun startPendingRequestCleanup() { - pendingCleanupJob = viewModelScope.launch { - while (true) { - delay(PENDING_REQUEST_TIMEOUT_MS) - val now = System.currentTimeMillis() - val stale = pendingRequests.entries.filter { - now - it.value.createdAt > PENDING_REQUEST_TIMEOUT_MS - } + pendingCleanupJob = + viewModelScope.launch { + while (true) { + delay(PENDING_REQUEST_TIMEOUT_MS) + val now = System.currentTimeMillis() + val stale = + pendingRequests.entries.filter { + now - it.value.createdAt > PENDING_REQUEST_TIMEOUT_MS + } for (entry in stale) { pendingRequests.remove(entry.key) Log.w(TAG, "Request timed out: ${entry.value.method} (id=${entry.key})")