feat: auto-dismiss reply notifications when read in chat (#1195)

This commit is contained in:
M57 authored and GitHub committed 2026-09-19 22:58:43 +03:00
1 parent 03ce822119
commit dc77b306b6
30 files changed
+4070 -15

No files matched your search

@@ -696,6 +696,11 @@ class GroupChatViewModel(
storedId?.let { inFlightTurns[it] = turnDeferred }
try {
// Bot-group turns are orchestrated across per-bot sessions that
// are created and swapped mid-flight, so no clean pre-submit
// turn boundary can be proven for them. They stay uncorrelated
// (reply notifications from group turns are never auto-dismissed
// from REST hydration) instead of borrowing another turn's bound.
HermesWsClient
.request(
WsMethods.PROMPT_SUBMIT,
@@ -85,6 +85,8 @@ data class ChatMessage(
val tokenCount: Int? = null,
/** Tokens per second generation speed (persisted to SQLite). */
val tps: Double? = null,
/** Stable completion identity correlating with reply notifications for read tracking. */
val completionId: String? = null,
)
/**
@@ -155,6 +155,7 @@ fun ChatScreen(
val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle()
val connectorsViewModel: ChatConnectorsViewModel = viewModel()
val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle()
val actionProgressState by viewModel.actionProgress.state.collectAsStateWithLifecycle()
// 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
@@ -426,6 +427,20 @@ fun ChatScreen(
context = context,
)
val isChatContentReadable =
!showReloginDialog &&
!state.updateConfirmOpen &&
!actionProgressState.visible &&
!state.showModelPicker &&
state.modelSwitchConfirmMessage == null &&
state.sudoPrompt == null &&
state.secretPrompt == null &&
!(showContextSheet && state.contextBreakdown != null) &&
!showSubagentInspectionSheet &&
state.btwState == null &&
viewingImage == null &&
!connectorsState.isVisible
// Lifecycle effects, permissions, session switching, auto-scroll, errors
ChatLifecycleEffects(
sessionId = sessionId,
@@ -442,6 +457,7 @@ fun ChatScreen(
scrollController = scrollController,
snackbarHostState = snackbarHostState,
viewModel = viewModel,
isOverlayActive = !isChatContentReadable,
)
HermesScaffold(
@@ -5,6 +5,7 @@ import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.AttachmentSource
import com.m57.hermescontrol.data.model.SessionMessage
import com.m57.hermescontrol.data.remote.GatewayFileClient
import com.m57.hermescontrol.notification.ReplyNotificationTracker
/**
* Maps REST transcript rows ([SessionMessage]) into UI [ChatMessage]s.
@@ -20,12 +21,93 @@ internal fun mapServerMessages(
offset: Int,
latestPaging: Boolean,
liveMessages: List<ChatMessage>,
isPagingOlder: Boolean = false,
context: android.content.Context? = null,
): List<ChatMessage> {
val existingReasoningMap =
liveMessages
.filter { it.reasoningText.isNotBlank() }
.associateBy { it.content }
val liveByExactId =
liveMessages
.filter { !it.completionId.isNullOrBlank() }
.associateBy { it.id }
val unmappedLiveWsAssistants =
if (isPagingOlder) {
emptyList()
} else {
liveMessages
.filter {
it.role == MessageRole.ASSISTANT && !it.id.startsWith("rest-") &&
!it.completionId.isNullOrBlank()
}
}
val wsCompletionIdByRestIndex = mutableMapOf<Int, String>()
if (!isPagingOlder) {
if (unmappedLiveWsAssistants.isNotEmpty()) {
val remainingWs = unmappedLiveWsAssistants.toMutableList()
for (i in messages.indices.reversed()) {
if (remainingWs.isEmpty()) break
val m = messages[i]
val r =
when (m.role?.lowercase()) {
"user" -> MessageRole.USER
"system" -> MessageRole.SYSTEM
"tool" -> MessageRole.TOOL
else -> MessageRole.ASSISTANT
}
if (r != MessageRole.ASSISTANT) continue
val rawContent = m.contentText
// Reasoning-only or empty tool placeholder: skip, does not become visible prose
if (rawContent.isBlank() && !rawContent.contains("MEDIA:")) continue
val restId =
if (latestPaging) {
m.id?.let { "rest-$sessionId-$it" } ?: "rest-$sessionId-${offset + i}"
} else {
"rest-$sessionId-${offset + i}"
}
if (liveByExactId.containsKey(restId)) continue
val canonicalContent =
if (rawContent.contains("MEDIA:")) {
HostMediaExtractor.strip(rawContent).trim()
} else {
rawContent.trim()
}
val wsIdx = remainingWs.indexOfLast { it.content.trim() == canonicalContent }
if (wsIdx >= 0) {
remainingWs.removeAt(wsIdx).completionId?.let { compId ->
wsCompletionIdByRestIndex[i] = compId
}
}
}
}
// REST-only recovery is fail-closed: only a durable server row ID may
// transfer notification identity after the WebSocket copy is gone.
val activeTarget = ReplyNotificationTracker.getActiveTarget(context)
if (
activeTarget != null &&
activeTarget.sessionId == sessionId &&
activeTarget.serverMessageId != null &&
wsCompletionIdByRestIndex.values.none { it == activeTarget.completionId }
) {
val exactIndex =
messages.indexOfFirst { message ->
message.id == activeTarget.serverMessageId &&
message.role.equals("assistant", ignoreCase = true)
}
if (exactIndex >= 0 && !liveByExactId.containsKey("rest-$sessionId-${activeTarget.serverMessageId}")) {
wsCompletionIdByRestIndex[exactIndex] = activeTarget.completionId
}
}
}
// Tool rows in the REST transcript carry NO tool name — the live WS
// stream was the only source of `toolName`. Match each REST tool row
// to its WS counterpart by RESULT CONTENT (not position — pagination
@@ -171,6 +253,12 @@ internal fun mapServerMessages(
}
}
val completionId =
if (role == MessageRole.ASSISTANT) {
liveByExactId[restId]?.completionId ?: wsCompletionIdByRestIndex[index]
} else {
null
}
val tokenCount = msg.tokenCount ?: TokenEstimator.estimate(finalContent).takeIf { it > 0 }
mapped.add(
ChatMessage(
@@ -184,6 +272,7 @@ internal fun mapServerMessages(
isStreaming = false,
displayKind = msg.display_kind,
tokenCount = tokenCount,
completionId = completionId,
),
)
}
@@ -28,6 +28,8 @@ import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.notification.captureTurnBoundary
import com.m57.hermescontrol.notification.correlationScopeId
import com.m57.hermescontrol.ui.common.ActionProgressController
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
@@ -1879,6 +1881,15 @@ class ChatViewModel(
},
)
} else {
// Arm the durable turn boundary BEFORE prompt.submit leaves
// the device: the REST high-watermark has to be read while
// the reply row cannot exist yet. A failed capture is
// non-fatal — the prompt is still sent, the turn just
// becomes uncorrelatable and its reply notification is never
// auto-dismissed from hydration instead of being bound to a
// guessed row.
prepareTurnCorrelation(storageSessionId)
if (dispatchGeneration != sessionGeneration) return@launch
wsClient.sendMessage(
agentSessionId,
fullText,
@@ -1898,6 +1909,17 @@ class ChatViewModel(
}
}
/**
* Arms the reply-notification turn boundary for a prompt about to be
* submitted. Best-effort by design: sending chat never depends on REST
* health, so a failed read only makes this turn's reply notification
* uncorrelatable — it stays active instead of being dismissed by a guess.
*/
private suspend fun prepareTurnCorrelation(storageSessionId: String?) {
if (storageSessionId.isNullOrBlank()) return
captureTurnBoundary(scopeId = correlationScopeId(), sessionId = storageSessionId)
}
private fun captureTurnUsageBaselineIfNeeded() {
if (_streamingState.value.turnUsageBaselineCaptured) return
_streamingState.update {
@@ -2354,8 +2376,15 @@ class ChatViewModel(
) {
if (text.isBlank()) return
val sessionId = runtimeSessionId ?: return
val storageSessionId = _uiState.value.currentSessionId
_uiState.update { it.copy(isAgentTyping = true) }
viewModelScope.launch(ioDispatcher) {
// A queued prompt runs behind a turn that is already in flight, so
// no clean lower bound exists for it (see /queue). It stays
// uncorrelated rather than borrowing the running turn's boundary.
if (!queued) {
prepareTurnCorrelation(storageSessionId)
}
wsClient.sendMessage(
sessionId,
text,
@@ -2778,6 +2807,7 @@ class ChatViewModel(
serverOffset,
latestPaging,
_uiState.value.messages,
context = getApplication(),
)
loadedMessageOffset = serverOffset
withContext(ioDispatcher) {
@@ -3211,6 +3241,7 @@ class ChatViewModel(
returnedOffset,
latestPaging,
_uiState.value.messages,
isPagingOlder = true,
)
loadedMessageOffset = returnedOffset
withContext(ioDispatcher) { repo.persistMessages(older, sessionId) }
@@ -3295,6 +3326,7 @@ class ChatViewModel(
nextOffset,
latestPaging,
_uiState.value.messages,
isPagingOlder = true,
)
if (incoming.isEmpty()) return@launch
withContext(ioDispatcher) { repo.persistMessages(incoming, sessionId) }
@@ -429,9 +429,38 @@ object ChatWsEventReducer {
// above before this return because the final snapshot is still needed
// as the next turn's cumulative baseline.
if (text.isBlank()) {
val effects = mutableListOf<ReducerEffect>()
val sid = state.currentSessionId
var updatedTargetMsg: ChatMessage? = null
val updatedMessages =
if (!event.completionId.isNullOrBlank()) {
val lastAssistantIdx =
usageState.messages
.indexOfLast {
it.role == MessageRole.ASSISTANT &&
(streamingState.sealedOrphanIds.contains(it.id) || it.id == streaming?.id)
}.takeIf { it >= 0 }
if (lastAssistantIdx != null) {
usageState.messages.toMutableList().also { list ->
val updated = list[lastAssistantIdx].copy(completionId = event.completionId)
list[lastAssistantIdx] = updated
updatedTargetMsg = updated
}
} else {
usageState.messages
}
} else {
usageState.messages
}
if (sid != null && updatedTargetMsg != null) {
effects.add(ReducerEffect.PersistMessage(updatedTargetMsg, sid))
}
effects.add(ReducerEffect.RefreshSessions)
effects.add(ReducerEffect.RefreshContextUsage)
return ReducerResult(
state = usageState.copy(isAgentTyping = false),
state = usageState.copy(messages = updatedMessages, isAgentTyping = false),
streamingState = StreamingState(),
effects = effects,
)
}
val tps = turnUsage?.avgTps.validTpsOrNull() ?: usageState.latestTps.validTpsOrNull()
@@ -449,6 +478,7 @@ object ChatWsEventReducer {
finishTimestamp = System.currentTimeMillis(),
tokenCount = tokenCount,
tps = tps,
completionId = event.completionId,
) ?: ChatMessage(
role = MessageRole.ASSISTANT,
content = text,
@@ -456,6 +486,7 @@ object ChatWsEventReducer {
finishTimestamp = System.currentTimeMillis(),
tokenCount = tokenCount,
tps = tps,
completionId = event.completionId,
)
val effects = mutableListOf<ReducerEffect>()
val sid = state.currentSessionId
@@ -10,18 +10,24 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.notification.ReplyNotificationTracker
import com.m57.hermescontrol.notification.correlationScopeId
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatViewModel
import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.SecretPromptUi
import com.m57.hermescontrol.ui.chat.SudoPromptUi
import kotlinx.coroutines.flow.distinctUntilChanged
@Composable
fun ChatLifecycleEffects(
@@ -39,6 +45,7 @@ fun ChatLifecycleEffects(
scrollController: ChatScrollController,
snackbarHostState: SnackbarHostState,
viewModel: ChatViewModel,
isOverlayActive: Boolean = false,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
@@ -87,6 +94,32 @@ fun ChatLifecycleEffects(
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
val messageMap = remember(messages) { messages.associateBy { it.id } }
// Auto-dismiss reply notifications when their message is displayed in the viewport
LaunchedEffect(lifecycleOwner, currentSessionId, messageMap, listState, isOverlayActive) {
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
snapshotFlow<List<ChatMessage>> {
if (currentSessionId.isNullOrBlank() || isOverlayActive) {
emptyList()
} else {
ChatReadObserver.findVisibleAssistantMessages(listState.layoutInfo, messageMap)
}
}.distinctUntilChanged()
.collect { visibleAssistantMsgs ->
val scopeId = correlationScopeId()
for (msg in visibleAssistantMsgs) {
ReplyNotificationTracker.onMessageVisible(
context = context,
scopeId = scopeId,
sessionId = currentSessionId,
completionId = msg.completionId,
)
}
}
}
}
// Request POST_NOTIFICATIONS permission on Android 13+
val requestNotificationPermission =
rememberLauncherForActivityResult(
@@ -0,0 +1,49 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.lazy.LazyListLayoutInfo
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.MessageRole
object ChatReadObserver {
/**
* Inspects the LazyColumn's visible items to determine which completed assistant
* messages currently have any portion visible within the viewport.
*
* In [com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList], assistant prose
* bubbles are keyed with `"prose-${message.id}"`. An item is visible when its top
* edge is before the viewport end offset AND its bottom edge (offset + size) is past
* the viewport start offset.
*/
fun findVisibleAssistantMessages(
layoutInfo: LazyListLayoutInfo,
messageMap: Map<String, ChatMessage>,
): List<ChatMessage> {
val visible = layoutInfo.visibleItemsInfo
if (visible.isEmpty() || messageMap.isEmpty()) return emptyList()
val start = layoutInfo.viewportStartOffset
val end = layoutInfo.viewportEndOffset
return visible
.filter { it.offset + it.size > start && it.offset < end }
.mapNotNull { item ->
val key = item.key as? String ?: return@mapNotNull null
if (key.startsWith("prose-")) {
val msgId = key.removePrefix("prose-")
val msg = messageMap[msgId]
if (msg != null && msg.role == MessageRole.ASSISTANT && !msg.isStreaming) {
msg
} else {
null
}
} else {
null
}
}
}
fun findVisibleAssistantMessages(
layoutInfo: LazyListLayoutInfo,
messages: List<ChatMessage>,
): List<ChatMessage> = findVisibleAssistantMessages(layoutInfo, messages.associateBy { it.id })
}