@@ -50,6 +50,7 @@ import soy.iko.opencode.core.Event
50import soy.iko.opencode.core.MessageView
50import soy.iko.opencode.core.MessageView
51import soy.iko.opencode.core.SseState
51import soy.iko.opencode.core.SseState
52import soy.iko.opencode.ui.components.ChatInputBar
52import soy.iko.opencode.ui.components.ChatInputBar
53+import soy.iko.opencode.ui.components.DateSeparator
53import soy.iko.opencode.ui.components.DualSnackbarHost
54import soy.iko.opencode.ui.components.DualSnackbarHost
54import soy.iko.opencode.ui.components.EmptyState
55import soy.iko.opencode.ui.components.EmptyState
55import soy.iko.opencode.ui.components.ErrorHost
56import soy.iko.opencode.ui.components.ErrorHost
@@ -58,6 +59,7 @@ import soy.iko.opencode.ui.components.InfoHost
58import soy.iko.opencode.ui.components.LoadingPlaceholder
59import soy.iko.opencode.ui.components.LoadingPlaceholder
59import soy.iko.opencode.ui.components.MessageBubble
60import soy.iko.opencode.ui.components.MessageBubble
60import soy.iko.opencode.ui.components.SseDisconnectedBanner
61import soy.iko.opencode.ui.components.SseDisconnectedBanner
62+import soy.iko.opencode.ui.components.dayOfEpoch
61import soy.iko.opencode.ui.theme.Dimens
63import soy.iko.opencode.ui.theme.Dimens
62import soy.iko.opencode.ui.theme.OpencodeTheme
64import soy.iko.opencode.ui.theme.OpencodeTheme
@@ -70,6 +72,7 @@ fun ChatScreen(core: Core) {
70 val listState = rememberLazyListState()
72 val listState = rememberLazyListState()
71 var scrollRequest by remember { mutableIntStateOf(0) }
73 var scrollRequest by remember { mutableIntStateOf(0) }
72 var menuExpanded by remember { mutableStateOf(false) }
74 var menuExpanded by remember { mutableStateOf(false) }
75+ var sseBannerDismissed by remember { mutableStateOf(false) }
73 val inputFocusRequester = remember { FocusRequester() }
76 val inputFocusRequester = remember { FocusRequester() }
75 // Track whether the user is parked at the bottom of the list. We only
78 // Track whether the user is parked at the bottom of the list. We only
@@ -118,10 +121,18 @@ fun ChatScreen(core: Core) {
118 core = core,
121 core = core,
119 successConnectedLabel = stringResource(R.string.connect_success),
122 successConnectedLabel = stringResource(R.string.connect_success),
120 successSessionCreatedLabel = stringResource(R.string.session_created),
123 successSessionCreatedLabel = stringResource(R.string.session_created),
124+ copiedLabel = stringResource(R.string.chat_copied),
123− val showSseBanner = sseState is SseState.Error ||
127+ val showSseBanner = (sseState is SseState.Error ||
124− (sseState == SseState.Disconnected && view.currentSessionId != null && !view.loading)
128+ (sseState == SseState.Disconnected && view.currentSessionId != null && !view.loading)) &&
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
126 // Resolve nullable session fields into locals once per recomposition so we
137 // Resolve nullable session fields into locals once per recomposition so we
127 // never smart-cast a delegate-backed collected property across calls.
138 // never smart-cast a delegate-backed collected property across calls.
@@ -219,6 +230,9 @@ fun ChatScreen(core: Core) {
219 // Disable while loading OR while the assistant is generating,
230 // Disable while loading OR while the assistant is generating,
220 // so users can't fire a second message mid-reply.
231 // so users can't fire a second message mid-reply.
221 enabled = !view.loading && !view.generating,
232 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),
222 placeholder = stringResource(R.string.chat_input_placeholder),
236 placeholder = stringResource(R.string.chat_input_placeholder),
223 sendContentDescription = stringResource(R.string.chat_cd_send),
237 sendContentDescription = stringResource(R.string.chat_cd_send),
224 modifier = Modifier.focusRequester(inputFocusRequester),
238 modifier = Modifier.focusRequester(inputFocusRequester),
@@ -237,8 +251,13 @@ fun ChatScreen(core: Core) {
237 ?: stringResource(R.string.sse_disconnected)
251 ?: stringResource(R.string.sse_disconnected)
238 SseDisconnectedBanner(
252 SseDisconnectedBanner(
239 message = bannerMessage,
253 message = bannerMessage,
255+ sseBannerDismissed = false
240− onReconnect = { core.reconnectSse() },
256+ core.reconnectSse()
258+ onDismiss = { sseBannerDismissed = true },
241 reconnectLabel = stringResource(R.string.sse_reconnect),
259 reconnectLabel = stringResource(R.string.sse_reconnect),
260+ dismissLabel = stringResource(R.string.sse_dismiss),
@@ -266,8 +285,28 @@ fun ChatScreen(core: Core) {
266 verticalArrangement = Arrangement.spacedBy(Dimens.listItemSpacing),
285 verticalArrangement = Arrangement.spacedBy(Dimens.listItemSpacing),
267 contentPadding = PaddingValues(vertical = Dimens.listVerticalPadding),
286 contentPadding = PaddingValues(vertical = Dimens.listVerticalPadding),
269− items(view.messages, key = { it.id }) { message ->
288+ val msgs = view.messages
289+ for (i in msgs.indices) {
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)
304+ item(key = msg.id) {
270− MessageBubble(message)
305+ MessageBubble(
307+ onCopied = { core.notifyCopied() },
272 // "Thinking" indicator shown after the user sends, while
311 // "Thinking" indicator shown after the user sends, while
273 // the assistant reply hasn't arrived yet. The indicator
312 // the assistant reply hasn't arrived yet. The indicator
@@ -303,7 +342,7 @@ fun ChatScreen(core: Core) {
303 contentDescription = stringResource(R.string.chat_cd_scroll_to_bottom),
342 contentDescription = stringResource(R.string.chat_cd_scroll_to_bottom),
306− text = { Text(stringResource(R.string.chat_cd_scroll_to_bottom)) },
345+ text = { Text(stringResource(R.string.chat_scroll_to_latest)) },
307 containerColor = MaterialTheme.colorScheme.primaryContainer,
346 containerColor = MaterialTheme.colorScheme.primaryContainer,
308 contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
347 contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
309 modifier = Modifier
348 modifier = Modifier