12 files changed
+1124
-34
No files matched your search
@@ -59,6 +59,7 @@ class ChatScreenTest {
|
|||||||
val mockViewModel = mockk<ChatViewModel>(relaxed = true)
|
val mockViewModel = mockk<ChatViewModel>(relaxed = true)
|
||||||
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
|
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
|
||||||
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
|
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
|
||||||
|
every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow()
|
||||||
// ActionProgressDialog collects the controller's StateFlow — a relaxed
|
// ActionProgressDialog collects the controller's StateFlow — a relaxed
|
||||||
// mock proxy would crash the cast inside collectAsStateWithLifecycle.
|
// mock proxy would crash the cast inside collectAsStateWithLifecycle.
|
||||||
// A real controller (never started here) stays invisible.
|
// A real controller (never started here) stays invisible.
|
||||||
@@ -105,6 +106,7 @@ class ChatScreenTest {
|
|||||||
val mockViewModel = mockk<ChatViewModel>(relaxed = true)
|
val mockViewModel = mockk<ChatViewModel>(relaxed = true)
|
||||||
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
|
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
|
||||||
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
|
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
|
||||||
|
every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow()
|
||||||
every { mockViewModel.actionProgress } returns
|
every { mockViewModel.actionProgress } returns
|
||||||
ActionProgressController(scope = CoroutineScope(Dispatchers.Main))
|
ActionProgressController(scope = CoroutineScope(Dispatchers.Main))
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ data class SessionMessage(
|
|||||||
val reasoning: JsonElement? = null,
|
val reasoning: JsonElement? = null,
|
||||||
val reasoning_text: JsonElement? = null,
|
val reasoning_text: JsonElement? = null,
|
||||||
val tool_call_id: String? = 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
|
* Timeline-marker tag (backend NS-656 lineage, issue #904): markers like
|
||||||
* `model_switch` / `personality_switch` / `auto_continue` ride as
|
* `model_switch` / `personality_switch` / `auto_continue` ride as
|
||||||
@@ -85,6 +91,14 @@ data class SessionMessage(
|
|||||||
else -> content.toString()
|
else -> content.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val displayContentText: String?
|
||||||
|
get() =
|
||||||
|
when (display_content) {
|
||||||
|
is JsonPrimitive -> display_content.content
|
||||||
|
null -> null
|
||||||
|
else -> display_content.toString()
|
||||||
|
}
|
||||||
|
|
||||||
val reasoningText: String
|
val reasoningText: String
|
||||||
get() =
|
get() =
|
||||||
when (val r = reasoning ?: reasoning_text) {
|
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.SessionDetailResponse
|
||||||
import com.m57.hermescontrol.data.model.SessionInfo
|
import com.m57.hermescontrol.data.model.SessionInfo
|
||||||
import com.m57.hermescontrol.data.model.SessionListResponse
|
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.SessionMessagesResponse
|
||||||
import com.m57.hermescontrol.data.model.SessionRenameRequest
|
import com.m57.hermescontrol.data.model.SessionRenameRequest
|
||||||
import com.m57.hermescontrol.data.model.SessionSearchResponse
|
import com.m57.hermescontrol.data.model.SessionSearchResponse
|
||||||
import com.m57.hermescontrol.data.model.SessionStatsResponse
|
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.SetActiveProfileRequest
|
||||||
import com.m57.hermescontrol.data.model.Skill
|
import com.m57.hermescontrol.data.model.Skill
|
||||||
import com.m57.hermescontrol.data.model.SkillContentResponse
|
import com.m57.hermescontrol.data.model.SkillContentResponse
|
||||||
@@ -209,6 +211,22 @@ interface HermesApiService : KanbanApiService {
|
|||||||
@Query("profile") profile: String? = null,
|
@Query("profile") profile: String? = null,
|
||||||
): Response<SessionMessagesResponse>
|
): 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")
|
@GET("api/sessions/stats")
|
||||||
suspend fun getSessionStats(): Response<SessionStatsResponse>
|
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.Add
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Extension
|
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.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.AlertDialog
|
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.theme.LocalHermesStatusColors
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatAppUpdateSection
|
import com.m57.hermescontrol.ui.chat.components.ChatAppUpdateSection
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner
|
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.ChatInputBar
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects
|
import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatLoadingOverlay
|
import com.m57.hermescontrol.ui.chat.components.ChatLoadingOverlay
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatResumeErrorOverlay
|
import com.m57.hermescontrol.ui.chat.components.ChatResumeErrorOverlay
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab
|
import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatTimelineNoPrefetchStrategy
|
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.ContextDetailSheet
|
||||||
import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
|
import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
|
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
|
||||||
@@ -149,6 +152,7 @@ fun ChatScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
|
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
|
||||||
|
val timelineState by viewModel.timelineState.collectAsStateWithLifecycle()
|
||||||
val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle()
|
val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle()
|
||||||
val connectorsViewModel: ChatConnectorsViewModel = viewModel()
|
val connectorsViewModel: ChatConnectorsViewModel = viewModel()
|
||||||
val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle()
|
val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle()
|
||||||
@@ -156,6 +160,7 @@ fun ChatScreen(
|
|||||||
// Snapshot-backed search state — read directly so only the scopes that
|
// Snapshot-backed search state — read directly so only the scopes that
|
||||||
// read its fields recompose on search changes (bar, matched bubbles).
|
// read its fields recompose on search changes (bar, matched bubbles).
|
||||||
val searchState = viewModel.searchState
|
val searchState = viewModel.searchState
|
||||||
|
val displayedMessages = timelineState.historyMessages ?: state.messages
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var browserAuthInFlight by rememberSaveable { mutableStateOf(false) }
|
var browserAuthInFlight by rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -275,6 +280,7 @@ fun ChatScreen(
|
|||||||
// (issue #682). Replaces the old item-count heuristic that ignored the
|
// (issue #682). Replaces the old item-count heuristic that ignored the
|
||||||
// streaming tail.
|
// streaming tail.
|
||||||
LaunchedEffect(
|
LaunchedEffect(
|
||||||
|
timelineState.isHistorical,
|
||||||
state.messages,
|
state.messages,
|
||||||
streamingState.streamingMessage,
|
streamingState.streamingMessage,
|
||||||
streamingState.isThinking,
|
streamingState.isThinking,
|
||||||
@@ -282,6 +288,10 @@ fun ChatScreen(
|
|||||||
state.todos,
|
state.todos,
|
||||||
state.clarifyRequest,
|
state.clarifyRequest,
|
||||||
) {
|
) {
|
||||||
|
if (timelineState.isHistorical) {
|
||||||
|
scrollController.pauseFollowing()
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
scrollController.onTailChanged(
|
scrollController.onTailChanged(
|
||||||
tailKey =
|
tailKey =
|
||||||
tailContentKey(
|
tailContentKey(
|
||||||
@@ -294,9 +304,15 @@ fun ChatScreen(
|
|||||||
messageCount = state.messages.size,
|
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 {
|
derivedStateOf {
|
||||||
scrollController.showFab(state.messages.isNotEmpty())
|
!timelineState.isHistorical && scrollController.showFab(displayedMessages.isNotEmpty())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||||
@@ -406,7 +422,8 @@ fun ChatScreen(
|
|||||||
!showSubagentInspectionSheet &&
|
!showSubagentInspectionSheet &&
|
||||||
state.btwState == null &&
|
state.btwState == null &&
|
||||||
viewingImage == null &&
|
viewingImage == null &&
|
||||||
!connectorsState.isVisible
|
!connectorsState.isVisible &&
|
||||||
|
!timelineState.isOpen
|
||||||
|
|
||||||
// Lifecycle effects, permissions, session switching, auto-scroll, errors
|
// Lifecycle effects, permissions, session switching, auto-scroll, errors
|
||||||
ChatLifecycleEffects(
|
ChatLifecycleEffects(
|
||||||
@@ -509,6 +526,22 @@ fun ChatScreen(
|
|||||||
expanded = showSessionMenu,
|
expanded = showSessionMenu,
|
||||||
onDismissRequest = { showSessionMenu = false },
|
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)
|
// Chat search lives in this overflow menu (moved from the top bar)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = {
|
text = {
|
||||||
@@ -533,6 +566,7 @@ fun ChatScreen(
|
|||||||
showSessionMenu = false
|
showSessionMenu = false
|
||||||
viewModel.toggleSearch()
|
viewModel.toggleSearch()
|
||||||
},
|
},
|
||||||
|
enabled = !timelineState.isHistorical,
|
||||||
modifier = Modifier.testTag("chat_menu_search"),
|
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(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -659,37 +704,44 @@ fun ChatScreen(
|
|||||||
// Full-bleed chat renderer (issue #866) — the single chat
|
// Full-bleed chat renderer (issue #866) — the single chat
|
||||||
// surface since the bubble renderer was removed.
|
// surface since the bubble renderer was removed.
|
||||||
FullBleedChatList(
|
FullBleedChatList(
|
||||||
messages = state.messages,
|
messages = displayedMessages,
|
||||||
streamingState = streamingState,
|
streamingState = if (timelineState.isHistorical) StreamingState() else streamingState,
|
||||||
isAgentTyping = state.isAgentTyping,
|
isAgentTyping = state.isAgentTyping && !timelineState.isHistorical,
|
||||||
searchState = searchState,
|
searchState = searchState,
|
||||||
typingEffectEnabled = state.typingEffectEnabled,
|
typingEffectEnabled = state.typingEffectEnabled && !timelineState.isHistorical,
|
||||||
typingEffectDelayMs = state.typingEffectDelayMs,
|
typingEffectDelayMs = state.typingEffectDelayMs,
|
||||||
messageStatsEnabled = state.messageStatsEnabled,
|
messageStatsEnabled = state.messageStatsEnabled,
|
||||||
showUserMessageTokens = state.showUserMessageTokens,
|
showUserMessageTokens = state.showUserMessageTokens,
|
||||||
showAssistantMessageTokens = state.showAssistantMessageTokens,
|
showAssistantMessageTokens = state.showAssistantMessageTokens,
|
||||||
showTokensPerSecond = state.showTokensPerSecond,
|
showTokensPerSecond = state.showTokensPerSecond,
|
||||||
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
|
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
|
||||||
isLoading = state.isLoading,
|
isLoading = state.isLoading && !timelineState.isHistorical,
|
||||||
isLoadingOlder = state.isLoadingOlder,
|
isLoadingOlder = state.isLoadingOlder && !timelineState.isHistorical,
|
||||||
hasOlderMessages = state.hasOlderMessages,
|
hasOlderMessages = state.hasOlderMessages && !timelineState.isHistorical,
|
||||||
pagingSessionId = state.currentSessionId,
|
pagingSessionId =
|
||||||
|
state.currentSessionId?.let { currentSessionId ->
|
||||||
|
if (timelineState.isHistorical) {
|
||||||
|
currentSessionId + ":history:" + timelineState.historyAnchorRowId
|
||||||
|
} else {
|
||||||
|
currentSessionId
|
||||||
|
}
|
||||||
|
},
|
||||||
listState = listState,
|
listState = listState,
|
||||||
scrollController = scrollController,
|
scrollController = scrollController,
|
||||||
lastAnimatedMessageId = lastAnimatedMessageId,
|
lastAnimatedMessageId = lastAnimatedMessageId,
|
||||||
onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
|
onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
clarifyRequest = state.clarifyRequest,
|
clarifyRequest = state.clarifyRequest.takeUnless { timelineState.isHistorical },
|
||||||
onRespondClarify = viewModel::respondToClarify,
|
onRespondClarify = viewModel::respondToClarify,
|
||||||
onRespondClarifyBatch = viewModel::respondToClarifyBatch,
|
onRespondClarifyBatch = viewModel::respondToClarifyBatch,
|
||||||
onDismissClarify = viewModel::dismissClarify,
|
onDismissClarify = viewModel::dismissClarify,
|
||||||
vaultUnlockPrompt = state.vaultUnlockPrompt,
|
vaultUnlockPrompt = state.vaultUnlockPrompt.takeUnless { timelineState.isHistorical },
|
||||||
onRespondVaultUnlock = viewModel::respondToVaultUnlock,
|
onRespondVaultUnlock = viewModel::respondToVaultUnlock,
|
||||||
onDismissVaultUnlock = viewModel::dismissVaultUnlock,
|
onDismissVaultUnlock = viewModel::dismissVaultUnlock,
|
||||||
vaultSaveLoginPrompt = state.vaultSaveLoginPrompt,
|
vaultSaveLoginPrompt = state.vaultSaveLoginPrompt.takeUnless { timelineState.isHistorical },
|
||||||
onRespondVaultSaveLogin = viewModel::respondToVaultSaveLogin,
|
onRespondVaultSaveLogin = viewModel::respondToVaultSaveLogin,
|
||||||
onDismissVaultSaveLogin = viewModel::dismissVaultSaveLogin,
|
onDismissVaultSaveLogin = viewModel::dismissVaultSaveLogin,
|
||||||
vaultCodePrompt = state.vaultCodePrompt,
|
vaultCodePrompt = state.vaultCodePrompt.takeUnless { timelineState.isHistorical },
|
||||||
onRespondVaultCode = viewModel::respondToVaultCode,
|
onRespondVaultCode = viewModel::respondToVaultCode,
|
||||||
onDismissVaultCode = viewModel::dismissVaultCode,
|
onDismissVaultCode = viewModel::dismissVaultCode,
|
||||||
onSaveAttachment = onSaveAttachment,
|
onSaveAttachment = onSaveAttachment,
|
||||||
@@ -699,14 +751,18 @@ fun ChatScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Loading overlay
|
// 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
|
// Resume-exhausted overlay — explicit error + Retry instead
|
||||||
// of an infinite spinner (desktop parity).
|
// of an infinite spinner (desktop parity).
|
||||||
ChatResumeErrorOverlay(
|
if (!timelineState.isHistorical) {
|
||||||
errorMessage = state.resumeError,
|
ChatResumeErrorOverlay(
|
||||||
onRetry = viewModel::retryResumeSession,
|
errorMessage = state.resumeError,
|
||||||
)
|
onRetry = viewModel::retryResumeSession,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Scroll-to-bottom FAB (issue #682): shows while follow is
|
// Scroll-to-bottom FAB (issue #682): shows while follow is
|
||||||
// paused and renders the unseen-message badge.
|
// paused and renders the unseen-message badge.
|
||||||
@@ -718,10 +774,12 @@ fun ChatScreen(
|
|||||||
|
|
||||||
// Reaction heartsanimation (purely cosmetic — fades out
|
// Reaction heartsanimation (purely cosmetic — fades out
|
||||||
// automatically after the ViewModel clears the state)
|
// automatically after the ViewModel clears the state)
|
||||||
key(state.reactionTriggerId) {
|
if (!timelineState.isHistorical) {
|
||||||
ReactionHeartsOverlay(
|
key(state.reactionTriggerId) {
|
||||||
reactionKind = state.reactionKind,
|
ReactionHeartsOverlay(
|
||||||
)
|
reactionKind = state.reactionKind,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -754,7 +812,7 @@ fun ChatScreen(
|
|||||||
isListening = mediaLaunchers.isListening,
|
isListening = mediaLaunchers.isListening,
|
||||||
isAgentTyping = state.isAgentTyping,
|
isAgentTyping = state.isAgentTyping,
|
||||||
isConnected = state.isConnected,
|
isConnected = state.isConnected,
|
||||||
isSessionReady = state.isSessionReady,
|
isSessionReady = state.isSessionReady && !timelineState.isHistorical,
|
||||||
sessionPreparationFailed = state.resumeError != null,
|
sessionPreparationFailed = state.resumeError != null,
|
||||||
commandCatalog = state.commandCatalog,
|
commandCatalog = state.commandCatalog,
|
||||||
slashUsageCounts = state.slashUsageCounts,
|
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) {
|
if (showReloginDialog) {
|
||||||
ReloginDialog(
|
ReloginDialog(
|
||||||
onDismiss = { showReloginDialog = false },
|
onDismiss = { showReloginDialog = false },
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ internal fun mapServerMessages(
|
|||||||
latestPaging: Boolean,
|
latestPaging: Boolean,
|
||||||
liveMessages: List<ChatMessage>,
|
liveMessages: List<ChatMessage>,
|
||||||
isPagingOlder: Boolean = false,
|
isPagingOlder: Boolean = false,
|
||||||
|
stableRowIds: Boolean = latestPaging,
|
||||||
context: android.content.Context? = null,
|
context: android.content.Context? = null,
|
||||||
): List<ChatMessage> {
|
): List<ChatMessage> {
|
||||||
val existingById = liveMessages.associateBy { it.canonicalRestId ?: it.id }
|
val existingById = liveMessages.associateBy { it.canonicalRestId ?: it.id }
|
||||||
@@ -41,7 +42,7 @@ internal fun mapServerMessages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun restIdAt(index: Int): String =
|
fun restIdAt(index: Int): String =
|
||||||
if (latestPaging) {
|
if (stableRowIds) {
|
||||||
"rest-$sessionId-${requireNotNull(messages[index].id) { "Latest transcript row has no stable id" }}"
|
"rest-$sessionId-${requireNotNull(messages[index].id) { "Latest transcript row has no stable id" }}"
|
||||||
} else {
|
} else {
|
||||||
"rest-$sessionId-${offset + index}"
|
"rest-$sessionId-${offset + index}"
|
||||||
@@ -79,7 +80,7 @@ internal fun mapServerMessages(
|
|||||||
if (i in wsCompletionIdByRestIndex || restIdAt(i) in reservedRestIds) continue
|
if (i in wsCompletionIdByRestIndex || restIdAt(i) in reservedRestIds) continue
|
||||||
val m = messages[i]
|
val m = messages[i]
|
||||||
if (m.role?.lowercase() in listOf("user", "system", "tool")) continue
|
if (m.role?.lowercase() in listOf("user", "system", "tool")) continue
|
||||||
val rawContent = m.contentText
|
val rawContent = m.displayContentText ?: m.contentText
|
||||||
if (rawContent.isBlank()) continue
|
if (rawContent.isBlank()) continue
|
||||||
val canonicalContent =
|
val canonicalContent =
|
||||||
if (rawContent.contains("MEDIA:")) HostMediaExtractor.strip(rawContent).trim() else rawContent.trim()
|
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()
|
assistants.filter { it.canonicalRestId == null && it.completionId == null }.toMutableList()
|
||||||
for (index in messages.indices.reversed()) {
|
for (index in messages.indices.reversed()) {
|
||||||
val row = messages[index]
|
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") ||
|
row.role?.lowercase() in listOf("user", "system", "tool") ||
|
||||||
liveByExactId[restIdAt(index)]?.completionId != null || index in wsCompletionIdByRestIndex
|
liveByExactId[restIdAt(index)]?.completionId != null || index in wsCompletionIdByRestIndex
|
||||||
) {
|
) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val content = HostMediaExtractor.strip(row.contentText).trim()
|
val content = HostMediaExtractor.strip(displayContent).trim()
|
||||||
val match = remaining.indexOfLast { it.content.trim() == content }
|
val match = remaining.indexOfLast { it.content.trim() == content }
|
||||||
if (match >= 0) reasoningSources[index] = remaining.removeAt(match)
|
if (match >= 0) reasoningSources[index] = remaining.removeAt(match)
|
||||||
}
|
}
|
||||||
@@ -142,7 +144,7 @@ internal fun mapServerMessages(
|
|||||||
?: existingById[restId]?.timestamp
|
?: existingById[restId]?.timestamp
|
||||||
?: System.currentTimeMillis()
|
?: System.currentTimeMillis()
|
||||||
|
|
||||||
val rawContent = msg.contentText
|
val rawContent = msg.displayContentText ?: msg.contentText
|
||||||
val rowReasoning =
|
val rowReasoning =
|
||||||
msg.reasoningText.ifBlank {
|
msg.reasoningText.ifBlank {
|
||||||
if (role == MessageRole.ASSISTANT) {
|
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.ModelCapabilities
|
||||||
import com.m57.hermescontrol.data.model.ModelProvider
|
import com.m57.hermescontrol.data.model.ModelProvider
|
||||||
import com.m57.hermescontrol.data.model.PinnedModel
|
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.UsageSnapshotResponse
|
||||||
import com.m57.hermescontrol.data.model.parseContextBreakdown
|
import com.m57.hermescontrol.data.model.parseContextBreakdown
|
||||||
import com.m57.hermescontrol.data.model.parseUsageSnapshot
|
import com.m57.hermescontrol.data.model.parseUsageSnapshot
|
||||||
@@ -59,6 +60,8 @@ import java.util.concurrent.ConcurrentHashMap
|
|||||||
|
|
||||||
private const val TAG = "ChatViewModel"
|
private const val TAG = "ChatViewModel"
|
||||||
private const val MESSAGE_PAGE_SIZE = 150
|
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 =
|
private val REASONING_EFFORT_LEVELS =
|
||||||
setOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra")
|
setOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra")
|
||||||
@@ -238,6 +241,23 @@ data class ChatUiState(
|
|||||||
val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED
|
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(
|
data class SessionUi(
|
||||||
val id: String,
|
val id: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
@@ -455,6 +475,13 @@ class ChatViewModel(
|
|||||||
private var olderJob: Job? = null
|
private var olderJob: Job? = null
|
||||||
private var syncJob: Job? = null
|
private var syncJob: Job? = null
|
||||||
private val historyFetchMutex = Mutex()
|
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) ────────────────────────
|
// ── Session resume recovery (desktop parity) ────────────────────────
|
||||||
// Bounded auto-retry with exponential backoff, mirroring the desktop's
|
// Bounded auto-retry with exponential backoff, mirroring the desktop's
|
||||||
@@ -465,6 +492,7 @@ class ChatViewModel(
|
|||||||
private var resumeRetryAttempt = 0
|
private var resumeRetryAttempt = 0
|
||||||
private var resumeRetryJob: Job? = null
|
private var resumeRetryJob: Job? = null
|
||||||
val streamingState: StateFlow<StreamingState> = _streamingState.asStateFlow()
|
val streamingState: StateFlow<StreamingState> = _streamingState.asStateFlow()
|
||||||
|
val timelineState: StateFlow<ChatTimelineState> = _timelineState.asStateFlow()
|
||||||
|
|
||||||
/** Tracks the auto-clear coroutine for reaction animations. */
|
/** Tracks the auto-clear coroutine for reaction animations. */
|
||||||
private var reactionClearJob: Job? = null
|
private var reactionClearJob: Job? = null
|
||||||
@@ -660,6 +688,7 @@ class ChatViewModel(
|
|||||||
syncJob?.cancel()
|
syncJob?.cancel()
|
||||||
cacheJob?.cancel()
|
cacheJob?.cancel()
|
||||||
isSyncingMessages = false
|
isSyncingMessages = false
|
||||||
|
resetTimelineState()
|
||||||
cancelResumeRetry()
|
cancelResumeRetry()
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(isSessionReady = false, isLoadingOlder = false, isResumeRetrying = false)
|
it.copy(isSessionReady = false, isLoadingOlder = false, isResumeRetrying = false)
|
||||||
@@ -2899,6 +2928,245 @@ class ChatViewModel(
|
|||||||
|
|
||||||
fun getCurrentModelCapabilities(): ModelCapabilities? = modelSwitchDelegate.getCurrentModelCapabilities()
|
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) {
|
fun switchSession(sessionId: String) {
|
||||||
if (sessionId == _uiState.value.currentSessionId) return
|
if (sessionId == _uiState.value.currentSessionId) return
|
||||||
|
|
||||||
@@ -2972,6 +3240,14 @@ class ChatViewModel(
|
|||||||
_uiState.update { it.copy(hasOlderMessages = cacheHasOlder || serverHasOlder) }
|
_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(
|
private suspend fun readCachedPage(
|
||||||
sessionId: String,
|
sessionId: String,
|
||||||
generation: Long,
|
generation: Long,
|
||||||
@@ -3174,6 +3450,7 @@ class ChatViewModel(
|
|||||||
hydrationJob?.cancel()
|
hydrationJob?.cancel()
|
||||||
olderJob?.cancel()
|
olderJob?.cancel()
|
||||||
syncJob?.cancel()
|
syncJob?.cancel()
|
||||||
|
resetTimelineState()
|
||||||
activeHydrationRequestSequence = 0L
|
activeHydrationRequestSequence = 0L
|
||||||
cacheCursor = null
|
cacheCursor = null
|
||||||
cacheHasOlder = false
|
cacheHasOlder = false
|
||||||
|
|||||||
@@ -158,11 +158,11 @@ class ChatScrollController(
|
|||||||
* item count still hasn't caught up (rare, heavy layout), wait briefly
|
* item count still hasn't caught up (rare, heavy layout), wait briefly
|
||||||
* via snapshotFlow on the layout info.
|
* 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 twice: first for recomposition, second for layout.
|
||||||
yield()
|
yield()
|
||||||
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
|
// 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.
|
// new item was laid out between the scroll and now), do one more pass.
|
||||||
if (!listState.isAtBottom(bottomPixelTolerance)) {
|
if (!listState.isAtBottom(bottomPixelTolerance)) {
|
||||||
@@ -171,7 +171,7 @@ class ChatScrollController(
|
|||||||
snapshotFlow { listState.layoutInfo.totalItemsCount }
|
snapshotFlow { listState.layoutInfo.totalItemsCount }
|
||||||
.first { it > 0 }
|
.first { it > 0 }
|
||||||
}
|
}
|
||||||
listState.scrollToBottom(animated = false)
|
listState.scrollToBottom(animated = animated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ class ChatScrollController(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
isProgrammaticScroll = true
|
isProgrammaticScroll = true
|
||||||
try {
|
try {
|
||||||
listState.scrollToBottom(animated = animated)
|
scrollToBottomAwaitingLayout(animated = animated)
|
||||||
} finally {
|
} finally {
|
||||||
isProgrammaticScroll = false
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,6 +161,19 @@
|
|||||||
<string name="bot_chat_never_resets_notice">Bot chats are one continuous conversation — compacting instead. For a throwaway session, create a standard chat.</string>
|
<string name="bot_chat_never_resets_notice">Bot chats are one continuous conversation — compacting instead. For a throwaway session, create a standard chat.</string>
|
||||||
<string name="chat_action_search">Search</string>
|
<string name="chat_action_search">Search</string>
|
||||||
<string name="chat_action_close_search">Close search</string>
|
<string name="chat_action_close_search">Close search</string>
|
||||||
|
<string name="chat_action_timeline">Timeline</string>
|
||||||
|
<string name="chat_timeline_title">Conversation timeline</string>
|
||||||
|
<string name="chat_timeline_subtitle">Jump to a prompt without loading the full transcript.</string>
|
||||||
|
<string name="chat_timeline_empty">No prompts yet.</string>
|
||||||
|
<string name="chat_timeline_prompt_fallback">Earlier prompt</string>
|
||||||
|
<string name="chat_timeline_load_more">Load more</string>
|
||||||
|
<string name="chat_timeline_retry">Retry</string>
|
||||||
|
<string name="chat_timeline_refresh">Refresh</string>
|
||||||
|
<string name="chat_history_window_title">Viewing earlier messages</string>
|
||||||
|
<string name="chat_history_window_latest">Back to latest</string>
|
||||||
|
<string name="chat_history_window_more_older">Earlier messages exist before this window.</string>
|
||||||
|
<string name="chat_history_window_more_newer">Newer messages exist beyond this bounded window.</string>
|
||||||
|
<string name="chat_history_window_more_both">More messages exist before and after this bounded window.</string>
|
||||||
<string name="chat_action_reconnect">Reconnect</string>
|
<string name="chat_action_reconnect">Reconnect</string>
|
||||||
<string name="chat_action_relogin">Re-login</string>
|
<string name="chat_action_relogin">Re-login</string>
|
||||||
<string name="chat_relogin_title">Re-login to Hermes</string>
|
<string name="chat_relogin_title">Re-login to Hermes</string>
|
||||||
|
|||||||
+130
@@ -9,6 +9,7 @@ import okhttp3.mockwebserver.MockResponse
|
|||||||
import okhttp3.mockwebserver.MockWebServer
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
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
|
@Test
|
||||||
fun getOAuthProviders_parsesList() =
|
fun getOAuthProviders_parsesList() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ import com.m57.hermescontrol.data.model.Attachment
|
|||||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||||
import com.m57.hermescontrol.data.model.PaginationInfo
|
import com.m57.hermescontrol.data.model.PaginationInfo
|
||||||
import com.m57.hermescontrol.data.model.SessionMessage
|
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.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.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.GatewayFile
|
import com.m57.hermescontrol.data.remote.GatewayFile
|
||||||
import com.m57.hermescontrol.data.remote.GatewayFileClient
|
import com.m57.hermescontrol.data.remote.GatewayFileClient
|
||||||
@@ -202,6 +207,28 @@ class ChatViewModelTest {
|
|||||||
assertFalse(mapped.single().content.contains("THE_SECRET_WORD_IS_MANGO_8421"))
|
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
|
@Test
|
||||||
fun stripAttachmentRefLines_preservesUserAuthoredAttachedContextHeading() {
|
fun stripAttachmentRefLines_preservesUserAuthoredAttachedContextHeading() {
|
||||||
val authored =
|
val authored =
|
||||||
@@ -6601,6 +6628,274 @@ class ChatViewModelTest {
|
|||||||
every { AuthManager.activeProfileId } returns MutableStateFlow<String?>("default")
|
every { AuthManager.activeProfileId } returns MutableStateFlow<String?>("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<String?>("default")
|
||||||
|
every { AuthManager.activeProfileId } returns profileFlow
|
||||||
|
val (viewModel, sessionId) = createViewModelWithSession()
|
||||||
|
val api = ApiClient.hermesApi
|
||||||
|
val response = CompletableDeferred<retrofit2.Response<SessionMessagesAroundResponse>>()
|
||||||
|
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<retrofit2.Response<SessionMessagesAroundResponse>>()
|
||||||
|
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
|
* The REST high-watermark must be read BEFORE prompt.submit leaves the
|
||||||
* device. Capturing it afterwards lets a fast turn persist its assistant row
|
* device. Capturing it afterwards lets a fast turn persist its assistant row
|
||||||
|
|||||||
Reference in new issue
Block a user