@@ -3,17 +3,23 @@ package soy.iko.opencode.ui.screens
33import androidx.compose.animation.AnimatedVisibility
44import androidx.compose.animation.scaleIn
55import androidx.compose.animation.scaleOut
6+import androidx.compose.foundation.ExperimentalFoundationApi
7+import androidx.compose.foundation.combinedClickable
68import androidx.compose.foundation.layout.Arrangement
79import androidx.compose.foundation.layout.Box
810import androidx.compose.foundation.layout.Column
911import androidx.compose.foundation.layout.PaddingValues
12+import androidx.compose.foundation.layout.Row
1013import androidx.compose.foundation.layout.fillMaxSize
14+import androidx.compose.foundation.layout.fillMaxWidth
1115import androidx.compose.foundation.layout.padding
16+import androidx.compose.foundation.layout.size
1217import androidx.compose.foundation.lazy.LazyColumn
1318import androidx.compose.foundation.lazy.items
1419import androidx.compose.foundation.lazy.rememberLazyListState
1520import androidx.compose.material.icons.Icons
1621import androidx.compose.material.icons.automirrored.filled.ArrowBack
22+import androidx.compose.material.icons.filled.ContentCopy
1723import androidx.compose.material.icons.filled.KeyboardArrowDown
1824import androidx.compose.material.icons.filled.MoreVert
1925import androidx.compose.material.icons.filled.Refresh
@@ -35,18 +41,24 @@ import androidx.compose.runtime.getValue
3541import androidx.compose.runtime.mutableIntStateOf
3642import androidx.compose.runtime.mutableStateOf
3743import androidx.compose.runtime.remember
44+import androidx.compose.runtime.saveable.rememberSaveable
3845import androidx.compose.runtime.setValue
3946import androidx.compose.ui.Alignment
4047import androidx.compose.ui.Modifier
4148import androidx.compose.ui.focus.FocusRequester
4249import androidx.compose.ui.focus.focusRequester
50+import androidx.compose.ui.platform.LocalClipboardManager
4351import androidx.compose.ui.res.stringResource
52+import androidx.compose.ui.semantics.contentDescription
53+import androidx.compose.ui.semantics.semantics
54+import androidx.compose.ui.text.AnnotatedString
4455import androidx.compose.ui.text.style.TextOverflow
4556import androidx.compose.ui.tooling.preview.Preview
4657import androidx.compose.ui.unit.dp
4758import soy.iko.opencode.R
4859import soy.iko.opencode.core.Core
4960import soy.iko.opencode.core.Event
61+import soy.iko.opencode.core.MessageStatus
5062import soy.iko.opencode.core.MessageView
5163import soy.iko.opencode.core.SseState
5264import soy.iko.opencode.ui.components.ChatInputBar
@@ -59,29 +71,36 @@ import soy.iko.opencode.ui.components.InfoHost
5971import soy.iko.opencode.ui.components.LoadingPlaceholder
6072import soy.iko.opencode.ui.components.MessageBubble
6173import soy.iko.opencode.ui.components.SseDisconnectedBanner
74+import soy.iko.opencode.ui.components.SseStatusBanner
6275import soy.iko.opencode.ui.components.dayOfEpoch
6376import soy.iko.opencode.ui.theme.Dimens
6477import soy.iko.opencode.ui.theme.OpencodeTheme
6578
66−@OptIn(ExperimentalMaterial3Api::class)
79+@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
6780@Composable
6881fun ChatScreen(core: Core) {
6982 val view by core.view.collectAsState()
7083 val sseState by core.sseState.collectAsState()
71− var inputText by remember { mutableStateOf("") }
84+ // Wire the draft to the core's ViewModel so it survives rotation and
85+ // process death. The core stores draftMessage; we seed local state from
86+ // it and write via Event.DraftChanged on every keystroke.
87+ var inputText by remember(view.draftMessage) { mutableStateOf(view.draftMessage) }
7288 val listState = rememberLazyListState()
73− var scrollRequest by remember { mutableIntStateOf(0) }
74− var menuExpanded by remember { mutableStateOf(false) }
75− var sseBannerDismissed by remember { mutableStateOf(false) }
89+ var scrollRequest by rememberSaveable { mutableIntStateOf(0) }
90+ var menuExpanded by rememberSaveable { mutableStateOf(false) }
91+ var sseBannerDismissed by rememberSaveable { mutableStateOf(false) }
7692 val inputFocusRequester = remember { FocusRequester() }
93+ val clipboardManager = LocalClipboardManager.current
7794
7895 // Track whether the user is parked at the bottom of the list. We only
7996 // auto-scroll when they are, so reading history isn't yanked away as
8097 // new messages stream in.
8198 val isAtBottom by remember {
8299 derivedStateOf {
83− val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
84− lastVisible >= view.messages.size - 2
100+ val layoutInfo = listState.layoutInfo
101+ val totalItems = layoutInfo.totalItemsCount
102+ val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
103+ totalItems == 0 || lastVisible >= totalItems - 2
85104 }
86105 }
87106
@@ -95,10 +114,7 @@ fun ChatScreen(core: Core) {
95114 // repeated taps keep firing.
96115 LaunchedEffect(scrollRequest) {
97116 if (scrollRequest > 0 && view.messages.isNotEmpty()) {
98− // Index of the trailing __generating__ item (it's last in the
99− // list, at position == messages.size). Falling back to the last
100− // message is safe even if the generating item is invisible.
101− listState.animateScrollToItem(view.messages.size)
117+ listState.animateScrollToItem(listState.layoutInfo.totalItemsCount - 1)
102118 }
103119 }
104120
@@ -122,6 +138,7 @@ fun ChatScreen(core: Core) {
122138 successConnectedLabel = stringResource(R.string.connect_success),
123139 successSessionCreatedLabel = stringResource(R.string.session_created),
124140 copiedLabel = stringResource(R.string.chat_copied),
141+ sessionDeletedLabel = stringResource(R.string.session_deleted),
125142 )
126143
127144 val showSseBanner = (sseState is SseState.Error ||
@@ -158,24 +175,53 @@ fun ChatScreen(core: Core) {
158175 val refreshLabel = stringResource(R.string.chat_menu_refresh)
159176 val overflowLabel = stringResource(R.string.chat_menu_overflow)
160177 val chatEmptyHint = stringResource(R.string.chat_empty_hint)
178+ val copySessionIdCd = stringResource(R.string.chat_cd_copy_session_id)
179+ val sessionIdCopiedLabel = stringResource(R.string.chat_session_id_copied)
161180
162181 Scaffold(
163182 topBar = {
164183 TopAppBar(
165184 title = {
166− Column {
185+ Column(
186+ modifier = Modifier.fillMaxWidth(),
187+ ) {
167188 Text(
168189 text = title,
169190 maxLines = 1,
170191 overflow = TextOverflow.Ellipsis,
171192 )
172− if (subtitle != null) {
173− Text(
174− text = subtitle,
175− style = MaterialTheme.typography.labelSmall,
176− color = MaterialTheme.colorScheme.onSurfaceVariant,
177− maxLines = 1,
178− )
193+ if (subtitle != null && sessionId != null) {
194+ Row(
195+ verticalAlignment = Alignment.CenterVertically,
196+ modifier = Modifier
197+ .fillMaxWidth()
198+ .combinedClickable(
199+ onClick = {
200+ clipboardManager.setText(AnnotatedString(sessionId))
201+ core.notifyCopied()
202+ },
203+ onLongClick = {
204+ clipboardManager.setText(AnnotatedString(sessionId))
205+ core.notifyCopied()
206+ },
207+ )
208+ .semantics {
209+ contentDescription = copySessionIdCd
210+ },
211+ ) {
212+ Text(
213+ text = subtitle,
214+ style = MaterialTheme.typography.labelSmall,
215+ color = MaterialTheme.colorScheme.onSurfaceVariant,
216+ maxLines = 1,
217+ )
218+ Icon(
219+ Icons.Default.ContentCopy,
220+ contentDescription = null,
221+ modifier = Modifier.size(Dimens.iconInlineSpinner),
222+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
223+ )
224+ }
179225 }
180226 }
181227 },
@@ -220,18 +266,22 @@ fun ChatScreen(core: Core) {
220266 bottomBar = {
221267 ChatInputBar(
222268 text = inputText,
223− onTextChange = { inputText = it },
269+ onTextChange = {
270+ inputText = it
271+ core.update(Event.DraftChanged(it))
272+ },
224273 onSend = {
225274 if (inputText.isNotBlank()) {
226275 core.update(Event.SendMessage(inputText))
227276 inputText = ""
277+ core.update(Event.DraftChanged(""))
228278 }
229279 },
230280 // Disable while loading OR while the assistant is generating,
231281 // so users can't fire a second message mid-reply.
232282 enabled = !view.loading && !view.generating,
233283 generating = view.generating,
234− onStop = { sessionId?.let { core.update(Event.LoadMessages(it)) } },
284+ onStop = { core.update(Event.CancelGeneration) },
235285 stopContentDescription = stringResource(R.string.chat_cd_stop),
236286 placeholder = stringResource(R.string.chat_input_placeholder),
237287 sendContentDescription = stringResource(R.string.chat_cd_send),
@@ -259,6 +309,14 @@ fun ChatScreen(core: Core) {
259309 reconnectLabel = stringResource(R.string.sse_reconnect),
260310 dismissLabel = stringResource(R.string.sse_dismiss),
261311 )
312+ } else if (sseState == SseState.Connecting && sessionId != null && !view.loading) {
313+ // Brief "connecting" indicator while the SSE stream
314+ // establishes — not an error, just a transitional state.
315+ SseStatusBanner(
316+ message = stringResource(R.string.sse_connecting),
317+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
318+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
319+ )
262320 }
263321
264322 when {
@@ -366,12 +424,13 @@ private fun ChatScreenPreview() {
366424 title = "Refactor the parser",
367425 sessionId = "abc12345",
368426 messages = listOf(
369− MessageView("u1", "user", "Can you clean up parse_messages?", 1_700_000_000uL),
427+ MessageView("u1", "user", "Can you clean up parse_messages?", 1_700_000_000uL, MessageStatus.SENT),
370428 MessageView(
371429 "a1",
372430 "assistant",
373431 "Sure — here's a **plan**:\n\n1. Extract helpers\n2. Add tests",
374432 1_700_000_010uL,
433+ MessageStatus.SENT,
375434 ),
376435 ),
377436 generating = false,
@@ -387,7 +446,7 @@ private fun ChatScreenGeneratingPreview() {
387446 PreviewChatScreen(
388447 title = "New session",
389448 sessionId = "xyz12345",
390− messages = listOf(MessageView("u1", "user", "Hello!", 1_700_000_000uL)),
449+ messages = listOf(MessageView("u1", "user", "Hello!", 1_700_000_000uL, MessageStatus.SENT)),
391450 generating = true,
392451 )
393452 }