fix(history): resolve session resume profile scoping and history pagination (#1040)

This commit is contained in:
M57 authored and GitHub committed 2026-09-02 00:01:19 +03:00
1 parent 7c00f4407d
commit 2d0b6ae1a9
5 files changed
+64 -10

No files matched your search

@@ -8,6 +8,7 @@ import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.RemoteInput
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsMethods
@@ -124,11 +125,20 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
}
private suspend fun resumeSession(storedSessionId: String): String {
val profile = AuthManager.activeProfileId.value
val params =
mutableMapOf<String, Any>(
"session_id" to storedSessionId,
"omit_messages" to true,
)
if (!profile.isNullOrBlank()) {
params["profile"] = profile
}
val result =
HermesWsClient
.request(
WsMethods.SESSION_RESUME,
mapOf("session_id" to storedSessionId, "omit_messages" to true),
params,
timeoutMs = REPLY_TIMEOUT_MS,
).await() as? Map<*, *>
val runtimeSessionId =
@@ -231,7 +231,7 @@ fun ChatScreen(
.collectLatest { firstVisibleIndex ->
if (firstVisibleIndex > 2) {
isOlderPagingArmed = true
} else if (isOlderPagingArmed) {
} else if (isOlderPagingArmed || state.messages.size <= 3) {
val anchorId = state.messages.getOrNull(firstVisibleIndex)?.id
val offset = scrollController.captureAnchorOffset()
pagingAnchor.value = if (anchorId != null) anchorId to offset else null
@@ -647,6 +647,8 @@ class ChatViewModel(
viewModelScope.launch {
ProfileSwitchCoordinator.switched
.collect { _ ->
pendingGoneSessionNotice = false
sessionHasServerPresence = false
resetSessionState(sessionId = null, title = "Hermes", isLoading = true)
}
}
@@ -657,6 +659,8 @@ class ChatViewModel(
viewModelScope.launch {
ProfileSwitchCoordinator.connectionSwitched
.collect { _ ->
pendingGoneSessionNotice = false
sessionHasServerPresence = false
resetSessionState(sessionId = null, title = "Hermes", isLoading = true)
}
}
@@ -2469,6 +2473,27 @@ class ChatViewModel(
}
is NetworkResult.Failure -> {
val errorMsg = result.error.message
val is404 =
errorMsg.contains("404", ignoreCase = true) ||
errorMsg.contains("not found", ignoreCase = true)
if (is404) {
// A 404 from GET /api/sessions/{id}/messages indicates the session has no
// persisted rows in state.db (e.g. newly created/lazy unprompted session, or empty history).
// It is NOT a fatal error: keep the session open with empty transcript, clear loading,
// and mark hydrated cleanly.
_uiState.update {
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@update it
it.copy(
isLoading = false,
isLoadingOlder = false,
hasOlderMessages = false,
)
}
hydratedGeneration = generation
finishResumeWhenHydrated(generation)
return@launch
}
if (hydratedGeneration == generation) hydratedGeneration = -1L
_uiState.update {
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@update it
@@ -2576,10 +2601,19 @@ class ChatViewModel(
) {
val requestSequence = ++resumeRequestSequence
activeResumeRequestSequence = requestSequence
val profile = AuthManager.activeProfileId.value
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"omit_messages" to true,
)
if (!profile.isNullOrBlank()) {
params["profile"] = profile
}
viewModelScope.launch(ioDispatcher) {
wsClient.send(
WsMethods.SESSION_RESUME,
mapOf("session_id" to sessionId, "omit_messages" to true),
params,
onSent = { id ->
trackSessionRequest(
id = id,
@@ -2653,6 +2687,17 @@ class ChatViewModel(
private fun recoverGoneSession(sessionId: String) {
if (_uiState.value.currentSessionId != sessionId) return
if (sessionGoneRecoveryInFlight) return
if (_uiState.value.messages.isNotEmpty()) {
cancelResumeRetry()
_uiState.update {
it.copy(
isLoading = false,
isResumeRetrying = false,
resumeError = "Session not found on server (displaying cached messages)",
)
}
return
}
sessionGoneRecoveryInFlight = true
cancelResumeRetry()
_uiState.update {
@@ -804,9 +804,13 @@ fun SessionsScreen(
derivedStateOf {
val info = listState.layoutInfo
val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1
lastVisible >= 0 &&
info.totalItemsCount >= AUTO_LOAD_THRESHOLD &&
if (lastVisible < 0 || info.totalItemsCount == 0) {
false
} else if (info.totalItemsCount <= AUTO_LOAD_THRESHOLD) {
lastVisible >= info.totalItemsCount - 1
} else {
lastVisible >= info.totalItemsCount - AUTO_LOAD_THRESHOLD
}
}
}
LaunchedEffect(
@@ -216,11 +216,6 @@ class SessionsViewModel :
val hasMore = receivedFullPage && addedNewItems && data.total > newSessions.size
it.copy(
isLoadingMore = false,
// distinctBy guards offset-pagination churn: if a new
// session lands on top between page loads, offsets
// shift and the next page can repeat an id we already
// have — LazyColumn would crash on the duplicate key.
// pinnedFirst keeps pinned sessions above the rest.
sessions = newSessions,
total = if (hasMore) data.total else newSessions.size,
hasMore = hasMore,