@@ -21,11 +21,13 @@ import androidx.compose.foundation.layout.Spacer
21import androidx.compose.foundation.layout.fillMaxSize
21import androidx.compose.foundation.layout.fillMaxSize
22import androidx.compose.foundation.layout.fillMaxWidth
22import androidx.compose.foundation.layout.fillMaxWidth
23import androidx.compose.foundation.layout.PaddingValues
23import androidx.compose.foundation.layout.PaddingValues
24+import androidx.compose.foundation.layout.WindowInsets
25+import androidx.compose.foundation.layout.ime
24import androidx.compose.foundation.layout.imePadding
26import androidx.compose.foundation.layout.imePadding
25import androidx.compose.foundation.layout.padding
27import androidx.compose.foundation.layout.padding
26import androidx.compose.foundation.layout.size
28import androidx.compose.foundation.layout.size
27import androidx.compose.foundation.lazy.LazyColumn
29import androidx.compose.foundation.lazy.LazyColumn
28−import androidx.compose.foundation.lazy.items
30+import androidx.compose.foundation.lazy.itemsIndexed
29import androidx.compose.foundation.lazy.rememberLazyListState
31import androidx.compose.foundation.lazy.rememberLazyListState
30import androidx.compose.foundation.shape.CircleShape
32import androidx.compose.foundation.shape.CircleShape
31import androidx.compose.foundation.shape.RoundedCornerShape
33import androidx.compose.foundation.shape.RoundedCornerShape
@@ -63,9 +65,11 @@ import androidx.compose.ui.Alignment
63import androidx.compose.ui.Modifier
65import androidx.compose.ui.Modifier
64import androidx.compose.ui.draw.clip
66import androidx.compose.ui.draw.clip
65import androidx.compose.ui.hapticfeedback.HapticFeedbackType
67import androidx.compose.ui.hapticfeedback.HapticFeedbackType
68+import androidx.compose.ui.platform.LocalDensity
66import androidx.compose.ui.platform.LocalHapticFeedback
69import androidx.compose.ui.platform.LocalHapticFeedback
67import androidx.compose.ui.text.font.FontStyle
70import androidx.compose.ui.text.font.FontStyle
68import androidx.compose.ui.text.input.ImeAction
71import androidx.compose.ui.text.input.ImeAction
72+import androidx.compose.ui.text.style.TextAlign
69import androidx.compose.ui.text.style.TextOverflow
73import androidx.compose.ui.text.style.TextOverflow
70import androidx.compose.ui.unit.dp
74import androidx.compose.ui.unit.dp
71import kotlinx.coroutines.launch
75import kotlinx.coroutines.launch
@@ -79,6 +83,11 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
79 val listState = rememberLazyListState()
83 val listState = rememberLazyListState()
80 val coroutineScope = rememberCoroutineScope()
84 val coroutineScope = rememberCoroutineScope()
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
82 // Show a jump-to-bottom button once the user scrolls up away from the latest
91 // Show a jump-to-bottom button once the user scrolls up away from the latest
83 // message. derivedStateOf keeps this from recomposing on every pixel of scroll —
92 // message. derivedStateOf keeps this from recomposing on every pixel of scroll —
84 // it only flips when the near-bottom threshold is actually crossed.
93 // it only flips when the near-bottom threshold is actually crossed.
@@ -180,7 +189,13 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
180 modifier = Modifier.fillMaxSize(),
189 modifier = Modifier.fillMaxSize(),
181 contentPadding = PaddingValues(vertical = 8.dp),
190 contentPadding = PaddingValues(vertical = 8.dp),
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)
184 MessageBubble(
199 MessageBubble(
186 onRetry = { dispatch(Event.SendMessage(message.text)) },
201 onRetry = { dispatch(Event.SendMessage(message.text)) },
@@ -196,7 +211,7 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
198 ScrollToBottomButton(
213 ScrollToBottomButton(
199− visible = showScrollButton,
214+ visible = showScrollButton && !isImeVisible,
200 showBadge = hasUnseenMessages,
215 showBadge = hasUnseenMessages,
201 onClick = {
216 onClick = {
202 coroutineScope.launch {
217 coroutineScope.launch {
@@ -245,6 +260,46 @@ private fun BoxScope.ScrollToBottomButton(visible: Boolean, showBadge: Boolean,
263+private val dateSeparatorFormat = java.time.format.DateTimeFormatter.ofPattern("MMM d")
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())
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.
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)
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()
287+ today.minusDays(1) -> "Yesterday"
288+ else -> day.format(dateSeparatorFormat)
293+private fun DateSeparator(epochMillis: Long) {
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),
248// Assistant-style bubble with three sequentially-pulsing dots, shown as a stand-in
303// Assistant-style bubble with three sequentially-pulsing dots, shown as a stand-in
249// until the real streaming reply starts. Mirrors MessageBubble's left-aligned,
304// until the real streaming reply starts. Mirrors MessageBubble's left-aligned,
250// surfaceVariant look so it reads as an incoming message.
305// surfaceVariant look so it reads as an incoming message.