@@ -61,6 +61,7 @@ import androidx.compose.runtime.getValue
6161import androidx.compose.runtime.mutableStateOf
6262import androidx.compose.runtime.remember
6363import androidx.compose.runtime.rememberCoroutineScope
64+import androidx.compose.runtime.rememberUpdatedState
6465import androidx.compose.runtime.saveable.rememberSaveable
6566import androidx.compose.runtime.setValue
6667import androidx.compose.runtime.snapshotFlow
@@ -440,6 +441,15 @@ fun ChatScreen(
440441 val listItems = remember(messages, todayLabel, yesterdayLabel) {
441442 buildMessageListItems(messages, todayLabel, yesterdayLabel)
442443 }
444+ // listItems is a plain (non-snapshot) local, rebuilt each recomposition. The
445+ // derivedStateOf and snapshotFlow lambdas below are created once (keyless
446+ // remember / LaunchedEffect(Unit)); reading `listItems` directly inside them
447+ // would capture the first composition's list — which is empty, since `messages`
448+ // starts empty — freezing it forever. That pinned isPinnedToBottom at true and
449+ // AutoScrollSignal.size at 0, breaking streaming auto-scroll and the
450+ // jump-to-latest FAB. rememberUpdatedState hands those lambdas a stable State
451+ // whose value tracks the latest list.
452+ val currentListItems by rememberUpdatedState(listItems)
443453
444454 val isPinnedToBottom by remember {
445455 derivedStateOf {
@@ -449,8 +459,9 @@ fun ChatScreen(
449459 // includes the working indicator — otherwise the pin check targets
450460 // listItems.lastIndex, the typing row sits just below the viewport,
451461 // and the user never sees the progress indicator even when pinned.
452− val effectiveLast = if (running) listItems.size else listItems.lastIndex
453− lastVisible >= effectiveLast || listItems.isEmpty()
462+ val items = currentListItems
463+ val effectiveLast = if (running) items.size else items.lastIndex
464+ lastVisible >= effectiveLast || items.isEmpty()
454465 }
455466 }
456467
@@ -495,13 +506,14 @@ fun ChatScreen(
495506 // and tool output — not just TextPart — keeps a pinned view following a long
496507 // reasoning/tool block while it streams (previously it stalled at 0).
497508 val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
498− AutoScrollSignal(listItems.size, lastLen, isPinnedToBottom)
509+ AutoScrollSignal(currentListItems.size, lastLen, isPinnedToBottom)
499510 }.collect { signal ->
500511 if (signal.size > 0 && signal.pinned) {
501512 // Scroll to the effective last index, including the trailing
502513 // "__typing" row when a run is active so the working indicator
503514 // is brought into view (not just the last message).
504− val target = if (running) listItems.size else listItems.lastIndex
515+ val items = currentListItems
516+ val target = if (running) items.size else items.lastIndex
505517 listState.scrollToItem(target)
506518 }
507519 }
@@ -1056,9 +1068,13 @@ private sealed interface MessageListItem {
10561068 override val contentType: Any get() = message.info::class
10571069 }
10581070
1059− data class Separator(val label: String) : MessageListItem {
1060− // Stable key per label so a recomposition doesn't re-create the item.
1061− override val key: Any get() = "sep_$label"
1071+ data class Separator(val label: String, val ordinal: Int) : MessageListItem {
1072+ // Key on the day-occurrence ordinal, not the label: two non-contiguous groups
1073+ // can share a label (e.g. an untimestamped message — empty label — between two
1074+ // same-day messages, or two untimestamped groups), and a label-only key would
1075+ // then collide, which makes LazyColumn throw on duplicate keys. The ordinal is
1076+ // stable for a given message ordering so slots aren't needlessly recreated.
1077+ override val key: Any get() = "sep_${ordinal}_$label"
10621078 // Separators share a contentType so the LazyColumn can recycle their slots.
10631079 override val contentType: Any get() = "separator"
10641080 }
@@ -1085,11 +1101,12 @@ private fun buildMessageListItems(
10851101 val dateFmt = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)
10861102 val result = ArrayList<MessageListItem>(messages.size + 4)
10871103 var lastDayKey: String? = null
1104+ var sepOrdinal = 0
10881105 for (message in messages) {
10891106 val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed
10901107 val dayKey = ts?.let { dayKey(it) } ?: ""
10911108 if (dayKey != lastDayKey) {
1092− result.add(MessageListItem.Separator(dayLabel(dayKey, ts ?: 0L, today, todayLabel, yesterdayLabel, dateFmt)))
1109+ result.add(MessageListItem.Separator(dayLabel(dayKey, ts ?: 0L, today, todayLabel, yesterdayLabel, dateFmt), sepOrdinal++))
10931110 lastDayKey = dayKey
10941111 }
10951112 result.add(MessageListItem.Message(message))