From 16967c7e918e7e3663faa61b9411173ebb9e8676 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:29:06 +0300 Subject: [PATCH] feat(#1217): add compacted-session timeline navigation (#1227) --- .../hermescontrol/ui/chat/ChatScreenTest.kt | 2 + .../data/model/SessionMessagesResponse.kt | 14 + .../data/model/SessionTimelineResponse.kt | 53 ++++ .../data/remote/HermesApiService.kt | 18 ++ .../m57/hermescontrol/ui/chat/ChatScreen.kt | 116 +++++-- .../ui/chat/ChatServerMessageMapper.kt | 12 +- .../hermescontrol/ui/chat/ChatViewModel.kt | 277 ++++++++++++++++ .../chat/components/ChatScrollController.kt | 8 +- .../ui/chat/components/ChatTimelineSheet.kt | 220 +++++++++++++ app/src/main/res/values/strings.xml | 13 + .../HermesApiServiceMockWebServerTest.kt | 130 ++++++++ .../ui/chat/ChatViewModelTest.kt | 295 ++++++++++++++++++ 12 files changed, 1124 insertions(+), 34 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/SessionTimelineResponse.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatTimelineSheet.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt index 505fd402..5f04473b 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt @@ -59,6 +59,7 @@ class ChatScreenTest { val mockViewModel = mockk(relaxed = true) every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow() every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow() + every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow() // ActionProgressDialog collects the controller's StateFlow — a relaxed // mock proxy would crash the cast inside collectAsStateWithLifecycle. // A real controller (never started here) stays invisible. @@ -105,6 +106,7 @@ class ChatScreenTest { val mockViewModel = mockk(relaxed = true) every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow() every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow() + every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow() every { mockViewModel.actionProgress } returns ActionProgressController(scope = CoroutineScope(Dispatchers.Main)) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt index c05002a3..9ea2521a 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt @@ -42,6 +42,12 @@ data class SessionMessage( val reasoning: JsonElement? = null, val reasoning_text: JsonElement? = null, val tool_call_id: String? = null, + /** + * User-visible projection supplied by the backend for rows whose physical + * [content] is model-facing scaffolding (notably compaction summaries). + * Prefer this for rendering whenever present. + */ + val display_content: JsonElement? = null, /** * Timeline-marker tag (backend NS-656 lineage, issue #904): markers like * `model_switch` / `personality_switch` / `auto_continue` ride as @@ -85,6 +91,14 @@ data class SessionMessage( else -> content.toString() } + val displayContentText: String? + get() = + when (display_content) { + is JsonPrimitive -> display_content.content + null -> null + else -> display_content.toString() + } + val reasoningText: String get() = when (val r = reasoning ?: reasoning_text) { diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionTimelineResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionTimelineResponse.kt new file mode 100644 index 00000000..d9d22cbc --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionTimelineResponse.kt @@ -0,0 +1,53 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +/** Lightweight prompt index returned by GET /api/sessions/{id}/timeline. */ +@Serializable +data class SessionTimelineResponse( + val session_id: String? = null, + val profile: String? = null, + val entries: List = emptyList(), + val pagination: SessionTimelinePagination = SessionTimelinePagination(), +) + +@Serializable +data class SessionTimelineEntry( + /** Stable representative row id. Pass this exact value to /messages/around. */ + val row_id: Int, + val preview: String = "", + val timestamp: JsonElement? = null, +) + +@Serializable +data class SessionTimelinePagination( + val limit: Int? = null, + val after_row_id: Int? = null, + val returned: Int? = null, + val total: Int? = null, + val has_more: Boolean = false, + /** Backend cursor. It is not interchangeable with the last returned row_id. */ + val next_cursor: Int? = null, +) + +/** Bounded chronological display page returned by GET /api/sessions/{id}/messages/around. */ +@Serializable +data class SessionMessagesAroundResponse( + val session_id: String? = null, + val profile: String? = null, + val messages: List = emptyList(), + val pagination: SessionMessagesAroundPagination = SessionMessagesAroundPagination(), +) + +@Serializable +data class SessionMessagesAroundPagination( + val row_id: Int? = null, + val limit: Int? = null, + val returned: Int? = null, + val order: String? = null, + val offset: Int = 0, + val total: Int? = null, + val has_older: Boolean = false, + val has_newer: Boolean = false, +) 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 41057877..8be894c1 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 @@ -102,10 +102,12 @@ import com.m57.hermescontrol.data.model.ScanStatus import com.m57.hermescontrol.data.model.SessionDetailResponse import com.m57.hermescontrol.data.model.SessionInfo import com.m57.hermescontrol.data.model.SessionListResponse +import com.m57.hermescontrol.data.model.SessionMessagesAroundResponse import com.m57.hermescontrol.data.model.SessionMessagesResponse import com.m57.hermescontrol.data.model.SessionRenameRequest import com.m57.hermescontrol.data.model.SessionSearchResponse import com.m57.hermescontrol.data.model.SessionStatsResponse +import com.m57.hermescontrol.data.model.SessionTimelineResponse import com.m57.hermescontrol.data.model.SetActiveProfileRequest import com.m57.hermescontrol.data.model.Skill import com.m57.hermescontrol.data.model.SkillContentResponse @@ -209,6 +211,22 @@ interface HermesApiService : KanbanApiService { @Query("profile") profile: String? = null, ): Response + @GET("api/sessions/{id}/timeline") + suspend fun getSessionTimeline( + @Path("id", encoded = true) sessionId: String, + @Query("profile") profile: String? = null, + @Query("limit") limit: Int = 500, + @Query("after_row_id") afterRowId: Int = 0, + ): Response + + @GET("api/sessions/{id}/messages/around") + suspend fun getSessionMessagesAround( + @Path("id", encoded = true) sessionId: String, + @Query("row_id") rowId: Int, + @Query("profile") profile: String? = null, + @Query("limit") limit: Int = 120, + ): Response + @GET("api/sessions/stats") suspend fun getSessionStats(): Response 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 49d7cfc9..daa29ed1 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 @@ -32,6 +32,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Extension +import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog @@ -86,12 +87,14 @@ import com.m57.hermescontrol.notification.NotificationHelper import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.ui.chat.components.ChatAppUpdateSection import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner +import com.m57.hermescontrol.ui.chat.components.ChatHistoryWindowBanner import com.m57.hermescontrol.ui.chat.components.ChatInputBar import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects import com.m57.hermescontrol.ui.chat.components.ChatLoadingOverlay import com.m57.hermescontrol.ui.chat.components.ChatResumeErrorOverlay import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab import com.m57.hermescontrol.ui.chat.components.ChatTimelineNoPrefetchStrategy +import com.m57.hermescontrol.ui.chat.components.ChatTimelineSheet import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet import com.m57.hermescontrol.ui.chat.components.ContextUsageChip import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay @@ -149,6 +152,7 @@ fun ChatScreen( ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val streamingState by viewModel.streamingState.collectAsStateWithLifecycle() + val timelineState by viewModel.timelineState.collectAsStateWithLifecycle() val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle() val connectorsViewModel: ChatConnectorsViewModel = viewModel() val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle() @@ -156,6 +160,7 @@ fun ChatScreen( // Snapshot-backed search state — read directly so only the scopes that // read its fields recompose on search changes (bar, matched bubbles). val searchState = viewModel.searchState + val displayedMessages = timelineState.historyMessages ?: state.messages val lifecycleOwner = LocalLifecycleOwner.current val context = LocalContext.current var browserAuthInFlight by rememberSaveable { mutableStateOf(false) } @@ -275,6 +280,7 @@ fun ChatScreen( // (issue #682). Replaces the old item-count heuristic that ignored the // streaming tail. LaunchedEffect( + timelineState.isHistorical, state.messages, streamingState.streamingMessage, streamingState.isThinking, @@ -282,6 +288,10 @@ fun ChatScreen( state.todos, state.clarifyRequest, ) { + if (timelineState.isHistorical) { + scrollController.pauseFollowing() + return@LaunchedEffect + } scrollController.onTailChanged( tailKey = tailContentKey( @@ -294,9 +304,15 @@ fun ChatScreen( messageCount = state.messages.size, ) } - val showScrollToBottom by remember { + LaunchedEffect(timelineState.historyAnchorRowId) { + if (timelineState.isHistorical && displayedMessages.isNotEmpty()) { + scrollController.pauseFollowing() + listState.scrollToItem(0) + } + } + val showScrollToBottom by remember(timelineState.isHistorical, displayedMessages) { derivedStateOf { - scrollController.showFab(state.messages.isNotEmpty()) + !timelineState.isHistorical && scrollController.showFab(displayedMessages.isNotEmpty()) } } var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { @@ -406,7 +422,8 @@ fun ChatScreen( !showSubagentInspectionSheet && state.btwState == null && viewingImage == null && - !connectorsState.isVisible + !connectorsState.isVisible && + !timelineState.isOpen // Lifecycle effects, permissions, session switching, auto-scroll, errors ChatLifecycleEffects( @@ -509,6 +526,22 @@ fun ChatScreen( expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }, ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.chat_action_timeline)) }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.History, + contentDescription = null, + ) + }, + enabled = state.currentSessionId != null, + onClick = { + showSessionMenu = false + viewModel.openTimeline() + }, + modifier = Modifier.testTag("chat_menu_timeline"), + ) + // Chat search lives in this overflow menu (moved from the top bar) DropdownMenuItem( text = { @@ -533,6 +566,7 @@ fun ChatScreen( showSessionMenu = false viewModel.toggleSearch() }, + enabled = !timelineState.isHistorical, modifier = Modifier.testTag("chat_menu_search"), ) @@ -623,6 +657,17 @@ fun ChatScreen( } } + if (timelineState.isHistorical) { + ChatHistoryWindowBanner( + hasOlder = timelineState.historyHasOlder, + hasNewer = timelineState.historyHasNewer, + onReturnToLatest = { + viewModel.returnToLatestMessages() + scrollController.jumpToBottom() + }, + ) + } + Box( modifier = Modifier @@ -659,37 +704,44 @@ fun ChatScreen( // Full-bleed chat renderer (issue #866) — the single chat // surface since the bubble renderer was removed. FullBleedChatList( - messages = state.messages, - streamingState = streamingState, - isAgentTyping = state.isAgentTyping, + messages = displayedMessages, + streamingState = if (timelineState.isHistorical) StreamingState() else streamingState, + isAgentTyping = state.isAgentTyping && !timelineState.isHistorical, searchState = searchState, - typingEffectEnabled = state.typingEffectEnabled, + typingEffectEnabled = state.typingEffectEnabled && !timelineState.isHistorical, typingEffectDelayMs = state.typingEffectDelayMs, messageStatsEnabled = state.messageStatsEnabled, showUserMessageTokens = state.showUserMessageTokens, showAssistantMessageTokens = state.showAssistantMessageTokens, showTokensPerSecond = state.showTokensPerSecond, maxToolCallsPerTurn = state.maxToolCallsPerTurn, - isLoading = state.isLoading, - isLoadingOlder = state.isLoadingOlder, - hasOlderMessages = state.hasOlderMessages, - pagingSessionId = state.currentSessionId, + isLoading = state.isLoading && !timelineState.isHistorical, + isLoadingOlder = state.isLoadingOlder && !timelineState.isHistorical, + hasOlderMessages = state.hasOlderMessages && !timelineState.isHistorical, + pagingSessionId = + state.currentSessionId?.let { currentSessionId -> + if (timelineState.isHistorical) { + currentSessionId + ":history:" + timelineState.historyAnchorRowId + } else { + currentSessionId + } + }, listState = listState, scrollController = scrollController, lastAnimatedMessageId = lastAnimatedMessageId, onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it }, viewModel = viewModel, - clarifyRequest = state.clarifyRequest, + clarifyRequest = state.clarifyRequest.takeUnless { timelineState.isHistorical }, onRespondClarify = viewModel::respondToClarify, onRespondClarifyBatch = viewModel::respondToClarifyBatch, onDismissClarify = viewModel::dismissClarify, - vaultUnlockPrompt = state.vaultUnlockPrompt, + vaultUnlockPrompt = state.vaultUnlockPrompt.takeUnless { timelineState.isHistorical }, onRespondVaultUnlock = viewModel::respondToVaultUnlock, onDismissVaultUnlock = viewModel::dismissVaultUnlock, - vaultSaveLoginPrompt = state.vaultSaveLoginPrompt, + vaultSaveLoginPrompt = state.vaultSaveLoginPrompt.takeUnless { timelineState.isHistorical }, onRespondVaultSaveLogin = viewModel::respondToVaultSaveLogin, onDismissVaultSaveLogin = viewModel::dismissVaultSaveLogin, - vaultCodePrompt = state.vaultCodePrompt, + vaultCodePrompt = state.vaultCodePrompt.takeUnless { timelineState.isHistorical }, onRespondVaultCode = viewModel::respondToVaultCode, onDismissVaultCode = viewModel::dismissVaultCode, onSaveAttachment = onSaveAttachment, @@ -699,14 +751,18 @@ fun ChatScreen( ) // Loading overlay - ChatLoadingOverlay(isLoading = state.isLoading && state.resumeError == null) + ChatLoadingOverlay( + isLoading = state.isLoading && state.resumeError == null && !timelineState.isHistorical, + ) // Resume-exhausted overlay — explicit error + Retry instead // of an infinite spinner (desktop parity). - ChatResumeErrorOverlay( - errorMessage = state.resumeError, - onRetry = viewModel::retryResumeSession, - ) + if (!timelineState.isHistorical) { + ChatResumeErrorOverlay( + errorMessage = state.resumeError, + onRetry = viewModel::retryResumeSession, + ) + } // Scroll-to-bottom FAB (issue #682): shows while follow is // paused and renders the unseen-message badge. @@ -718,10 +774,12 @@ fun ChatScreen( // Reaction heartsanimation (purely cosmetic — fades out // automatically after the ViewModel clears the state) - key(state.reactionTriggerId) { - ReactionHeartsOverlay( - reactionKind = state.reactionKind, - ) + if (!timelineState.isHistorical) { + key(state.reactionTriggerId) { + ReactionHeartsOverlay( + reactionKind = state.reactionKind, + ) + } } } @@ -754,7 +812,7 @@ fun ChatScreen( isListening = mediaLaunchers.isListening, isAgentTyping = state.isAgentTyping, isConnected = state.isConnected, - isSessionReady = state.isSessionReady, + isSessionReady = state.isSessionReady && !timelineState.isHistorical, sessionPreparationFailed = state.resumeError != null, commandCatalog = state.commandCatalog, slashUsageCounts = state.slashUsageCounts, @@ -790,6 +848,14 @@ fun ChatScreen( ) } + ChatTimelineSheet( + state = timelineState, + onDismiss = viewModel::closeTimeline, + onJump = viewModel::jumpToTimelineEntry, + onLoadMore = viewModel::loadMoreTimeline, + onRetry = viewModel::retryTimeline, + ) + if (showReloginDialog) { ReloginDialog( onDismiss = { showReloginDialog = false }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt index f4b24abc..5a7ce54d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatServerMessageMapper.kt @@ -21,6 +21,7 @@ internal fun mapServerMessages( latestPaging: Boolean, liveMessages: List, isPagingOlder: Boolean = false, + stableRowIds: Boolean = latestPaging, context: android.content.Context? = null, ): List { val existingById = liveMessages.associateBy { it.canonicalRestId ?: it.id } @@ -41,7 +42,7 @@ internal fun mapServerMessages( } fun restIdAt(index: Int): String = - if (latestPaging) { + if (stableRowIds) { "rest-$sessionId-${requireNotNull(messages[index].id) { "Latest transcript row has no stable id" }}" } else { "rest-$sessionId-${offset + index}" @@ -79,7 +80,7 @@ internal fun mapServerMessages( if (i in wsCompletionIdByRestIndex || restIdAt(i) in reservedRestIds) continue val m = messages[i] if (m.role?.lowercase() in listOf("user", "system", "tool")) continue - val rawContent = m.contentText + val rawContent = m.displayContentText ?: m.contentText if (rawContent.isBlank()) continue val canonicalContent = if (rawContent.contains("MEDIA:")) HostMediaExtractor.strip(rawContent).trim() else rawContent.trim() @@ -107,13 +108,14 @@ internal fun mapServerMessages( assistants.filter { it.canonicalRestId == null && it.completionId == null }.toMutableList() for (index in messages.indices.reversed()) { val row = messages[index] - if (reasoningSources[index] != null || row.contentText.isBlank() || + val displayContent = row.displayContentText ?: row.contentText + if (reasoningSources[index] != null || displayContent.isBlank() || row.role?.lowercase() in listOf("user", "system", "tool") || liveByExactId[restIdAt(index)]?.completionId != null || index in wsCompletionIdByRestIndex ) { continue } - val content = HostMediaExtractor.strip(row.contentText).trim() + val content = HostMediaExtractor.strip(displayContent).trim() val match = remaining.indexOfLast { it.content.trim() == content } if (match >= 0) reasoningSources[index] = remaining.removeAt(match) } @@ -142,7 +144,7 @@ internal fun mapServerMessages( ?: existingById[restId]?.timestamp ?: System.currentTimeMillis() - val rawContent = msg.contentText + val rawContent = msg.displayContentText ?: msg.contentText val rowReasoning = msg.reasoningText.ifBlank { if (role == MessageRole.ASSISTANT) { 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 09a0faa1..36e38374 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 @@ -12,6 +12,7 @@ import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.ModelCapabilities import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel +import com.m57.hermescontrol.data.model.SessionTimelineEntry import com.m57.hermescontrol.data.model.UsageSnapshotResponse import com.m57.hermescontrol.data.model.parseContextBreakdown import com.m57.hermescontrol.data.model.parseUsageSnapshot @@ -59,6 +60,8 @@ import java.util.concurrent.ConcurrentHashMap private const val TAG = "ChatViewModel" private const val MESSAGE_PAGE_SIZE = 150 +private const val TIMELINE_PAGE_SIZE = 500 +private const val HISTORY_WINDOW_SIZE = 120 private val REASONING_EFFORT_LEVELS = setOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra") @@ -238,6 +241,23 @@ data class ChatUiState( val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED } +data class ChatTimelineState( + val isOpen: Boolean = false, + val entries: List = emptyList(), + val isLoading: Boolean = false, + val hasMore: Boolean = false, + val nextCursor: Int? = null, + val errorMessage: String? = null, + val jumpingRowId: Int? = null, + val windowErrorMessage: String? = null, + val historyMessages: List? = null, + val historyAnchorRowId: Int? = null, + val historyHasOlder: Boolean = false, + val historyHasNewer: Boolean = false, +) { + val isHistorical: Boolean get() = historyMessages != null +} + data class SessionUi( val id: String, val title: String, @@ -455,6 +475,13 @@ class ChatViewModel( private var olderJob: Job? = null private var syncJob: Job? = null private val historyFetchMutex = Mutex() + private var timelineJob: Job? = null + private var historyWindowJob: Job? = null + private var timelineRequestSequence = 0L + private var activeTimelineRequestSequence = 0L + private var historyWindowRequestSequence = 0L + private var activeHistoryWindowRequestSequence = 0L + private val _timelineState = MutableStateFlow(ChatTimelineState()) // ── Session resume recovery (desktop parity) ──────────────────────── // Bounded auto-retry with exponential backoff, mirroring the desktop's @@ -465,6 +492,7 @@ class ChatViewModel( private var resumeRetryAttempt = 0 private var resumeRetryJob: Job? = null val streamingState: StateFlow = _streamingState.asStateFlow() + val timelineState: StateFlow = _timelineState.asStateFlow() /** Tracks the auto-clear coroutine for reaction animations. */ private var reactionClearJob: Job? = null @@ -660,6 +688,7 @@ class ChatViewModel( syncJob?.cancel() cacheJob?.cancel() isSyncingMessages = false + resetTimelineState() cancelResumeRetry() _uiState.update { it.copy(isSessionReady = false, isLoadingOlder = false, isResumeRetrying = false) @@ -2899,6 +2928,245 @@ class ChatViewModel( fun getCurrentModelCapabilities(): ModelCapabilities? = modelSwitchDelegate.getCurrentModelCapabilities() + fun openTimeline() { + if (_uiState.value.currentSessionId == null || _timelineState.value.isOpen) return + searchDelegate.clearSearch() + _timelineState.update { + it.copy( + isOpen = true, + entries = emptyList(), + isLoading = false, + hasMore = false, + nextCursor = null, + errorMessage = null, + windowErrorMessage = null, + ) + } + loadTimelinePage(reset = true) + } + + fun closeTimeline() { + timelineJob?.cancel() + historyWindowJob?.cancel() + activeTimelineRequestSequence = ++timelineRequestSequence + activeHistoryWindowRequestSequence = ++historyWindowRequestSequence + _timelineState.update { + it.copy( + isOpen = false, + isLoading = false, + jumpingRowId = null, + ) + } + } + + fun retryTimeline() { + if (!_timelineState.value.isOpen) return + _timelineState.update { it.copy(windowErrorMessage = null) } + loadTimelinePage(reset = true) + } + + fun loadMoreTimeline() { + val state = _timelineState.value + if (!state.isOpen || state.isLoading || !state.hasMore || state.nextCursor == null) return + loadTimelinePage(reset = false) + } + + private fun loadTimelinePage(reset: Boolean) { + val sessionId = _uiState.value.currentSessionId ?: return + val generation = sessionGeneration + val profile = AuthManager.activeProfileId.value + val cursor = if (reset) 0 else _timelineState.value.nextCursor ?: return + val requestSequence = ++timelineRequestSequence + activeTimelineRequestSequence = requestSequence + timelineJob?.cancel() + _timelineState.update { + it.copy( + isLoading = true, + errorMessage = null, + entries = if (reset) emptyList() else it.entries, + hasMore = if (reset) false else it.hasMore, + nextCursor = if (reset) null else it.nextCursor, + ) + } + val valid = { + generation == sessionGeneration && + sessionId == _uiState.value.currentSessionId && + profile == AuthManager.activeProfileId.value && + requestSequence == activeTimelineRequestSequence + } + timelineJob = + viewModelScope.launch { + try { + val result = + withContext(ioDispatcher) { + safeApiCall { + ApiClient.hermesApi.getSessionTimeline( + sessionId = sessionId, + profile = profile, + limit = TIMELINE_PAGE_SIZE, + afterRowId = cursor, + ) + } + } + if (!valid()) return@launch + when (result) { + is NetworkResult.Success -> { + val page = result.data + val nextCursor = page.pagination.next_cursor + _timelineState.update { current -> + if (!valid()) { + current + } else { + val entries = + (if (reset) page.entries else current.entries + page.entries) + .distinctBy { it.row_id } + current.copy( + entries = entries, + hasMore = page.pagination.has_more && nextCursor != null, + nextCursor = nextCursor, + errorMessage = null, + ) + } + } + } + + is NetworkResult.Failure -> { + _timelineState.update { + if (valid()) { + it.copy(errorMessage = "Failed to load timeline: ${result.error.message}") + } else { + it + } + } + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (valid()) { + _timelineState.update { it.copy(errorMessage = "Failed to load timeline: ${e.message}") } + } + } finally { + if (valid()) _timelineState.update { it.copy(isLoading = false) } + } + } + } + + fun jumpToTimelineEntry(rowId: Int) { + if (rowId <= 0) return + val sessionId = _uiState.value.currentSessionId ?: return + val generation = sessionGeneration + val profile = AuthManager.activeProfileId.value + val requestSequence = ++historyWindowRequestSequence + activeHistoryWindowRequestSequence = requestSequence + historyWindowJob?.cancel() + _timelineState.update { + it.copy( + jumpingRowId = rowId, + windowErrorMessage = null, + ) + } + val valid = { + generation == sessionGeneration && + sessionId == _uiState.value.currentSessionId && + profile == AuthManager.activeProfileId.value && + requestSequence == activeHistoryWindowRequestSequence + } + historyWindowJob = + viewModelScope.launch { + try { + val result = + withContext(ioDispatcher) { + safeApiCall { + ApiClient.hermesApi.getSessionMessagesAround( + sessionId = sessionId, + rowId = rowId, + profile = profile, + limit = HISTORY_WINDOW_SIZE, + ) + } + } + if (!valid()) return@launch + when (result) { + is NetworkResult.Success -> { + val response = result.data + if (response.messages.size > HISTORY_WINDOW_SIZE) { + _timelineState.update { + it.copy(windowErrorMessage = "History response exceeded the page limit.") + } + return@launch + } + val page = + withContext(historyDispatcher) { + mapServerMessages( + sessionId = sessionId, + messages = response.messages, + offset = response.pagination.offset, + latestPaging = false, + liveMessages = _uiState.value.messages, + isPagingOlder = true, + stableRowIds = true, + context = getApplication(), + ) + } + if (!valid()) return@launch + val targetId = "rest-$sessionId-$rowId" + if (page.none { it.id == targetId || it.canonicalRestId == targetId }) { + _timelineState.update { + it.copy(windowErrorMessage = "The selected prompt is no longer available.") + } + return@launch + } + if (!valid()) return@launch + _timelineState.update { + it.copy( + isOpen = false, + historyMessages = page, + historyAnchorRowId = rowId, + historyHasOlder = response.pagination.has_older, + historyHasNewer = response.pagination.has_newer, + windowErrorMessage = null, + ) + } + } + + is NetworkResult.Failure -> { + _timelineState.update { + if (valid()) { + it.copy(windowErrorMessage = "Failed to load message: ${result.error.message}") + } else { + it + } + } + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (valid()) { + _timelineState.update { it.copy(windowErrorMessage = "Failed to load message: ${e.message}") } + } + } finally { + if (valid()) _timelineState.update { it.copy(jumpingRowId = null) } + } + } + } + + fun returnToLatestMessages() { + historyWindowJob?.cancel() + activeHistoryWindowRequestSequence = ++historyWindowRequestSequence + _timelineState.update { + it.copy( + jumpingRowId = null, + windowErrorMessage = null, + historyMessages = null, + historyAnchorRowId = null, + historyHasOlder = false, + historyHasNewer = false, + ) + } + } + fun switchSession(sessionId: String) { if (sessionId == _uiState.value.currentSessionId) return @@ -2972,6 +3240,14 @@ class ChatViewModel( _uiState.update { it.copy(hasOlderMessages = cacheHasOlder || serverHasOlder) } } + private fun resetTimelineState() { + timelineJob?.cancel() + historyWindowJob?.cancel() + activeTimelineRequestSequence = ++timelineRequestSequence + activeHistoryWindowRequestSequence = ++historyWindowRequestSequence + _timelineState.value = ChatTimelineState() + } + private suspend fun readCachedPage( sessionId: String, generation: Long, @@ -3174,6 +3450,7 @@ class ChatViewModel( hydrationJob?.cancel() olderJob?.cancel() syncJob?.cancel() + resetTimelineState() activeHydrationRequestSequence = 0L cacheCursor = null cacheHasOlder = false diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt index 20e76b3d..60a6c506 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt @@ -158,11 +158,11 @@ class ChatScrollController( * item count still hasn't caught up (rare, heavy layout), wait briefly * via snapshotFlow on the layout info. */ - private suspend fun scrollToBottomAwaitingLayout() { + private suspend fun scrollToBottomAwaitingLayout(animated: Boolean = false) { // Yield twice: first for recomposition, second for layout. yield() yield() - listState.scrollToBottom(animated = false) + listState.scrollToBottom(animated = animated) // If we're still not at the bottom after the first scroll (e.g. a // new item was laid out between the scroll and now), do one more pass. if (!listState.isAtBottom(bottomPixelTolerance)) { @@ -171,7 +171,7 @@ class ChatScrollController( snapshotFlow { listState.layoutInfo.totalItemsCount } .first { it > 0 } } - listState.scrollToBottom(animated = false) + listState.scrollToBottom(animated = animated) } } @@ -182,7 +182,7 @@ class ChatScrollController( scope.launch { isProgrammaticScroll = true try { - listState.scrollToBottom(animated = animated) + scrollToBottomAwaitingLayout(animated = animated) } finally { isProgrammaticScroll = false } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatTimelineSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatTimelineSheet.kt new file mode 100644 index 00000000..c3526c83 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatTimelineSheet.kt @@ -0,0 +1,220 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.chat.ChatTimelineState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatTimelineSheet( + state: ChatTimelineState, + onDismiss: () -> Unit, + onJump: (Int) -> Unit, + onLoadMore: () -> Unit, + onRetry: () -> Unit, +) { + if (!state.isOpen) return + + ModalBottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + ) { + Text( + text = stringResource(R.string.chat_timeline_title), + modifier = Modifier.padding(horizontal = 20.dp), + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = stringResource(R.string.chat_timeline_subtitle), + modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + val error = state.windowErrorMessage ?: state.errorMessage + if (error != null) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = error, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + if (!state.isLoading && state.jumpingRowId == null) { + TextButton(onClick = onRetry) { + Text( + stringResource( + if (state.entries.isEmpty()) { + R.string.chat_timeline_retry + } else { + R.string.chat_timeline_refresh + }, + ), + ) + } + } + } + } + + when { + state.isLoading && state.entries.isEmpty() -> { + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 160.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + state.entries.isEmpty() && state.errorMessage == null -> { + Text( + text = stringResource(R.string.chat_timeline_empty), + modifier = Modifier.padding(horizontal = 20.dp, vertical = 24.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + state.entries.isNotEmpty() -> { + LazyColumn( + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 520.dp), + ) { + items( + items = state.entries, + key = { it.row_id }, + ) { entry -> + ListItem( + headlineContent = { + Text( + text = + entry.preview.ifBlank { + stringResource(R.string.chat_timeline_prompt_fallback) + }, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + }, + trailingContent = { + if (state.jumpingRowId == entry.row_id) { + CircularProgressIndicator(modifier = Modifier.size(20.dp)) + } + }, + modifier = + Modifier.clickable( + enabled = state.jumpingRowId == null, + onClick = { onJump(entry.row_id) }, + ), + ) + HorizontalDivider() + } + + if (state.hasMore || (state.isLoading && state.entries.isNotEmpty())) { + item(key = "timeline-load-more") { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + if (state.isLoading) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } else { + TextButton(onClick = onLoadMore) { + Text(stringResource(R.string.chat_timeline_load_more)) + } + } + } + } + } + } + } + } + } + } +} + +@Composable +fun ChatHistoryWindowBanner( + hasOlder: Boolean, + hasNewer: Boolean, + onReturnToLatest: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.chat_history_window_title), + style = MaterialTheme.typography.labelLarge, + ) + val rangeMessage = + when { + hasOlder && hasNewer -> R.string.chat_history_window_more_both + hasNewer -> R.string.chat_history_window_more_newer + hasOlder -> R.string.chat_history_window_more_older + else -> null + } + if (rangeMessage != null) { + Text( + text = stringResource(rangeMessage), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f), + ) + } + } + TextButton(onClick = onReturnToLatest) { + Text(stringResource(R.string.chat_history_window_latest)) + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ed803776..4045ab92 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -161,6 +161,19 @@ Bot chats are one continuous conversation — compacting instead. For a throwaway session, create a standard chat. Search Close search + Timeline + Conversation timeline + Jump to a prompt without loading the full transcript. + No prompts yet. + Earlier prompt + Load more + Retry + Refresh + Viewing earlier messages + Back to latest + Earlier messages exist before this window. + Newer messages exist beyond this bounded window. + More messages exist before and after this bounded window. Reconnect Re-login Re-login to Hermes 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 c2ba8202..a221a389 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 @@ -9,6 +9,7 @@ import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -468,6 +469,135 @@ class HermesApiServiceMockWebServerTest { ) } + @Test + fun getSessionTimeline_sendsCursorAndParsesFinalPageWithUnknownFields() = + runBlocking { + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody( + """ + { + "session_id": "desktop/session", + "profile": "work", + "future_top_level_field": "ignored", + "entries": [ + { + "row_id": 91, + "preview": "First compacted prompt", + "timestamp": 1718000000, + "future_entry_field": {"anything": true} + } + ], + "pagination": { + "limit": 500, + "after_row_id": 77, + "returned": 1, + "total": 1, + "has_more": false, + "next_cursor": null, + "future_pagination_field": "ignored" + } + } + """.trimIndent(), + ), + ) + + val response = + api.getSessionTimeline( + sessionId = "desktop/session", + profile = "work", + limit = 500, + afterRowId = 77, + ) + + assertTrue(response.isSuccessful) + val body = response.body() + assertNotNull(body) + assertEquals(91, body!!.entries.single().row_id) + assertEquals("First compacted prompt", body.entries.single().preview) + assertFalse(body.pagination.has_more) + assertNull(body.pagination.next_cursor) + assertEquals( + "/api/sessions/desktop/session/timeline?profile=work&limit=500&after_row_id=77", + mockServer.takeRequest().path, + ) + } + + @Test + fun getSessionMessagesAround_sendsStableRowAndPreservesChronologicalOrder() = + runBlocking { + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody( + """ + { + "session_id": "desktop/session", + "profile": "work", + "messages": [ + { + "id": 42, + "role": "user", + "content": "[model-facing compaction summary]", + "display_content": "target" + }, + {"id": 43, "role": "assistant", "content": "answer"} + ], + "pagination": { + "row_id": 42, + "limit": 120, + "returned": 2, + "order": "oldest", + "offset": 7, + "total": 100, + "has_older": true, + "has_newer": true, + "future_field": 123 + } + } + """.trimIndent(), + ), + ) + + val response = + api.getSessionMessagesAround( + sessionId = "desktop/session", + rowId = 42, + profile = "work", + limit = 120, + ) + + assertTrue(response.isSuccessful) + val body = response.body() + assertNotNull(body) + assertEquals(listOf(42, 43), body!!.messages.map { it.id }) + assertEquals("target", body.messages.first().displayContentText) + assertEquals("oldest", body.pagination.order) + assertEquals(7, body.pagination.offset) + assertEquals(true, body.pagination.has_older) + assertEquals(true, body.pagination.has_newer) + assertEquals( + "/api/sessions/desktop/session/messages/around?row_id=42&profile=work&limit=120", + mockServer.takeRequest().path, + ) + } + + @Test + fun getSessionMessagesAround_missingPromptReturns404() = + runBlocking { + mockServer.enqueue( + MockResponse() + .setResponseCode(404) + .setBody("""{"detail":"Prompt not found"}"""), + ) + + val response = api.getSessionMessagesAround(sessionId = "session-1", rowId = 999) + + assertFalse(response.isSuccessful) + assertEquals(404, response.code()) + } + @Test fun getOAuthProviders_parsesList() = runBlocking { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 0180dfce..a624c929 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -12,7 +12,12 @@ import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.AttachmentSource import com.m57.hermescontrol.data.model.PaginationInfo import com.m57.hermescontrol.data.model.SessionMessage +import com.m57.hermescontrol.data.model.SessionMessagesAroundPagination +import com.m57.hermescontrol.data.model.SessionMessagesAroundResponse import com.m57.hermescontrol.data.model.SessionMessagesResponse +import com.m57.hermescontrol.data.model.SessionTimelineEntry +import com.m57.hermescontrol.data.model.SessionTimelinePagination +import com.m57.hermescontrol.data.model.SessionTimelineResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.GatewayFile import com.m57.hermescontrol.data.remote.GatewayFileClient @@ -202,6 +207,28 @@ class ChatViewModelTest { assertFalse(mapped.single().content.contains("THE_SECRET_WORD_IS_MANGO_8421")) } + @Test + fun mapServerMessages_prefersCompactionDisplayContent() { + val mapped = + mapServerMessages( + sessionId = "session-1", + messages = + listOf( + SessionMessage( + id = 42, + role = "user", + content = JsonPrimitive("[model-facing compaction summary]"), + display_content = JsonPrimitive("The original user prompt"), + ), + ), + offset = 0, + latestPaging = true, + liveMessages = emptyList(), + ) + + assertEquals("The original user prompt", mapped.single().content) + } + @Test fun stripAttachmentRefLines_preservesUserAuthoredAttachedContextHeading() { val authored = @@ -6601,6 +6628,274 @@ class ChatViewModelTest { every { AuthManager.activeProfileId } returns MutableStateFlow("default") } + @Test + fun timelinePagesUseBackendCursorAndStopOnFinalPage() = + runTest { + stubActiveProfile() + val (viewModel, sessionId) = createViewModelWithSession() + val api = ApiClient.hermesApi + coEvery { + api.getSessionTimeline(sessionId, "default", 500, 0) + } returns + retrofit2.Response.success( + SessionTimelineResponse( + entries = listOf(SessionTimelineEntry(row_id = 10, preview = "first")), + pagination = + SessionTimelinePagination( + limit = 500, + after_row_id = 0, + returned = 1, + total = 2, + has_more = true, + next_cursor = 123, + ), + ), + ) + coEvery { + api.getSessionTimeline(sessionId, "default", 500, 123) + } returns + retrofit2.Response.success( + SessionTimelineResponse( + entries = listOf(SessionTimelineEntry(row_id = 200, preview = "second")), + pagination = + SessionTimelinePagination( + limit = 500, + after_row_id = 123, + returned = 1, + total = 2, + has_more = false, + next_cursor = null, + ), + ), + ) + + viewModel.openTimeline() + advanceUntilIdle() + + assertEquals( + listOf(10), + viewModel.timelineState.value.entries + .map { it.row_id }, + ) + assertEquals(123, viewModel.timelineState.value.nextCursor) + assertTrue(viewModel.timelineState.value.hasMore) + + viewModel.loadMoreTimeline() + advanceUntilIdle() + + assertEquals( + listOf(10, 200), + viewModel.timelineState.value.entries + .map { it.row_id }, + ) + assertFalse(viewModel.timelineState.value.hasMore) + assertNull(viewModel.timelineState.value.nextCursor) + coVerify(exactly = 1) { api.getSessionTimeline(sessionId, "default", 500, 123) } + } + + @Test + fun timelineJumpUsesBoundedHistoryWindowWithoutReplacingLiveTail() = + runTest { + stubActiveProfile() + val (viewModel, sessionId) = createViewModelWithSession() + val api = ApiClient.hermesApi + val persistedBeforeJump = fakeRepo.dao.count() + val liveMessages = viewModel.uiState.value.messages + coEvery { + api.getSessionMessagesAround(sessionId, 42, "default", 120) + } returns + retrofit2.Response.success( + SessionMessagesAroundResponse( + messages = + listOf( + SessionMessage(id = 42, role = "user", content = JsonPrimitive("target")), + SessionMessage(id = 43, role = "assistant", content = JsonPrimitive("answer")), + ), + pagination = + SessionMessagesAroundPagination( + row_id = 42, + limit = 120, + returned = 2, + order = "oldest", + offset = 7, + total = 100, + has_older = true, + has_newer = true, + ), + ), + ) + + viewModel.jumpToTimelineEntry(42) + advanceUntilIdle() + + val timeline = viewModel.timelineState.value + assertTrue(timeline.isHistorical) + assertEquals(42, timeline.historyAnchorRowId) + assertTrue(timeline.historyHasOlder) + assertTrue(timeline.historyHasNewer) + assertEquals( + listOf("rest-$sessionId-42", "rest-$sessionId-43"), + timeline.historyMessages!!.map { it.id }, + ) + assertEquals(listOf("target", "answer"), timeline.historyMessages.map { it.content }) + assertEquals(liveMessages, viewModel.uiState.value.messages) + // Direct-address history is a replaceable display window, not a + // contiguous live-cache page. Persisting it would create fake gaps. + assertEquals(persistedBeforeJump, fakeRepo.dao.count()) + + viewModel.returnToLatestMessages() + assertFalse(viewModel.timelineState.value.isHistorical) + assertNull(viewModel.timelineState.value.historyMessages) + assertFalse(viewModel.timelineState.value.historyHasOlder) + assertFalse(viewModel.timelineState.value.historyHasNewer) + } + + @Test + fun missingTimelineRowKeepsCurrentHistoryWindow() = + runTest { + stubActiveProfile() + val (viewModel, sessionId) = createViewModelWithSession() + val api = ApiClient.hermesApi + coEvery { + api.getSessionMessagesAround(sessionId, 42, "default", 120) + } returns + retrofit2.Response.success( + SessionMessagesAroundResponse( + messages = listOf(SessionMessage(id = 42, role = "user", content = JsonPrimitive("target"))), + pagination = SessionMessagesAroundPagination(row_id = 42), + ), + ) + coEvery { + api.getSessionMessagesAround(sessionId, 999, "default", 120) + } returns + retrofit2.Response.error( + 404, + """{"detail":"Prompt not found"}""".toResponseBody(), + ) + + viewModel.jumpToTimelineEntry(42) + advanceUntilIdle() + val originalWindow = viewModel.timelineState.value.historyMessages + + viewModel.jumpToTimelineEntry(999) + advanceUntilIdle() + + assertEquals(42, viewModel.timelineState.value.historyAnchorRowId) + assertEquals(originalWindow, viewModel.timelineState.value.historyMessages) + assertNotNull(viewModel.timelineState.value.windowErrorMessage) + } + + @Test + fun retryTimelineClearsWindowErrorBeforeRefreshing() = + runTest { + stubActiveProfile() + val (viewModel, sessionId) = createViewModelWithSession() + val api = ApiClient.hermesApi + coEvery { + api.getSessionTimeline(sessionId, "default", 500, 0) + } returns + retrofit2.Response.success( + SessionTimelineResponse( + entries = listOf(SessionTimelineEntry(row_id = 42, preview = "target")), + pagination = SessionTimelinePagination(has_more = false), + ), + ) + coEvery { + api.getSessionMessagesAround(sessionId, 42, "default", 120) + } returns + retrofit2.Response.error( + 404, + """{"detail":"Prompt not found"}""".toResponseBody(), + ) + + viewModel.openTimeline() + advanceUntilIdle() + viewModel.jumpToTimelineEntry(42) + advanceUntilIdle() + + assertNotNull(viewModel.timelineState.value.windowErrorMessage) + + viewModel.retryTimeline() + + assertNull(viewModel.timelineState.value.windowErrorMessage) + advanceUntilIdle() + assertNull(viewModel.timelineState.value.windowErrorMessage) + assertEquals( + listOf(42), + viewModel.timelineState.value.entries + .map { it.row_id }, + ) + coVerify(exactly = 2) { api.getSessionTimeline(sessionId, "default", 500, 0) } + } + + @Test + fun profileSwitchRejectsLateTimelineJumpResponse() = + runTest { + val profileFlow = MutableStateFlow("default") + every { AuthManager.activeProfileId } returns profileFlow + val (viewModel, sessionId) = createViewModelWithSession() + val api = ApiClient.hermesApi + val response = CompletableDeferred>() + coEvery { + api.getSessionMessagesAround(sessionId, 42, "default", 120) + } coAnswers { + response.await() + } + + viewModel.jumpToTimelineEntry(42) + runCurrent() + profileFlow.value = "other" + mockSwitchFlow.emit("other") + runCurrent() + response.complete( + retrofit2.Response.success( + SessionMessagesAroundResponse( + messages = listOf(SessionMessage(id = 42, role = "user", content = JsonPrimitive("stale"))), + pagination = SessionMessagesAroundPagination(row_id = 42), + ), + ), + ) + advanceUntilIdle() + + assertNull(viewModel.uiState.value.currentSessionId) + assertFalse(viewModel.timelineState.value.isOpen) + assertNull(viewModel.timelineState.value.historyMessages) + assertNull(viewModel.timelineState.value.historyAnchorRowId) + } + + @Test + fun sessionSwitchRejectsLateTimelineJumpResponse() = + runTest { + stubActiveProfile() + val (viewModel, sessionId) = createViewModelWithSession() + val api = ApiClient.hermesApi + val response = CompletableDeferred>() + coEvery { + api.getSessionMessagesAround(sessionId, 42, "default", 120) + } coAnswers { + response.await() + } + + viewModel.jumpToTimelineEntry(42) + runCurrent() + viewModel.switchSession("session-other") + runCurrent() + response.complete( + retrofit2.Response.success( + SessionMessagesAroundResponse( + messages = listOf(SessionMessage(id = 42, role = "user", content = JsonPrimitive("stale"))), + pagination = SessionMessagesAroundPagination(row_id = 42), + ), + ), + ) + runCurrent() + + assertEquals("session-other", viewModel.uiState.value.currentSessionId) + assertFalse(viewModel.timelineState.value.isOpen) + assertNull(viewModel.timelineState.value.historyMessages) + assertNull(viewModel.timelineState.value.historyAnchorRowId) + } + /** * The REST high-watermark must be read BEFORE prompt.submit leaves the * device. Capturing it afterwards lets a fast turn persist its assistant row