feat(app): unread message indicator (channel view, header, hamburger)
CI / Gateway plugin tests (push) Successful in 6m18s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m57s

Tracks per-lane unread counts for finalized assistant messages. A message
counts as unread unless the user is actively reading that lane (current
lane, app focused, newest content at the bottom of the viewport).

- ChatStore: ephemeral per-lane unread map (markUnread/markLaneRead/unreadFor).
- IrisController: 'being read' decision on incoming messages; clears the
  current lane when the app returns to the foreground at the bottom.
- Bridges: push foreground changes to the controller.
- ChatScreen: per-channel badges in the drawer/rail, an 'N new' pill in the
  header (tap glides to the latest message), and a red dot on the hamburger
  (single-pane/mobile only) when another channel has unread.

Closes #3.
This commit is contained in:
ARIA committed 2026-08-23 01:34:42 +02:00
1 parent 9c50f2dbc1
commit 6458c3183c
6 files changed
+278 -6

No files matched your search

@@ -14,7 +14,14 @@ object AppBridge {
@Volatile @Volatile
var controller: IrisController? = null var controller: IrisController? = null
/** True while the launcher activity is resumed (set by MainActivity). */ /** True while the launcher activity is resumed (set by MainActivity).
* A change is forwarded to the controller (M8: unread clear on focus). */
@Volatile @Volatile
var foreground: Boolean = false var foreground: Boolean = false
set(value) {
if (field != value) {
field = value
controller?.setForeground(value)
}
}
} }
@@ -149,6 +149,13 @@ class ChatStore {
private val _todos = MutableStateFlow<Map<String, List<TodoItem>>>(emptyMap()) private val _todos = MutableStateFlow<Map<String, List<TodoItem>>>(emptyMap())
val todos: StateFlow<Map<String, List<TodoItem>>> = _todos.asStateFlow() val todos: StateFlow<Map<String, List<TodoItem>>> = _todos.asStateFlow()
/** Unread message count per lane (M8: unread indicator). Ephemeral
* (in-memory): a process death resets it, and the `sync` delta re-counts
* genuinely new messages on reconnect. A lane absent from the map has
* no unread messages. */
private val _unread = MutableStateFlow<Map<String, Int>>(emptyMap())
val unread: StateFlow<Map<String, Int>> = _unread.asStateFlow()
private var localSeq = 0 private var localSeq = 0
/** When false, `message.start`/`message.update` frames are ignored and each /** When false, `message.start`/`message.update` frames are ignored and each
@@ -694,6 +701,24 @@ class ChatStore {
if (changed) _lanes.value = map if (changed) _lanes.value = map
} }
/** M8: a new message arrived in [lane] that the user hasn't seen —
* increment its unread count. */
fun markUnread(lane: String) {
val map = _unread.value.toMutableMap()
map[lane] = (map[lane] ?: 0) + 1
_unread.value = map
}
/** M8: the user is now viewing [lane]'s newest content — clear its unread
* count. Idempotent (a lane with no unread is a no-op). */
fun markLaneRead(lane: String) {
val map = _unread.value.toMutableMap()
if (map.remove(lane) != null) _unread.value = map
}
/** M8: unread count for a single lane (0 when none). */
fun unreadFor(lane: String): Int = _unread.value[lane] ?: 0
/** M5: mark the user message [messageId] as read (read.receipt). */ /** M5: mark the user message [messageId] as read (read.receipt). */
fun markRead(messageId: String) { fun markRead(messageId: String) {
val map = _lanes.value.toMutableMap() val map = _lanes.value.toMutableMap()
@@ -904,5 +929,6 @@ class ChatStore {
fun clear() { fun clear() {
_lanes.value = emptyMap() _lanes.value = emptyMap()
_unread.value = emptyMap()
} }
} }
@@ -152,6 +152,42 @@ class IrisController(
private val _gatewayStatus = MutableStateFlow<String?>(null) private val _gatewayStatus = MutableStateFlow<String?>(null)
val gatewayStatus: StateFlow<String?> = _gatewayStatus.asStateFlow() val gatewayStatus: StateFlow<String?> = _gatewayStatus.asStateFlow()
// ── M8: unread indicator ──────────────────────────────────────────────
/** True while the current lane's newest content sits at the bottom of the
* viewport (set by the UI from its scroll state). Used to decide whether
* an incoming message in the current lane is "being read" (not unread).
* @Volatile: written on the UI thread, read on the frame-handler
* coroutine (Dispatchers.Default). */
@Volatile
var currentLaneAtBottom: Boolean = true
/** App foreground state (M8: clear the current lane's unread when the app
* is focused and the user is at the bottom). Mirrors the platform bridge
* ([isAppForeground]); the bridges push changes via [setForeground]. */
private val _foreground = MutableStateFlow(isAppForeground())
val foreground: StateFlow<Boolean> = _foreground.asStateFlow()
/** Bridge entry point: the platform shell reports a focus change. */
fun setForeground(fg: Boolean) {
_foreground.value = fg
}
/** 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) {
val beingRead = lane == chat.currentLane.value && isAppForeground() && currentLaneAtBottom
if (!beingRead) chat.markUnread(lane)
}
/** M8: the user is now viewing the current lane's newest content — clear
* its unread count. */
fun markCurrentLaneRead() {
chat.markLaneRead(chat.currentLane.value)
}
// Latches the restart pair: set when the "restarting" notice is posted // Latches the restart pair: set when the "restarting" notice is posted
// (on the gateway's status{restarting} frame), consumed by the matching // (on the gateway's status{restarting} frame), consumed by the matching
// "online" notice on the next reconnect. A plain network drop never sets // "online" notice on the next reconnect. A plain network drop never sets
@@ -465,6 +501,15 @@ class IrisController(
scope.launch { scope.launch {
chat.currentLane.debounce(CACHE_SAVE_DEBOUNCE_MS).collect { chatDb.metaPut(META_LAST_LANE, it) } chat.currentLane.debounce(CACHE_SAVE_DEBOUNCE_MS).collect { chatDb.metaPut(META_LAST_LANE, it) }
} }
// M8: when the app returns to the foreground and the user is at the
// bottom of the current lane, its newest content is on screen — clear
// any unread that accumulated while backgrounded. (The UI separately
// clears on scroll-to-bottom while already focused.)
scope.launch {
foreground.collect { fg ->
if (fg && currentLaneAtBottom) markCurrentLaneRead()
}
}
scope.launch { scope.launch {
client.events.collect { frame -> client.events.collect { frame ->
try { try {
@@ -511,6 +556,12 @@ class IrisController(
when (frame.type) { when (frame.type) {
TYPE_MESSAGE_STOP -> { TYPE_MESSAGE_STOP -> {
frame.payloadAs<MessageStopPayload>()?.let { frame.payloadAs<MessageStopPayload>()?.let {
// M8: a finalized streaming reply is new
// content — count it as unread unless the
// user is reading this lane right now.
frame.chatId?.let { cid ->
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId))
}
if (!isPushedReplay(frame)) { if (!isPushedReplay(frame)) {
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText) notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText)
} }
@@ -519,8 +570,14 @@ class IrisController(
TYPE_MESSAGE -> { TYPE_MESSAGE -> {
frame.payloadAs<MessagePayload>()?.let { frame.payloadAs<MessagePayload>()?.let {
if (it.role == ROLE_ASSISTANT && !isPushedReplay(frame)) { if (it.role == ROLE_ASSISTANT) {
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text) // M8: a finalized (non-streaming) reply.
frame.chatId?.let { cid ->
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId))
}
if (!isPushedReplay(frame)) {
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text)
}
} }
} }
} }
@@ -177,6 +177,22 @@ fun ChatScreen(controller: IrisController) {
val currentChannel = channels.firstOrNull { it.chatId == currentChatId } val currentChannel = channels.firstOrNull { it.chatId == currentChatId }
val threads = channels.filter { it.kind == "thread" && it.parentChatId == currentChatId } val threads = channels.filter { it.kind == "thread" && it.parentChatId == currentChatId }
// M8: unread counts. Per-lane from the store; per-channel aggregated
// (flat lane + all threads) for the drawer/rail badges.
val unreadMap by controller.chat.unread.collectAsState()
val channelUnread: Map<String, Int> =
remember(unreadMap, channels) {
val threadsByParent = channels.filter { it.kind == "thread" }.groupBy { it.parentChatId }
channels.filter { it.kind != "thread" }.associate { ch ->
val flat = unreadMap[ch.chatId] ?: 0
val threadTotal =
(threadsByParent[ch.chatId].orEmpty()).sumOf { t ->
unreadMap[controller.chat.laneKey(ch.chatId, t.chatId)] ?: 0
}
ch.chatId to (flat + threadTotal)
}
}
val listState = rememberLazyListState() val listState = rememberLazyListState()
// "At the bottom" when the list can't scroll further forward, or the last // "At the bottom" when the list can't scroll further forward, or the last
// row's bottom edge is within a small tolerance of the viewport bottom // row's bottom edge is within a small tolerance of the viewport bottom
@@ -203,6 +219,16 @@ fun ChatScreen(controller: IrisController) {
}.collect { isAtBottom = it } }.collect { isAtBottom = it }
} }
// M8: publish the scroll position to the controller (it drives the
// "being read" decision for incoming messages) and clear the current
// lane's unread once its newest content is at the bottom of the viewport
// (the "bottom anchor in viewport" condition). Re-fires on lane change so
// opening a lane marks it read (the view lands on the latest message).
LaunchedEffect(isAtBottom, currentLane) {
controller.currentLaneAtBottom = isAtBottom
if (isAtBottom) controller.markCurrentLaneRead()
}
// Scroll so the newest row's BOTTOM edge is visible: first bring the last // Scroll so the newest row's BOTTOM edge is visible: first bring the last
// row into view (top-aligned), then, if it is taller than the viewport, // row into view (top-aligned), then, if it is taller than the viewport,
// align its bottom with the viewport bottom. A plain scrollToItem(last) // align its bottom with the viewport bottom. A plain scrollToItem(last)
@@ -500,8 +526,28 @@ fun ChatScreen(controller: IrisController) {
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (showHamburger) { if (showHamburger) {
HeaderIconButton(onClick = onOpenDrawer) { // M8: red dot when another channel has unread messages. In
Text("☰", fontSize = 18.sp) // single-pane (mobile) the channel rail isn't visible, so
// the drawer's per-channel badges aren't enough — this
// flags "something happened elsewhere." Desktop always
// shows the rail (showHamburger = false), so no dot there.
val otherChannelsUnread =
channelUnread.entries.sumOf { (chId, c) -> if (chId != currentChatId) c else 0 }
Box {
HeaderIconButton(onClick = onOpenDrawer) {
Text("☰", fontSize = 18.sp)
}
if (otherChannelsUnread > 0) {
Box(
modifier =
Modifier
.align(Alignment.TopEnd)
.padding(top = 5.dp, end = 5.dp)
.size(9.dp)
.clip(CircleShape)
.background(IrisColors.statusRed),
)
}
} }
} }
val headerClipboard = LocalClipboardManager.current val headerClipboard = LocalClipboardManager.current
@@ -509,6 +555,28 @@ fun ChatScreen(controller: IrisController) {
headerClipboard.setText(AnnotatedString(id)) headerClipboard.setText(AnnotatedString(id))
toastMessage = "Copied $id" toastMessage = "Copied $id"
} }
// M8: unread indicator for the current lane. Appears when new
// messages arrived while the user was scrolled up (or in
// another lane); a tap glides back to the latest message.
val currentLaneUnread = unreadMap[currentLane] ?: 0
if (currentLaneUnread > 0) {
Spacer(modifier = Modifier.width(6.dp))
Box(
modifier =
Modifier
.clip(RoundedCornerShape(10.dp))
.background(IrisColors.primary)
.clickable { drawerScope.launch { scrollToBottom(animate = true) } }
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text(
"$currentLaneUnread new",
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
StatusBubble(state) { toastMessage = statusToastText(state) } StatusBubble(state) { toastMessage = statusToastText(state) }
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
@@ -1029,6 +1097,7 @@ fun ChatScreen(controller: IrisController) {
ChannelRail( ChannelRail(
channels = nonThreadChannels, channels = nonThreadChannels,
currentChatId = currentChatId, currentChatId = currentChatId,
channelUnread = channelUnread,
selectedIndex = railIndex, selectedIndex = railIndex,
onSelectedIndex = { railIndex = it }, onSelectedIndex = { railIndex = it },
focusRequester = railFocusRequester, focusRequester = railFocusRequester,
@@ -1065,6 +1134,7 @@ fun ChatScreen(controller: IrisController) {
ChannelDrawer( ChannelDrawer(
channels = nonThreadChannels, channels = nonThreadChannels,
currentChatId = currentChatId, currentChatId = currentChatId,
channelUnread = channelUnread,
onOpen = { ch -> onOpen = { ch ->
controller.openChannel(ch.chatId) controller.openChannel(ch.chatId)
drawerScope.launch { drawerState.close() } drawerScope.launch { drawerState.close() }
@@ -1531,6 +1601,31 @@ private fun ChannelBadges(ch: ChannelInfo) {
} }
} }
/**
* M8: unread-count badge for a channel row (drawer / rail). Hidden when the
* channel has no unread messages. Caps at "99+" so a long backlog doesn't
* blow out the row.
*/
@Composable
private fun UnreadBadge(count: Int) {
if (count <= 0) return
Box(
modifier =
Modifier
.padding(start = 6.dp)
.clip(RoundedCornerShape(8.dp))
.background(IrisColors.primary)
.padding(horizontal = 6.dp, vertical = 2.dp),
) {
Text(
if (count > 99) "99+" else count.toString(),
color = Color.White,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
/** /**
* Long-press / right-click context menu for a channel row: rename, favorite * Long-press / right-click context menu for a channel row: rename, favorite
* (state-aware), icon (state-aware), automation (state-aware), delete. The * (state-aware), icon (state-aware), automation (state-aware), delete. The
@@ -1686,6 +1781,7 @@ private fun ChannelIconDialog(
private fun ChannelDrawer( private fun ChannelDrawer(
channels: List<ChannelInfo>, channels: List<ChannelInfo>,
currentChatId: String, currentChatId: String,
channelUnread: Map<String, Int>,
onOpen: (ChannelInfo) -> Unit, onOpen: (ChannelInfo) -> Unit,
onNewChannel: () -> Unit, onNewChannel: () -> Unit,
onRename: (ChannelInfo) -> Unit, onRename: (ChannelInfo) -> Unit,
@@ -1729,6 +1825,7 @@ private fun ChannelDrawer(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
ChannelBadges(ch) ChannelBadges(ch)
UnreadBadge(channelUnread[ch.chatId] ?: 0)
} }
ChannelContextMenu( ChannelContextMenu(
expanded = menuOpen, expanded = menuOpen,
@@ -1763,6 +1860,7 @@ private fun ChannelDrawer(
private fun ChannelRail( private fun ChannelRail(
channels: List<ChannelInfo>, channels: List<ChannelInfo>,
currentChatId: String, currentChatId: String,
channelUnread: Map<String, Int>,
selectedIndex: Int, selectedIndex: Int,
onSelectedIndex: (Int) -> Unit, onSelectedIndex: (Int) -> Unit,
focusRequester: FocusRequester, focusRequester: FocusRequester,
@@ -1858,6 +1956,7 @@ private fun ChannelRail(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
ChannelBadges(ch) ChannelBadges(ch)
UnreadBadge(channelUnread[ch.chatId] ?: 0)
} }
ChannelContextMenu( ChannelContextMenu(
expanded = menuOpen, expanded = menuOpen,
@@ -0,0 +1,70 @@
package iris.data
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* M8: per-lane unread tracking (the data behind the channel-view / header
* unread indicator). The increment/clear *policy* (which incoming message is
* "being read") lives in IrisController; here we cover the store's count
* semantics that the UI and controller build on.
*/
class ChatStoreUnreadTest {
@Test
fun markUnreadIncrementsPerLane() {
val store = ChatStore()
assertEquals(0, store.unreadFor("default"))
store.markUnread("default")
store.markUnread("default")
store.markUnread("chan_7")
assertEquals(2, store.unreadFor("default"))
assertEquals(1, store.unreadFor("chan_7"))
// A lane that never had a message stays at 0.
assertEquals(0, store.unreadFor("chan_9"))
}
@Test
fun markLaneReadClearsOnlyThatLane() {
val store = ChatStore()
store.markUnread("default")
store.markUnread("chan_7")
store.markLaneRead("default")
assertEquals(0, store.unreadFor("default"))
// Other lanes are untouched.
assertEquals(1, store.unreadFor("chan_7"))
}
@Test
fun markLaneReadIsIdempotent() {
val store = ChatStore()
store.markLaneRead("default") // no unread -> no-op
assertEquals(0, store.unreadFor("default"))
store.markUnread("default")
store.markLaneRead("default")
store.markLaneRead("default") // clearing twice is safe
assertEquals(0, store.unreadFor("default"))
}
@Test
fun unreadMapReflectsCounts() {
val store = ChatStore()
assertNull(store.unread.value["default"])
store.markUnread("default")
assertEquals(1, store.unread.value["default"])
store.markLaneRead("default")
// A cleared lane is removed from the map (absent == 0 unread).
assertNull(store.unread.value["default"])
}
@Test
fun clearWipesUnread() {
val store = ChatStore()
store.markUnread("default")
store.markUnread("chan_7")
store.clear()
assertEquals(0, store.unreadFor("default"))
assertEquals(0, store.unreadFor("chan_7"))
assertEquals(emptyMap(), store.unread.value)
}
}
@@ -12,8 +12,16 @@ typealias NotificationListener =
(chatId: String?, title: String, body: String, threadId: String?) -> Unit (chatId: String?, title: String, body: String, threadId: String?) -> Unit
object DesktopBridge { object DesktopBridge {
/** True while the window has focus (set by the shell). A change is
* forwarded to the controller (M8: unread clear on focus). */
@Volatile @Volatile
var foreground: Boolean = true var foreground: Boolean = true
set(value) {
if (field != value) {
field = value
controller?.setForeground(value)
}
}
@Volatile @Volatile
var controller: IrisController? = null var controller: IrisController? = null
@@ -24,7 +32,12 @@ object DesktopBridge {
notificationListeners.add(listener) notificationListeners.add(listener)
} }
fun notifyListeners(chatId: String?, title: String, body: String, threadId: String?) { fun notifyListeners(
chatId: String?,
title: String,
body: String,
threadId: String?,
) {
notificationListeners.toList().forEach { it(chatId, title, body, threadId) } notificationListeners.toList().forEach { it(chatId, title, body, threadId) }
} }
} }