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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user