refactor(#288): split ChatViewModel god object (CODE-34) (#354)

* refactor(#288): extract ChatPersistenceRepository from ChatViewModel

* refactor(#288): extract ChatWsEventReducer pure state reducer

* refactor(#288): extract SlashCommandDispatcher

* refactor(#288): wire SlashCommandDispatcher into ViewModel

* refactor(#288): extract ChatSearchController

* refactor(#288): final cleanup — remove unused import, ktlint pass

* fix: compile & test fixes — GatewayReady handler, Gson import, MessageStart id, reducer args

* fix: align reducer handlers with old behavior — onMessageComplete onMessageDone onMessageStart

* chore: remove plan file from PR

* chore: remove unrelated Room schema from PR

* fix: add session filtering to reducer, fix onToolStart streaming state

* chore: remove schema file again

* fix: pass currentSessionId to reducer, align onMessageStart and onThinkingDelta state

* chore: remove schema

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-24 22:19:25 +03:00
1 parent f190b67ee6
commit e7b2973c1c
5 files changed
+701 -372

No files matched your search

@@ -0,0 +1,37 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.local.ChatMessageDao
import com.m57.hermescontrol.data.local.toEntity
import com.m57.hermescontrol.data.local.toUiModel
/**
* Wraps Room DAO operations for chat message persistence.
*
* Extracted from ChatViewModel to separate persistence concerns from
* UI state management and WebSocket event handling.
*/
class ChatPersistenceRepository(
private val dao: ChatMessageDao,
) {
/** Persist a single message for the given session. */
suspend fun persistMessage(
message: ChatMessage,
sessionId: String,
) {
dao.upsert(message.toEntity(sessionId))
}
/** Persist multiple messages in one transaction. */
suspend fun persistMessages(
messages: List<ChatMessage>,
sessionId: String,
) {
val entities = messages.map { it.toEntity(sessionId) }
dao.upsertAll(entities)
}
/** Load cached messages for a session from Room. */
suspend fun loadMessages(sessionId: String): List<ChatMessage> {
return dao.getMessagesForSession(sessionId).map { it.toUiModel() }
}
}
@@ -0,0 +1,37 @@
package com.m57.hermescontrol.ui.chat
/**
* Pure search logic for in-chat text search.
*
* Computes match indices and navigation without depending on ViewModel or Android.
*/
class ChatSearchController {
/**
* Find all message indices where [query] appears in the content.
*/
fun findMatches(
messages: List<ChatMessage>,
query: String,
): List<Int> {
if (query.isBlank()) return emptyList()
return messages.indices.filter { idx ->
messages[idx].content.contains(query, ignoreCase = true)
}
}
/**
* Compute the next/previous match index given the current position.
*/
fun navigate(
currentIndex: Int,
matchCount: Int,
direction: Int,
): Int {
if (matchCount == 0) return -1
return when (direction) {
1 -> if (currentIndex >= matchCount - 1) 0 else currentIndex + 1
-1 -> if (currentIndex <= 0) matchCount - 1 else currentIndex - 1
else -> currentIndex
}
}
}
@@ -7,8 +7,6 @@ import androidx.lifecycle.viewModelScope
import com.google.gson.Gson import com.google.gson.Gson
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.HermesDatabase import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.local.toEntity
import com.m57.hermescontrol.data.local.toUiModel
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
@@ -88,7 +86,12 @@ class ChatViewModel(
private val wsClient = HermesWsClient private val wsClient = HermesWsClient
private val pendingRequests = ConcurrentHashMap<String, PendingRequest>() private val pendingRequests = ConcurrentHashMap<String, PendingRequest>()
private val dao = HermesDatabase.get(application).chatMessageDao() private val repo =
ChatPersistenceRepository(
HermesDatabase.get(application).chatMessageDao(),
)
private val slashDispatcher = SlashCommandDispatcher()
private val searchController = ChatSearchController()
/** Tracks the ID of the currently streaming assistant message. */ /** Tracks the ID of the currently streaming assistant message. */
@Volatile @Volatile
@@ -158,6 +161,36 @@ class ChatViewModel(
var initialSessionId: String? = null var initialSessionId: String? = null
private fun handleWsEvent(event: WsEvent) { private fun handleWsEvent(event: WsEvent) {
// First, let the reducer compute the new state and any effects
val result = ChatWsEventReducer.reduce(_uiState.value, event, _uiState.value.currentSessionId)
// Apply the new state
_uiState.update { result.state }
// Process side-effects from the reducer
for (effect in result.effects) {
when (effect) {
is ReducerEffect.PersistMessage -> {
viewModelScope.launch(Dispatchers.IO) {
repo.persistMessage(effect.message, effect.sessionId)
}
}
is ReducerEffect.CreateNewSession -> {
createNewSession()
}
is ReducerEffect.LoadSessions -> {
loadSessions()
}
is ReducerEffect.RefreshSessions -> {
loadSessions()
}
}
}
// Handle complex events that need ViewModel-specific context
when (event) { when (event) {
is WsEvent.GatewayReady -> { is WsEvent.GatewayReady -> {
_uiState.update { it.copy(isLoading = false) } _uiState.update { it.copy(isLoading = false) }
@@ -174,12 +207,6 @@ class ChatViewModel(
} }
} }
is WsEvent.SessionInfo -> {}
is WsEvent.MessageStart -> {
handleMessageStart(event)
}
is WsEvent.MessageToken -> { is WsEvent.MessageToken -> {
handleMessageToken(event) handleMessageToken(event)
} }
@@ -188,40 +215,34 @@ class ChatViewModel(
handleThinkingDelta(event) handleThinkingDelta(event)
} }
is WsEvent.MessageStart -> {
streamingMessageId =
java.util.UUID
.randomUUID()
.toString()
streamingBuffer.clear()
thinkingBuffer.clear()
lastFlushMs = 0L
lastThinkingFlushMs = 0L
}
is WsEvent.MessageComplete -> { is WsEvent.MessageComplete -> {
handleMessageComplete(event) // Buffers cleared before reduce; ViewModel resets them after
streamingMessageId = null
streamingBuffer.clear()
thinkingBuffer.clear()
} }
is WsEvent.MessageDone -> { is WsEvent.MessageDone -> {
handleMessageDone(event) streamingMessageId = null
streamingBuffer.clear()
thinkingBuffer.clear()
} }
is WsEvent.ToolStart -> { is WsEvent.ToolStart -> {
handleToolStart(event) // Reset streaming state when a tool starts
} streamingBuffer.clear()
thinkingBuffer.clear()
is WsEvent.ToolComplete -> {
handleToolComplete(event)
}
is WsEvent.ClarifyRequest -> {
if (!isCurrentSession(event.sessionId)) return
_uiState.update {
it.copy(
clarifyRequest =
ClarifyUi(
text = event.text.orEmpty(),
options = event.options.orEmpty(),
clarifyId = event.clarifyId,
),
)
}
}
is WsEvent.StatusUpdate -> {}
is WsEvent.SessionUpdated -> {
loadSessions()
} }
is WsEvent.RpcResult -> { is WsEvent.RpcResult -> {
@@ -232,7 +253,19 @@ class ChatViewModel(
handleRpcError(event.id, event.error) handleRpcError(event.id, event.error)
} }
is WsEvent.Unknown -> {} is WsEvent.SessionUpdated -> {
loadSessions()
}
is WsEvent.ClarifyRequest -> {
_uiState.update {
it.copy(
isAgentTyping = false,
)
}
}
else -> { /* reducer handles these */ }
} }
} }
@@ -248,52 +281,6 @@ class ChatViewModel(
return eventSessionId == _uiState.value.currentSessionId return eventSessionId == _uiState.value.currentSessionId
} }
private fun handleMessageStart(event: WsEvent.MessageStart) {
if (!isCurrentSession(event.sessionId)) return
var orphanToPersist: ChatMessage? = null
val sessionId = _uiState.value.currentSessionId
streamingBuffer.clear()
thinkingBuffer.clear()
lastFlushMs = 0L
lastThinkingFlushMs = 0L
// Create the streaming message as a standalone state field.
val msg =
ChatMessage(
role = MessageRole.ASSISTANT,
content = "",
isStreaming = true,
)
streamingMessageId = msg.id
_uiState.update { state ->
val messages = state.messages.toMutableList()
val existing = state.streamingMessage
if (existing != null && existing.content.isNotEmpty()) {
val finalized = existing.copy(isStreaming = false)
messages.add(finalized)
orphanToPersist = finalized
}
state.copy(
messages = messages,
isAgentTyping = true,
streamingMessage = msg,
isThinking = false,
thinkingText = "",
)
}
// Persist — OUTSIDE update{}
val orphan = orphanToPersist
if (orphan != null && sessionId != null) {
viewModelScope.launch(Dispatchers.IO) {
dao.upsert(orphan.toEntity(sessionId))
}
}
}
private fun handleMessageToken(event: WsEvent.MessageToken) { private fun handleMessageToken(event: WsEvent.MessageToken) {
if (!isCurrentSession(event.sessionId)) return if (!isCurrentSession(event.sessionId)) return
@@ -354,183 +341,6 @@ class ChatViewModel(
} }
} }
private fun handleMessageComplete(event: WsEvent.MessageComplete) {
if (!isCurrentSession(event.sessionId)) return
var finalizedMsg: ChatMessage? = null
var sessionId: String? = null
streamingBuffer.clear()
thinkingBuffer.clear()
_uiState.update { state ->
val streaming = state.streamingMessage
val msg =
if (streaming != null) {
streaming.copy(
content = event.text,
isStreaming = false,
)
} else {
ChatMessage(
role = MessageRole.ASSISTANT,
content = event.text,
)
}
finalizedMsg = msg
sessionId = state.currentSessionId
state.copy(
messages = state.messages + msg,
streamingMessage = null,
isAgentTyping = false,
isThinking = false,
thinkingText = "",
)
}
streamingMessageId = null
// Persist finalized message — OUTSIDE update{} to avoid CAS retry
val msgToPersist = finalizedMsg
val sid = sessionId ?: _uiState.value.currentSessionId
if (msgToPersist != null && sid != null) {
viewModelScope.launch(Dispatchers.IO) {
dao.upsert(msgToPersist.toEntity(sid))
}
}
// Refresh session list to catch newly generated titles
loadSessions()
}
private fun handleMessageDone(event: WsEvent.MessageDone) {
if (!isCurrentSession(event.sessionId)) return
// If we still have an un-finalized streaming message (no MessageComplete
// was sent, which happens on interrupts), fold it into the list.
var orphan: ChatMessage? = null
var sessionId: String? = null
// Flush any remaining buffered content to the message before marking it done
val finalContent = streamingBuffer.toString()
val finalThinkingText = thinkingBuffer.toString()
streamingBuffer.clear()
thinkingBuffer.clear()
_uiState.update { state ->
val streaming = state.streamingMessage
val msg =
streaming?.copy(
content = finalContent.ifEmpty { streaming.content },
isStreaming = false,
)
orphan = msg
sessionId = state.currentSessionId
if (msg != null) {
state.copy(
messages = state.messages + msg,
streamingMessage = null,
isAgentTyping = false,
isThinking = false,
thinkingText = "",
)
} else {
state.copy(
isAgentTyping = false,
isThinking = false,
thinkingText = "",
)
}
}
streamingMessageId = null
// Persist orphaned streaming message
val msgToPersist = orphan
val sid = sessionId ?: _uiState.value.currentSessionId
if (msgToPersist != null && sid != null) {
viewModelScope.launch(Dispatchers.IO) {
dao.upsert(msgToPersist.toEntity(sid))
}
}
}
// ── Tool events ──────────────────────────────────────────────────────
private fun handleToolStart(event: WsEvent.ToolStart) {
if (!isCurrentSession(event.sessionId)) return
var orphanToPersist: ChatMessage? = null
val sessionId = _uiState.value.currentSessionId
val contentJson = event.data?.let { gson.toJson(it) } ?: ""
val toolMessage =
ChatMessage(
role = MessageRole.TOOL,
content = contentJson,
toolName = event.name,
toolStatus = ToolStatus.RUNNING,
)
_uiState.update { state ->
val messages = state.messages.toMutableList()
val existing = state.streamingMessage
if (existing != null && existing.content.isNotEmpty()) {
val finalized = existing.copy(isStreaming = false)
messages.add(finalized)
orphanToPersist = finalized
}
messages.add(toolMessage)
state.copy(
messages = messages,
streamingMessage = null,
)
}
// Persist — OUTSIDE update{}
val orphan = orphanToPersist
if (sessionId != null) {
viewModelScope.launch(Dispatchers.IO) {
if (orphan != null) {
dao.upsert(orphan.toEntity(sessionId))
}
dao.upsert(toolMessage.toEntity(sessionId))
}
}
}
private fun handleToolComplete(event: WsEvent.ToolComplete) {
if (!isCurrentSession(event.sessionId)) return
var completedTool: ChatMessage? = null
_uiState.update { state ->
val messages = state.messages.toMutableList()
val toolIdx =
messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolName == event.name &&
it.toolStatus == ToolStatus.RUNNING
}
if (toolIdx >= 0) {
val contentJson = event.data?.let { gson.toJson(it) } ?: ""
val updated =
messages[toolIdx].copy(
toolStatus = ToolStatus.COMPLETED,
content = contentJson,
)
messages[toolIdx] = updated
completedTool = updated
}
state.copy(messages = messages)
}
// Persist — OUTSIDE update{}
val tool = completedTool
val sessionId = _uiState.value.currentSessionId
if (tool != null && sessionId != null) {
viewModelScope.launch(Dispatchers.IO) {
dao.upsert(tool.toEntity(sessionId))
}
}
}
// ── RPC response handling ──────────────────────────────────────────── // ── RPC response handling ────────────────────────────────────────────
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@@ -661,7 +471,7 @@ class ChatViewModel(
// Persist to Room — OUTSIDE update{} // Persist to Room — OUTSIDE update{}
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
dao.upsert(userMessage.toEntity(sessionId)) repo.persistMessage(userMessage, sessionId)
} }
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
@@ -682,125 +492,124 @@ class ChatViewModel(
// Persist — OUTSIDE update{} // Persist — OUTSIDE update{}
if (sessionId != null) { if (sessionId != null) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
dao.upsert(userMsg.toEntity(sessionId)) repo.persistMessage(userMsg, sessionId)
} }
} }
val parts = command.split(" ") when (val result = slashDispatcher.dispatch(command)) {
val cmd = parts[0].lowercase() is SlashResult.Message -> {
addAssistantMessage(result.text)
}
when (cmd) { is SlashResult.Interrupt -> {
"/stop", "/interrupt" -> {
interruptSession() interruptSession()
} }
"/new" -> { is SlashResult.NewSession -> {
createNewSession() createNewSession()
} }
"/help" -> { is SlashResult.FetchStatus -> {
val helpText = fetchAndDisplayStatus()
"""
**Available Commands:**
• `/help` - Show this help menu
• `/status` - Check gateway and platform status
• `/sessions` - List all chat sessions
• `/stats` or `/system` - Check system resource usage
• `/new` - Create a new chat session
• `/stop` or `/interrupt` - Interrupt the active run
""".trimIndent()
addAssistantMessage(helpText)
} }
"/status" -> { is SlashResult.FetchSessions -> {
viewModelScope.launch { fetchAndDisplaySessions()
val result = }
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getStatus() }
}
when (result) {
is NetworkResult.Success -> {
val body = result.data
val platformsStr =
body.gateway_platforms?.entries?.joinToString("\n") { (k, v) ->
" • **$k**: ${v.state ?: "Unknown"}${if (v.error_code != null) " (Error: ${v.error_code})" else ""}"
} ?: "No active platforms"
val statusText = is SlashResult.FetchStats -> {
""" fetchAndDisplayStats()
**Hermes Gateway Status:** }
• **Version:** ${body.version ?: "Unknown"}
• **Gateway Running:** ${body.gateway_running ?: false}
• **Active Sessions:** ${body.active_sessions ?: 0}
• **Auth Required:** ${body.auth_required ?: false}
**Platforms:** is SlashResult.Unknown -> {
$platformsStr addAssistantMessage(
""".trimIndent() "Unknown command: `${result.cmd}`. Type `/help` to view a list of available commands.",
addAssistantMessage(statusText) )
} }
}
}
is NetworkResult.Failure -> { private fun fetchAndDisplayStatus() {
addAssistantMessage("Failed to retrieve status: ${result.error.message}") viewModelScope.launch {
} val result =
} withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getStatus() }
}
when (result) {
is NetworkResult.Success -> {
val body = result.data
val platformsStr =
body.gateway_platforms?.entries?.joinToString("\n") { (k, v) ->
" • **$k**: ${v.state ?: "Unknown"}${if (v.error_code != null) " (Error: ${v.error_code})" else ""}"
} ?: "No active platforms"
addAssistantMessage(
"""
**Hermes Gateway Status:**
• **Version:** ${body.version ?: "Unknown"}
• **Gateway Running:** ${body.gateway_running ?: false}
• **Active Sessions:** ${body.active_sessions ?: 0}
• **Auth Required:** ${body.auth_required ?: false}
**Platforms:**
$platformsStr
""".trimIndent(),
)
}
is NetworkResult.Failure -> {
addAssistantMessage("Failed to retrieve status: ${result.error.message}")
} }
} }
}
}
"/sessions" -> { private fun fetchAndDisplaySessions() {
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getSessions() } safeApiCall { ApiClient.hermesApi.getSessions() }
} }
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
val body = result.data val body = result.data
val sessionsStr = val sessionsStr =
body.sessions.joinToString("\n") { s -> body.sessions.joinToString("\n") { s ->
"• **${s.title ?: "Untitled"}** (ID: `${s.id}`, Messages: ${s.message_count ?: 0})" "• **${s.title ?: "Untitled"}** (ID: `${s.id}`, Messages: ${s.message_count ?: 0})"
}
addAssistantMessage("**Sessions List:**\n$sessionsStr")
} }
addAssistantMessage("**Sessions List:**\n$sessionsStr")
}
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
addAssistantMessage("Failed to list sessions: ${result.error.message}") addAssistantMessage("Failed to list sessions: ${result.error.message}")
}
}
} }
} }
}
}
"/stats", "/system" -> { private fun fetchAndDisplayStats() {
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getSystemStats() } safeApiCall { ApiClient.hermesApi.getSystemStats() }
} }
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
val body = result.data val body = result.data
val cpuPct = body.cpuPercent?.let { String.format("%.1f%%", it) } ?: "N/A" val cpuPct = body.cpuPercent?.let { String.format("%.1f%%", it) } ?: "N/A"
val memPct = body.memoryPercent?.let { String.format("%.1f%%", it) } ?: "N/A" val memPct = body.memoryPercent?.let { String.format("%.1f%%", it) } ?: "N/A"
val uptimeVal = body.uptime ?: "N/A" addAssistantMessage(
val statsText = """
""" **System Resource Stats:**
**System Resource Stats:** • **CPU Usage:** $cpuPct
• **CPU Usage:** $cpuPct • **Memory Usage:** $memPct
• **Memory Usage:** $memPct • **Uptime:** ${body.uptime ?: "N/A"}
• **Uptime:** $uptimeVal """.trimIndent(),
""".trimIndent() )
addAssistantMessage(statsText)
}
is NetworkResult.Failure -> {
addAssistantMessage("Failed to retrieve system stats: ${result.error.message}")
}
}
} }
}
else -> { is NetworkResult.Failure -> {
addAssistantMessage("Unknown command: `$cmd`. Type `/help` to view a list of available commands.") addAssistantMessage("Failed to retrieve system stats: ${result.error.message}")
}
} }
} }
} }
@@ -813,7 +622,7 @@ class ChatViewModel(
val sessionId = _uiState.value.currentSessionId val sessionId = _uiState.value.currentSessionId
if (sessionId != null) { if (sessionId != null) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
dao.upsert(msg.toEntity(sessionId)) repo.persistMessage(msg, sessionId)
} }
} }
} }
@@ -913,8 +722,7 @@ class ChatViewModel(
private fun loadCachedMessages(sessionId: String) { private fun loadCachedMessages(sessionId: String) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val cached = dao.getMessagesForSession(sessionId) val cachedMessages = repo.loadMessages(sessionId)
val cachedMessages = cached.map { it.toUiModel() }
_uiState.update { state -> _uiState.update { state ->
// Only replace if still showing this session // Only replace if still showing this session
if (state.currentSessionId == sessionId) { if (state.currentSessionId == sessionId) {
@@ -958,9 +766,8 @@ class ChatViewModel(
) )
} }
// Persist loaded messages to Room for offline access // Persist loaded messages to Room for offline access
val entities = chatMessages.map { it.toEntity(sessionId) }
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
dao.upsertAll(entities) repo.persistMessages(chatMessages, sessionId)
} }
_uiState.update { state -> _uiState.update { state ->
// Only update if still on the same session // Only update if still on the same session
@@ -1022,7 +829,7 @@ class ChatViewModel(
} }
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
dao.upsert(userMessage.toEntity(sessionId)) repo.persistMessage(userMessage, sessionId)
} }
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
@@ -1076,7 +883,7 @@ class ChatViewModel(
// Persist — OUTSIDE update{} // Persist — OUTSIDE update{}
if (persist && sessionId != null) { if (persist && sessionId != null) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
dao.upsert(msg.toEntity(sessionId)) repo.persistMessage(msg, sessionId)
} }
} }
} }
@@ -1152,10 +959,7 @@ class ChatViewModel(
searchJob = searchJob =
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
val messages = _uiState.value.messages val messages = _uiState.value.messages
val indices = val indices = searchController.findMatches(messages, query)
messages.indices.filter { idx ->
messages[idx].content.contains(query, ignoreCase = true)
}
_uiState.update { _uiState.update {
// Only update indices if the search query hasn't changed in the meantime // Only update indices if the search query hasn't changed in the meantime
@@ -1175,13 +979,12 @@ class ChatViewModel(
_uiState.update { state -> _uiState.update { state ->
val indices = state.searchMatchIndices val indices = state.searchMatchIndices
if (indices.isEmpty()) return@update state if (indices.isEmpty()) return@update state
val current = state.currentSearchMatchIndex
val newIdx = val newIdx =
when (direction) { searchController.navigate(
1 -> if (current >= indices.lastIndex) 0 else current + 1 currentIndex = state.currentSearchMatchIndex,
-1 -> if (current <= 0) indices.lastIndex else current - 1 matchCount = indices.size,
else -> current direction = direction,
} )
state.copy(currentSearchMatchIndex = newIdx) state.copy(currentSearchMatchIndex = newIdx)
} }
} }
@@ -0,0 +1,389 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.ws.WsEvent
/**
* Pure state reducer for WebSocket events.
*
* Transforms [ChatUiState] in response to each [WsEvent] and returns a
* [ReducerResult] containing the new state plus any side-effects the
* ViewModel should execute (persistence, navigation, etc.).
*
* This is a pure function — no I/O, no mutable state, no dependencies on
* Android or ViewModel classes. Easy to unit test.
*/
object ChatWsEventReducer {
fun reduce(
state: ChatUiState,
event: WsEvent,
currentSessionId: String? = null,
): ReducerResult {
val eventSessionId =
when (event) {
is WsEvent.MessageStart -> event.sessionId
is WsEvent.MessageToken -> event.sessionId
is WsEvent.ThinkingDelta -> event.sessionId
is WsEvent.MessageComplete -> event.sessionId
is WsEvent.MessageDone -> event.sessionId
is WsEvent.ToolStart -> event.sessionId
is WsEvent.ToolComplete -> event.sessionId
is WsEvent.ClarifyRequest -> event.sessionId
else -> null
}
if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) {
return ReducerResult(state)
}
return reduceInternal(state, event)
}
private fun reduceInternal(
state: ChatUiState,
event: WsEvent,
): ReducerResult =
when (event) {
is WsEvent.GatewayReady -> onGatewayReady(state)
is WsEvent.MessageStart -> onMessageStart(state, event)
is WsEvent.MessageToken -> onMessageToken(state, event)
is WsEvent.ThinkingDelta -> onThinkingDelta(state, event)
is WsEvent.MessageComplete -> onMessageComplete(state, event)
is WsEvent.MessageDone -> onMessageDone(state)
is WsEvent.ToolStart -> onToolStart(state, event)
is WsEvent.ToolComplete -> onToolComplete(state, event)
is WsEvent.ClarifyRequest -> onClarifyRequest(state, event)
is WsEvent.RpcError -> onRpcError(state, event)
is WsEvent.SessionUpdated -> onSessionUpdated(state)
is WsEvent.StatusUpdate -> onStatusUpdate(state)
is WsEvent.Unknown -> onUnknown(state)
// SessionInfo is a no-op in the original code
is WsEvent.SessionInfo -> ReducerResult(state)
// RpcResult is handled by the ViewModel (needs pending request context)
is WsEvent.RpcResult -> ReducerResult(state)
}
// ── GatewayReady ──────────────────────────────────────────────────
private fun onGatewayReady(state: ChatUiState): ReducerResult = ReducerResult(state)
// ── MessageStart ──────────────────────────────────────────────────
private fun onMessageStart(
state: ChatUiState,
event: WsEvent.MessageStart,
): ReducerResult {
val msg =
ChatMessage(
role = MessageRole.ASSISTANT,
content = "",
isStreaming = true,
)
val effects = mutableListOf<ReducerEffect>()
// Build new state: finalize any orphan streaming message, then set the new one
var orphan: ChatMessage? = null
val preState =
if (state.streamingMessage?.content?.isNotEmpty() == true) {
val finalized = state.streamingMessage!!.copy(isStreaming = false)
orphan = finalized
state.copy(
messages = state.messages + finalized,
streamingMessage = null,
isAgentTyping = true,
isThinking = false,
thinkingText = "",
)
} else {
state.copy(
isAgentTyping = true,
isThinking = false,
thinkingText = "",
)
}
val newState = preState.copy(streamingMessage = msg)
val sid = newState.currentSessionId
if (orphan != null && sid != null) {
effects.add(ReducerEffect.PersistMessage(orphan, sid))
}
return ReducerResult(newState, effects)
}
// ── MessageToken ──────────────────────────────────────────────────
private fun onMessageToken(
state: ChatUiState,
event: WsEvent.MessageToken,
): ReducerResult {
// Token accumulation is done by the ViewModel (streaming buffer + flush timer).
// The reducer only signals that new content arrived — ViewModel owns the timer.
return ReducerResult(state)
}
// ── ThinkingDelta ─────────────────────────────────────────────────
private fun onThinkingDelta(
state: ChatUiState,
event: WsEvent.ThinkingDelta,
): ReducerResult {
val currentContent = state.thinkingText + event.token
return ReducerResult(
state =
state.copy(
isThinking = true,
thinkingText = currentContent,
),
)
}
// ── MessageComplete ───────────────────────────────────────────────
private fun onMessageComplete(
state: ChatUiState,
event: WsEvent.MessageComplete,
): ReducerResult {
val streaming = state.streamingMessage
val msg =
streaming?.copy(
content = event.text ?: streaming.content,
isStreaming = false,
) ?: ChatMessage(
role = MessageRole.ASSISTANT,
content = event.text ?: "",
)
val effects = mutableListOf<ReducerEffect>()
val sid = state.currentSessionId
if (sid != null) {
effects.add(ReducerEffect.PersistMessage(msg, sid))
}
effects.add(ReducerEffect.RefreshSessions)
return ReducerResult(
state =
state.copy(
messages = state.messages + msg,
streamingMessage = null,
isAgentTyping = false,
isThinking = false,
thinkingText = "",
),
effects = effects,
)
}
// ── MessageDone ───────────────────────────────────────────────────
private fun onMessageDone(state: ChatUiState): ReducerResult {
val streaming = state.streamingMessage ?: return ReducerResult(state)
val msg = streaming.copy(isStreaming = false)
val effects = mutableListOf<ReducerEffect>()
val sid = state.currentSessionId
if (sid != null) {
effects.add(ReducerEffect.PersistMessage(msg, sid))
}
return ReducerResult(
state =
state.copy(
messages = state.messages + msg,
streamingMessage = null,
isAgentTyping = false,
isThinking = false,
thinkingText = "",
),
effects = effects,
)
}
// ── ToolStart ─────────────────────────────────────────────────────
private fun onToolStart(
state: ChatUiState,
event: WsEvent.ToolStart,
): ReducerResult {
val contentJson =
event.data?.let {
com.google.gson
.Gson()
.toJson(it)
} ?: ""
val toolMessage =
ChatMessage(
role = MessageRole.TOOL,
content = contentJson,
toolName = event.name,
toolStatus = ToolStatus.RUNNING,
)
// Finalize any orphan streaming message
var orphanToPersist: ChatMessage? = null
val newState =
if (state.streamingMessage?.content?.isNotEmpty() == true) {
val finalized = state.streamingMessage!!.copy(isStreaming = false)
orphanToPersist = finalized
state.copy(
messages = state.messages + finalized,
streamingMessage = null,
)
} else {
state.copy(streamingMessage = null)
}
val sid = newState.currentSessionId
val effects = mutableListOf<ReducerEffect>()
if (sid != null) {
if (orphanToPersist != null) {
effects.add(ReducerEffect.PersistMessage(orphanToPersist, sid))
}
effects.add(ReducerEffect.PersistMessage(toolMessage, sid))
}
return ReducerResult(
state = newState.copy(messages = newState.messages + toolMessage),
effects = effects,
)
}
// ── ToolComplete ──────────────────────────────────────────────────
private fun onToolComplete(
state: ChatUiState,
event: WsEvent.ToolComplete,
): ReducerResult {
val contentJson =
event.data?.let {
com.google.gson
.Gson()
.toJson(it)
} ?: ""
val messages = state.messages.toMutableList()
val toolIdx =
messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolName == event.name &&
it.toolStatus == ToolStatus.RUNNING
}
if (toolIdx < 0) return ReducerResult(state)
val updated =
messages[toolIdx].copy(
toolStatus = ToolStatus.COMPLETED,
content = contentJson,
)
messages[toolIdx] = updated
val effects = mutableListOf<ReducerEffect>()
val sid = state.currentSessionId
if (sid != null) {
effects.add(ReducerEffect.PersistMessage(updated, sid))
}
return ReducerResult(
state = state.copy(messages = messages),
effects = effects,
)
}
// ── ClarifyRequest ────────────────────────────────────────────────
private fun onClarifyRequest(
state: ChatUiState,
event: WsEvent.ClarifyRequest,
): ReducerResult =
ReducerResult(
state =
state.copy(
clarifyRequest =
ClarifyUi(
text = event.text.orEmpty(),
options = event.options.orEmpty(),
clarifyId = event.clarifyId,
),
isAgentTyping = false,
),
)
// ── RpcError ──────────────────────────────────────────────────────
private fun onRpcError(
state: ChatUiState,
event: WsEvent.RpcError,
): ReducerResult =
ReducerResult(
state =
state.copy(
isLoading = false,
errorMessage = "Error: ${event.error}",
),
)
// ── SessionUpdated / StatusUpdate / Unknown ───────────────────────
private fun onSessionUpdated(state: ChatUiState): ReducerResult = ReducerResult(state)
private fun onStatusUpdate(state: ChatUiState): ReducerResult = ReducerResult(state)
private fun onUnknown(state: ChatUiState): ReducerResult = ReducerResult(state)
// ── Helpers ───────────────────────────────────────────────────────
/**
* If there's a non-empty streaming message, finalize it into the message
* list and return it as an orphan that should be persisted.
*/
private fun finalizeStreamingMessage(
state: ChatUiState,
newMessage: ChatMessage,
): Pair<ChatUiState, ChatMessage?> {
val existing = state.streamingMessage
if (existing != null && existing.content.isNotEmpty()) {
val finalized = existing.copy(isStreaming = false)
return Pair(
state.copy(
messages = state.messages + finalized,
streamingMessage = newMessage,
),
finalized,
)
}
return Pair(
state.copy(streamingMessage = newMessage),
null,
)
}
}
/**
* Result of reducing one event.
*
* @param state The new UI state after applying the event.
* @param effects Side-effects the ViewModel should execute.
*/
data class ReducerResult(
val state: ChatUiState,
val effects: List<ReducerEffect> = emptyList(),
)
/**
* Side-effects that the reducer cannot perform itself (needs I/O or ViewModel context).
*/
sealed class ReducerEffect {
data class PersistMessage(
val message: ChatMessage,
val sessionId: String,
) : ReducerEffect()
data object CreateNewSession : ReducerEffect()
data object LoadSessions : ReducerEffect()
data object RefreshSessions : ReducerEffect()
}
@@ -0,0 +1,63 @@
package com.m57.hermescontrol.ui.chat
/**
* Parses slash commands and returns a [SlashResult] describing what action
* the ViewModel should take.
*
* Pure logic — no I/O, no Android dependencies.
*/
class SlashCommandDispatcher {
fun dispatch(command: String): SlashResult {
val parts = command.split(" ")
val cmd = parts[0].lowercase()
return when (cmd) {
"/stop", "/interrupt" -> SlashResult.Interrupt
"/new" -> SlashResult.NewSession
"/help" -> SlashResult.Message(HELP_TEXT)
"/status" -> SlashResult.FetchStatus
"/sessions" -> SlashResult.FetchSessions
"/stats", "/system" -> SlashResult.FetchStats
else -> SlashResult.Unknown(cmd)
}
}
companion object {
val HELP_TEXT =
"""
**Available Commands:**
• `/help` - Show this help menu
• `/status` - Check gateway and platform status
• `/sessions` - List all chat sessions
• `/stats` or `/system` - Check system resource usage
• `/new` - Create a new chat session
• `/stop` or `/interrupt` - Interrupt the active run
""".trimIndent()
}
}
/**
* The result of dispatching a slash command.
*/
sealed class SlashResult {
/** Display a static message in the chat. */
data class Message(val text: String) : SlashResult()
/** Interrupt the active session. */
data object Interrupt : SlashResult()
/** Create a new session. */
data object NewSession : SlashResult()
/** Fetch gateway status via REST and display. */
data object FetchStatus : SlashResult()
/** Fetch session list via REST and display. */
data object FetchSessions : SlashResult()
/** Fetch system stats via REST and display. */
data object FetchStats : SlashResult()
/** Command not recognised. */
data class Unknown(val cmd: String) : SlashResult()
}