diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt index 32f6cbe5..96a5ab35 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt @@ -19,6 +19,8 @@ data class SessionInfo( val preview: String? = null, val started_at: Double? = null, val source: String? = null, + val parent_session_id: String? = null, + val display_name: String? = null, ) @Serializable diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionTreeItem.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionTreeItem.kt new file mode 100644 index 00000000..f5cccdc4 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionTreeItem.kt @@ -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): List { + val ids = sessions.mapTo(mutableSetOf()) { it.id } + val children = sessions.groupBy { it.parent_session_id?.takeIf(ids::contains) } + val result = mutableListOf() + val visited = mutableSetOf() + + 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 +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 52b6e909..22ca15bd 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -131,6 +131,8 @@ interface HermesApiService { // 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). @Path("id", encoded = true) sessionId: String, + @Query("limit") limit: Int? = null, + @Query("offset") offset: Int = 0, ): Response @GET("api/sessions/stats") 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 09af7055..86587f50 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 @@ -95,6 +95,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.NavIcon import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import java.io.File import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +private const val SESSION_SYNC_INTERVAL_MS = 5_000L + @OptIn(ExperimentalFoundationApi::class) @Composable fun ChatScreen( @@ -147,6 +152,27 @@ fun ChatScreen( val state by viewModel.uiState.collectAsStateWithLifecycle() val streamingState by viewModel.streamingState.collectAsStateWithLifecycle() 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 { derivedStateOf { state.messages.isNotEmpty() && listState.canScrollForward @@ -395,6 +421,7 @@ fun ChatScreen( typingEffectEnabled = state.typingEffectEnabled, typingEffectDelayMs = state.typingEffectDelayMs, isLoading = state.isLoading, + isLoadingOlder = state.isLoadingOlder, isDark = isDark, listState = listState, lastAnimatedMessageId = lastAnimatedMessageId, @@ -1664,6 +1691,7 @@ private fun ChatMessageList( typingEffectEnabled: Boolean, typingEffectDelayMs: Int, isLoading: Boolean, + isLoadingOlder: Boolean, isDark: Boolean, listState: LazyListState, lastAnimatedMessageId: String?, @@ -1717,6 +1745,16 @@ private fun ChatMessageList( modifier = Modifier.fillMaxWidth().weight(1f), 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( items = messages, key = { _, message -> message.id }, 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 adb77976..ecfd22ff 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 @@ -10,6 +10,7 @@ import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.HermesDatabase 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.NetworkResult import com.m57.hermescontrol.data.remote.OkHttpProvider @@ -43,6 +44,7 @@ import java.io.ByteArrayOutputStream import java.util.concurrent.ConcurrentHashMap private const val TAG = "ChatViewModel" +private const val MESSAGE_PAGE_SIZE = 150 data class ChatUiState( val messages: List = emptyList(), @@ -54,6 +56,8 @@ data class ChatUiState( val isThinking: Boolean = false, val thinkingText: String = "", val isLoading: Boolean = false, + val isLoadingOlder: Boolean = false, + val hasOlderMessages: Boolean = false, /** Standalone streaming message — rendered after the main list. */ val streamingMessage: ChatMessage? = null, val errorMessage: String? = null, @@ -89,6 +93,8 @@ data class SessionUi( val id: String, val title: String, val messageCount: Int = 0, + val parentSessionId: String? = null, + val depth: Int = 0, ) data class ClarifyUi( @@ -123,6 +129,14 @@ class ChatViewModel( private val _uiState = MutableStateFlow(ChatUiState()) private val _streamingState = MutableStateFlow(StreamingState()) + + /** Maps an in-flight RPC id to its method for UI error labeling. */ + private val idToMethod = ConcurrentHashMap() + + /** 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.asStateFlow() /** Tracks the auto-clear coroutine for reaction animations. */ @@ -279,7 +293,7 @@ class ChatViewModel( _uiState.value, _streamingState.value, event, - _uiState.value.currentSessionId, + runtimeSessionId ?: _uiState.value.currentSessionId, ) // Apply the new state @@ -418,7 +432,7 @@ class ChatViewModel( private fun isCurrentSession(eventSessionId: String?): Boolean { // If the event has no session ID, process it (legacy compatibility) if (eventSessionId == null) return true - return eventSessionId == _uiState.value.currentSessionId + return eventSessionId == runtimeSessionId || eventSessionId == _uiState.value.currentSessionId } // ── RPC response handling ──────────────────────────────────────────── @@ -432,10 +446,12 @@ class ChatViewModel( when (method) { WsMethods.SESSION_CREATE -> { val resultMap = result as? Map ?: 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 { it.copy( - currentSessionId = sessionId, + currentSessionId = storageId, isLoading = false, messages = emptyList(), chatTitle = "Hermes", @@ -444,7 +460,7 @@ class ChatViewModel( // Mirror the active session id app-wide so session-scoped // drawer screens (e.g. Processes, issue #532) can issue // session-scoped RPCs. See ActiveSessionHolder. - ActiveSessionHolder.set(sessionId) + ActiveSessionHolder.set(runtimeId) _streamingState.update { StreamingState() } addSystemMessage("Session created", persist = true) loadSessions() @@ -472,8 +488,9 @@ class ChatViewModel( WsMethods.SESSION_RESUME -> { val resultMap = result as? Map + runtimeSessionId = resultMap?.get("session_id") as? String val sessionId = - (resultMap?.get("session_id") as? String) + (resultMap?.get("resumed") as? String) ?: _uiState.value.currentSessionId // B8 (Jun 20 2026, kanban t_session_resume): do NOT reload @@ -487,8 +504,8 @@ class ChatViewModel( currentSessionId = sessionId, ) } - // Mirror the active session id app-wide (issue #532). - ActiveSessionHolder.set(sessionId) + // Mirror the active runtime session id app-wide (issue #532). + ActiveSessionHolder.set(runtimeSessionId ?: sessionId) addSystemMessage("Session resumed") } @@ -599,7 +616,8 @@ class ChatViewModel( */ fun sendMessage(text: String) { 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() 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) { - repo.persistMessage(userMessage, sessionId) + repo.persistMessage(userMessage, storageSessionId) } // Upload attachments then submit prompt @@ -689,7 +707,7 @@ class ChatViewModel( } wsClient.sendMessage( - sessionId, + agentSessionId, fullText, onSent = { id -> trackRequest(id, WsMethods.PROMPT_SUBMIT) }, ) @@ -779,7 +797,7 @@ class ChatViewModel( } private fun dispatchViaRpc(command: String) { - val sessionId = _uiState.value.currentSessionId + val sessionId = runtimeSessionId if (sessionId == null) { addAssistantMessage("No active session. Use `/new` to create one.") return @@ -803,7 +821,7 @@ class ChatViewModel( */ private fun submitPrompt(text: String) { if (text.isBlank()) return - val sessionId = _uiState.value.currentSessionId ?: return + val sessionId = runtimeSessionId ?: return _uiState.update { it.copy(isAgentTyping = true) } viewModelScope.launch(Dispatchers.IO) { wsClient.sendMessage( @@ -830,7 +848,7 @@ class ChatViewModel( // ── Session management ─────────────────────────────────────────────── fun interruptSession() { - val sessionId = _uiState.value.currentSessionId ?: return + val sessionId = runtimeSessionId ?: return viewModelScope.launch(Dispatchers.IO) { wsClient.send( WsMethods.SESSION_INTERRUPT, @@ -853,6 +871,7 @@ class ChatViewModel( viewModelScope.launch(Dispatchers.IO) { wsClient.send( WsMethods.SESSION_CREATE, + params = mapOf("source" to "desktop"), onSent = { id -> trackRequest(id, WsMethods.SESSION_CREATE) }, ) } @@ -878,15 +897,7 @@ class ChatViewModel( fun refreshCurrentSession() { val sessionId = _uiState.value.currentSessionId ?: return - viewModelScope.launch { - 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) - } + loadSessionMessages(sessionId) } fun refreshSettings() { @@ -911,12 +922,16 @@ class ChatViewModel( fun switchSession(sessionId: String) { 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() _uiState.update { val title = it.sessions.find { s -> s.id == sessionId }?.title ?: "Hermes" it.copy( isLoading = true, + isLoadingOlder = false, + hasOlderMessages = false, currentSessionId = sessionId, messages = emptyList(), chatTitle = title, @@ -928,9 +943,7 @@ class ChatViewModel( ActiveSessionHolder.set(sessionId) _streamingState.update { StreamingState() } viewModelScope.launch { - // Step 1: Load cached messages first — instant display - loadCachedMessages(sessionId).join() - // Step 2: Resume session on server + // Resume the selected desktop session, then load its complete transcript. launch(Dispatchers.IO) { wsClient.send( WsMethods.SESSION_RESUME, @@ -938,9 +951,7 @@ class ChatViewModel( onSent = { id -> trackRequest(id, WsMethods.SESSION_RESUME) }, ) } - // Step 3: Load fresh messages from REST and merge loadSessionMessages(sessionId) - // Step 4: Refresh session list to get latest titles loadSessions() } } @@ -960,62 +971,33 @@ class ChatViewModel( private fun loadSessionMessages(sessionId: String) { viewModelScope.launch { - val result = - withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.getSessionMessages(sessionId) } - } + val messageCount = fetchServerMessageCount(sessionId) + val offset = (messageCount - MESSAGE_PAGE_SIZE).coerceAtLeast(0) + val result = fetchMessagePage(sessionId, offset, MESSAGE_PAGE_SIZE) when (result) { is NetworkResult.Success -> { - val messagesList = result.data.messages.orEmpty() - val chatMessages = - 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 + val chatMessages = mapServerMessages(sessionId, result.data.messages.orEmpty(), offset) + loadedMessageOffset = offset withContext(Dispatchers.IO) { repo.persistMessages(chatMessages, sessionId) } _uiState.update { state -> - // Only update if still on the same session 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( - messages = chatMessages + localOnly, + messages = chatMessages, isLoading = false, + hasOlderMessages = offset > 0, + isLoadingOlder = false, ) } } is NetworkResult.Failure -> { - // Don't overwrite messages on REST failure — cached messages - // are still valid. _uiState.update { if (it.currentSessionId != sessionId) return@update it it.copy( isLoading = false, + isLoadingOlder = false, 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, + offset: Int, + ): List = + 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, + right: List, + ): 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 ─────────────────────────────────────────────────────── fun toggleSessionPicker() { @@ -1363,9 +1509,6 @@ class ChatViewModel( // ── 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() - private fun trackRequest( id: String, method: String, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index 8d722499..262d296c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -76,6 +76,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.ChatScreen import com.m57.hermescontrol.NavigationController 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.LocalSpacing import com.m57.hermescontrol.ui.common.EmptyState @@ -170,11 +172,17 @@ fun SessionsScreen( var query by remember { mutableStateOf("") } var pruneDays by remember { mutableStateOf("7") } + val sessionTree = remember(state.sessions) { flattenSessionTree(state.sessions) } val filteredSessions = - remember(query, state.sessions) { - state.sessions.filter { session -> - session.title?.contains(query, ignoreCase = true) == true || - session.status?.contains(query, ignoreCase = true) == true + remember(query, sessionTree) { + if (query.isBlank()) { + sessionTree + } 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, verticalArrangement = listItemSpacing, ) { - items(filteredSessions, key = { it.id }) { session -> + items(filteredSessions, key = { it.session.id }) { item -> + val session = item.session SessionCard( session = session, + displayTitle = item.displayTitle, + depth = item.depth, + branchStem = item.branchStem, query = query, isSelecting = state.isSelecting, isSelected = session.id in state.selectedIds, @@ -589,7 +601,10 @@ fun SessionsScreen( @OptIn(ExperimentalFoundationApi::class) @Composable private fun SessionCard( - session: com.m57.hermescontrol.data.model.SessionInfo, + session: SessionInfo, + displayTitle: String, + depth: Int, + branchStem: String?, query: String, isSelecting: Boolean, isSelected: Boolean, @@ -603,7 +618,6 @@ private fun SessionCard( ) { val spacing = LocalSpacing.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 srcIcon = sourceIcon(session.source) @@ -611,6 +625,7 @@ private fun SessionCard( modifier = Modifier .fillMaxWidth() + .padding(start = (depth * 16).dp) .testTag("session_card_${session.id}") .combinedClickable( onClick = onCardClick, @@ -643,6 +658,16 @@ private fun SessionCard( 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 if (srcIcon != null && !isSelecting) { Icon( diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/SessionTreeTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/SessionTreeTest.kt new file mode 100644 index 00000000..bfc28b16 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/model/SessionTreeTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt index d91fdd51..124edadb 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt @@ -141,7 +141,8 @@ class HermesApiServiceMockWebServerTest { "created_at": "2026-06-20T14:30:00Z", "message_count": 7, "status": "idle", - "source": "web" + "source": "web", + "parent_session_id": "abc-123" } ], "total": 2, @@ -173,6 +174,7 @@ class HermesApiServiceMockWebServerTest { assertEquals(7, second.message_count) assertEquals("idle", second.status) assertEquals("web", second.source) + assertEquals("abc-123", second.parent_session_id) } @Test @@ -254,6 +256,24 @@ class HermesApiServiceMockWebServerTest { 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 fun getSessionMessages_withNullRole_doesNotCrash() = runBlocking {