@@ -21,11 +21,13 @@ import androidx.compose.foundation.layout.Spacer
2121import androidx.compose.foundation.layout.fillMaxSize
2222import androidx.compose.foundation.layout.fillMaxWidth
2323import androidx.compose.foundation.layout.PaddingValues
24+import androidx.compose.foundation.layout.WindowInsets
25+import androidx.compose.foundation.layout.ime
2426import androidx.compose.foundation.layout.imePadding
2527import androidx.compose.foundation.layout.padding
2628import androidx.compose.foundation.layout.size
2729import androidx.compose.foundation.lazy.LazyColumn
28−import androidx.compose.foundation.lazy.items
30+import androidx.compose.foundation.lazy.itemsIndexed
2931import androidx.compose.foundation.lazy.rememberLazyListState
3032import androidx.compose.foundation.shape.CircleShape
3133import androidx.compose.foundation.shape.RoundedCornerShape
@@ -63,9 +65,11 @@ import androidx.compose.ui.Alignment
6365import androidx.compose.ui.Modifier
6466import androidx.compose.ui.draw.clip
6567import androidx.compose.ui.hapticfeedback.HapticFeedbackType
68+import androidx.compose.ui.platform.LocalDensity
6669import androidx.compose.ui.platform.LocalHapticFeedback
6770import androidx.compose.ui.text.font.FontStyle
6871import androidx.compose.ui.text.input.ImeAction
72+import androidx.compose.ui.text.style.TextAlign
6973import androidx.compose.ui.text.style.TextOverflow
7074import androidx.compose.ui.unit.dp
7175import kotlinx.coroutines.launch
@@ -79,6 +83,11 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
7983 val listState = rememberLazyListState()
8084 val coroutineScope = rememberCoroutineScope()
8185
86+ // Hide the jump-to-bottom FAB while the keyboard is up: it would otherwise
87+ // sit over the top of the IME. Reading the ime inset bottom during composition
88+ // observes snapshot state, so this flips as the keyboard opens/closes.
89+ val isImeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0
90+
8291 // Show a jump-to-bottom button once the user scrolls up away from the latest
8392 // message. derivedStateOf keeps this from recomposing on every pixel of scroll —
8493 // it only flips when the near-bottom threshold is actually crossed.
@@ -180,7 +189,13 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
180189 modifier = Modifier.fillMaxSize(),
181190 contentPadding = PaddingValues(vertical = 8.dp),
182191 ) {
183− items(state.messages, key = { it.id }) { message ->
192+ itemsIndexed(state.messages, key = { _, message -> message.id }) { index, message ->
193+ // Insert a date separator whenever a message falls on a different
194+ // calendar day than the one before it (and above the first message).
195+ val previous = state.messages.getOrNull(index - 1)
196+ if (shouldShowDateSeparator(previous, message)) {
197+ DateSeparator(message.time)
198+ }
184199 MessageBubble(
185200 message,
186201 onRetry = { dispatch(Event.SendMessage(message.text)) },
@@ -196,7 +211,7 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
196211 }
197212 }
198213 ScrollToBottomButton(
199− visible = showScrollButton,
214+ visible = showScrollButton && !isImeVisible,
200215 showBadge = hasUnseenMessages,
201216 onClick = {
202217 coroutineScope.launch {
@@ -245,6 +260,46 @@ private fun BoxScope.ScrollToBottomButton(visible: Boolean, showBadge: Boolean,
245260 }
246261}
247262
263+private val dateSeparatorFormat = java.time.format.DateTimeFormatter.ofPattern("MMM d")
264+
265+/** Convert an epoch-millis timestamp to the local calendar day it falls on. */
266+private fun localDayOf(epochMillis: Long): java.time.LocalDate =
267+ java.time.Instant.ofEpochMilli(epochMillis)
268+ .atZone(java.time.ZoneId.systemDefault())
269+ .toLocalDate()
270+
271+/**
272+ * A separator is shown above a message when it starts a new calendar day — i.e. the
273+ * first timestamped message, or one whose day differs from the previous message's.
274+ * Messages without a valid time (time <= 0) never get one.
275+ */
276+private fun shouldShowDateSeparator(previous: soy.iko.opencode.core.MessageView?, current: soy.iko.opencode.core.MessageView): Boolean {
277+ if (current.time <= 0) return false
278+ if (previous == null || previous.time <= 0) return true
279+ return localDayOf(previous.time) != localDayOf(current.time)
280+}
281+
282+/** Human-friendly day label: "Today", "Yesterday", or "MMM d" (e.g. "Jul 8"). */
283+private fun dateSeparatorLabel(day: java.time.LocalDate): String {
284+ val today = java.time.LocalDate.now()
285+ return when (day) {
286+ today -> "Today"
287+ today.minusDays(1) -> "Yesterday"
288+ else -> day.format(dateSeparatorFormat)
289+ }
290+}
291+
292+@Composable
293+private fun DateSeparator(epochMillis: Long) {
294+ Text(
295+ text = dateSeparatorLabel(localDayOf(epochMillis)),
296+ style = MaterialTheme.typography.labelSmall,
297+ color = MaterialTheme.colorScheme.onSurfaceVariant,
298+ textAlign = TextAlign.Center,
299+ modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
300+ )
301+}
302+
248303// Assistant-style bubble with three sequentially-pulsing dots, shown as a stand-in
249304// until the real streaming reply starts. Mirrors MessageBubble's left-aligned,
250305// surfaceVariant look so it reads as an incoming message.