@@ -7,12 +7,15 @@ import androidx.compose.foundation.layout.Column
77import androidx.compose.foundation.layout.PaddingValues
88import androidx.compose.foundation.layout.fillMaxSize
99import androidx.compose.foundation.layout.padding
10+import androidx.compose.foundation.layout.size
1011import androidx.compose.foundation.lazy.LazyColumn
1112import androidx.compose.foundation.lazy.items
1213import androidx.compose.foundation.lazy.rememberLazyListState
1314import androidx.compose.material.icons.Icons
1415import androidx.compose.material.icons.automirrored.filled.ArrowBack
16+import androidx.compose.material.icons.filled.KeyboardArrowDown
1517import androidx.compose.material3.ExperimentalMaterial3Api
18+import androidx.compose.material3.FloatingActionButton
1619import androidx.compose.material3.Icon
1720import androidx.compose.material3.IconButton
1821import androidx.compose.material3.MaterialTheme
@@ -24,9 +27,11 @@ import androidx.compose.runtime.LaunchedEffect
2427import androidx.compose.runtime.collectAsState
2528import androidx.compose.runtime.derivedStateOf
2629import androidx.compose.runtime.getValue
30+import androidx.compose.runtime.mutableIntStateOf
2731import androidx.compose.runtime.mutableStateOf
2832import androidx.compose.runtime.remember
2933import androidx.compose.runtime.setValue
34+import androidx.compose.ui.Alignment
3035import androidx.compose.ui.Modifier
3136import androidx.compose.ui.res.stringResource
3237import androidx.compose.ui.text.style.TextOverflow
@@ -38,9 +43,9 @@ import soy.iko.opencode.core.Event
3843import soy.iko.opencode.core.MessageView
3944import soy.iko.opencode.core.SseState
4045import soy.iko.opencode.ui.components.ChatInputBar
46+import soy.iko.opencode.ui.components.DualSnackbarHost
4147import soy.iko.opencode.ui.components.EmptyState
4248import soy.iko.opencode.ui.components.ErrorHost
43−import soy.iko.opencode.ui.components.ErrorSnackbarHost
4449import soy.iko.opencode.ui.components.GeneratingIndicator
4550import soy.iko.opencode.ui.components.InfoHost
4651import soy.iko.opencode.ui.components.LoadingPlaceholder
@@ -56,6 +61,7 @@ fun ChatScreen(core: Core) {
5661 val sseState by core.sseState.collectAsState()
5762 var inputText by remember { mutableStateOf("") }
5863 val listState = rememberLazyListState()
64+ var scrollRequest by remember { mutableIntStateOf(0) }
5965
6066 // Track whether the user is parked at the bottom of the list. We only
6167 // auto-scroll when they are, so reading history isn't yanked away as
@@ -73,37 +79,66 @@ fun ChatScreen(core: Core) {
7379 }
7480 }
7581
76− val snackbarHostState = ErrorHost(
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)
90+ }
91+ }
92+
93+ val errorHostState = ErrorHost(
7794 core = core,
7895 dismissLabel = stringResource(R.string.action_dismiss),
7996 retryLabel = stringResource(R.string.action_retry),
8097 )
81− InfoHost(
98+ val infoHostState = InfoHost(
8299 core = core,
83100 successConnectedLabel = stringResource(R.string.connect_success),
84101 successSessionCreatedLabel = stringResource(R.string.session_created),
85− snackbarHostState = snackbarHostState,
86102 )
87103
88104 val showSseBanner = sseState is SseState.Error ||
89105 (sseState == SseState.Disconnected && view.currentSessionId != null && !view.loading)
90106
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) {
114+ when {
115+ sessionTitle.isNotEmpty() -> sessionTitle
116+ sessionId != null -> {
117+ val prefix = sessionId.take(8)
118+ if (sessionId.length > prefix.length) "$prefix…" else prefix
119+ }
120+ else -> chatFallback
121+ }
122+ }
123+ val subtitle = remember(sessionId, sessionTitle) {
124+ if (sessionTitle.isNotEmpty() && sessionId != null) {
125+ idShortTemplate.format(sessionId.take(8))
126+ } else null
127+ }
128+
91129 Scaffold(
92130 topBar = {
93131 TopAppBar(
94132 title = {
95133 Column {
96134 Text(
97− text = view.currentSessionTitle.ifEmpty {
98− view.currentSessionId?.take(8)?.plus("…")
99− ?: stringResource(R.string.top_bar_chat_fallback)
100− },
135+ text = title,
101136 maxLines = 1,
102137 overflow = TextOverflow.Ellipsis,
103138 )
104− if (view.currentSessionTitle.isNotEmpty() && view.currentSessionId != null) {
139+ if (subtitle != null) {
105140 Text(
106− text = view.currentSessionId!!.take(8),
141+ text = subtitle,
107142 style = MaterialTheme.typography.labelSmall,
108143 color = MaterialTheme.colorScheme.onSurfaceVariant,
109144 maxLines = 1,
@@ -131,12 +166,14 @@ fun ChatScreen(core: Core) {
131166 inputText = ""
132167 }
133168 },
134− enabled = !view.loading,
169+ // Disable while loading OR while the assistant is generating,
170+ // so users can't fire a second message mid-reply.
171+ enabled = !view.loading && !view.generating,
135172 placeholder = stringResource(R.string.chat_input_placeholder),
136173 sendContentDescription = stringResource(R.string.chat_cd_send),
137174 )
138175 },
139− snackbarHost = { ErrorSnackbarHost(snackbarHostState) },
176+ snackbarHost = { DualSnackbarHost(errorHostState, infoHostState) },
140177 ) { padding ->
141178 Box(
142179 modifier = Modifier
@@ -184,6 +221,28 @@ fun ChatScreen(core: Core) {
184221 }
185222 }
186223 }
224+
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++ },
230+ modifier = Modifier
231+ .align(Alignment.BottomEnd)
232+ .padding(
233+ end = Dimens.fabEndMargin,
234+ bottom = Dimens.fabBottomMargin,
235+ )
236+ .size(Dimens.fabSize),
237+ containerColor = MaterialTheme.colorScheme.primaryContainer,
238+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
239+ ) {
240+ Icon(
241+ Icons.Default.KeyboardArrowDown,
242+ contentDescription = stringResource(R.string.chat_cd_scroll_to_bottom),
243+ )
244+ }
245+ }
187246 }
188247 }
189248}
@@ -242,14 +301,20 @@ private fun PreviewChatScreen(
242301 messages: List<MessageView>,
243302 generating: Boolean,
244303) {
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
309+ }
310+ else -> "Chat"
311+ }
245312 Scaffold(
246313 topBar = {
247314 TopAppBar(
248315 title = {
249316 Text(
250− text = title.ifEmpty {
251− sessionId?.take(8)?.plus("…") ?: "Chat"
252− },
317+ text = resolvedTitle,
253318 maxLines = 1,
254319 overflow = TextOverflow.Ellipsis,
255320 )