@@ -26,16 +26,17 @@ import androidx.compose.material.icons.Icons
26import androidx.compose.material.icons.automirrored.filled.ArrowBack
26import androidx.compose.material.icons.automirrored.filled.ArrowBack
27import androidx.compose.material.icons.automirrored.filled.Send
27import androidx.compose.material.icons.automirrored.filled.Send
28import androidx.compose.material.icons.filled.AutoAwesome
28import androidx.compose.material.icons.filled.AutoAwesome
29+import androidx.compose.material.icons.filled.Delete
30+import androidx.compose.material.icons.filled.Edit
29import androidx.compose.material.icons.filled.KeyboardArrowDown
31import androidx.compose.material.icons.filled.KeyboardArrowDown
32+import androidx.compose.material.icons.filled.MoreVert
30import androidx.compose.material.icons.filled.Refresh
33import androidx.compose.material.icons.filled.Refresh
31−import androidx.compose.material.icons.filled.Share
32−import androidx.compose.material.icons.filled.SmartToy
33import androidx.compose.material.icons.filled.Stop
34import androidx.compose.material.icons.filled.Stop
34−import androidx.compose.material.icons.filled.Terminal
35−import androidx.compose.material.icons.filled.Tune
36import androidx.compose.material3.AlertDialog
35import androidx.compose.material3.AlertDialog
37import androidx.compose.material3.Button
36import androidx.compose.material3.Button
38import androidx.compose.material3.CircularProgressIndicator
37import androidx.compose.material3.CircularProgressIndicator
38+import androidx.compose.material3.DropdownMenu
39+import androidx.compose.material3.DropdownMenuItem
39import androidx.compose.material3.ExperimentalMaterial3Api
40import androidx.compose.material3.ExperimentalMaterial3Api
40import androidx.compose.material3.ExtendedFloatingActionButton
41import androidx.compose.material3.ExtendedFloatingActionButton
41import androidx.compose.material3.FloatingActionButton
42import androidx.compose.material3.FloatingActionButton
@@ -93,6 +94,7 @@ import kotlinx.coroutines.Dispatchers
93import kotlinx.coroutines.launch
94import kotlinx.coroutines.launch
94import kotlinx.coroutines.withContext
95import kotlinx.coroutines.withContext
95import soy.iko.opencode.data.model.Part
96import soy.iko.opencode.data.model.Part
97+import soy.iko.opencode.data.model.MessageWithParts
96import soy.iko.opencode.data.model.ReasoningPart
98import soy.iko.opencode.data.model.ReasoningPart
97import soy.iko.opencode.data.model.TextPart
99import soy.iko.opencode.data.model.TextPart
98import soy.iko.opencode.data.model.ToolCompleted
100import soy.iko.opencode.data.model.ToolCompleted
@@ -148,7 +150,6 @@ fun ChatScreen(
148 val haptics = LocalHapticFeedback.current
150 val haptics = LocalHapticFeedback.current
149 val scope = rememberCoroutineScope()
151 val scope = rememberCoroutineScope()
150 val shareContext = LocalContext.current
152 val shareContext = LocalContext.current
151− val shareLabel = stringResource(R.string.share)
152 val defaultShareSubject = stringResource(R.string.share_subject)
153 val defaultShareSubject = stringResource(R.string.share_subject)
153 val sessionLabel = stringResource(R.string.session)
154 val sessionLabel = stringResource(R.string.session)
154 val defaultModelLabel = stringResource(R.string.default_model)
155 val defaultModelLabel = stringResource(R.string.default_model)
@@ -177,10 +178,13 @@ fun ChatScreen(
177 // on a zombie screen showing a conversation that no longer exists. Surface a
178 // on a zombie screen showing a conversation that no longer exists. Surface a
178 // toast (not a snackbar) because the screen is about to be popped — a snackbar
179 // toast (not a snackbar) because the screen is about to be popped — a snackbar
179 // would be torn down before the user could read it, while a toast survives.
180 // 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).
180 val deletedContext = LocalContext.current
184 val deletedContext = LocalContext.current
181 LaunchedEffect(sessionDeleted) {
185 LaunchedEffect(sessionDeleted) {
182 if (sessionDeleted) {
186 if (sessionDeleted) {
183− showToast(deletedContext, deletedContext.getString(R.string.session_deleted_msg))
187+ showToast(deletedContext, deletedContext.getString(R.string.session_deleted_chat))
@@ -193,6 +197,9 @@ fun ChatScreen(
193 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
197 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
194 var showExitConfirm by rememberSaveable { mutableStateOf(false) }
198 var showExitConfirm by rememberSaveable { mutableStateOf(false) }
195 var showStopConfirm by rememberSaveable { mutableStateOf(false) }
199 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) }
197 // Keep the screen awake and hold a foreground priority while the agent is working,
204 // Keep the screen awake and hold a foreground priority while the agent is working,
198 // so backgrounding mid-run doesn't let Doze choke the SSE stream.
205 // so backgrounding mid-run doesn't let Doze choke the SSE stream.
@@ -241,12 +248,20 @@ fun ChatScreen(
241 .semantics(mergeDescendants = true) { contentDescription = changeLabel },
248 .semantics(mergeDescendants = true) { contentDescription = changeLabel },
242 verticalAlignment = Alignment.CenterVertically,
249 verticalAlignment = Alignment.CenterVertically,
244− Column {
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.
245− Text(sessionTitle ?: sessionLabel)
255+ sessionTitle ?: sessionLabel,
257+ overflow = TextOverflow.Ellipsis,
247 subtitle,
260 subtitle,
248 style = MaterialTheme.typography.labelSmall,
261 style = MaterialTheme.typography.labelSmall,
249 color = MaterialTheme.colorScheme.onSurfaceVariant,
262 color = MaterialTheme.colorScheme.onSurfaceVariant,
264+ overflow = TextOverflow.Ellipsis,
252 // Visual affordance that the title opens the model picker;
267 // Visual affordance that the title opens the model picker;
@@ -301,60 +316,88 @@ fun ChatScreen(
301 Icon(Icons.Filled.Refresh, contentDescription = refreshLabel)
316 Icon(Icons.Filled.Refresh, contentDescription = refreshLabel)
304− IconButton(
319+ // Overflow menu consolidates the less-frequent actions (share,
305− onClick = {
320+ // commands, agent, model, rename, delete) so the top bar stays
306− scope.launch {
321+ // scannable on narrow phones — previously 5 icons crowded the bar
307− val md = withContext(Dispatchers.Default) {
322+ // alongside the back button and tappable title. Catalog-loading
308− buildConversationMarkdown(vm.messages.value, sessionTitle)
323+ // spinners stay inline (next to the overflow) so a load state is
309− }
324+ // visible without opening the menu.
310− val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
325+ val moreLabel = stringResource(R.string.more)
311− type = "text/markdown"
312− putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
313− putExtra(android.content.Intent.EXTRA_TEXT, md)
315− runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
316− .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
319− enabled = hasMessages,
321− Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share_conversation))
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)
326+ val loadingLabel = stringResource(R.string.loading)
329− CircularProgressIndicator(
327+ val shareLabel = stringResource(R.string.share_conversation)
330− Modifier.size(18.dp).semantics { contentDescription = loadingLabel },
328+ val commandsLabel = stringResource(R.string.commands)
331− strokeWidth = 2.dp,
329+ val agentLabel = stringResource(R.string.choose_agent)
332− )
330+ val modelLabel = stringResource(R.string.choose_model)
333− } else {
331+ val renameLabel = stringResource(R.string.rename_session_chat)
332+ val deleteLabel = stringResource(R.string.delete_session_chat)
334− IconButton(onClick = { showCommandPicker = true }) {
334+ IconButton(onClick = { showOverflowMenu = true }) {
335− Icon(Icons.Filled.Terminal, contentDescription = stringResource(R.string.commands))
335+ Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
338+ expanded = showOverflowMenu,
339+ onDismissRequest = { showOverflowMenu = false },
342+ text = { Text(shareLabel) },
343+ enabled = hasMessages,
345+ showOverflowMenu = false
347+ val md = withContext(Dispatchers.Default) {
348+ buildConversationMarkdown(vm.messages.value, sessionTitle)
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)
355+ runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
356+ .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
361+ text = { Text(commandsLabel) },
362+ enabled = !commandsLoading || commands.isNotEmpty(),
363+ onClick = { showOverflowMenu = false; showCommandPicker = true },
366+ text = { Text(agentLabel) },
338− if (agentsLoading && agents.isEmpty()) {
367+ enabled = !agentsLoading || agents.isNotEmpty(),
339− val loadingLabel = stringResource(R.string.loading)
340− CircularProgressIndicator(
341− Modifier.size(18.dp).semantics { contentDescription = loadingLabel },
345− IconButton(onClick = { showAgentPicker = true }) {
368+ onClick = { showOverflowMenu = false; showAgentPicker = true },
346− Icon(Icons.Filled.SmartToy, contentDescription = stringResource(R.string.choose_agent))
369+ )
371+ text = { Text(modelLabel) },
372+ enabled = !modelsLoading || models.isNotEmpty(),
373+ onClick = { showOverflowMenu = false; showModelPicker = true },
375+ androidx.compose.material3.HorizontalDivider()
377+ text = { Text(renameLabel) },
378+ onClick = { showOverflowMenu = false; showRenameDialog = true },
381+ text = { Text(deleteLabel, color = MaterialTheme.colorScheme.error) },
382+ onClick = { showOverflowMenu = false; showDeleteDialog = true },
348− }
385+ // While a catalog is initially loading (empty list + loading flag),
349− if (modelsLoading && models.isEmpty()) {
386+ // show a tiny inline spinner beside the overflow so the user can see
350− val loadingLabel = stringResource(R.string.loading)
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) {
351− CircularProgressIndicator(
394+ CircularProgressIndicator(
396+ .align(Alignment.TopEnd)
352− Modifier.size(18.dp).semantics { contentDescription = loadingLabel },
398+ .semantics { contentDescription = loadingLabel },
353− strokeWidth = 2.dp,
399+ strokeWidth = 1.dp,
356− IconButton(onClick = { showModelPicker = true }) {
357− Icon(Icons.Filled.Tune, contentDescription = stringResource(R.string.choose_model))
@@ -386,17 +429,28 @@ fun ChatScreen(
386 val listState = rememberLazyListState()
429 val listState = rememberLazyListState()
387 val contentScope = rememberCoroutineScope()
430 val contentScope = rememberCoroutineScope()
388 val timeTick = rememberRelativeTimeTick()
431 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)
390 val isPinnedToBottom by remember {
444 val isPinnedToBottom by remember {
391 derivedStateOf {
445 derivedStateOf {
392 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
446 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
393 // When a run is active, the LazyColumn has one trailing "__typing"
447 // 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"
395 // includes the working indicator — otherwise the pin check targets
449 // 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,
397 // and the user never sees the progress indicator even when pinned.
451 // and the user never sees the progress indicator even when pinned.
398− val effectiveLast = if (running) messages.size else messages.lastIndex
452+ val effectiveLast = if (running) listItems.size else listItems.lastIndex
399− lastVisible >= effectiveLast || messages.isEmpty()
453+ lastVisible >= effectiveLast || listItems.isEmpty()
@@ -405,28 +459,28 @@ fun ChatScreen(
405 // (rotation) — rememberLazyListState restores the user's scroll offset, and an
459 // (rotation) — rememberLazyListState restores the user's scroll offset, and an
406 // unconditional scrollToItem here would clobber it, snapping them back to the bottom.
460 // unconditional scrollToItem here would clobber it, snapping them back to the bottom.
407 var didInitialScroll by rememberSaveable { mutableStateOf(false) }
461 var didInitialScroll by rememberSaveable { mutableStateOf(false) }
408− LaunchedEffect(messages.isNotEmpty()) {
462+ LaunchedEffect(listItems.isNotEmpty()) {
409− if (messages.isNotEmpty() && !didInitialScroll) {
463+ if (listItems.isNotEmpty() && !didInitialScroll) {
410 // When a run is active the LazyColumn has a trailing "__typing" row at
464 // 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
412 // running session lands truly pinned to bottom and streaming auto-scroll engages.
466 // 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)
414 didInitialScroll = true
468 didInitialScroll = true
418 // When a run starts, the LazyColumn gains a trailing "__typing" row, so
472 // 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
420 // isPinnedToBottom check flips to false the instant you send — freezing
474 // 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
422 // began, bring the typing row into view so the pin (and streaming follow) is
476 // began, bring the typing row into view so the pin (and streaming follow) is
423 // preserved. Guard on the raw last-visible index (isPinnedToBottom has already
477 // preserved. Guard on the raw last-visible index (isPinnedToBottom has already
424 // recomputed to false by now) so we don't scroll when the user had scrolled up.
478 // recomputed to false by now) so we don't scroll when the user had scrolled up.
425 LaunchedEffect(running) {
479 LaunchedEffect(running) {
426− if (running && messages.isNotEmpty()) {
480+ if (running && listItems.isNotEmpty()) {
427 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
481 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
428− if (lastVisible >= messages.lastIndex) {
482+ if (lastVisible >= listItems.lastIndex) {
429− runCatchingCancellable { listState.animateScrollToItem(messages.size) }
483+ runCatchingCancellable { listState.animateScrollToItem(listItems.size) }
@@ -441,13 +495,13 @@ fun ChatScreen(
441 // and tool output — not just TextPart — keeps a pinned view following a long
495 // and tool output — not just TextPart — keeps a pinned view following a long
442 // reasoning/tool block while it streams (previously it stalled at 0).
496 // reasoning/tool block while it streams (previously it stalled at 0).
443 val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
497 val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
444− AutoScrollSignal(messages.size, lastLen, isPinnedToBottom)
498+ AutoScrollSignal(listItems.size, lastLen, isPinnedToBottom)
445 }.collect { signal ->
499 }.collect { signal ->
446 if (signal.size > 0 && signal.pinned) {
500 if (signal.size > 0 && signal.pinned) {
447 // Scroll to the effective last index, including the trailing
501 // Scroll to the effective last index, including the trailing
448 // "__typing" row when a run is active so the working indicator
502 // "__typing" row when a run is active so the working indicator
449 // is brought into view (not just the last message).
503 // 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
451 listState.scrollToItem(target)
505 listState.scrollToItem(target)
@@ -559,19 +613,29 @@ fun ChatScreen(
559 contentPadding = PaddingValues(16.dp),
613 contentPadding = PaddingValues(16.dp),
560 verticalArrangement = Arrangement.spacedBy(16.dp),
614 verticalArrangement = Arrangement.spacedBy(16.dp),
562− items(messages, key = { it.info.id }, contentType = { it.info::class }) { message ->
616+ items(
619+ contentType = { it.contentType },
622+ is MessageListItem.Separator -> DateSeparator(item.label)
623+ is MessageListItem.Message -> {
624+ val message = item.message
563− // Only the last (streaming) message needs isRunning — it drives the
625+ // Only the last (streaming) message needs isRunning — it drives the
564− // reasoning-block spinner. Passing the live flag to every bubble
626+ // reasoning-block spinner. Passing the live flag to every bubble
565− // makes all visible messages recompose whenever a run starts or stops.
627+ // makes all visible messages recompose whenever a run starts or stops.
566− val modelLabel = (message.info as? soy.iko.opencode.data.model.AssistantMessage)
628+ val modelLabel = (message.info as? soy.iko.opencode.data.model.AssistantMessage)
567− ?.let { resolveModelLabel(it, models) }
629+ ?.let { resolveModelLabel(it, models) }
568− MessageBubble(
630+ MessageBubble(
569− message,
631+ message,
570− isRunning = running && message.info.id == lastMessageId,
632+ isRunning = running && message.info.id == lastMessageId,
571− imageContext = imageContext,
633+ imageContext = imageContext,
572− modelLabel = modelLabel,
634+ modelLabel = modelLabel,
573− onOpenFile = onOpenFile,
635+ onOpenFile = onOpenFile,
576 if (running) {
640 if (running) {
577 item(key = "__typing") {
641 item(key = "__typing") {
@@ -591,15 +655,15 @@ fun ChatScreen(
592 // Jump-to-latest affordance when the user has scrolled away during a stream.
656 // Jump-to-latest affordance when the user has scrolled away during a stream.
593 AnimatedVisibility(
657 AnimatedVisibility(
594− visible = !isPinnedToBottom && messages.isNotEmpty(),
658+ visible = !isPinnedToBottom && listItems.isNotEmpty(),
595 modifier = Modifier.align(Alignment.BottomEnd),
659 modifier = Modifier.align(Alignment.BottomEnd),
597 ExtendedFloatingActionButton(
661 ExtendedFloatingActionButton(
598 onClick = {
662 onClick = {
599− if (messages.isNotEmpty()) {
663+ if (listItems.isNotEmpty()) {
600 // Scroll to the effective last index, including the
664 // Scroll to the effective last index, including the
601 // trailing "__typing" row when a run is active.
665 // 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
603 contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
667 contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
@@ -651,6 +715,35 @@ fun ChatScreen(
718+ if (showRenameDialog) {
719+ RenameSessionChatDialog(
720+ initialTitle = sessionTitle ?: "",
721+ onDismiss = { showRenameDialog = false },
722+ onConfirm = { newName ->
723+ showRenameDialog = false
724+ vm.renameSession(newName)
729+ if (showDeleteDialog) {
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)) },
735+ TextButton(onClick = {
736+ showDeleteDialog = false
737+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
739+ }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
742+ TextButton(onClick = { showDeleteDialog = false }) { Text(stringResource(R.string.cancel)) }
654 if (showModelPicker) {
747 if (showModelPicker) {
655 ModelPickerSheet(
748 ModelPickerSheet(
656 options = models,
749 options = models,
@@ -911,3 +1004,151 @@ private fun streamingContentLength(part: Part?): Int = when (part) {
912 else -> 0
1005 else -> 0
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. */
1012+private fun RenameSessionChatDialog(
1013+ initialTitle: String,
1014+ onDismiss: () -> Unit,
1015+ onConfirm: (String) -> Unit,
1017+ var title by rememberSaveable(initialTitle) { mutableStateOf(initialTitle) }
1019+ onDismissRequest = onDismiss,
1020+ title = { Text(stringResource(R.string.rename_session_chat)) },
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)) },
1029+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
1030+ keyboardActions = KeyboardActions(onDone = { if (title.isNotBlank()) onConfirm(title.trim()) }),
1034+ TextButton(onClick = { if (title.isNotBlank()) onConfirm(title.trim()) }, enabled = title.isNotBlank()) {
1035+ Text(stringResource(R.string.save))
1039+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
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`).
1050+private sealed interface MessageListItem {
1052+ val contentType: Any
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
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"
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.
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.
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
1095+ result.add(MessageListItem.Message(message))
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()
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
1113+private fun dayLabel(
1116+ today: java.time.LocalDate,
1117+ todayLabel: String,
1118+ yesterdayLabel: String,
1119+ dateFmt: java.text.DateFormat,
1121+ if (dayKey.isEmpty()) return ""
1122+ val epochDay = dayKey.toLongOrNull() ?: return ""
1123+ val date = java.time.LocalDate.ofEpochDay(epochDay)
1125+ date == today -> todayLabel
1126+ date == today.minusDays(1) -> yesterdayLabel
1127+ else -> dateFmt.format(java.util.Date(ts))
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.
1136+private fun DateSeparator(label: String) {
1137+ if (label.isBlank()) return
1139+ modifier = Modifier.fillMaxWidth(),
1140+ horizontalArrangement = Arrangement.Center,
1143+ color = MaterialTheme.colorScheme.surfaceVariant,
1144+ shape = MaterialTheme.shapes.small,
1148+ style = MaterialTheme.typography.labelSmall,
1149+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1150+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),