@@ -3,17 +3,23 @@ package soy.iko.opencode.ui.screens
3import androidx.compose.animation.AnimatedVisibility
3import androidx.compose.animation.AnimatedVisibility
4import androidx.compose.animation.scaleIn
4import androidx.compose.animation.scaleIn
5import androidx.compose.animation.scaleOut
5import androidx.compose.animation.scaleOut
6+import androidx.compose.foundation.ExperimentalFoundationApi
7+import androidx.compose.foundation.combinedClickable
6import androidx.compose.foundation.layout.Arrangement
8import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.Box
9import androidx.compose.foundation.layout.Box
8import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.Column
9import androidx.compose.foundation.layout.PaddingValues
11import androidx.compose.foundation.layout.PaddingValues
12+import androidx.compose.foundation.layout.Row
10import androidx.compose.foundation.layout.fillMaxSize
13import androidx.compose.foundation.layout.fillMaxSize
14+import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.padding
16+import androidx.compose.foundation.layout.size
12import androidx.compose.foundation.lazy.LazyColumn
17import androidx.compose.foundation.lazy.LazyColumn
13import androidx.compose.foundation.lazy.items
18import androidx.compose.foundation.lazy.items
14import androidx.compose.foundation.lazy.rememberLazyListState
19import androidx.compose.foundation.lazy.rememberLazyListState
15import androidx.compose.material.icons.Icons
20import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.automirrored.filled.ArrowBack
21import androidx.compose.material.icons.automirrored.filled.ArrowBack
22+import androidx.compose.material.icons.filled.ContentCopy
17import androidx.compose.material.icons.filled.KeyboardArrowDown
23import androidx.compose.material.icons.filled.KeyboardArrowDown
18import androidx.compose.material.icons.filled.MoreVert
24import androidx.compose.material.icons.filled.MoreVert
19import androidx.compose.material.icons.filled.Refresh
25import androidx.compose.material.icons.filled.Refresh
@@ -35,18 +41,24 @@ import androidx.compose.runtime.getValue
35import androidx.compose.runtime.mutableIntStateOf
41import androidx.compose.runtime.mutableIntStateOf
36import androidx.compose.runtime.mutableStateOf
42import androidx.compose.runtime.mutableStateOf
37import androidx.compose.runtime.remember
43import androidx.compose.runtime.remember
44+import androidx.compose.runtime.saveable.rememberSaveable
38import androidx.compose.runtime.setValue
45import androidx.compose.runtime.setValue
39import androidx.compose.ui.Alignment
46import androidx.compose.ui.Alignment
40import androidx.compose.ui.Modifier
47import androidx.compose.ui.Modifier
41import androidx.compose.ui.focus.FocusRequester
48import androidx.compose.ui.focus.FocusRequester
42import androidx.compose.ui.focus.focusRequester
49import androidx.compose.ui.focus.focusRequester
50+import androidx.compose.ui.platform.LocalClipboardManager
43import androidx.compose.ui.res.stringResource
51import 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
44import androidx.compose.ui.text.style.TextOverflow
55import androidx.compose.ui.text.style.TextOverflow
45import androidx.compose.ui.tooling.preview.Preview
56import androidx.compose.ui.tooling.preview.Preview
46import androidx.compose.ui.unit.dp
57import androidx.compose.ui.unit.dp
47import soy.iko.opencode.R
58import soy.iko.opencode.R
48import soy.iko.opencode.core.Core
59import soy.iko.opencode.core.Core
49import soy.iko.opencode.core.Event
60import soy.iko.opencode.core.Event
61+import soy.iko.opencode.core.MessageStatus
50import soy.iko.opencode.core.MessageView
62import soy.iko.opencode.core.MessageView
51import soy.iko.opencode.core.SseState
63import soy.iko.opencode.core.SseState
52import soy.iko.opencode.ui.components.ChatInputBar
64import soy.iko.opencode.ui.components.ChatInputBar
@@ -59,29 +71,36 @@ import soy.iko.opencode.ui.components.InfoHost
59import soy.iko.opencode.ui.components.LoadingPlaceholder
71import soy.iko.opencode.ui.components.LoadingPlaceholder
60import soy.iko.opencode.ui.components.MessageBubble
72import soy.iko.opencode.ui.components.MessageBubble
61import soy.iko.opencode.ui.components.SseDisconnectedBanner
73import soy.iko.opencode.ui.components.SseDisconnectedBanner
74+import soy.iko.opencode.ui.components.SseStatusBanner
62import soy.iko.opencode.ui.components.dayOfEpoch
75import soy.iko.opencode.ui.components.dayOfEpoch
63import soy.iko.opencode.ui.theme.Dimens
76import soy.iko.opencode.ui.theme.Dimens
64import soy.iko.opencode.ui.theme.OpencodeTheme
77import soy.iko.opencode.ui.theme.OpencodeTheme
66−@OptIn(ExperimentalMaterial3Api::class)
79+@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
67@Composable
80@Composable
68fun ChatScreen(core: Core) {
81fun ChatScreen(core: Core) {
69 val view by core.view.collectAsState()
82 val view by core.view.collectAsState()
70 val sseState by core.sseState.collectAsState()
83 val sseState by core.sseState.collectAsState()
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.
71− var inputText by remember { mutableStateOf("") }
87+ var inputText by remember(view.draftMessage) { mutableStateOf(view.draftMessage) }
72 val listState = rememberLazyListState()
88 val listState = rememberLazyListState()
73− var scrollRequest by remember { mutableIntStateOf(0) }
89+ var scrollRequest by rememberSaveable { mutableIntStateOf(0) }
74− var menuExpanded by remember { mutableStateOf(false) }
90+ var menuExpanded by rememberSaveable { mutableStateOf(false) }
75− var sseBannerDismissed by remember { mutableStateOf(false) }
91+ var sseBannerDismissed by rememberSaveable { mutableStateOf(false) }
76 val inputFocusRequester = remember { FocusRequester() }
92 val inputFocusRequester = remember { FocusRequester() }
93+ val clipboardManager = LocalClipboardManager.current
78 // Track whether the user is parked at the bottom of the list. We only
95 // Track whether the user is parked at the bottom of the list. We only
79 // auto-scroll when they are, so reading history isn't yanked away as
96 // auto-scroll when they are, so reading history isn't yanked away as
80 // new messages stream in.
97 // new messages stream in.
81 val isAtBottom by remember {
98 val isAtBottom by remember {
82 derivedStateOf {
99 derivedStateOf {
100+ val layoutInfo = listState.layoutInfo
101+ val totalItems = layoutInfo.totalItemsCount
83− val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
102+ val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
84− lastVisible >= view.messages.size - 2
103+ totalItems == 0 || lastVisible >= totalItems - 2
@@ -95,10 +114,7 @@ fun ChatScreen(core: Core) {
95 // repeated taps keep firing.
114 // repeated taps keep firing.
96 LaunchedEffect(scrollRequest) {
115 LaunchedEffect(scrollRequest) {
97 if (scrollRequest > 0 && view.messages.isNotEmpty()) {
116 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)
@@ -122,6 +138,7 @@ fun ChatScreen(core: Core) {
122 successConnectedLabel = stringResource(R.string.connect_success),
138 successConnectedLabel = stringResource(R.string.connect_success),
123 successSessionCreatedLabel = stringResource(R.string.session_created),
139 successSessionCreatedLabel = stringResource(R.string.session_created),
124 copiedLabel = stringResource(R.string.chat_copied),
140 copiedLabel = stringResource(R.string.chat_copied),
141+ sessionDeletedLabel = stringResource(R.string.session_deleted),
127 val showSseBanner = (sseState is SseState.Error ||
144 val showSseBanner = (sseState is SseState.Error ||
@@ -158,24 +175,53 @@ fun ChatScreen(core: Core) {
158 val refreshLabel = stringResource(R.string.chat_menu_refresh)
175 val refreshLabel = stringResource(R.string.chat_menu_refresh)
159 val overflowLabel = stringResource(R.string.chat_menu_overflow)
176 val overflowLabel = stringResource(R.string.chat_menu_overflow)
160 val chatEmptyHint = stringResource(R.string.chat_empty_hint)
177 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)
162 Scaffold(
181 Scaffold(
163 topBar = {
182 topBar = {
164 TopAppBar(
183 TopAppBar(
165 title = {
184 title = {
166− Column {
185+ Column(
186+ modifier = Modifier.fillMaxWidth(),
168 text = title,
189 text = title,
169 maxLines = 1,
190 maxLines = 1,
170 overflow = TextOverflow.Ellipsis,
191 overflow = TextOverflow.Ellipsis,
172− if (subtitle != null) {
193+ if (subtitle != null && sessionId != null) {
195+ verticalAlignment = Alignment.CenterVertically,
200+ clipboardManager.setText(AnnotatedString(sessionId))
204+ clipboardManager.setText(AnnotatedString(sessionId))
209+ contentDescription = copySessionIdCd
174− text = subtitle,
213+ text = subtitle,
175− style = MaterialTheme.typography.labelSmall,
214+ style = MaterialTheme.typography.labelSmall,
176− color = MaterialTheme.colorScheme.onSurfaceVariant,
215+ color = MaterialTheme.colorScheme.onSurfaceVariant,
177− maxLines = 1,
216+ maxLines = 1,
219+ Icons.Default.ContentCopy,
220+ contentDescription = null,
221+ modifier = Modifier.size(Dimens.iconInlineSpinner),
222+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -220,18 +266,22 @@ fun ChatScreen(core: Core) {
220 bottomBar = {
266 bottomBar = {
221 ChatInputBar(
267 ChatInputBar(
222 text = inputText,
268 text = inputText,
223− onTextChange = { inputText = it },
269+ onTextChange = {
271+ core.update(Event.DraftChanged(it))
224 onSend = {
273 onSend = {
225 if (inputText.isNotBlank()) {
274 if (inputText.isNotBlank()) {
226 core.update(Event.SendMessage(inputText))
275 core.update(Event.SendMessage(inputText))
227 inputText = ""
276 inputText = ""
277+ core.update(Event.DraftChanged(""))
230 // Disable while loading OR while the assistant is generating,
280 // Disable while loading OR while the assistant is generating,
231 // so users can't fire a second message mid-reply.
281 // so users can't fire a second message mid-reply.
232 enabled = !view.loading && !view.generating,
282 enabled = !view.loading && !view.generating,
233 generating = view.generating,
283 generating = view.generating,
234− onStop = { sessionId?.let { core.update(Event.LoadMessages(it)) } },
284+ onStop = { core.update(Event.CancelGeneration) },
235 stopContentDescription = stringResource(R.string.chat_cd_stop),
285 stopContentDescription = stringResource(R.string.chat_cd_stop),
236 placeholder = stringResource(R.string.chat_input_placeholder),
286 placeholder = stringResource(R.string.chat_input_placeholder),
237 sendContentDescription = stringResource(R.string.chat_cd_send),
287 sendContentDescription = stringResource(R.string.chat_cd_send),
@@ -259,6 +309,14 @@ fun ChatScreen(core: Core) {
259 reconnectLabel = stringResource(R.string.sse_reconnect),
309 reconnectLabel = stringResource(R.string.sse_reconnect),
260 dismissLabel = stringResource(R.string.sse_dismiss),
310 dismissLabel = stringResource(R.string.sse_dismiss),
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.
316+ message = stringResource(R.string.sse_connecting),
317+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
318+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -366,12 +424,13 @@ private fun ChatScreenPreview() {
366 title = "Refactor the parser",
424 title = "Refactor the parser",
367 sessionId = "abc12345",
425 sessionId = "abc12345",
368 messages = listOf(
426 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),
370 MessageView(
428 MessageView(
372 "assistant",
430 "assistant",
373 "Sure — here's a **plan**:\n\n1. Extract helpers\n2. Add tests",
431 "Sure — here's a **plan**:\n\n1. Extract helpers\n2. Add tests",
374 1_700_000_010uL,
432 1_700_000_010uL,
377 generating = false,
436 generating = false,
@@ -387,7 +446,7 @@ private fun ChatScreenGeneratingPreview() {
387 PreviewChatScreen(
446 PreviewChatScreen(
388 title = "New session",
447 title = "New session",
389 sessionId = "xyz12345",
448 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)),
391 generating = true,
450 generating = true,