@@ -50,6 +50,7 @@ import soy.iko.opencode.core.Event
5050import soy.iko.opencode.core.MessageView
5151import soy.iko.opencode.core.SseState
5252import soy.iko.opencode.ui.components.ChatInputBar
53+import soy.iko.opencode.ui.components.DateSeparator
5354import soy.iko.opencode.ui.components.DualSnackbarHost
5455import soy.iko.opencode.ui.components.EmptyState
5556import soy.iko.opencode.ui.components.ErrorHost
@@ -58,6 +59,7 @@ import soy.iko.opencode.ui.components.InfoHost
5859import soy.iko.opencode.ui.components.LoadingPlaceholder
5960import soy.iko.opencode.ui.components.MessageBubble
6061import soy.iko.opencode.ui.components.SseDisconnectedBanner
62+import soy.iko.opencode.ui.components.dayOfEpoch
6163import soy.iko.opencode.ui.theme.Dimens
6264import soy.iko.opencode.ui.theme.OpencodeTheme
6365
@@ -70,6 +72,7 @@ fun ChatScreen(core: Core) {
7072 val listState = rememberLazyListState()
7173 var scrollRequest by remember { mutableIntStateOf(0) }
7274 var menuExpanded by remember { mutableStateOf(false) }
75+ var sseBannerDismissed by remember { mutableStateOf(false) }
7376 val inputFocusRequester = remember { FocusRequester() }
7477
7578 // Track whether the user is parked at the bottom of the list. We only
@@ -118,10 +121,18 @@ fun ChatScreen(core: Core) {
118121 core = core,
119122 successConnectedLabel = stringResource(R.string.connect_success),
120123 successSessionCreatedLabel = stringResource(R.string.session_created),
124+ copiedLabel = stringResource(R.string.chat_copied),
121125 )
122126
123− val showSseBanner = sseState is SseState.Error ||
124− (sseState == SseState.Disconnected && view.currentSessionId != null && !view.loading)
127+ val showSseBanner = (sseState is SseState.Error ||
128+ (sseState == SseState.Disconnected && view.currentSessionId != null && !view.loading)) &&
129+ !sseBannerDismissed
130+
131+ // Reset the dismissed flag whenever a fresh SSE error arrives so the
132+ // banner reappears for a new disconnect.
133+ LaunchedEffect((sseState as? SseState.Error)?.message) {
134+ if (sseState is SseState.Error) sseBannerDismissed = false
135+ }
125136
126137 // Resolve nullable session fields into locals once per recomposition so we
127138 // never smart-cast a delegate-backed collected property across calls.
@@ -219,6 +230,9 @@ fun ChatScreen(core: Core) {
219230 // Disable while loading OR while the assistant is generating,
220231 // so users can't fire a second message mid-reply.
221232 enabled = !view.loading && !view.generating,
233+ generating = view.generating,
234+ onStop = { sessionId?.let { core.update(Event.LoadMessages(it)) } },
235+ stopContentDescription = stringResource(R.string.chat_cd_stop),
222236 placeholder = stringResource(R.string.chat_input_placeholder),
223237 sendContentDescription = stringResource(R.string.chat_cd_send),
224238 modifier = Modifier.focusRequester(inputFocusRequester),
@@ -237,8 +251,13 @@ fun ChatScreen(core: Core) {
237251 ?: stringResource(R.string.sse_disconnected)
238252 SseDisconnectedBanner(
239253 message = bannerMessage,
240− onReconnect = { core.reconnectSse() },
254+ onReconnect = {
255+ sseBannerDismissed = false
256+ core.reconnectSse()
257+ },
258+ onDismiss = { sseBannerDismissed = true },
241259 reconnectLabel = stringResource(R.string.sse_reconnect),
260+ dismissLabel = stringResource(R.string.sse_dismiss),
242261 )
243262 }
244263
@@ -266,8 +285,28 @@ fun ChatScreen(core: Core) {
266285 verticalArrangement = Arrangement.spacedBy(Dimens.listItemSpacing),
267286 contentPadding = PaddingValues(vertical = Dimens.listVerticalPadding),
268287 ) {
269− items(view.messages, key = { it.id }) { message ->
270− MessageBubble(message)
288+ val msgs = view.messages
289+ for (i in msgs.indices) {
290+ val msg = msgs[i]
291+ // Insert a date separator before a message
292+ // when its calendar day differs from the
293+ // previous message's day (and it has a
294+ // valid timestamp).
295+ if (msg.time > 0uL) {
296+ val prevDay = if (i > 0) dayOfEpoch(msgs[i - 1].time) else Int.MIN_VALUE
297+ val curDay = dayOfEpoch(msg.time)
298+ if (curDay != prevDay) {
299+ item(key = "date_${msg.id}") {
300+ DateSeparator(epochSeconds = msg.time)
301+ }
302+ }
303+ }
304+ item(key = msg.id) {
305+ MessageBubble(
306+ message = msg,
307+ onCopied = { core.notifyCopied() },
308+ )
309+ }
271310 }
272311 // "Thinking" indicator shown after the user sends, while
273312 // the assistant reply hasn't arrived yet. The indicator
@@ -303,7 +342,7 @@ fun ChatScreen(core: Core) {
303342 contentDescription = stringResource(R.string.chat_cd_scroll_to_bottom),
304343 )
305344 },
306− text = { Text(stringResource(R.string.chat_cd_scroll_to_bottom)) },
345+ text = { Text(stringResource(R.string.chat_scroll_to_latest)) },
307346 containerColor = MaterialTheme.colorScheme.primaryContainer,
308347 contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
309348 modifier = Modifier