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

@@ -14,6 +14,7 @@ import com.m57.hermescontrol.data.local.SessionListCacheStore
import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.update.UpdateNoticeManager
import com.m57.hermescontrol.notification.TurnCorrelationTracker
import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -31,6 +32,9 @@ class HermesControlApp :
AuthManager.init(this)
NetworkMonitor.init(this)
SessionListCacheStore.init(this)
// Reply-notification turn boundaries survive process death (armed before
// each mobile prompt, consumed by the matching completion).
TurnCorrelationTracker.attach(this)
appScope.launch {
AuthManager.initializationState.first { it == AuthManager.InitializationState.Ready }
// Issue #537 follow-up (A): preload analytics in the background after launch
@@ -46,4 +46,6 @@ data class ChatMessageEntity(
val tokenCount: Int? = null,
@ColumnInfo(name = "tps")
val tps: Double? = null,
@ColumnInfo(name = "completion_id")
val completionId: String? = null,
)
@@ -37,6 +37,7 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
displayKind = displayKind,
tokenCount = tokenCount,
tps = tps,
completionId = completionId,
)
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
@@ -54,4 +55,5 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
displayKind = displayKind,
tokenCount = tokenCount,
tps = tps,
completionId = completionId,
)
@@ -14,7 +14,7 @@ import java.io.File
@Database(
entities = [ChatMessageEntity::class],
version = 7,
version = 8,
exportSchema = true,
)
abstract class HermesDatabase : RoomDatabase() {
@@ -81,6 +81,15 @@ abstract class HermesDatabase : RoomDatabase() {
}
}
val MIGRATION_7_8: Migration =
object : Migration(7, 8) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL(
"ALTER TABLE `chat_messages` ADD COLUMN `completion_id` TEXT",
)
}
}
suspend fun get(context: Context): HermesDatabase =
withContext(Dispatchers.IO) {
instance?.let { return@withContext it }
@@ -116,6 +125,7 @@ abstract class HermesDatabase : RoomDatabase() {
MIGRATION_4_5,
MIGRATION_5_6,
MIGRATION_6_7,
MIGRATION_7_8,
).fallbackToDestructiveMigration(false)
.build()
.also { instance = it }
@@ -110,7 +110,18 @@ object EventParser {
"message.complete" -> {
val text = payload?.get("text") as? String ?: ""
val reasoning = payload?.get("reasoning") as? String
WsEvent.MessageComplete(text, sessionId, reasoning, rawPayload = payload)
val completionId =
payload?.get("completion_id") as? String
?: java.util.UUID
.randomUUID()
.toString()
WsEvent.MessageComplete(
text,
sessionId,
reasoning,
rawPayload = payload,
completionId = completionId,
)
}
"message.done" -> {
@@ -63,6 +63,11 @@ sealed class WsEvent {
val storedSessionId: String? = null,
/** Full raw payload map (including usage/avg_tps) emitted with message.complete. */
val rawPayload: Map<String, Any?>? = null,
/** Stable completion identity for notification and read-tracking correlation. */
val completionId: String =
java.util.UUID
.randomUUID()
.toString(),
) : WsEvent()
data class MessageDone(
@@ -13,7 +13,6 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.RemoteInput
import com.m57.hermescontrol.MainActivity
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient
@@ -69,6 +68,12 @@ class ChatNotificationService : Service() {
private val isAppInForeground = AtomicBoolean(false)
internal val lifecycle = ForegroundServiceLifecycle()
/**
* Turn-row correlation used when a reply completes in the background.
* Injectable so the resolution path can be exercised without REST.
*/
internal var turnRowResolver: TurnRowResolver = defaultTurnRowResolver
@Volatile
internal var activeServiceInstance: ChatNotificationService? = null
@@ -111,10 +116,23 @@ class ChatNotificationService : Service() {
.take(100)
.replace("\n", " ")
.ifBlank { getString(R.string.notif_new_message) }
showReplyNotification(
preview,
val targetSessionId =
event.storedSessionId
?: ActiveSessionHolder.resolveStoredSessionId(event.sessionId),
?: ActiveSessionHolder.resolveStoredSessionId(event.sessionId)
showReplyNotification(
text = preview,
sessionId = targetSessionId,
isReplyMessage = true,
completionId = event.completionId,
// The durable REST row for this turn, when the
// boundary armed before the prompt was submitted
// still lets us name it unambiguously. Null is a
// normal, safe outcome: the notification is then
// never auto-dismissed from REST hydration, which
// is strictly better than dismissing the wrong
// duplicate reply.
serverMessageId =
coalesceTurnRow(targetSessionId, event.text),
)
// The wait is over — retire the foreground
// service. The reply notification above
@@ -167,9 +185,37 @@ class ChatNotificationService : Service() {
}
}
/**
* Names the exact REST row this completion produced, using the turn
* boundary armed before the prompt was submitted. Any failure to prove that
* identity returns null — the notification is still posted, it just will not
* be auto-dismissed from REST hydration.
*/
private suspend fun coalesceTurnRow(
sessionId: String?,
completionText: String,
): Int? {
if (sessionId.isNullOrBlank()) return null
return try {
correlateCompletedTurnRow(
scopeId = correlationScopeId(),
sessionId = sessionId,
completionText = completionText,
resolver = turnRowResolver,
)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
null
}
}
private fun showReplyNotification(
text: String,
sessionId: String?,
isReplyMessage: Boolean = false,
completionId: String? = null,
serverMessageId: Int? = null,
) {
val builder =
NotificationCompat
@@ -183,6 +229,39 @@ class ChatNotificationService : Service() {
.setAutoCancel(true)
.setContentIntent(buildContentIntent(sessionId))
var replyGeneration: Long? = null
if (isReplyMessage && !sessionId.isNullOrBlank() && !completionId.isNullOrBlank()) {
val scopeId = correlationScopeId()
val generation =
ReplyNotificationTracker.registerPendingReply(
scopeId = scopeId,
sessionId = sessionId,
completionId = completionId,
textSnippet = text,
serverMessageId = serverMessageId,
)
replyGeneration = generation
builder.addExtras(
android.os.Bundle().apply {
putString(ReplyNotificationTracker.EXTRA_NOTIF_KIND, ReplyNotificationTracker.KIND_REPLY)
putString(ReplyNotificationTracker.EXTRA_SCOPE_ID, scopeId)
putString(ReplyNotificationTracker.EXTRA_SESSION_ID, sessionId)
putString(ReplyNotificationTracker.EXTRA_COMPLETION_ID, completionId)
putString(ReplyNotificationTracker.EXTRA_TEXT_SNIPPET, text)
putLong(ReplyNotificationTracker.EXTRA_GENERATION, generation)
serverMessageId?.let {
putInt(ReplyNotificationTracker.EXTRA_SERVER_MESSAGE_ID, it)
}
},
)
} else {
builder.addExtras(
android.os.Bundle().apply {
putString(ReplyNotificationTracker.EXTRA_NOTIF_KIND, ReplyNotificationTracker.KIND_ACTION)
},
)
}
if (!sessionId.isNullOrBlank()) {
val replyLabel = getString(R.string.notif_reply_placeholder)
val remoteInput =
@@ -194,6 +273,11 @@ class ChatNotificationService : Service() {
val replyIntent =
Intent(this, NotificationReplyReceiver::class.java).apply {
action = "$packageName.ACTION_NOTIFICATION_REPLY"
component =
android.content.ComponentName(
this@ChatNotificationService,
NotificationReplyReceiver::class.java,
)
setPackage(packageName)
putExtra(NotificationReplyReceiver.EXTRA_SESSION_ID, sessionId)
}
@@ -218,19 +302,32 @@ class ChatNotificationService : Service() {
builder.addAction(action)
}
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(PENDING_NOTIFICATION_ID, builder.build())
val notification = builder.build()
if (replyGeneration != null) {
ReplyNotificationTracker.postReplyNotification(
context = this,
notification = notification,
generation = replyGeneration,
)
} else {
ReplyNotificationTracker.postActionNotification(
context = this,
notification = notification,
)
}
}
private fun buildContentIntent(sessionId: String?): PendingIntent {
val intent =
Intent(this, MainActivity::class.java).apply {
action = MainActivity.ACTION_OPEN_CHAT_FROM_NOTIFICATION
component = android.content.ComponentName(this@ChatNotificationService, MainActivity::class.java)
setPackage(packageName)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
if (!sessionId.isNullOrBlank()) {
putExtra(NotificationReplyReceiver.EXTRA_SESSION_ID, sessionId)
}
}
if (!sessionId.isNullOrBlank()) {
intent.putExtra(NotificationReplyReceiver.EXTRA_SESSION_ID, sessionId)
}
return PendingIntent.getActivity(
this,
sessionId?.hashCode() ?: 0,
@@ -24,6 +24,13 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
const val KEY_TEXT_REPLY = "key_text_reply"
const val EXTRA_SESSION_ID = "extra_session_id"
private const val REPLY_TIMEOUT_MS = 5_000L
/**
* Turn-boundary read budget inside the reply deadline. Deliberately tight:
* the probe sits on the reply path, so a slow or unreachable gateway must
* cost the user's reply only a sliver of its 5s deadline.
*/
private const val BOUNDARY_TIMEOUT_MS = 250L
}
// Reusable scope for async reply processing — avoids creating a new
@@ -50,7 +57,11 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
.setContentText("Replied")
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.build()
.addExtras(
android.os.Bundle().apply {
putString(ReplyNotificationTracker.EXTRA_NOTIF_KIND, ReplyNotificationTracker.KIND_REPLIED)
},
).build()
override fun onReceive(
context: Context,
@@ -78,6 +89,17 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
return@withContext
}
// Mobile-originated follow-up turn: arm its durable
// boundary BEFORE prompt.submit. Budgeted tighter
// than the chat composer's — this receiver has its
// own 5s deadline and a failed capture must only
// make the reply uncorrelatable, never lose it.
captureTurnBoundary(
scopeId = correlationScopeId(),
sessionId = sessionId,
timeoutMs = BOUNDARY_TIMEOUT_MS,
)
val runtimeSessionId =
ActiveSessionHolder.resolveRuntimeSessionId(sessionId)
?: resumeSession(sessionId)
@@ -102,8 +124,7 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
dao.upsert(entity)
val repliedNotification = buildReplyNotification(context)
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, repliedNotification)
ReplyNotificationTracker.postRepliedNotification(context, repliedNotification)
// The follow-up turn is now pending — keep the
// foreground service alive if it retired after the
@@ -0,0 +1,302 @@
package com.m57.hermescontrol.notification
import android.app.Notification
import android.app.NotificationManager
import android.content.Context
import androidx.annotation.VisibleForTesting
import java.util.concurrent.atomic.AtomicLong
data class ReplyNotificationTarget(
val scopeId: String,
val sessionId: String,
val completionId: String,
val generation: Long,
val textSnippet: String = "",
val timestamp: Long = System.currentTimeMillis(),
/**
* Durable REST `messages.id` of the row this reply produced. Resolved by
* [TurnCorrelationTracker] from the boundary armed before the prompt was
* submitted — never taken from the completion event, text, or a clock. Null
* means the turn could not be correlated, and then hydration must not touch
* this notification at all (fail closed).
*/
val serverMessageId: Int? = null,
) {
fun matches(
candidateScopeId: String?,
candidateSessionId: String?,
candidateCompletionId: String?,
): Boolean {
if (scopeId.isNotBlank() && scopeId != candidateScopeId) {
return false
}
if (candidateSessionId.isNullOrBlank() || sessionId != candidateSessionId) {
return false
}
if (candidateCompletionId.isNullOrBlank() || completionId.isBlank()) {
return false
}
return candidateCompletionId == completionId
}
}
internal data class ActiveReplyInfo(
val id: Int,
val kind: String?,
val scopeId: String?,
val sessionId: String?,
val completionId: String?,
val textSnippet: String?,
val generation: Long,
val timestamp: Long,
val serverMessageId: Int? = null,
)
object ReplyNotificationTracker {
const val EXTRA_NOTIF_KIND = "hermes_notif_kind"
const val EXTRA_SCOPE_ID = "hermes_scope_id"
const val EXTRA_SESSION_ID = "hermes_session_id"
const val EXTRA_COMPLETION_ID = "hermes_completion_id"
const val EXTRA_TEXT_SNIPPET = "hermes_text_snippet"
const val EXTRA_GENERATION = "hermes_generation"
const val EXTRA_SERVER_MESSAGE_ID = "hermes_server_message_id"
const val KIND_REPLY = "reply"
const val KIND_ACTION = "action"
const val KIND_REPLIED = "replied"
private val generationCounter = AtomicLong(0L)
private val tombstoneGeneration = AtomicLong(0L)
@Volatile
private var activeTarget: ReplyNotificationTarget? = null
private val defaultActiveNotificationProvider: (Context) -> ActiveReplyInfo? = { context ->
runCatching {
val manager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
val sbn =
manager?.activeNotifications?.firstOrNull {
it.id == ChatNotificationService.PENDING_NOTIFICATION_ID
}
if (sbn != null) {
val extras = sbn.notification?.extras
ActiveReplyInfo(
id = sbn.id,
kind = extras?.getString(EXTRA_NOTIF_KIND),
scopeId = extras?.getString(EXTRA_SCOPE_ID),
sessionId = extras?.getString(EXTRA_SESSION_ID),
completionId = extras?.getString(EXTRA_COMPLETION_ID),
textSnippet = extras?.getString(EXTRA_TEXT_SNIPPET),
generation = extras?.getLong(EXTRA_GENERATION, 0L) ?: 0L,
timestamp = sbn.postTime,
serverMessageId = extras?.getInt(EXTRA_SERVER_MESSAGE_ID, -1)?.takeIf { it >= 0 },
)
} else {
null
}
}.getOrNull()
}
internal var activeNotificationProvider: (Context) -> ActiveReplyInfo? = defaultActiveNotificationProvider
fun nextGeneration(): Long {
val floor = maxOf(generationCounter.get(), tombstoneGeneration.get())
return generationCounter.updateAndGet { maxOf(it, floor) + 1 }
}
@Synchronized
fun registerPendingReply(
scopeId: String,
sessionId: String,
completionId: String,
textSnippet: String,
timestamp: Long = System.currentTimeMillis(),
serverMessageId: Int? = null,
): Long {
val generation = nextGeneration()
activeTarget =
ReplyNotificationTarget(
scopeId = scopeId,
sessionId = sessionId,
completionId = completionId,
generation = generation,
textSnippet = textSnippet,
timestamp = timestamp,
serverMessageId = serverMessageId,
)
return generation
}
@Synchronized
fun postReplyNotification(
context: Context,
notification: Notification,
generation: Long,
): Boolean {
if (generation <= tombstoneGeneration.get()) {
return false
}
val current = activeTarget
if (current != null && current.generation > generation) {
return false
}
val manager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
?: return false
manager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, notification)
return true
}
@Synchronized
fun postActionNotification(
context: Context,
notification: Notification,
): Boolean {
val nextGen = nextGeneration()
tombstoneGeneration.updateAndGet { maxOf(it, nextGen) }
activeTarget = null
val manager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
?: return false
manager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, notification)
return true
}
@Synchronized
fun postRepliedNotification(
context: Context,
notification: Notification,
): Boolean {
val nextGen = nextGeneration()
tombstoneGeneration.updateAndGet { maxOf(it, nextGen) }
activeTarget = null
val manager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
?: return false
manager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, notification)
return true
}
@Synchronized
fun onReplyNotificationPosted(
scopeId: String,
sessionId: String,
completionId: String,
textSnippet: String,
generation: Long,
timestamp: Long = System.currentTimeMillis(),
serverMessageId: Int? = null,
) {
if (generation <= tombstoneGeneration.get()) return
generationCounter.updateAndGet { maxOf(it, generation) }
activeTarget =
ReplyNotificationTarget(
scopeId = scopeId,
sessionId = sessionId,
completionId = completionId,
generation = generation,
textSnippet = textSnippet,
timestamp = timestamp,
serverMessageId = serverMessageId,
)
}
@Synchronized
fun onNonReplyNotificationPosted() {
val nextGen = nextGeneration()
tombstoneGeneration.updateAndGet { maxOf(it, nextGen) }
activeTarget = null
}
@Synchronized
fun getActiveTarget(context: Context? = null): ReplyNotificationTarget? {
val inMemory = activeTarget
if (inMemory != null) return inMemory
return if (context != null) resolveTarget(context) else null
}
@Synchronized
fun onMessageVisible(
context: Context,
scopeId: String?,
sessionId: String?,
completionId: String?,
): Boolean {
if (sessionId.isNullOrBlank()) return false
val target = resolveTarget(context) ?: return false
if (target.matches(scopeId, sessionId, completionId)) {
return cancelReplyNotification(context, target.generation)
}
return false
}
@Synchronized
fun cancelReplyNotification(
context: Context,
expectedGeneration: Long,
): Boolean {
val target = resolveTarget(context)
if (target != null && target.generation > expectedGeneration) {
return false
}
val manager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
?: return false
val activeInfo = activeNotificationProvider(context)
if (activeInfo != null) {
if (activeInfo.kind != KIND_REPLY) {
activeTarget = null
return false
}
if (activeInfo.generation <= tombstoneGeneration.get()) {
activeTarget = null
return false
}
if (activeInfo.generation > expectedGeneration) {
return false
}
}
tombstoneGeneration.updateAndGet { maxOf(it, expectedGeneration) }
manager.cancel(ChatNotificationService.PENDING_NOTIFICATION_ID)
activeTarget = null
return true
}
private fun resolveTarget(context: Context): ReplyNotificationTarget? {
val inMemory = activeTarget
if (inMemory != null) return inMemory
val activeInfo = activeNotificationProvider(context) ?: return null
if (activeInfo.kind != KIND_REPLY) return null
generationCounter.updateAndGet { maxOf(it, activeInfo.generation) }
if (activeInfo.generation <= tombstoneGeneration.get()) return null
val sessionId = activeInfo.sessionId.orEmpty()
if (sessionId.isBlank()) return null
return ReplyNotificationTarget(
scopeId = activeInfo.scopeId.orEmpty(),
sessionId = sessionId,
completionId = activeInfo.completionId.orEmpty(),
generation = activeInfo.generation,
textSnippet = activeInfo.textSnippet.orEmpty(),
timestamp = activeInfo.timestamp,
serverMessageId = activeInfo.serverMessageId,
).also { activeTarget = it }
}
@VisibleForTesting
fun resetForTest() {
activeTarget = null
generationCounter.set(0L)
tombstoneGeneration.set(0L)
activeNotificationProvider = defaultActiveNotificationProvider
}
@VisibleForTesting
fun setTargetForTest(target: ReplyNotificationTarget?) {
activeTarget = target
}
}
@@ -0,0 +1,384 @@
package com.m57.hermescontrol.notification
import android.content.Context
import androidx.annotation.VisibleForTesting
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.SessionMessage
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.atomic.AtomicLong
/**
* Durable lower bound for one mobile-originated turn: the highest REST
* transcript row id that existed immediately BEFORE `prompt.submit` left the
* device.
*
* The id is the gateway's AUTOINCREMENT `messages.id`, so every row persisted
* by the turn itself is strictly greater than [beforeMessageId]. That gives the
* notification layer a turn-scoped window it can search without text
* heuristics, row ordering, or cross-machine clocks.
*
* Captured before the prompt is submitted, never after: a fast turn can persist
* its assistant row before a post-submit snapshot request finishes, which would
* place the reply inside the "before" snapshot (and a lower bound can then never
* exclude it).
*/
data class TurnBoundary(
val scopeId: String,
val sessionId: String,
val beforeMessageId: Int,
/** Monotonic arm order — a stale clear must not remove a newer turn's boundary. */
val generation: Long,
val armedAt: Long,
) {
fun matchesKey(
otherScopeId: String,
otherSessionId: String,
): Boolean = scopeId == otherScopeId && sessionId == otherSessionId
}
/** Persistence seam so the tracker can be unit-tested without an Android context. */
internal interface TurnBoundaryStore {
fun read(): List<TurnBoundary>
fun write(boundaries: List<TurnBoundary>)
}
/**
* SharedPreferences-backed store — a handful of tiny `scope|session -> state`
* rows, so a process death during a running turn does not lose the boundary.
*/
internal class PrefsTurnBoundaryStore(
context: Context,
) : TurnBoundaryStore {
private val prefs =
context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
override fun read(): List<TurnBoundary> =
prefs.all.mapNotNull { (key, value) -> decodeBoundary(key, value as? String) }
override fun write(boundaries: List<TurnBoundary>) {
val editor = prefs.edit().clear()
boundaries.forEach { editor.putString(encodeKey(it.scopeId, it.sessionId), encodeValue(it)) }
editor.apply()
}
internal companion object {
const val PREFS_NAME = "hermes_turn_boundaries"
private const val KEY_SEPARATOR = '|'
fun encodeKey(
scopeId: String,
sessionId: String,
): String = "$scopeId$KEY_SEPARATOR$sessionId"
fun encodeValue(boundary: TurnBoundary): String =
"${boundary.beforeMessageId}:${boundary.generation}:${boundary.armedAt}"
fun decodeBoundary(
key: String,
value: String?,
): TurnBoundary? {
val separator = key.indexOf(KEY_SEPARATOR)
if (separator <= 0 || value == null) return null
val parts = value.split(':')
if (parts.size != 3) return null
val beforeMessageId = parts[0].toIntOrNull() ?: return null
val generation = parts[1].toLongOrNull() ?: return null
val armedAt = parts[2].toLongOrNull() ?: return null
return TurnBoundary(
scopeId = key.substring(0, separator),
sessionId = key.substring(separator + 1),
beforeMessageId = beforeMessageId,
generation = generation,
armedAt = armedAt,
)
}
}
}
/**
* Turn-boundary registry for reply-notification correlation.
*
* A boundary is armed per (profile scope, stored session id) immediately before
* a mobile-originated prompt is submitted, and consumed when a completion for
* that session is handled. Sessions that never had a boundary (desktop/TUI
* turns, another connected client, a failed capture, queued or group-chat
* turns) simply fail closed: the reply notification is posted without a durable
* row id and therefore is never auto-dismissed from REST hydration alone.
*
* Scope isolation is structural — a boundary is only visible to the exact
* profile that armed it, so switching profiles makes the old boundary
* unreachable instead of resolvable against the wrong namespace.
*/
object TurnCorrelationTracker {
/** Bound the registry: stale turns must not accumulate across app restarts. */
private const val MAX_ENTRIES = 32
/** A boundary older than this belongs to a turn nobody is waiting on anymore. */
private const val MAX_AGE_MS = 6 * 60 * 60 * 1000L
private val generationCounter = AtomicLong(0L)
private val lock = Any()
@Volatile
private var cache: List<TurnBoundary> = emptyList()
@Volatile
private var store: TurnBoundaryStore? = null
/** Idempotent — called once from [com.m57.hermescontrol.HermesControlApp]. */
fun attach(context: Context) {
synchronized(lock) {
if (store != null) return
// Never let persistence break app startup: an unusable context
// degrades to in-memory boundaries.
val prefsStore = runCatching { PrefsTurnBoundaryStore(context) }.getOrNull() ?: return
store = prefsStore
val restored = prune(runCatching { prefsStore.read() }.getOrDefault(emptyList()))
cache = restored
reserveGenerationFloor(restored)
}
}
/** Test seam: swap the persistent store (or detach with `null`). */
@VisibleForTesting
internal fun attachStore(store: TurnBoundaryStore?) {
synchronized(lock) {
this.store = store
val restored = store?.let { prune(it.read()) } ?: emptyList()
cache = restored
reserveGenerationFloor(restored)
}
}
/**
* Records the pre-submit high-watermark. Returns the armed boundary, or null
* when the identity is unusable (blank scope/session, no rows and no
* confirmed read).
*/
fun armBoundary(
scopeId: String,
sessionId: String,
beforeMessageId: Int,
now: Long = System.currentTimeMillis(),
): TurnBoundary? {
if (scopeId.isBlank() || sessionId.isBlank() || beforeMessageId < 0) return null
val boundary =
TurnBoundary(
scopeId = scopeId,
sessionId = sessionId,
beforeMessageId = beforeMessageId,
generation = generationCounter.incrementAndGet(),
armedAt = now,
)
synchronized(lock) {
val next = prune(cache.filterNot { it.matchesKey(scopeId, sessionId) } + boundary, now = now)
cache = next
runCatching { store?.write(next) }
}
return boundary
}
fun boundaryFor(
scopeId: String,
sessionId: String,
now: Long = System.currentTimeMillis(),
): TurnBoundary? {
if (scopeId.isBlank() || sessionId.isBlank()) return null
return synchronized(lock) {
cache.firstOrNull { it.matchesKey(scopeId, sessionId) && isFresh(it, now) }
}
}
/**
* Consumes a boundary. [generation] guards the race where a newer turn armed
* a replacement while an older completion was still being correlated.
*/
fun clearBoundary(
scopeId: String,
sessionId: String,
generation: Long? = null,
): Boolean {
synchronized(lock) {
val existing = cache.firstOrNull { it.matchesKey(scopeId, sessionId) } ?: return false
if (generation != null && existing.generation != generation) return false
val next = cache.filterNot { it === existing }
cache = next
runCatching { store?.write(next) }
return true
}
}
private fun prune(
boundaries: List<TurnBoundary>,
now: Long = System.currentTimeMillis(),
): List<TurnBoundary> =
boundaries
.filter { isFresh(it, now) }
.sortedByDescending { it.armedAt }
.take(MAX_ENTRIES)
/**
* Keeps generations unique across process death.
*
* The counter lives in memory only, so after a restart a restored boundary
* (generation N) and the next armed boundary would both be N — and the
* stale-clear guard, which exists precisely to stop an old completion's
* clear from removing a newer turn's boundary, would stop protecting
* anything. Lifting the counter above the recovered generations keeps the
* guard meaningful.
*/
private fun reserveGenerationFloor(restored: List<TurnBoundary>) {
val maxRestored = restored.maxOfOrNull { it.generation } ?: 0L
generationCounter.updateAndGet { maxOf(it, maxRestored) }
}
private fun isFresh(
boundary: TurnBoundary,
now: Long,
): Boolean = now - boundary.armedAt <= MAX_AGE_MS
@VisibleForTesting
internal fun resetForTest() {
synchronized(lock) {
cache = emptyList()
store = null
generationCounter.set(0L)
}
}
}
/** How long the pre-submit boundary read may take before the turn goes uncorrelatable. */
private const val TURN_BOUNDARY_TIMEOUT_MS = 1_500L
/**
* Correlation scope for the active connection profile.
*
* `AuthManager.activeProfileId` stays null until a server profile is explicitly
* selected, and the rest of AuthManager treats that as
* [AuthManager.DEFAULT_PROFILE_ID] (see `currentDataScope()`). Collapsing
* null/blank here gives this feature ONE scope identity — a raw `.orEmpty()`
* would reject the default profile, so a normal install would arm no boundary,
* resolve no row, and silently never auto-dismiss a reply notification.
*
* The arming side (prompt submit), the resolution side (completion), the
* notification extras, and the chat viewport observer must ALL use this, or the
* two halves disagree on the scope and dismissal stops working.
*/
internal fun correlationScopeId(): String =
AuthManager.activeProfileId.value?.takeIf { it.isNotBlank() }
?: AuthManager.DEFAULT_PROFILE_ID
/** Rows scanned when correlating a completed turn (newest-first page). */
private const val TURN_ROW_SCAN_LIMIT = 20
/**
* Reads the durable REST transcript high-watermark for [sessionId] and arms a
* boundary for it. Call immediately BEFORE submitting the prompt.
*
* Deliberately does not send a `profile` query param: the boundary ids are later
* compared against rows hydrated by the chat screen, which fetches this
* endpoint without a profile. Keeping both reads in the same namespace is what
* makes the id comparison meaningful.
*
* Returns false on any failure (including a gateway that does not confirm
* `order=latest`) — the caller sends its prompt anyway and the turn simply
* becomes uncorrelatable.
*/
internal suspend fun captureTurnBoundary(
scopeId: String,
sessionId: String,
timeoutMs: Long = TURN_BOUNDARY_TIMEOUT_MS,
): Boolean {
if (scopeId.isBlank() || sessionId.isBlank()) return false
val maxMessageId =
try {
withTimeoutOrNull(timeoutMs) { fetchBoundaryTailId(sessionId) }
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
// Total by contract: this probe is best-effort identity for a
// notification convenience and must NEVER fail the caller's turn.
// Swallowing Throwable (not just Exception) is deliberate — a
// gateway-layer Error (uninitialised client, NoClassDefFound) used to
// escape past the caller's `catch (Exception)` and get thrown inside
// a background scope, which aborted the send and leaked as an
// uncaught exception attributed to an unrelated later test.
null
} ?: return false
return TurnCorrelationTracker.armBoundary(scopeId, sessionId, maxMessageId) != null
}
/**
* Newest row id of the transcript, or null when the read cannot be trusted.
*
* `pagination` is only returned by gateways that honoured `order=latest`; a
* backend that ignores the param would answer a `limit=1` probe with the OLDEST
* row, producing a boundary that is too low — a lower bound that is too low is
* exactly what lets a historical duplicate win. Absent proof, fail closed.
*/
private suspend fun fetchBoundaryTailId(sessionId: String): Int? {
val result =
safeApiCall {
ApiClient.hermesApi.getSessionMessages(
sessionId = sessionId,
limit = 1,
offset = 0,
includeCompacted = true,
order = "latest",
)
}
val body = (result as? NetworkResult.Success)?.data ?: return null
// The whole safety property rests on this single row being the HIGHEST id in
// the transcript. Require the gateway to confirm it honoured order=latest
// (same proof the chat hydration uses) — a backend that ignores the param
// answers with the OLDEST row, and a lower bound that is too low is exactly
// what lets a historical duplicate win.
if (body.pagination?.order != "latest") return null
return body.messages.mapNotNull { it.id }.maxOrNull() ?: 0
}
/** Newest-first REST page used to correlate a completed turn. */
internal suspend fun fetchTurnRows(sessionId: String): List<SessionMessage>? {
val result =
safeApiCall {
ApiClient.hermesApi.getSessionMessages(
sessionId = sessionId,
limit = TURN_ROW_SCAN_LIMIT,
offset = 0,
includeCompacted = true,
order = "latest",
)
}
return (result as? NetworkResult.Success)?.data?.messages
}
/**
* Resolves the exact REST row persisted by the turn that just completed, given
* the boundary armed before that turn was submitted.
*
* The boundary is consumed either way in `finally`: the turn it describes is
* over, and leaving it armed would let the next completion bind to a row above
* a stale lower bound.
*/
internal suspend fun correlateCompletedTurnRow(
scopeId: String,
sessionId: String,
completionText: String,
resolver: TurnRowResolver = defaultTurnRowResolver,
): Int? {
val boundary = TurnCorrelationTracker.boundaryFor(scopeId, sessionId) ?: return null
return try {
resolver.resolve(sessionId, boundary, completionText)
} finally {
TurnCorrelationTracker.clearBoundary(scopeId, sessionId, boundary.generation)
}
}
/** Production resolver — reads the newest REST page for the session. */
internal val defaultTurnRowResolver =
TurnRowResolver(fetchLatestRows = { sessionId -> fetchTurnRows(sessionId) })
@@ -0,0 +1,93 @@
package com.m57.hermescontrol.notification
import com.m57.hermescontrol.data.model.SessionMessage
import com.m57.hermescontrol.ui.chat.HostMediaExtractor
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
/**
* Resolves the one persisted REST assistant row produced by a completed
* mobile-originated turn.
*
* Design rules (all of them are load-bearing):
* - Only rows strictly above [TurnBoundary.beforeMessageId] are candidates, so
* historical duplicates can never win.
* - The FULL completion text is compared, never the 100-character notification
* preview.
* - Exactly one candidate resolves. Zero candidates retry (the row may not be
* persisted yet). More than one candidate is ambiguous and fails closed —
* `firstOrNull`, `lastOrNull`, `maxBy` and "newest match" are all guessing.
* - Never compares Android notification time with server timestamps.
*/
internal class TurnRowResolver(
private val fetchLatestRows: suspend (String) -> List<SessionMessage>?,
/** Attempt delays; the first attempt runs immediately. */
private val retryDelaysMs: List<Long> = listOf(0L, 200L, 400L),
) {
suspend fun resolve(
sessionId: String,
boundary: TurnBoundary,
completionText: String,
): Int? {
val wanted = assistantTextKey(completionText)
if (wanted.isEmpty) return null
if (sessionId.isBlank()) return null
for ((attempt, delayMs) in retryDelaysMs.withIndex()) {
if (attempt > 0) delay(delayMs)
val rows = fetchRows(sessionId) ?: continue
val candidates =
rows.mapNotNull { row ->
val id = row.id ?: return@mapNotNull null
if (id <= boundary.beforeMessageId) return@mapNotNull null
if (!row.role.equals("assistant", ignoreCase = true)) return@mapNotNull null
if (assistantTextKey(row.contentText) != wanted) return@mapNotNull null
id
}
if (candidates.size == 1) return candidates.single()
// Two rows above the boundary match this completion: the turn's own
// row cannot be told apart from the other. Leave the notification
// active rather than bind it to a coin flip.
if (candidates.size > 1) return null
}
return null
}
private suspend fun fetchRows(sessionId: String): List<SessionMessage>? =
try {
fetchLatestRows(sessionId)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
null
}
}
/**
* Normalized comparison key for an assistant row.
*
* Text replies compare their trimmed full text. `MEDIA:` replies compare the
* directive-stripped text PLUS the extracted host paths, using the same
* canonicalization the chat mapper applies before rendering — so a MEDIA row
* and the matching completion agree on both halves, and a MEDIA-only reply
* (which strips to an empty string) still compares by its paths instead of
* degenerating into "any assistant row".
*/
internal data class AssistantTextKey(
val text: String,
val media: List<String>,
) {
val isEmpty: Boolean
get() = text.isBlank() && media.isEmpty()
}
private const val MEDIA_DIRECTIVE = "MEDIA:"
internal fun assistantTextKey(raw: String): AssistantTextKey {
val trimmed = raw.trim()
if (!trimmed.contains(MEDIA_DIRECTIVE)) return AssistantTextKey(trimmed, emptyList())
return AssistantTextKey(
text = HostMediaExtractor.strip(trimmed).trim(),
media = HostMediaExtractor.extract(trimmed).map { it.path },
)
}
@@ -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 })
}