feat(app): unread message indicator (channel view, header, hamburger)
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:
1 parent
9c50f2dbc1
commit
6458c3183c
6 files changed
+280
-8
No files matched your search
@@ -149,6 +149,13 @@ class ChatStore {
|
||||
private val _todos = MutableStateFlow<Map<String, List<TodoItem>>>(emptyMap())
|
||||
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
|
||||
|
||||
/** When false, `message.start`/`message.update` frames are ignored and each
|
||||
@@ -694,6 +701,24 @@ class ChatStore {
|
||||
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). */
|
||||
fun markRead(messageId: String) {
|
||||
val map = _lanes.value.toMutableMap()
|
||||
@@ -904,5 +929,6 @@ class ChatStore {
|
||||
|
||||
fun clear() {
|
||||
_lanes.value = emptyMap()
|
||||
_unread.value = emptyMap()
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,42 @@ class IrisController(
|
||||
private val _gatewayStatus = MutableStateFlow<String?>(null)
|
||||
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
|
||||
// (on the gateway's status{restarting} frame), consumed by the matching
|
||||
// "online" notice on the next reconnect. A plain network drop never sets
|
||||
@@ -465,6 +501,15 @@ class IrisController(
|
||||
scope.launch {
|
||||
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 {
|
||||
client.events.collect { frame ->
|
||||
try {
|
||||
@@ -511,6 +556,12 @@ class IrisController(
|
||||
when (frame.type) {
|
||||
TYPE_MESSAGE_STOP -> {
|
||||
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)) {
|
||||
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText)
|
||||
}
|
||||
@@ -519,8 +570,14 @@ class IrisController(
|
||||
|
||||
TYPE_MESSAGE -> {
|
||||
frame.payloadAs<MessagePayload>()?.let {
|
||||
if (it.role == ROLE_ASSISTANT && !isPushedReplay(frame)) {
|
||||
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text)
|
||||
if (it.role == ROLE_ASSISTANT) {
|
||||
// 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 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()
|
||||
// "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
|
||||
@@ -203,6 +219,16 @@ fun ChatScreen(controller: IrisController) {
|
||||
}.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
|
||||
// row into view (top-aligned), then, if it is taller than the viewport,
|
||||
// align its bottom with the viewport bottom. A plain scrollToItem(last)
|
||||
@@ -500,8 +526,28 @@ fun ChatScreen(controller: IrisController) {
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (showHamburger) {
|
||||
HeaderIconButton(onClick = onOpenDrawer) {
|
||||
Text("☰", fontSize = 18.sp)
|
||||
// M8: red dot when another channel has unread messages. In
|
||||
// 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
|
||||
@@ -509,6 +555,28 @@ fun ChatScreen(controller: IrisController) {
|
||||
headerClipboard.setText(AnnotatedString(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))
|
||||
StatusBubble(state) { toastMessage = statusToastText(state) }
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
@@ -1029,6 +1097,7 @@ fun ChatScreen(controller: IrisController) {
|
||||
ChannelRail(
|
||||
channels = nonThreadChannels,
|
||||
currentChatId = currentChatId,
|
||||
channelUnread = channelUnread,
|
||||
selectedIndex = railIndex,
|
||||
onSelectedIndex = { railIndex = it },
|
||||
focusRequester = railFocusRequester,
|
||||
@@ -1065,6 +1134,7 @@ fun ChatScreen(controller: IrisController) {
|
||||
ChannelDrawer(
|
||||
channels = nonThreadChannels,
|
||||
currentChatId = currentChatId,
|
||||
channelUnread = channelUnread,
|
||||
onOpen = { ch ->
|
||||
controller.openChannel(ch.chatId)
|
||||
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
|
||||
* (state-aware), icon (state-aware), automation (state-aware), delete. The
|
||||
@@ -1686,6 +1781,7 @@ private fun ChannelIconDialog(
|
||||
private fun ChannelDrawer(
|
||||
channels: List<ChannelInfo>,
|
||||
currentChatId: String,
|
||||
channelUnread: Map<String, Int>,
|
||||
onOpen: (ChannelInfo) -> Unit,
|
||||
onNewChannel: () -> Unit,
|
||||
onRename: (ChannelInfo) -> Unit,
|
||||
@@ -1729,6 +1825,7 @@ private fun ChannelDrawer(
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
ChannelBadges(ch)
|
||||
UnreadBadge(channelUnread[ch.chatId] ?: 0)
|
||||
}
|
||||
ChannelContextMenu(
|
||||
expanded = menuOpen,
|
||||
@@ -1763,6 +1860,7 @@ private fun ChannelDrawer(
|
||||
private fun ChannelRail(
|
||||
channels: List<ChannelInfo>,
|
||||
currentChatId: String,
|
||||
channelUnread: Map<String, Int>,
|
||||
selectedIndex: Int,
|
||||
onSelectedIndex: (Int) -> Unit,
|
||||
focusRequester: FocusRequester,
|
||||
@@ -1858,6 +1956,7 @@ private fun ChannelRail(
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
ChannelBadges(ch)
|
||||
UnreadBadge(channelUnread[ch.chatId] ?: 0)
|
||||
}
|
||||
ChannelContextMenu(
|
||||
expanded = menuOpen,
|
||||
|
||||
Reference in new issue
Block a user