feat: paginate, sync, and display branched sessions (#563)

* mobile: 以 Desktop Sessions 作为聊天历史源

* mobile: 修复快速连接崩溃与无标题会话隐藏

* mobile: 对齐 Desktop 标题并恢复完整历史

* mobile: 分离 Desktop 存储 ID 与运行 Session ID

* mobile: 将新对话持久化为 Desktop Session

* mobile: 渲染 Desktop 本地媒体

* feat: 分页加载并同步当前会话

* fix: 显示分支会话名称与层级

* fix: align PR with upstream app contract

* fix: preserve upstream session RPC lifecycle
This commit is contained in:
Genesi599 authored and GitHub committed 2026-07-12 12:53:23 +03:00
1 parent 306861a222
commit 3052db7d65
8 files changed
+405 -79

No files matched your search

@@ -19,6 +19,8 @@ data class SessionInfo(
val preview: String? = null, val preview: String? = null,
val started_at: Double? = null, val started_at: Double? = null,
val source: String? = null, val source: String? = null,
val parent_session_id: String? = null,
val display_name: String? = null,
) )
@Serializable @Serializable
@@ -0,0 +1,51 @@
package com.m57.hermescontrol.data.model
data class SessionTreeItem(
val session: SessionInfo,
val depth: Int,
val branchStem: String?,
val displayTitle: String,
)
fun flattenSessionTree(sessions: List<SessionInfo>): List<SessionTreeItem> {
val ids = sessions.mapTo(mutableSetOf()) { it.id }
val children = sessions.groupBy { it.parent_session_id?.takeIf(ids::contains) }
val result = mutableListOf<SessionTreeItem>()
val visited = mutableSetOf<String>()
fun append(
session: SessionInfo,
depth: Int,
branchStem: String?,
siblingIndex: Int,
) {
if (!visited.add(session.id)) return
val isBranch = session.parent_session_id?.takeIf(ids::contains) != null
val title =
session.title?.takeIf(String::isNotBlank)
?: session.display_name?.takeIf(String::isNotBlank)
?: if (isBranch) {
"branch ${siblingIndex + 1}"
} else {
session.preview?.takeIf(String::isNotBlank)?.take(80) ?: "Untitled"
}
result += SessionTreeItem(session, depth, branchStem, title)
val descendants = children[session.id].orEmpty()
descendants.forEachIndexed { index, child ->
append(
session = child,
depth = depth + 1,
branchStem = if (index == descendants.lastIndex) "└─" else "├─",
siblingIndex = index,
)
}
}
children[null].orEmpty().forEachIndexed { index, root ->
append(root, depth = 0, branchStem = null, siblingIndex = index)
}
sessions.filterNot { it.id in visited }.forEachIndexed { index, orphan ->
append(orphan, depth = 0, branchStem = null, siblingIndex = index)
}
return result
}
@@ -131,6 +131,8 @@ interface HermesApiService {
// Preserve slashes in session IDs — backend generates IDs containing '/' characters (issue #468). // Preserve slashes in session IDs — backend generates IDs containing '/' characters (issue #468).
// Contract: The server-generated sessionId must only contain URL-safe characters (no ?, #, or spaces). // Contract: The server-generated sessionId must only contain URL-safe characters (no ?, #, or spaces).
@Path("id", encoded = true) sessionId: String, @Path("id", encoded = true) sessionId: String,
@Query("limit") limit: Int? = null,
@Query("offset") offset: Int = 0,
): Response<SessionMessagesResponse> ): Response<SessionMessagesResponse>
@GET("api/sessions/stats") @GET("api/sessions/stats")
@@ -95,6 +95,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState 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.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -130,12 +131,16 @@ import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.common.NavIcon
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
private const val SESSION_SYNC_INTERVAL_MS = 5_000L
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ChatScreen( fun ChatScreen(
@@ -147,6 +152,27 @@ fun ChatScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle() val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
val listState = rememberLazyListState() val listState = rememberLazyListState()
var isOlderPagingArmed by remember(state.currentSessionId) { mutableStateOf(false) }
LaunchedEffect(state.currentSessionId, state.connectionStatus) {
while (state.currentSessionId != null && state.connectionStatus == ConnectionStatus.CONNECTED) {
delay(SESSION_SYNC_INTERVAL_MS)
viewModel.syncCurrentSession()
}
}
LaunchedEffect(listState, state.currentSessionId, state.hasOlderMessages, state.isLoadingOlder) {
if (!state.hasOlderMessages || state.isLoadingOlder) return@LaunchedEffect
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.collectLatest { firstVisibleIndex ->
if (firstVisibleIndex > 2) {
isOlderPagingArmed = true
} else if (isOlderPagingArmed) {
viewModel.loadOlderMessages()
}
}
}
val showScrollToBottom by remember { val showScrollToBottom by remember {
derivedStateOf { derivedStateOf {
state.messages.isNotEmpty() && listState.canScrollForward state.messages.isNotEmpty() && listState.canScrollForward
@@ -395,6 +421,7 @@ fun ChatScreen(
typingEffectEnabled = state.typingEffectEnabled, typingEffectEnabled = state.typingEffectEnabled,
typingEffectDelayMs = state.typingEffectDelayMs, typingEffectDelayMs = state.typingEffectDelayMs,
isLoading = state.isLoading, isLoading = state.isLoading,
isLoadingOlder = state.isLoadingOlder,
isDark = isDark, isDark = isDark,
listState = listState, listState = listState,
lastAnimatedMessageId = lastAnimatedMessageId, lastAnimatedMessageId = lastAnimatedMessageId,
@@ -1664,6 +1691,7 @@ private fun ChatMessageList(
typingEffectEnabled: Boolean, typingEffectEnabled: Boolean,
typingEffectDelayMs: Int, typingEffectDelayMs: Int,
isLoading: Boolean, isLoading: Boolean,
isLoadingOlder: Boolean,
isDark: Boolean, isDark: Boolean,
listState: LazyListState, listState: LazyListState,
lastAnimatedMessageId: String?, lastAnimatedMessageId: String?,
@@ -1717,6 +1745,16 @@ private fun ChatMessageList(
modifier = Modifier.fillMaxWidth().weight(1f), modifier = Modifier.fillMaxWidth().weight(1f),
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
) { ) {
if (isLoadingOlder) {
item(key = "loading-older") {
Box(
modifier = Modifier.fillMaxWidth().padding(12.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
}
itemsIndexed( itemsIndexed(
items = messages, items = messages,
key = { _, message -> message.id }, key = { _, message -> message.id },
@@ -10,6 +10,7 @@ import androidx.lifecycle.viewModelScope
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.model.Attachment import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.SessionMessage
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.OkHttpProvider import com.m57.hermescontrol.data.remote.OkHttpProvider
@@ -43,6 +44,7 @@ import java.io.ByteArrayOutputStream
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
private const val TAG = "ChatViewModel" private const val TAG = "ChatViewModel"
private const val MESSAGE_PAGE_SIZE = 150
data class ChatUiState( data class ChatUiState(
val messages: List<ChatMessage> = emptyList(), val messages: List<ChatMessage> = emptyList(),
@@ -54,6 +56,8 @@ data class ChatUiState(
val isThinking: Boolean = false, val isThinking: Boolean = false,
val thinkingText: String = "", val thinkingText: String = "",
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isLoadingOlder: Boolean = false,
val hasOlderMessages: Boolean = false,
/** Standalone streaming message — rendered after the main list. */ /** Standalone streaming message — rendered after the main list. */
val streamingMessage: ChatMessage? = null, val streamingMessage: ChatMessage? = null,
val errorMessage: String? = null, val errorMessage: String? = null,
@@ -89,6 +93,8 @@ data class SessionUi(
val id: String, val id: String,
val title: String, val title: String,
val messageCount: Int = 0, val messageCount: Int = 0,
val parentSessionId: String? = null,
val depth: Int = 0,
) )
data class ClarifyUi( data class ClarifyUi(
@@ -123,6 +129,14 @@ class ChatViewModel(
private val _uiState = MutableStateFlow(ChatUiState()) private val _uiState = MutableStateFlow(ChatUiState())
private val _streamingState = MutableStateFlow(StreamingState()) private val _streamingState = MutableStateFlow(StreamingState())
/** Maps an in-flight RPC id to its method for UI error labeling. */
private val idToMethod = ConcurrentHashMap<String, String>()
/** Runtime TUI session returned by session.resume; Desktop storage keeps the original ID. */
private var runtimeSessionId: String? = null
private var loadedMessageOffset = 0
private var isSyncingMessages = false
val streamingState: StateFlow<StreamingState> = _streamingState.asStateFlow() val streamingState: StateFlow<StreamingState> = _streamingState.asStateFlow()
/** Tracks the auto-clear coroutine for reaction animations. */ /** Tracks the auto-clear coroutine for reaction animations. */
@@ -279,7 +293,7 @@ class ChatViewModel(
_uiState.value, _uiState.value,
_streamingState.value, _streamingState.value,
event, event,
_uiState.value.currentSessionId, runtimeSessionId ?: _uiState.value.currentSessionId,
) )
// Apply the new state // Apply the new state
@@ -418,7 +432,7 @@ class ChatViewModel(
private fun isCurrentSession(eventSessionId: String?): Boolean { private fun isCurrentSession(eventSessionId: String?): Boolean {
// If the event has no session ID, process it (legacy compatibility) // If the event has no session ID, process it (legacy compatibility)
if (eventSessionId == null) return true if (eventSessionId == null) return true
return eventSessionId == _uiState.value.currentSessionId return eventSessionId == runtimeSessionId || eventSessionId == _uiState.value.currentSessionId
} }
// ── RPC response handling ──────────────────────────────────────────── // ── RPC response handling ────────────────────────────────────────────
@@ -432,10 +446,12 @@ class ChatViewModel(
when (method) { when (method) {
WsMethods.SESSION_CREATE -> { WsMethods.SESSION_CREATE -> {
val resultMap = result as? Map<String, Any?> ?: return val resultMap = result as? Map<String, Any?> ?: return
val sessionId = resultMap["session_id"] as? String ?: return val runtimeId = resultMap["session_id"] as? String ?: return
val storageId = resultMap["stored_session_id"] as? String ?: runtimeId
runtimeSessionId = runtimeId
_uiState.update { _uiState.update {
it.copy( it.copy(
currentSessionId = sessionId, currentSessionId = storageId,
isLoading = false, isLoading = false,
messages = emptyList(), messages = emptyList(),
chatTitle = "Hermes", chatTitle = "Hermes",
@@ -444,7 +460,7 @@ class ChatViewModel(
// Mirror the active session id app-wide so session-scoped // Mirror the active session id app-wide so session-scoped
// drawer screens (e.g. Processes, issue #532) can issue // drawer screens (e.g. Processes, issue #532) can issue
// session-scoped RPCs. See ActiveSessionHolder. // session-scoped RPCs. See ActiveSessionHolder.
ActiveSessionHolder.set(sessionId) ActiveSessionHolder.set(runtimeId)
_streamingState.update { StreamingState() } _streamingState.update { StreamingState() }
addSystemMessage("Session created", persist = true) addSystemMessage("Session created", persist = true)
loadSessions() loadSessions()
@@ -472,8 +488,9 @@ class ChatViewModel(
WsMethods.SESSION_RESUME -> { WsMethods.SESSION_RESUME -> {
val resultMap = result as? Map<String, Any?> val resultMap = result as? Map<String, Any?>
runtimeSessionId = resultMap?.get("session_id") as? String
val sessionId = val sessionId =
(resultMap?.get("session_id") as? String) (resultMap?.get("resumed") as? String)
?: _uiState.value.currentSessionId ?: _uiState.value.currentSessionId
// B8 (Jun 20 2026, kanban t_session_resume): do NOT reload // B8 (Jun 20 2026, kanban t_session_resume): do NOT reload
@@ -487,8 +504,8 @@ class ChatViewModel(
currentSessionId = sessionId, currentSessionId = sessionId,
) )
} }
// Mirror the active session id app-wide (issue #532). // Mirror the active runtime session id app-wide (issue #532).
ActiveSessionHolder.set(sessionId) ActiveSessionHolder.set(runtimeSessionId ?: sessionId)
addSystemMessage("Session resumed") addSystemMessage("Session resumed")
} }
@@ -599,7 +616,8 @@ class ChatViewModel(
*/ */
fun sendMessage(text: String) { fun sendMessage(text: String) {
if (text.isBlank() && _uiState.value.pendingAttachments.isEmpty()) return if (text.isBlank() && _uiState.value.pendingAttachments.isEmpty()) return
val sessionId = _uiState.value.currentSessionId ?: return val storageSessionId = _uiState.value.currentSessionId ?: return
val agentSessionId = runtimeSessionId ?: return
val trimmed = text.trim() val trimmed = text.trim()
if (trimmed.startsWith("/", ignoreCase = true)) { if (trimmed.startsWith("/", ignoreCase = true)) {
@@ -626,9 +644,9 @@ class ChatViewModel(
) )
} }
// Persist to Room // Persist under the original Desktop session ID.
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
repo.persistMessage(userMessage, sessionId) repo.persistMessage(userMessage, storageSessionId)
} }
// Upload attachments then submit prompt // Upload attachments then submit prompt
@@ -689,7 +707,7 @@ class ChatViewModel(
} }
wsClient.sendMessage( wsClient.sendMessage(
sessionId, agentSessionId,
fullText, fullText,
onSent = { id -> trackRequest(id, WsMethods.PROMPT_SUBMIT) }, onSent = { id -> trackRequest(id, WsMethods.PROMPT_SUBMIT) },
) )
@@ -779,7 +797,7 @@ class ChatViewModel(
} }
private fun dispatchViaRpc(command: String) { private fun dispatchViaRpc(command: String) {
val sessionId = _uiState.value.currentSessionId val sessionId = runtimeSessionId
if (sessionId == null) { if (sessionId == null) {
addAssistantMessage("No active session. Use `/new` to create one.") addAssistantMessage("No active session. Use `/new` to create one.")
return return
@@ -803,7 +821,7 @@ class ChatViewModel(
*/ */
private fun submitPrompt(text: String) { private fun submitPrompt(text: String) {
if (text.isBlank()) return if (text.isBlank()) return
val sessionId = _uiState.value.currentSessionId ?: return val sessionId = runtimeSessionId ?: return
_uiState.update { it.copy(isAgentTyping = true) } _uiState.update { it.copy(isAgentTyping = true) }
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
wsClient.sendMessage( wsClient.sendMessage(
@@ -830,7 +848,7 @@ class ChatViewModel(
// ── Session management ─────────────────────────────────────────────── // ── Session management ───────────────────────────────────────────────
fun interruptSession() { fun interruptSession() {
val sessionId = _uiState.value.currentSessionId ?: return val sessionId = runtimeSessionId ?: return
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
wsClient.send( wsClient.send(
WsMethods.SESSION_INTERRUPT, WsMethods.SESSION_INTERRUPT,
@@ -853,6 +871,7 @@ class ChatViewModel(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
wsClient.send( wsClient.send(
WsMethods.SESSION_CREATE, WsMethods.SESSION_CREATE,
params = mapOf("source" to "desktop"),
onSent = { id -> trackRequest(id, WsMethods.SESSION_CREATE) }, onSent = { id -> trackRequest(id, WsMethods.SESSION_CREATE) },
) )
} }
@@ -878,15 +897,7 @@ class ChatViewModel(
fun refreshCurrentSession() { fun refreshCurrentSession() {
val sessionId = _uiState.value.currentSessionId ?: return val sessionId = _uiState.value.currentSessionId ?: return
viewModelScope.launch { loadSessionMessages(sessionId)
loadCachedMessages(sessionId).join()
// Skip the REST call if we already have messages — the WebSocket keeps
// the UI current. The REST endpoint can 404 on tab switch-back when the
// WS session ID doesn't round-trip through the resolver (issue #366),
// showing a misleading error banner while the connection is fine.
if (_uiState.value.messages.isNotEmpty()) return@launch
loadSessionMessages(sessionId)
}
} }
fun refreshSettings() { fun refreshSettings() {
@@ -911,12 +922,16 @@ class ChatViewModel(
fun switchSession(sessionId: String) { fun switchSession(sessionId: String) {
if (sessionId == _uiState.value.currentSessionId) return if (sessionId == _uiState.value.currentSessionId) return
// Reset streaming state // Reset streaming and pagination state before resuming the Desktop session.
runtimeSessionId = null
loadedMessageOffset = 0
streamingController.resetStreaming() streamingController.resetStreaming()
_uiState.update { _uiState.update {
val title = it.sessions.find { s -> s.id == sessionId }?.title ?: "Hermes" val title = it.sessions.find { s -> s.id == sessionId }?.title ?: "Hermes"
it.copy( it.copy(
isLoading = true, isLoading = true,
isLoadingOlder = false,
hasOlderMessages = false,
currentSessionId = sessionId, currentSessionId = sessionId,
messages = emptyList(), messages = emptyList(),
chatTitle = title, chatTitle = title,
@@ -928,9 +943,7 @@ class ChatViewModel(
ActiveSessionHolder.set(sessionId) ActiveSessionHolder.set(sessionId)
_streamingState.update { StreamingState() } _streamingState.update { StreamingState() }
viewModelScope.launch { viewModelScope.launch {
// Step 1: Load cached messages first — instant display // Resume the selected desktop session, then load its complete transcript.
loadCachedMessages(sessionId).join()
// Step 2: Resume session on server
launch(Dispatchers.IO) { launch(Dispatchers.IO) {
wsClient.send( wsClient.send(
WsMethods.SESSION_RESUME, WsMethods.SESSION_RESUME,
@@ -938,9 +951,7 @@ class ChatViewModel(
onSent = { id -> trackRequest(id, WsMethods.SESSION_RESUME) }, onSent = { id -> trackRequest(id, WsMethods.SESSION_RESUME) },
) )
} }
// Step 3: Load fresh messages from REST and merge
loadSessionMessages(sessionId) loadSessionMessages(sessionId)
// Step 4: Refresh session list to get latest titles
loadSessions() loadSessions()
} }
} }
@@ -960,62 +971,33 @@ class ChatViewModel(
private fun loadSessionMessages(sessionId: String) { private fun loadSessionMessages(sessionId: String) {
viewModelScope.launch { viewModelScope.launch {
val result = val messageCount = fetchServerMessageCount(sessionId)
withContext(Dispatchers.IO) { val offset = (messageCount - MESSAGE_PAGE_SIZE).coerceAtLeast(0)
safeApiCall { ApiClient.hermesApi.getSessionMessages(sessionId) } val result = fetchMessagePage(sessionId, offset, MESSAGE_PAGE_SIZE)
}
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
val messagesList = result.data.messages.orEmpty() val chatMessages = mapServerMessages(sessionId, result.data.messages.orEmpty(), offset)
val chatMessages = loadedMessageOffset = offset
messagesList.mapIndexed { index, msg ->
val role =
when (msg.role?.lowercase()) {
"user" -> MessageRole.USER
"system" -> MessageRole.SYSTEM
"tool" -> MessageRole.TOOL
else -> MessageRole.ASSISTANT
}
// Use stable IDs based on session + index to prevent
// Room duplicates when re-loading the same session.
val stableId = "rest-$sessionId-$index"
// Preserve original timestamp from the API when available
val ts = msg.timestampText?.toLongOrNull() ?: System.currentTimeMillis()
ChatMessage(
id = stableId,
role = role,
content = msg.content.orEmpty(),
timestamp = ts,
isStreaming = false,
)
}
// Persist loaded messages to Room for offline access
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
repo.persistMessages(chatMessages, sessionId) repo.persistMessages(chatMessages, sessionId)
} }
_uiState.update { state -> _uiState.update { state ->
// Only update if still on the same session
if (state.currentSessionId != sessionId) return@update state if (state.currentSessionId != sessionId) return@update state
// Merge: keep any user messages sent between cache load
// and REST response (they won't be in the REST response
// yet), plus preserve any active streaming message.
val existingIds = chatMessages.mapTo(HashSet(chatMessages.size)) { it.id }
val localOnly = state.messages.filter { it.id !in existingIds }
state.copy( state.copy(
messages = chatMessages + localOnly, messages = chatMessages,
isLoading = false, isLoading = false,
hasOlderMessages = offset > 0,
isLoadingOlder = false,
) )
} }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
// Don't overwrite messages on REST failure — cached messages
// are still valid.
_uiState.update { _uiState.update {
if (it.currentSessionId != sessionId) return@update it if (it.currentSessionId != sessionId) return@update it
it.copy( it.copy(
isLoading = false, isLoading = false,
isLoadingOlder = false,
errorMessage = "Failed to load messages: ${result.error.message}", errorMessage = "Failed to load messages: ${result.error.message}",
) )
} }
@@ -1024,6 +1006,170 @@ class ChatViewModel(
} }
} }
fun loadOlderMessages() {
val state = _uiState.value
val sessionId = state.currentSessionId ?: return
if (!state.hasOlderMessages || state.isLoadingOlder || loadedMessageOffset <= 0) return
val oldOffset = loadedMessageOffset
val newOffset = (oldOffset - MESSAGE_PAGE_SIZE).coerceAtLeast(0)
val limit = oldOffset - newOffset
_uiState.update { it.copy(isLoadingOlder = true) }
viewModelScope.launch {
when (val result = fetchMessagePage(sessionId, newOffset, limit)) {
is NetworkResult.Success -> {
val older = mapServerMessages(sessionId, result.data.messages.orEmpty(), newOffset)
loadedMessageOffset = newOffset
withContext(Dispatchers.IO) { repo.persistMessages(older, sessionId) }
_uiState.update { current ->
if (current.currentSessionId != sessionId) return@update current
current.copy(
messages = (older + current.messages).distinctBy { it.id },
isLoadingOlder = false,
hasOlderMessages = newOffset > 0,
)
}
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(isLoadingOlder = false) }
}
}
}
}
fun syncCurrentSession() {
val state = _uiState.value
val sessionId = state.currentSessionId ?: return
if (isSyncingMessages || state.isLoading || state.isLoadingOlder || state.isAgentTyping ||
_streamingState.value.streamingMessage != null
) {
return
}
val nextOffset =
state.messages
.mapNotNull { serverMessageIndex(it.id, sessionId) }
.maxOrNull()
?.plus(1)
?: loadedMessageOffset
isSyncingMessages = true
viewModelScope.launch {
try {
when (val result = fetchMessagePage(sessionId, nextOffset, MESSAGE_PAGE_SIZE)) {
is NetworkResult.Success -> {
val incoming = mapServerMessages(sessionId, result.data.messages.orEmpty(), nextOffset)
if (incoming.isEmpty()) return@launch
withContext(Dispatchers.IO) { repo.persistMessages(incoming, sessionId) }
_uiState.update { current ->
if (current.currentSessionId != sessionId) return@update current
val unmatched = incoming.map { it.role to it.content }.toMutableList()
val retained =
current.messages.filter { existing ->
if (serverMessageIndex(existing.id, sessionId) != null) {
true
} else {
val duplicate =
unmatched.indexOfFirst { (role, content) ->
role == existing.role && content == existing.content
}
if (duplicate >= 0) unmatched.removeAt(duplicate)
duplicate < 0
}
}
val merged = (retained + incoming).distinctBy { it.id }
if (sameMessages(current.messages, merged)) current else current.copy(messages = merged)
}
}
is NetworkResult.Failure -> Unit
}
} finally {
isSyncingMessages = false
}
}
}
private suspend fun fetchServerMessageCount(sessionId: String): Int {
val known = _uiState.value.sessions.find { it.id == sessionId }?.messageCount
if (known != null) return known
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getSessions(limit = 500, offset = 0, order = "recent") }
}
if (result is NetworkResult.Success) {
val sessions = result.data.sessions.orEmpty()
val count = sessions.find { it.id == sessionId }?.message_count
if (count != null) {
_uiState.update { current ->
current.copy(
sessions =
current.sessions.map {
if (it.id == sessionId) {
it.copy(
messageCount = count,
)
} else {
it
}
},
)
}
return count
}
}
return known ?: _uiState.value.messages.size
}
private suspend fun fetchMessagePage(
sessionId: String,
offset: Int,
limit: Int,
) = withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getSessionMessages(sessionId, limit = limit, offset = offset) }
}
private fun mapServerMessages(
sessionId: String,
messages: List<SessionMessage>,
offset: Int,
): List<ChatMessage> =
messages.mapIndexed { index, msg ->
val role =
when (msg.role?.lowercase()) {
"user" -> MessageRole.USER
"system" -> MessageRole.SYSTEM
"tool" -> MessageRole.TOOL
else -> MessageRole.ASSISTANT
}
val globalIndex = offset + index
val timestamp =
msg.timestampText
?.toDoubleOrNull()
?.times(1000)
?.toLong()
?: System.currentTimeMillis()
ChatMessage(
id = "rest-$sessionId-$globalIndex",
role = role,
content = msg.content.orEmpty(),
timestamp = timestamp,
isStreaming = false,
)
}
private fun serverMessageIndex(
id: String,
sessionId: String,
): Int? = id.removePrefix("rest-$sessionId-").takeIf { it != id }?.toIntOrNull()
private fun sameMessages(
left: List<ChatMessage>,
right: List<ChatMessage>,
): Boolean =
left.size == right.size &&
left.zip(right).all { (a, b) ->
a.id == b.id && a.role == b.role && a.content == b.content
}
// ── UI actions ─────────────────────────────────────────────────────── // ── UI actions ───────────────────────────────────────────────────────
fun toggleSessionPicker() { fun toggleSessionPicker() {
@@ -1363,9 +1509,6 @@ class ChatViewModel(
// ── Pending request tracking ───────────────────────────────────────── // ── Pending request tracking ─────────────────────────────────────────
/** Maps an in-flight RPC id → its method, purely for UI error labeling. The deferred/timeout lives in [HermesWsClient.request] (issue #526). */
private val idToMethod = ConcurrentHashMap<String, String>()
private fun trackRequest( private fun trackRequest(
id: String, id: String,
method: String, method: String,
@@ -76,6 +76,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.ChatScreen import com.m57.hermescontrol.ChatScreen
import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.model.flattenSessionTree
import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.LocalSpacing import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.EmptyState
@@ -170,11 +172,17 @@ fun SessionsScreen(
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
var pruneDays by remember { mutableStateOf("7") } var pruneDays by remember { mutableStateOf("7") }
val sessionTree = remember(state.sessions) { flattenSessionTree(state.sessions) }
val filteredSessions = val filteredSessions =
remember(query, state.sessions) { remember(query, sessionTree) {
state.sessions.filter { session -> if (query.isBlank()) {
session.title?.contains(query, ignoreCase = true) == true || sessionTree
session.status?.contains(query, ignoreCase = true) == true } else {
sessionTree.filter { item ->
item.displayTitle.contains(query, ignoreCase = true) ||
item.session.preview?.contains(query, ignoreCase = true) == true ||
item.session.status?.contains(query, ignoreCase = true) == true
}
} }
} }
@@ -430,9 +438,13 @@ fun SessionsScreen(
contentPadding = listContentPadding, contentPadding = listContentPadding,
verticalArrangement = listItemSpacing, verticalArrangement = listItemSpacing,
) { ) {
items(filteredSessions, key = { it.id }) { session -> items(filteredSessions, key = { it.session.id }) { item ->
val session = item.session
SessionCard( SessionCard(
session = session, session = session,
displayTitle = item.displayTitle,
depth = item.depth,
branchStem = item.branchStem,
query = query, query = query,
isSelecting = state.isSelecting, isSelecting = state.isSelecting,
isSelected = session.id in state.selectedIds, isSelected = session.id in state.selectedIds,
@@ -589,7 +601,10 @@ fun SessionsScreen(
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun SessionCard( private fun SessionCard(
session: com.m57.hermescontrol.data.model.SessionInfo, session: SessionInfo,
displayTitle: String,
depth: Int,
branchStem: String?,
query: String, query: String,
isSelecting: Boolean, isSelecting: Boolean,
isSelected: Boolean, isSelected: Boolean,
@@ -603,7 +618,6 @@ private fun SessionCard(
) { ) {
val spacing = LocalSpacing.current val spacing = LocalSpacing.current
val statusColors = LocalHermesStatusColors.current val statusColors = LocalHermesStatusColors.current
val displayTitle = session.title?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled)
val isActive = session.status?.lowercase() == "active" || session.status?.lowercase() == "streaming" val isActive = session.status?.lowercase() == "active" || session.status?.lowercase() == "streaming"
val srcIcon = sourceIcon(session.source) val srcIcon = sourceIcon(session.source)
@@ -611,6 +625,7 @@ private fun SessionCard(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = (depth * 16).dp)
.testTag("session_card_${session.id}") .testTag("session_card_${session.id}")
.combinedClickable( .combinedClickable(
onClick = onCardClick, onClick = onCardClick,
@@ -643,6 +658,16 @@ private fun SessionCard(
Spacer(modifier = Modifier.width(spacing.sm)) Spacer(modifier = Modifier.width(spacing.sm))
} }
// Branch tree stem
if (branchStem != null && !isSelecting) {
Text(
text = branchStem,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.outline,
)
Spacer(modifier = Modifier.width(spacing.sm))
}
// Source icon // Source icon
if (srcIcon != null && !isSelecting) { if (srcIcon != null && !isSelecting) {
Icon( Icon(
@@ -0,0 +1,45 @@
package com.m57.hermescontrol.data.model
import org.junit.Assert.assertEquals
import org.junit.Test
class SessionTreeTest {
@Test
fun nestsBranchesAndUsesDesktopFallbackNames() {
val sessions =
listOf(
SessionInfo(id = "unrelated", title = "Recent root"),
SessionInfo(id = "branch-b", parent_session_id = "parent"),
SessionInfo(id = "parent", title = "Original"),
SessionInfo(id = "branch-a", parent_session_id = "parent", preview = "copied prompt"),
SessionInfo(id = "nested", parent_session_id = "branch-a", title = "Named nested branch"),
)
val tree = flattenSessionTree(sessions)
assertEquals(listOf("unrelated", "parent", "branch-b", "branch-a", "nested"), tree.map { it.session.id })
assertEquals(listOf(0, 0, 1, 1, 2), tree.map { it.depth })
assertEquals(
listOf("Recent root", "Original", "branch 1", "branch 2", "Named nested branch"),
tree.map {
it.displayTitle
},
)
assertEquals(listOf(null, null, "├─", "└─", "└─"), tree.map { it.branchStem })
}
@Test
fun keepsMissingParentsAndCyclesVisible() {
val sessions =
listOf(
SessionInfo(id = "orphan", parent_session_id = "missing", preview = "orphan preview"),
SessionInfo(id = "a", parent_session_id = "b"),
SessionInfo(id = "b", parent_session_id = "a"),
)
val tree = flattenSessionTree(sessions)
assertEquals(setOf("orphan", "a", "b"), tree.map { it.session.id }.toSet())
assertEquals("orphan preview", tree.first { it.session.id == "orphan" }.displayTitle)
}
}
@@ -141,7 +141,8 @@ class HermesApiServiceMockWebServerTest {
"created_at": "2026-06-20T14:30:00Z", "created_at": "2026-06-20T14:30:00Z",
"message_count": 7, "message_count": 7,
"status": "idle", "status": "idle",
"source": "web" "source": "web",
"parent_session_id": "abc-123"
} }
], ],
"total": 2, "total": 2,
@@ -173,6 +174,7 @@ class HermesApiServiceMockWebServerTest {
assertEquals(7, second.message_count) assertEquals(7, second.message_count)
assertEquals("idle", second.status) assertEquals("idle", second.status)
assertEquals("web", second.source) assertEquals("web", second.source)
assertEquals("abc-123", second.parent_session_id)
} }
@Test @Test
@@ -254,6 +256,24 @@ class HermesApiServiceMockWebServerTest {
assertEquals("tool_execution", messages[2].type) assertEquals("tool_execution", messages[2].type)
} }
@Test
fun getSessionMessages_sendsPaginationQuery() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("{\"messages\": []}"),
)
val response = api.getSessionMessages("desktop/session", limit = 150, offset = 300)
assertTrue(response.isSuccessful)
assertEquals(
"/api/sessions/desktop/session/messages?limit=150&offset=300",
mockServer.takeRequest().path,
)
}
@Test @Test
fun getSessionMessages_withNullRole_doesNotCrash() = fun getSessionMessages_withNullRole_doesNotCrash() =
runBlocking { runBlocking {