diff --git a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt index 77034c0..000719b 100644 --- a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt +++ b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt @@ -95,6 +95,8 @@ import kotlinx.coroutines.launch import java.util.Collections import java.util.concurrent.atomic.AtomicLong import kotlin.random.Random +import kotlin.time.TimeMark +import kotlin.time.TimeSource /** * App-level controller (M3): owns the GatewayClient + ChatStore + ChannelStore, @@ -188,13 +190,34 @@ class IrisController( _foreground.value = fg } + /** M8: the last message id whose arrival incremented [lane]'s unread + * badge. The same finalized message can be delivered twice (live SSE + * plus the sync replay after a push-triggered reconnect) — only the + * first delivery may count. Frame-handler coroutine only. */ + private val countedMessageIds = HashMap() + + /** M5/M8: when a high-priority notification banner (cron/approval/clarify) + * was last posted per lane. Cron delivery = notification frame + message + * frame; the banner already announced the delivery, so the accompanying + * message frame must not post a second system notification. Frame-handler + * coroutine only. */ + private val lastHighPriorityBannerAt = HashMap() + /** M8: a finalized assistant message arrived in [lane]. Count it as unread * unless the user is actively reading that lane right now (it is the * current lane, the app is focused, and the newest content is at the - * bottom of the viewport). */ - private fun noteIncomingAssistantMessage(lane: String) { + * bottom of the viewport). [messageId] dedupes redeliveries of the same + * frame (see [countedMessageIds]). */ + private fun noteIncomingAssistantMessage( + lane: String, + messageId: String?, + ) { + if (messageId != null && countedMessageIds[lane] == messageId) return val beingRead = lane == chat.currentLane.value && isAppForeground() && currentLaneAtBottom - if (!beingRead) chat.markUnread(lane) + if (!beingRead) { + if (messageId != null) countedMessageIds[lane] = messageId + chat.markUnread(lane) + } } /** M8: the user is now viewing the current lane's newest content — clear @@ -332,6 +355,11 @@ class IrisController( /** Max simultaneous banners; persistent ones are exempt from the cap. */ private const val MAX_BANNERS = 5 + /** Window in which a high-priority banner suppresses the system + * notification for its accompanying message frame (mirrors the + * gateway's push-coalesce window, classify._PUSH_COALESCE_S). */ + private const val BANNER_NOTIFY_SUPPRESS_MS = 5_000L + const val FONT_SCALE_MIN = 0.8f const val FONT_SCALE_MAX = 1.5f @@ -523,6 +551,13 @@ class IrisController( ) { if (isAppForeground()) return if (text.isBlank()) return + // A high-priority banner (cron/approval/clarify) for this lane just + // announced this delivery — don't stack a second notification for the + // accompanying message frame. + chatId?.let { cid -> + val mark = lastHighPriorityBannerAt[chat.laneKey(cid, threadId)] + if (mark != null && mark.elapsedNow().inWholeMilliseconds < BANNER_NOTIFY_SUPPRESS_MS) return + } val id = chatId ?: "default" val chatName = channels.byId(id)?.name postSystemNotification(id, chatName, chatName ?: "Iris", preview(text), threadId) @@ -647,7 +682,7 @@ class IrisController( // content — count it as unread unless the // user is reading this lane right now. frame.chatId?.let { cid -> - noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId)) + noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId), it.messageId) } if (!alreadyConsumed && !isPushedReplay(frame)) { notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText) @@ -660,7 +695,7 @@ class IrisController( if (it.role == ROLE_ASSISTANT) { // M8: a finalized (non-streaming) reply. frame.chatId?.let { cid -> - noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId)) + noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId), it.messageId) } if (!alreadyConsumed && !isPushedReplay(frame)) { notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text) @@ -787,6 +822,15 @@ class IrisController( // sync replay) must not re-show the banner. if (!alreadyConsumed) { pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId) + // Cron delivery = notification frame + message frame: remember + // that the banner announced this lane so the message frame + // doesn't post a second system notification. + if (p.kind in HIGH_PRIORITY_NOTIF_KINDS) { + p.chatId?.let { cid -> + lastHighPriorityBannerAt[chat.laneKey(cid, p.threadId)] = + TimeSource.Monotonic.markNow() + } + } // M5: the connection is live but the app is backgrounded — the // in-app banner is invisible, so mirror to a system // notification (the push backend only fires when