diff --git a/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt b/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt index c082afaf..dde82529 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt @@ -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( + "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 = diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 7a09cfb5..7e3a16d2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index a774705f..c96f4924 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -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( + "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 { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index bcefaa5b..fe711a88 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -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( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index e8182e00..6ac54fe2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -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,