feat(#1217): add compacted-session timeline navigation (#1227)

This commit is contained in:
m57 authored and GitHub committed 2026-09-22 18:29:06 +03:00
1 parent 2e6ae95fe0
commit 16967c7e91
12 files changed
+1124 -34

No files matched your search

@@ -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) {
@@ -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<SessionTimelineEntry> = 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<SessionMessage> = 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,
)
@@ -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<SessionMessagesResponse>
@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<SessionTimelineResponse>
@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<SessionMessagesAroundResponse>
@GET("api/sessions/stats")
suspend fun getSessionStats(): Response<SessionStatsResponse>
@@ -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 },
@@ -21,6 +21,7 @@ internal fun mapServerMessages(
latestPaging: Boolean,
liveMessages: List<ChatMessage>,
isPagingOlder: Boolean = false,
stableRowIds: Boolean = latestPaging,
context: android.content.Context? = null,
): List<ChatMessage> {
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) {
@@ -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<SessionTimelineEntry> = 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<ChatMessage>? = 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> = _streamingState.asStateFlow()
val timelineState: StateFlow<ChatTimelineState> = _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
@@ -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
}
@@ -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))
}
}
}
}