feat: auto-dismiss reply notifications when read in chat (#1195)
This commit is contained in:
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 })
|
||||
}
|
||||
Reference in new issue
Block a user