@@ -7,12 +7,15 @@ import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.PaddingValues
7import androidx.compose.foundation.layout.PaddingValues
8import androidx.compose.foundation.layout.fillMaxSize
8import androidx.compose.foundation.layout.fillMaxSize
9import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.padding
10+import androidx.compose.foundation.layout.size
10import androidx.compose.foundation.lazy.LazyColumn
11import androidx.compose.foundation.lazy.LazyColumn
11import androidx.compose.foundation.lazy.items
12import androidx.compose.foundation.lazy.items
12import androidx.compose.foundation.lazy.rememberLazyListState
13import androidx.compose.foundation.lazy.rememberLazyListState
13import androidx.compose.material.icons.Icons
14import androidx.compose.material.icons.Icons
14import androidx.compose.material.icons.automirrored.filled.ArrowBack
15import androidx.compose.material.icons.automirrored.filled.ArrowBack
16+import androidx.compose.material.icons.filled.KeyboardArrowDown
15import androidx.compose.material3.ExperimentalMaterial3Api
17import androidx.compose.material3.ExperimentalMaterial3Api
18+import androidx.compose.material3.FloatingActionButton
16import androidx.compose.material3.Icon
19import androidx.compose.material3.Icon
17import androidx.compose.material3.IconButton
20import androidx.compose.material3.IconButton
18import androidx.compose.material3.MaterialTheme
21import androidx.compose.material3.MaterialTheme
@@ -24,9 +27,11 @@ import androidx.compose.runtime.LaunchedEffect
24import androidx.compose.runtime.collectAsState
27import androidx.compose.runtime.collectAsState
25import androidx.compose.runtime.derivedStateOf
28import androidx.compose.runtime.derivedStateOf
26import androidx.compose.runtime.getValue
29import androidx.compose.runtime.getValue
30+import androidx.compose.runtime.mutableIntStateOf
27import androidx.compose.runtime.mutableStateOf
31import androidx.compose.runtime.mutableStateOf
28import androidx.compose.runtime.remember
32import androidx.compose.runtime.remember
29import androidx.compose.runtime.setValue
33import androidx.compose.runtime.setValue
34+import androidx.compose.ui.Alignment
30import androidx.compose.ui.Modifier
35import androidx.compose.ui.Modifier
31import androidx.compose.ui.res.stringResource
36import androidx.compose.ui.res.stringResource
32import androidx.compose.ui.text.style.TextOverflow
37import androidx.compose.ui.text.style.TextOverflow
@@ -38,9 +43,9 @@ import soy.iko.opencode.core.Event
38import soy.iko.opencode.core.MessageView
43import soy.iko.opencode.core.MessageView
39import soy.iko.opencode.core.SseState
44import soy.iko.opencode.core.SseState
40import soy.iko.opencode.ui.components.ChatInputBar
45import soy.iko.opencode.ui.components.ChatInputBar
46+import soy.iko.opencode.ui.components.DualSnackbarHost
41import soy.iko.opencode.ui.components.EmptyState
47import soy.iko.opencode.ui.components.EmptyState
42import soy.iko.opencode.ui.components.ErrorHost
48import soy.iko.opencode.ui.components.ErrorHost
43−import soy.iko.opencode.ui.components.ErrorSnackbarHost
44import soy.iko.opencode.ui.components.GeneratingIndicator
49import soy.iko.opencode.ui.components.GeneratingIndicator
45import soy.iko.opencode.ui.components.InfoHost
50import soy.iko.opencode.ui.components.InfoHost
46import soy.iko.opencode.ui.components.LoadingPlaceholder
51import soy.iko.opencode.ui.components.LoadingPlaceholder
@@ -56,6 +61,7 @@ fun ChatScreen(core: Core) {
56 val sseState by core.sseState.collectAsState()
61 val sseState by core.sseState.collectAsState()
57 var inputText by remember { mutableStateOf("") }
62 var inputText by remember { mutableStateOf("") }
58 val listState = rememberLazyListState()
63 val listState = rememberLazyListState()
64+ var scrollRequest by remember { mutableIntStateOf(0) }
60 // Track whether the user is parked at the bottom of the list. We only
66 // Track whether the user is parked at the bottom of the list. We only
61 // auto-scroll when they are, so reading history isn't yanked away as
67 // auto-scroll when they are, so reading history isn't yanked away as
@@ -73,37 +79,66 @@ fun ChatScreen(core: Core) {
82+ // Scroll-to-bottom when the user taps the FAB. Keyed on a counter so
83+ // repeated taps keep firing.
84+ LaunchedEffect(scrollRequest) {
85+ if (scrollRequest > 0 && view.messages.isNotEmpty()) {
86+ // Index of the trailing __generating__ item (it's last in the
87+ // list, at position == messages.size). Falling back to the last
88+ // message is safe even if the generating item is invisible.
89+ listState.animateScrollToItem(view.messages.size)
76− val snackbarHostState = ErrorHost(
93+ val errorHostState = ErrorHost(
77 core = core,
94 core = core,
78 dismissLabel = stringResource(R.string.action_dismiss),
95 dismissLabel = stringResource(R.string.action_dismiss),
79 retryLabel = stringResource(R.string.action_retry),
96 retryLabel = stringResource(R.string.action_retry),
81− InfoHost(
98+ val infoHostState = InfoHost(
82 core = core,
99 core = core,
83 successConnectedLabel = stringResource(R.string.connect_success),
100 successConnectedLabel = stringResource(R.string.connect_success),
84 successSessionCreatedLabel = stringResource(R.string.session_created),
101 successSessionCreatedLabel = stringResource(R.string.session_created),
85− snackbarHostState = snackbarHostState,
88 val showSseBanner = sseState is SseState.Error ||
104 val showSseBanner = sseState is SseState.Error ||
89 (sseState == SseState.Disconnected && view.currentSessionId != null && !view.loading)
105 (sseState == SseState.Disconnected && view.currentSessionId != null && !view.loading)
107+ // Resolve nullable session fields into locals once per recomposition so we
108+ // never smart-cast a delegate-backed collected property across calls.
109+ val sessionId = view.currentSessionId
110+ val sessionTitle = view.currentSessionTitle
111+ val chatFallback = stringResource(R.string.top_bar_chat_fallback)
112+ val idShortTemplate = stringResource(R.string.chat_session_id_short)
113+ val title = remember(sessionId, sessionTitle) {
115+ sessionTitle.isNotEmpty() -> sessionTitle
116+ sessionId != null -> {
117+ val prefix = sessionId.take(8)
118+ if (sessionId.length > prefix.length) "$prefix…" else prefix
120+ else -> chatFallback
123+ val subtitle = remember(sessionId, sessionTitle) {
124+ if (sessionTitle.isNotEmpty() && sessionId != null) {
125+ idShortTemplate.format(sessionId.take(8))
91 Scaffold(
129 Scaffold(
92 topBar = {
130 topBar = {
93 TopAppBar(
131 TopAppBar(
94 title = {
132 title = {
97− text = view.currentSessionTitle.ifEmpty {
135+ text = title,
98− view.currentSessionId?.take(8)?.plus("…")
99− ?: stringResource(R.string.top_bar_chat_fallback)
101 maxLines = 1,
136 maxLines = 1,
102 overflow = TextOverflow.Ellipsis,
137 overflow = TextOverflow.Ellipsis,
104− if (view.currentSessionTitle.isNotEmpty() && view.currentSessionId != null) {
139+ if (subtitle != null) {
106− text = view.currentSessionId!!.take(8),
141+ text = subtitle,
107 style = MaterialTheme.typography.labelSmall,
142 style = MaterialTheme.typography.labelSmall,
108 color = MaterialTheme.colorScheme.onSurfaceVariant,
143 color = MaterialTheme.colorScheme.onSurfaceVariant,
109 maxLines = 1,
144 maxLines = 1,
@@ -131,12 +166,14 @@ fun ChatScreen(core: Core) {
131 inputText = ""
166 inputText = ""
169+ // Disable while loading OR while the assistant is generating,
170+ // so users can't fire a second message mid-reply.
134− enabled = !view.loading,
171+ enabled = !view.loading && !view.generating,
135 placeholder = stringResource(R.string.chat_input_placeholder),
172 placeholder = stringResource(R.string.chat_input_placeholder),
136 sendContentDescription = stringResource(R.string.chat_cd_send),
173 sendContentDescription = stringResource(R.string.chat_cd_send),
139− snackbarHost = { ErrorSnackbarHost(snackbarHostState) },
176+ snackbarHost = { DualSnackbarHost(errorHostState, infoHostState) },
140 ) { padding ->
177 ) { padding ->
142 modifier = Modifier
179 modifier = Modifier
@@ -184,6 +221,28 @@ fun ChatScreen(core: Core) {
225+ // Scroll-to-bottom FAB: only when the user has scrolled up away
226+ // from the latest message and there's content to scroll to.
227+ if (view.messages.isNotEmpty() && !isAtBottom) {
228+ FloatingActionButton(
229+ onClick = { scrollRequest++ },
231+ .align(Alignment.BottomEnd)
233+ end = Dimens.fabEndMargin,
234+ bottom = Dimens.fabBottomMargin,
236+ .size(Dimens.fabSize),
237+ containerColor = MaterialTheme.colorScheme.primaryContainer,
238+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
241+ Icons.Default.KeyboardArrowDown,
242+ contentDescription = stringResource(R.string.chat_cd_scroll_to_bottom),
@@ -242,14 +301,20 @@ private fun PreviewChatScreen(
242 messages: List<MessageView>,
301 messages: List<MessageView>,
243 generating: Boolean,
302 generating: Boolean,
304+ val resolvedTitle = when {
305+ title.isNotEmpty() -> title
306+ sessionId != null -> {
307+ val prefix = sessionId.take(8)
308+ if (sessionId.length > prefix.length) "$prefix…" else prefix
245 Scaffold(
312 Scaffold(
246 topBar = {
313 topBar = {
247 TopAppBar(
314 TopAppBar(
248 title = {
315 title = {
250− text = title.ifEmpty {
317+ text = resolvedTitle,
251− sessionId?.take(8)?.plus("…") ?: "Chat"
253 maxLines = 1,
318 maxLines = 1,
254 overflow = TextOverflow.Ellipsis,
319 overflow = TextOverflow.Ellipsis,