@@ -61,6 +61,7 @@ import androidx.compose.runtime.getValue
61import androidx.compose.runtime.mutableStateOf
61import androidx.compose.runtime.mutableStateOf
62import androidx.compose.runtime.remember
62import androidx.compose.runtime.remember
63import androidx.compose.runtime.rememberCoroutineScope
63import androidx.compose.runtime.rememberCoroutineScope
64+import androidx.compose.runtime.rememberUpdatedState
64import androidx.compose.runtime.saveable.rememberSaveable
65import androidx.compose.runtime.saveable.rememberSaveable
65import androidx.compose.runtime.setValue
66import androidx.compose.runtime.setValue
66import androidx.compose.runtime.snapshotFlow
67import androidx.compose.runtime.snapshotFlow
@@ -440,6 +441,15 @@ fun ChatScreen(
440 val listItems = remember(messages, todayLabel, yesterdayLabel) {
441 val listItems = remember(messages, todayLabel, yesterdayLabel) {
441 buildMessageListItems(messages, todayLabel, yesterdayLabel)
442 buildMessageListItems(messages, todayLabel, yesterdayLabel)
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)
444 val isPinnedToBottom by remember {
454 val isPinnedToBottom by remember {
445 derivedStateOf {
455 derivedStateOf {
@@ -449,8 +459,9 @@ fun ChatScreen(
449 // includes the working indicator — otherwise the pin check targets
459 // includes the working indicator — otherwise the pin check targets
450 // listItems.lastIndex, the typing row sits just below the viewport,
460 // listItems.lastIndex, the typing row sits just below the viewport,
451 // and the user never sees the progress indicator even when pinned.
461 // and the user never sees the progress indicator even when pinned.
462+ val items = currentListItems
452− val effectiveLast = if (running) listItems.size else listItems.lastIndex
463+ val effectiveLast = if (running) items.size else items.lastIndex
453− lastVisible >= effectiveLast || listItems.isEmpty()
464+ lastVisible >= effectiveLast || items.isEmpty()
@@ -495,13 +506,14 @@ fun ChatScreen(
495 // and tool output — not just TextPart — keeps a pinned view following a long
506 // and tool output — not just TextPart — keeps a pinned view following a long
496 // reasoning/tool block while it streams (previously it stalled at 0).
507 // reasoning/tool block while it streams (previously it stalled at 0).
497 val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
508 val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
498− AutoScrollSignal(listItems.size, lastLen, isPinnedToBottom)
509+ AutoScrollSignal(currentListItems.size, lastLen, isPinnedToBottom)
499 }.collect { signal ->
510 }.collect { signal ->
500 if (signal.size > 0 && signal.pinned) {
511 if (signal.size > 0 && signal.pinned) {
501 // Scroll to the effective last index, including the trailing
512 // Scroll to the effective last index, including the trailing
502 // "__typing" row when a run is active so the working indicator
513 // "__typing" row when a run is active so the working indicator
503 // is brought into view (not just the last message).
514 // is brought into view (not just the last message).
515+ val items = currentListItems
504− val target = if (running) listItems.size else listItems.lastIndex
516+ val target = if (running) items.size else items.lastIndex
505 listState.scrollToItem(target)
517 listState.scrollToItem(target)
@@ -1056,9 +1068,13 @@ private sealed interface MessageListItem {
1056 override val contentType: Any get() = message.info::class
1068 override val contentType: Any get() = message.info::class
1059− data class Separator(val label: String) : MessageListItem {
1071+ data class Separator(val label: String, val ordinal: Int) : MessageListItem {
1060− // Stable key per label so a recomposition doesn't re-create the item.
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.
1061− override val key: Any get() = "sep_$label"
1077+ override val key: Any get() = "sep_${ordinal}_$label"
1062 // Separators share a contentType so the LazyColumn can recycle their slots.
1078 // Separators share a contentType so the LazyColumn can recycle their slots.
1063 override val contentType: Any get() = "separator"
1079 override val contentType: Any get() = "separator"
@@ -1085,11 +1101,12 @@ private fun buildMessageListItems(
1085 val dateFmt = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)
1101 val dateFmt = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)
1086 val result = ArrayList<MessageListItem>(messages.size + 4)
1102 val result = ArrayList<MessageListItem>(messages.size + 4)
1087 var lastDayKey: String? = null
1103 var lastDayKey: String? = null
1088 for (message in messages) {
1105 for (message in messages) {
1089 val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed
1106 val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed
1090 val dayKey = ts?.let { dayKey(it) } ?: ""
1107 val dayKey = ts?.let { dayKey(it) } ?: ""
1091 if (dayKey != lastDayKey) {
1108 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++))
1093 lastDayKey = dayKey
1110 lastDayKey = dayKey
1095 result.add(MessageListItem.Message(message))
1112 result.add(MessageListItem.Message(message))