@@ -26,16 +26,17 @@ import androidx.compose.material.icons.Icons
2626import androidx.compose.material.icons.automirrored.filled.ArrowBack
2727import androidx.compose.material.icons.automirrored.filled.Send
2828import androidx.compose.material.icons.filled.AutoAwesome
29+import androidx.compose.material.icons.filled.Delete
30+import androidx.compose.material.icons.filled.Edit
2931import androidx.compose.material.icons.filled.KeyboardArrowDown
32+import androidx.compose.material.icons.filled.MoreVert
3033import androidx.compose.material.icons.filled.Refresh
31−import androidx.compose.material.icons.filled.Share
32−import androidx.compose.material.icons.filled.SmartToy
3334import androidx.compose.material.icons.filled.Stop
34−import androidx.compose.material.icons.filled.Terminal
35−import androidx.compose.material.icons.filled.Tune
3635import androidx.compose.material3.AlertDialog
3736import androidx.compose.material3.Button
3837import androidx.compose.material3.CircularProgressIndicator
38+import androidx.compose.material3.DropdownMenu
39+import androidx.compose.material3.DropdownMenuItem
3940import androidx.compose.material3.ExperimentalMaterial3Api
4041import androidx.compose.material3.ExtendedFloatingActionButton
4142import androidx.compose.material3.FloatingActionButton
@@ -93,6 +94,7 @@ import kotlinx.coroutines.Dispatchers
9394import kotlinx.coroutines.launch
9495import kotlinx.coroutines.withContext
9596import soy.iko.opencode.data.model.Part
97+import soy.iko.opencode.data.model.MessageWithParts
9698import soy.iko.opencode.data.model.ReasoningPart
9799import soy.iko.opencode.data.model.TextPart
98100import soy.iko.opencode.data.model.ToolCompleted
@@ -148,7 +150,6 @@ fun ChatScreen(
148150 val haptics = LocalHapticFeedback.current
149151 val scope = rememberCoroutineScope()
150152 val shareContext = LocalContext.current
151− val shareLabel = stringResource(R.string.share)
152153 val defaultShareSubject = stringResource(R.string.share_subject)
153154 val sessionLabel = stringResource(R.string.session)
154155 val defaultModelLabel = stringResource(R.string.default_model)
@@ -177,10 +178,13 @@ fun ChatScreen(
177178 // on a zombie screen showing a conversation that no longer exists. Surface a
178179 // toast (not a snackbar) because the screen is about to be popped — a snackbar
179180 // would be torn down before the user could read it, while a toast survives.
181+ // Covers both SSE-driven deletion (deleted on another device) and a user-
182+ // initiated delete from the chat overflow menu (deleteSession in ChatViewModel
183+ // sets the same flag on success).
180184 val deletedContext = LocalContext.current
181185 LaunchedEffect(sessionDeleted) {
182186 if (sessionDeleted) {
183− showToast(deletedContext, deletedContext.getString(R.string.session_deleted_msg))
187+ showToast(deletedContext, deletedContext.getString(R.string.session_deleted_chat))
184188 onBack()
185189 }
186190 }
@@ -193,6 +197,9 @@ fun ChatScreen(
193197 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
194198 var showExitConfirm by rememberSaveable { mutableStateOf(false) }
195199 var showStopConfirm by rememberSaveable { mutableStateOf(false) }
200+ var showOverflowMenu by rememberSaveable { mutableStateOf(false) }
201+ var showRenameDialog by rememberSaveable { mutableStateOf(false) }
202+ var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
196203
197204 // Keep the screen awake and hold a foreground priority while the agent is working,
198205 // so backgrounding mid-run doesn't let Doze choke the SSE stream.
@@ -241,12 +248,20 @@ fun ChatScreen(
241248 .semantics(mergeDescendants = true) { contentDescription = changeLabel },
242249 verticalAlignment = Alignment.CenterVertically,
243250 ) {
244− Column {
245− Text(sessionTitle ?: sessionLabel)
251+ Column(modifier = Modifier.weight(1f, fill = false)) {
252+ // maxLines=1 + Ellipsis so a long session title can't wrap and
253+ // push the top bar to multiple lines, shifting the whole layout.
254+ Text(
255+ sessionTitle ?: sessionLabel,
256+ maxLines = 1,
257+ overflow = TextOverflow.Ellipsis,
258+ )
246259 Text(
247260 subtitle,
248261 style = MaterialTheme.typography.labelSmall,
249262 color = MaterialTheme.colorScheme.onSurfaceVariant,
263+ maxLines = 1,
264+ overflow = TextOverflow.Ellipsis,
250265 )
251266 }
252267 // Visual affordance that the title opens the model picker;
@@ -301,60 +316,88 @@ fun ChatScreen(
301316 Icon(Icons.Filled.Refresh, contentDescription = refreshLabel)
302317 }
303318 }
304− IconButton(
305− onClick = {
306− scope.launch {
307− val md = withContext(Dispatchers.Default) {
308− buildConversationMarkdown(vm.messages.value, sessionTitle)
309− }
310− val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
311− type = "text/markdown"
312− putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
313− putExtra(android.content.Intent.EXTRA_TEXT, md)
314− }
315− runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
316− .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
317− }
318− },
319− enabled = hasMessages,
320− ) {
321− Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share_conversation))
322− }
323− // Show a spinner while a catalog is initially loading (empty list +
324− // loading flag) so the icon doesn't look permanently disabled. Once
325− // loaded, the icon is always enabled — tapping a picker with no items
326− // shows the "no models/agents/commands" or error-with-retry state.
327− if (commandsLoading && commands.isEmpty()) {
328− val loadingLabel = stringResource(R.string.loading)
329− CircularProgressIndicator(
330− Modifier.size(18.dp).semantics { contentDescription = loadingLabel },
331− strokeWidth = 2.dp,
332− )
333− } else {
334− IconButton(onClick = { showCommandPicker = true }) {
335− Icon(Icons.Filled.Terminal, contentDescription = stringResource(R.string.commands))
319+ // Overflow menu consolidates the less-frequent actions (share,
320+ // commands, agent, model, rename, delete) so the top bar stays
321+ // scannable on narrow phones — previously 5 icons crowded the bar
322+ // alongside the back button and tappable title. Catalog-loading
323+ // spinners stay inline (next to the overflow) so a load state is
324+ // visible without opening the menu.
325+ val moreLabel = stringResource(R.string.more)
326+ val loadingLabel = stringResource(R.string.loading)
327+ val shareLabel = stringResource(R.string.share_conversation)
328+ val commandsLabel = stringResource(R.string.commands)
329+ val agentLabel = stringResource(R.string.choose_agent)
330+ val modelLabel = stringResource(R.string.choose_model)
331+ val renameLabel = stringResource(R.string.rename_session_chat)
332+ val deleteLabel = stringResource(R.string.delete_session_chat)
333+ Box {
334+ IconButton(onClick = { showOverflowMenu = true }) {
335+ Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
336336 }
337− }
338− if (agentsLoading && agents.isEmpty()) {
339− val loadingLabel = stringResource(R.string.loading)
340− CircularProgressIndicator(
341− Modifier.size(18.dp).semantics { contentDescription = loadingLabel },
342− strokeWidth = 2.dp,
343− )
344− } else {
345− IconButton(onClick = { showAgentPicker = true }) {
346− Icon(Icons.Filled.SmartToy, contentDescription = stringResource(R.string.choose_agent))
337+ DropdownMenu(
338+ expanded = showOverflowMenu,
339+ onDismissRequest = { showOverflowMenu = false },
340+ ) {
341+ DropdownMenuItem(
342+ text = { Text(shareLabel) },
343+ enabled = hasMessages,
344+ onClick = {
345+ showOverflowMenu = false
346+ scope.launch {
347+ val md = withContext(Dispatchers.Default) {
348+ buildConversationMarkdown(vm.messages.value, sessionTitle)
349+ }
350+ val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
351+ type = "text/markdown"
352+ putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
353+ putExtra(android.content.Intent.EXTRA_TEXT, md)
354+ }
355+ runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
356+ .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
357+ }
358+ },
359+ )
360+ DropdownMenuItem(
361+ text = { Text(commandsLabel) },
362+ enabled = !commandsLoading || commands.isNotEmpty(),
363+ onClick = { showOverflowMenu = false; showCommandPicker = true },
364+ )
365+ DropdownMenuItem(
366+ text = { Text(agentLabel) },
367+ enabled = !agentsLoading || agents.isNotEmpty(),
368+ onClick = { showOverflowMenu = false; showAgentPicker = true },
369+ )
370+ DropdownMenuItem(
371+ text = { Text(modelLabel) },
372+ enabled = !modelsLoading || models.isNotEmpty(),
373+ onClick = { showOverflowMenu = false; showModelPicker = true },
374+ )
375+ androidx.compose.material3.HorizontalDivider()
376+ DropdownMenuItem(
377+ text = { Text(renameLabel) },
378+ onClick = { showOverflowMenu = false; showRenameDialog = true },
379+ )
380+ DropdownMenuItem(
381+ text = { Text(deleteLabel, color = MaterialTheme.colorScheme.error) },
382+ onClick = { showOverflowMenu = false; showDeleteDialog = true },
383+ )
347384 }
348− }
349− if (modelsLoading && models.isEmpty()) {
350− val loadingLabel = stringResource(R.string.loading)
351− CircularProgressIndicator(
352− Modifier.size(18.dp).semantics { contentDescription = loadingLabel },
353− strokeWidth = 2.dp,
354− )
355− } else {
356− IconButton(onClick = { showModelPicker = true }) {
357− Icon(Icons.Filled.Tune, contentDescription = stringResource(R.string.choose_model))
385+ // While a catalog is initially loading (empty list + loading flag),
386+ // show a tiny inline spinner beside the overflow so the user can see
387+ // a load is in progress without opening the menu. Once loaded the
388+ // spinner disappears; the menu items are always enabled (tapping a
389+ // picker with no items shows the "no models/agents/commands" state).
390+ val catalogLoading = (commandsLoading && commands.isEmpty()) ||
391+ (agentsLoading && agents.isEmpty()) ||
392+ (modelsLoading && models.isEmpty())
393+ if (catalogLoading) {
394+ CircularProgressIndicator(
395+ Modifier
396+ .align(Alignment.TopEnd)
397+ .size(10.dp)
398+ .semantics { contentDescription = loadingLabel },
399+ strokeWidth = 1.dp,
400+ )
358401 }
359402 }
360403 },
@@ -386,17 +429,28 @@ fun ChatScreen(
386429 val listState = rememberLazyListState()
387430 val contentScope = rememberCoroutineScope()
388431 val timeTick = rememberRelativeTimeTick()
432+ // Group messages by day and interleave date separators so a long conversation
433+ // has visual "Today"/"Yesterday"/date breaks. Computed once per messages
434+ // emission (memoized) so a scroll-induced recomposition doesn't re-scan. Lives
435+ // outside the PullToRefreshBox so the auto-scroll effects below can reference
436+ // the same list the LazyColumn renders (separators shift the indices vs. the
437+ // raw messages list, so scroll targets must be in listItems space).
438+ val todayLabel = stringResource(R.string.today)
439+ val yesterdayLabel = stringResource(R.string.yesterday)
440+ val listItems = remember(messages, todayLabel, yesterdayLabel) {
441+ buildMessageListItems(messages, todayLabel, yesterdayLabel)
442+ }
389443
390444 val isPinnedToBottom by remember {
391445 derivedStateOf {
392446 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
393447 // When a run is active, the LazyColumn has one trailing "__typing"
394− // item after the last message. Account for it so "pinned to bottom"
448+ // item after the last list item. Account for it so "pinned to bottom"
395449 // includes the working indicator — otherwise the pin check targets
396− // messages.lastIndex, the typing row sits just below the viewport,
450+ // listItems.lastIndex, the typing row sits just below the viewport,
397451 // and the user never sees the progress indicator even when pinned.
398− val effectiveLast = if (running) messages.size else messages.lastIndex
399− lastVisible >= effectiveLast || messages.isEmpty()
452+ val effectiveLast = if (running) listItems.size else listItems.lastIndex
453+ lastVisible >= effectiveLast || listItems.isEmpty()
400454 }
401455 }
402456
@@ -405,28 +459,28 @@ fun ChatScreen(
405459 // (rotation) — rememberLazyListState restores the user's scroll offset, and an
406460 // unconditional scrollToItem here would clobber it, snapping them back to the bottom.
407461 var didInitialScroll by rememberSaveable { mutableStateOf(false) }
408− LaunchedEffect(messages.isNotEmpty()) {
409− if (messages.isNotEmpty() && !didInitialScroll) {
462+ LaunchedEffect(listItems.isNotEmpty()) {
463+ if (listItems.isNotEmpty() && !didInitialScroll) {
410464 // When a run is active the LazyColumn has a trailing "__typing" row at
411− // messages.size; target it (not messages.lastIndex) so deep-linking into a
465+ // listItems.size; target it (not listItems.lastIndex) so deep-linking into a
412466 // running session lands truly pinned to bottom and streaming auto-scroll engages.
413− listState.scrollToItem(if (running) messages.size else messages.lastIndex)
467+ listState.scrollToItem(if (running) listItems.size else listItems.lastIndex)
414468 didInitialScroll = true
415469 }
416470 }
417471
418472 // When a run starts, the LazyColumn gains a trailing "__typing" row, so
419− // effectiveLast jumps from messages.lastIndex to messages.size and the
473+ // effectiveLast jumps from listItems.lastIndex to listItems.size and the
420474 // isPinnedToBottom check flips to false the instant you send — freezing
421− // auto-scroll. If the user was pinned to the last message just before the run
475+ // auto-scroll. If the user was pinned to the last item just before the run
422476 // began, bring the typing row into view so the pin (and streaming follow) is
423477 // preserved. Guard on the raw last-visible index (isPinnedToBottom has already
424478 // recomputed to false by now) so we don't scroll when the user had scrolled up.
425479 LaunchedEffect(running) {
426− if (running && messages.isNotEmpty()) {
480+ if (running && listItems.isNotEmpty()) {
427481 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
428− if (lastVisible >= messages.lastIndex) {
429− runCatchingCancellable { listState.animateScrollToItem(messages.size) }
482+ if (lastVisible >= listItems.lastIndex) {
483+ runCatchingCancellable { listState.animateScrollToItem(listItems.size) }
430484 }
431485 }
432486 }
@@ -441,13 +495,13 @@ fun ChatScreen(
441495 // and tool output — not just TextPart — keeps a pinned view following a long
442496 // reasoning/tool block while it streams (previously it stalled at 0).
443497 val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
444− AutoScrollSignal(messages.size, lastLen, isPinnedToBottom)
498+ AutoScrollSignal(listItems.size, lastLen, isPinnedToBottom)
445499 }.collect { signal ->
446500 if (signal.size > 0 && signal.pinned) {
447501 // Scroll to the effective last index, including the trailing
448502 // "__typing" row when a run is active so the working indicator
449503 // is brought into view (not just the last message).
450− val target = if (running) messages.size else messages.lastIndex
504+ val target = if (running) listItems.size else listItems.lastIndex
451505 listState.scrollToItem(target)
452506 }
453507 }
@@ -559,19 +613,29 @@ fun ChatScreen(
559613 contentPadding = PaddingValues(16.dp),
560614 verticalArrangement = Arrangement.spacedBy(16.dp),
561615 ) {
562− items(messages, key = { it.info.id }, contentType = { it.info::class }) { message ->
563− // Only the last (streaming) message needs isRunning — it drives the
564− // reasoning-block spinner. Passing the live flag to every bubble
565− // makes all visible messages recompose whenever a run starts or stops.
566− val modelLabel = (message.info as? soy.iko.opencode.data.model.AssistantMessage)
567− ?.let { resolveModelLabel(it, models) }
568− MessageBubble(
569− message,
570− isRunning = running && message.info.id == lastMessageId,
571− imageContext = imageContext,
572− modelLabel = modelLabel,
573− onOpenFile = onOpenFile,
574− )
616+ items(
617+ items = listItems,
618+ key = { it.key },
619+ contentType = { it.contentType },
620+ ) { item ->
621+ when (item) {
622+ is MessageListItem.Separator -> DateSeparator(item.label)
623+ is MessageListItem.Message -> {
624+ val message = item.message
625+ // Only the last (streaming) message needs isRunning — it drives the
626+ // reasoning-block spinner. Passing the live flag to every bubble
627+ // makes all visible messages recompose whenever a run starts or stops.
628+ val modelLabel = (message.info as? soy.iko.opencode.data.model.AssistantMessage)
629+ ?.let { resolveModelLabel(it, models) }
630+ MessageBubble(
631+ message,
632+ isRunning = running && message.info.id == lastMessageId,
633+ imageContext = imageContext,
634+ modelLabel = modelLabel,
635+ onOpenFile = onOpenFile,
636+ )
637+ }
638+ }
575639 }
576640 if (running) {
577641 item(key = "__typing") {
@@ -591,15 +655,15 @@ fun ChatScreen(
591655 }
592656 // Jump-to-latest affordance when the user has scrolled away during a stream.
593657 AnimatedVisibility(
594− visible = !isPinnedToBottom && messages.isNotEmpty(),
658+ visible = !isPinnedToBottom && listItems.isNotEmpty(),
595659 modifier = Modifier.align(Alignment.BottomEnd),
596660 ) {
597661 ExtendedFloatingActionButton(
598662 onClick = {
599− if (messages.isNotEmpty()) {
663+ if (listItems.isNotEmpty()) {
600664 // Scroll to the effective last index, including the
601665 // trailing "__typing" row when a run is active.
602− val target = if (running) messages.size else messages.lastIndex
666+ val target = if (running) listItems.size else listItems.lastIndex
603667 contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
604668 }
605669 },
@@ -651,6 +715,35 @@ fun ChatScreen(
651715 )
652716 }
653717
718+ if (showRenameDialog) {
719+ RenameSessionChatDialog(
720+ initialTitle = sessionTitle ?: "",
721+ onDismiss = { showRenameDialog = false },
722+ onConfirm = { newName ->
723+ showRenameDialog = false
724+ vm.renameSession(newName)
725+ },
726+ )
727+ }
728+
729+ if (showDeleteDialog) {
730+ AlertDialog(
731+ onDismissRequest = { showDeleteDialog = false },
732+ title = { Text(stringResource(R.string.delete_session_chat_title)) },
733+ text = { Text(stringResource(R.string.delete_session_chat_text, sessionTitle ?: sessionLabel)) },
734+ confirmButton = {
735+ TextButton(onClick = {
736+ showDeleteDialog = false
737+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
738+ vm.deleteSession()
739+ }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
740+ },
741+ dismissButton = {
742+ TextButton(onClick = { showDeleteDialog = false }) { Text(stringResource(R.string.cancel)) }
743+ },
744+ )
745+ }
746+
654747 if (showModelPicker) {
655748 ModelPickerSheet(
656749 options = models,
@@ -911,3 +1004,151 @@ private fun streamingContentLength(part: Part?): Int = when (part) {
9111004 }
9121005 else -> 0
9131006}
1007+
1008+/** Rename dialog for the currently-open session. Mirrors the SessionListScreen rename
1009+ * dialog but lives in the chat screen so the user can rename without backing out to
1010+ * the list. Pre-filled with the current title; the cap matches the list dialog. */
1011+@Composable
1012+private fun RenameSessionChatDialog(
1013+ initialTitle: String,
1014+ onDismiss: () -> Unit,
1015+ onConfirm: (String) -> Unit,
1016+) {
1017+ var title by rememberSaveable(initialTitle) { mutableStateOf(initialTitle) }
1018+ AlertDialog(
1019+ onDismissRequest = onDismiss,
1020+ title = { Text(stringResource(R.string.rename_session_chat)) },
1021+ text = {
1022+ OutlinedTextField(
1023+ value = title,
1024+ onValueChange = { v -> title = v.take(NetworkConfig.maxSessionTitleChars) },
1025+ modifier = Modifier.fillMaxWidth(),
1026+ placeholder = { Text(stringResource(R.string.session_title_hint)) },
1027+ label = { Text(stringResource(R.string.rename_session_chat)) },
1028+ singleLine = true,
1029+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
1030+ keyboardActions = KeyboardActions(onDone = { if (title.isNotBlank()) onConfirm(title.trim()) }),
1031+ )
1032+ },
1033+ confirmButton = {
1034+ TextButton(onClick = { if (title.isNotBlank()) onConfirm(title.trim()) }, enabled = title.isNotBlank()) {
1035+ Text(stringResource(R.string.save))
1036+ }
1037+ },
1038+ dismissButton = {
1039+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
1040+ },
1041+ )
1042+}
1043+
1044+/**
1045+ * A single item in the chat message list — either a [Message] bubble or a day
1046+ * [Separator] inserted before the first message of a new day. The separator shifts
1047+ * the LazyColumn indices vs. the raw messages list, so scroll-target math must use
1048+ * the [List] of these items (not `messages.size`/`lastIndex`).
1049+ */
1050+private sealed interface MessageListItem {
1051+ val key: Any
1052+ val contentType: Any
1053+
1054+ data class Message(val message: MessageWithParts) : MessageListItem {
1055+ override val key: Any get() = message.info.id
1056+ override val contentType: Any get() = message.info::class
1057+ }
1058+
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"
1062+ // Separators share a contentType so the LazyColumn can recycle their slots.
1063+ override val contentType: Any get() = "separator"
1064+ }
1065+}
1066+
1067+/**
1068+ * Build the interleaved list of [MessageListItem]s for the message list, inserting a
1069+ * [MessageListItem.Separator] before the first message of each new calendar day. Uses
1070+ * the message's `time.created` (falling back to `updated` then `completed`) to bucket.
1071+ * Messages with no timestamp are grouped under an empty-label separator only if they
1072+ * start the list, so a server that omits timestamps doesn't suppress the first divider.
1073+ *
1074+ * [todayLabel]/[yesterdayLabel] are resolved by the caller (a @Composable can't call
1075+ * stringResource from inside this plain function) and used for the "Today"/"Yesterday"
1076+ * labels; older days fall back to a locale-stable medium-date format.
1077+ */
1078+private fun buildMessageListItems(
1079+ messages: List<MessageWithParts>,
1080+ todayLabel: String,
1081+ yesterdayLabel: String,
1082+): List<MessageListItem> {
1083+ if (messages.isEmpty()) return emptyList()
1084+ val today = java.time.LocalDate.now(java.time.ZoneId.systemDefault())
1085+ val dateFmt = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)
1086+ val result = ArrayList<MessageListItem>(messages.size + 4)
1087+ var lastDayKey: String? = null
1088+ for (message in messages) {
1089+ val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed
1090+ val dayKey = ts?.let { dayKey(it) } ?: ""
1091+ if (dayKey != lastDayKey) {
1092+ result.add(MessageListItem.Separator(dayLabel(dayKey, ts ?: 0L, today, todayLabel, yesterdayLabel, dateFmt)))
1093+ lastDayKey = dayKey
1094+ }
1095+ result.add(MessageListItem.Message(message))
1096+ }
1097+ return result
1098+}
1099+
1100+/** A stable bucket key for a timestamp's calendar day (epoch-days as a string). */
1101+private fun dayKey(epochMillis: Long): String {
1102+ val instant = java.time.Instant.ofEpochMilli(epochMillis)
1103+ val date = instant.atZone(java.time.ZoneId.systemDefault()).toLocalDate()
1104+ return date.toEpochDay().toString()
1105+}
1106+
1107+/**
1108+ * Resolve a day-key back to a human-readable label. "Today" / "Yesterday" for the
1109+ * recent days, otherwise a locale-stable medium-date format. Returns an empty string
1110+ * when [dayKey] is empty (no timestamp) so no separator is rendered for un-timestamped
1111+ * messages.
1112+ */
1113+private fun dayLabel(
1114+ dayKey: String,
1115+ ts: Long,
1116+ today: java.time.LocalDate,
1117+ todayLabel: String,
1118+ yesterdayLabel: String,
1119+ dateFmt: java.text.DateFormat,
1120+): String {
1121+ if (dayKey.isEmpty()) return ""
1122+ val epochDay = dayKey.toLongOrNull() ?: return ""
1123+ val date = java.time.LocalDate.ofEpochDay(epochDay)
1124+ return when {
1125+ date == today -> todayLabel
1126+ date == today.minusDays(1) -> yesterdayLabel
1127+ else -> dateFmt.format(java.util.Date(ts))
1128+ }
1129+}
1130+
1131+/**
1132+ * A centered day divider in the message list. Renders nothing for an empty label
1133+ * (the no-timestamp case) so un-timestamped messages don't get a stray blank divider.
1134+ */
1135+@Composable
1136+private fun DateSeparator(label: String) {
1137+ if (label.isBlank()) return
1138+ Row(
1139+ modifier = Modifier.fillMaxWidth(),
1140+ horizontalArrangement = Arrangement.Center,
1141+ ) {
1142+ Surface(
1143+ color = MaterialTheme.colorScheme.surfaceVariant,
1144+ shape = MaterialTheme.shapes.small,
1145+ ) {
1146+ Text(
1147+ label,
1148+ style = MaterialTheme.typography.labelSmall,
1149+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1150+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
1151+ )
1152+ }
1153+ }
1154+}