@@ -1,21 +1,26 @@
11package soy.iko.opencode.ui.screens
22
33import androidx.compose.animation.AnimatedVisibility
4+import androidx.compose.animation.scaleIn
5+import androidx.compose.animation.scaleOut
46import androidx.compose.foundation.layout.Arrangement
57import androidx.compose.foundation.layout.Box
68import androidx.compose.foundation.layout.Column
79import androidx.compose.foundation.layout.PaddingValues
810import androidx.compose.foundation.layout.fillMaxSize
911import androidx.compose.foundation.layout.padding
10−import androidx.compose.foundation.layout.size
1112import androidx.compose.foundation.lazy.LazyColumn
1213import androidx.compose.foundation.lazy.items
1314import androidx.compose.foundation.lazy.rememberLazyListState
1415import androidx.compose.material.icons.Icons
1516import androidx.compose.material.icons.automirrored.filled.ArrowBack
1617import androidx.compose.material.icons.filled.KeyboardArrowDown
18+import androidx.compose.material.icons.filled.MoreVert
19+import androidx.compose.material.icons.filled.Refresh
20+import androidx.compose.material3.DropdownMenu
21+import androidx.compose.material3.DropdownMenuItem
1722import androidx.compose.material3.ExperimentalMaterial3Api
18−import androidx.compose.material3.FloatingActionButton
23+import androidx.compose.material3.ExtendedFloatingActionButton
1924import androidx.compose.material3.Icon
2025import androidx.compose.material3.IconButton
2126import androidx.compose.material3.MaterialTheme
@@ -33,6 +38,8 @@ import androidx.compose.runtime.remember
3338import androidx.compose.runtime.setValue
3439import androidx.compose.ui.Alignment
3540import androidx.compose.ui.Modifier
41+import androidx.compose.ui.focus.FocusRequester
42+import androidx.compose.ui.focus.focusRequester
3643import androidx.compose.ui.res.stringResource
3744import androidx.compose.ui.text.style.TextOverflow
3845import androidx.compose.ui.tooling.preview.Preview
@@ -62,6 +69,8 @@ fun ChatScreen(core: Core) {
6269 var inputText by remember { mutableStateOf("") }
6370 val listState = rememberLazyListState()
6471 var scrollRequest by remember { mutableIntStateOf(0) }
72+ var menuExpanded by remember { mutableStateOf(false) }
73+ val inputFocusRequester = remember { FocusRequester() }
6574
6675 // Track whether the user is parked at the bottom of the list. We only
6776 // auto-scroll when they are, so reading history isn't yanked away as
@@ -90,6 +99,16 @@ fun ChatScreen(core: Core) {
9099 }
91100 }
92101
102+ // Auto-focus the input when the chat is empty so the user can start
103+ // typing immediately without an extra tap. Only request focus once per
104+ // session entry — keyed on the session id so switching sessions retriggers.
105+ val sessionId = view.currentSessionId
106+ LaunchedEffect(sessionId, view.messages.isEmpty()) {
107+ if (sessionId != null && view.messages.isEmpty() && !view.loading && !view.generating) {
108+ inputFocusRequester.requestFocus()
109+ }
110+ }
111+
93112 val errorHostState = ErrorHost(
94113 core = core,
95114 dismissLabel = stringResource(R.string.action_dismiss),
@@ -106,7 +125,6 @@ fun ChatScreen(core: Core) {
106125
107126 // Resolve nullable session fields into locals once per recomposition so we
108127 // never smart-cast a delegate-backed collected property across calls.
109− val sessionId = view.currentSessionId
110128 val sessionTitle = view.currentSessionTitle
111129 val chatFallback = stringResource(R.string.top_bar_chat_fallback)
112130 val idShortTemplate = stringResource(R.string.chat_session_id_short)
@@ -126,6 +144,10 @@ fun ChatScreen(core: Core) {
126144 } else null
127145 }
128146
147+ val refreshLabel = stringResource(R.string.chat_menu_refresh)
148+ val overflowLabel = stringResource(R.string.chat_menu_overflow)
149+ val chatEmptyHint = stringResource(R.string.chat_empty_hint)
150+
129151 Scaffold(
130152 topBar = {
131153 TopAppBar(
@@ -154,6 +176,34 @@ fun ChatScreen(core: Core) {
154176 )
155177 }
156178 },
179+ actions = {
180+ // Overflow menu with a "Refresh messages" action. Gives
181+ // a discoverable manual reload path (e.g. when the SSE
182+ // banner has been dismissed) and a home for future items.
183+ Box {
184+ IconButton(onClick = { menuExpanded = true }) {
185+ Icon(
186+ Icons.Default.MoreVert,
187+ contentDescription = overflowLabel,
188+ )
189+ }
190+ DropdownMenu(
191+ expanded = menuExpanded,
192+ onDismissRequest = { menuExpanded = false },
193+ ) {
194+ DropdownMenuItem(
195+ text = { Text(refreshLabel) },
196+ leadingIcon = {
197+ Icon(Icons.Default.Refresh, contentDescription = null)
198+ },
199+ onClick = {
200+ menuExpanded = false
201+ sessionId?.let { core.update(Event.LoadMessages(it)) }
202+ },
203+ )
204+ }
205+ }
206+ },
157207 )
158208 },
159209 bottomBar = {
@@ -171,6 +221,7 @@ fun ChatScreen(core: Core) {
171221 enabled = !view.loading && !view.generating,
172222 placeholder = stringResource(R.string.chat_input_placeholder),
173223 sendContentDescription = stringResource(R.string.chat_cd_send),
224+ modifier = Modifier.focusRequester(inputFocusRequester),
174225 )
175226 },
176227 snackbarHost = { DualSnackbarHost(errorHostState, infoHostState) },
@@ -196,25 +247,38 @@ fun ChatScreen(core: Core) {
196247 LoadingPlaceholder()
197248 }
198249 view.messages.isEmpty() -> {
199− EmptyState(message = stringResource(R.string.chat_empty))
250+ EmptyState(message = chatEmptyHint)
200251 }
201252 else -> {
202− LazyColumn(
203− state = listState,
204− modifier = Modifier
205− .fillMaxSize()
206− .padding(horizontal = Dimens.listHorizontalPadding),
207− verticalArrangement = Arrangement.spacedBy(Dimens.listItemSpacing),
208− contentPadding = PaddingValues(vertical = Dimens.listVerticalPadding),
253+ // Pull-to-refresh lets the user manually reload
254+ // messages, e.g. when the SSE stream stalled and the
255+ // banner was dismissed. Mirrors the Sessions screen.
256+ androidx.compose.material3.pulltorefresh.PullToRefreshBox(
257+ isRefreshing = view.loading,
258+ onRefresh = { sessionId?.let { core.update(Event.LoadMessages(it)) } },
259+ modifier = Modifier.fillMaxSize(),
209260 ) {
210− items(view.messages, key = { it.id }) { message ->
211− MessageBubble(message)
212− }
213− // "Thinking" indicator shown after the user sends, while
214− // the assistant reply hasn't arrived yet.
215− item(key = "__generating__") {
216− AnimatedVisibility(visible = view.generating) {
217− GeneratingIndicator()
261+ LazyColumn(
262+ state = listState,
263+ modifier = Modifier
264+ .fillMaxSize()
265+ .padding(horizontal = Dimens.listHorizontalPadding),
266+ verticalArrangement = Arrangement.spacedBy(Dimens.listItemSpacing),
267+ contentPadding = PaddingValues(vertical = Dimens.listVerticalPadding),
268+ ) {
269+ items(view.messages, key = { it.id }) { message ->
270+ MessageBubble(message)
271+ }
272+ // "Thinking" indicator shown after the user sends, while
273+ // the assistant reply hasn't arrived yet. The indicator
274+ // itself animates (pulsing dots), so we don't wrap it in
275+ // AnimatedVisibility — an outer ColumnScope receiver from
276+ // the chat Column would shadow the LazyItemScope here and
277+ // break AnimatedVisibility overload resolution.
278+ if (view.generating) {
279+ item(key = "__generating__") {
280+ GeneratingIndicator()
281+ }
218282 }
219283 }
220284 }
@@ -224,24 +288,30 @@ fun ChatScreen(core: Core) {
224288
225289 // Scroll-to-bottom FAB: only when the user has scrolled up away
226290 // from the latest message and there's content to scroll to.
227− if (view.messages.isNotEmpty() && !isAtBottom) {
228− FloatingActionButton(
291+ // Animated so it scales in/out rather than popping.
292+ AnimatedVisibility(
293+ visible = view.messages.isNotEmpty() && !isAtBottom,
294+ enter = scaleIn(),
295+ exit = scaleOut(),
296+ modifier = Modifier.align(Alignment.BottomEnd),
297+ ) {
298+ ExtendedFloatingActionButton(
229299 onClick = { scrollRequest++ },
300+ icon = {
301+ Icon(
302+ Icons.Default.KeyboardArrowDown,
303+ contentDescription = stringResource(R.string.chat_cd_scroll_to_bottom),
304+ )
305+ },
306+ text = { Text(stringResource(R.string.chat_cd_scroll_to_bottom)) },
307+ containerColor = MaterialTheme.colorScheme.primaryContainer,
308+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
230309 modifier = Modifier
231− .align(Alignment.BottomEnd)
232310 .padding(
233311 end = Dimens.fabEndMargin,
234312 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− }
313+ ),
314+ )
245315 }
246316 }
247317 }