@@ -1,21 +1,26 @@
1package soy.iko.opencode.ui.screens
1package soy.iko.opencode.ui.screens
3import androidx.compose.animation.AnimatedVisibility
3import androidx.compose.animation.AnimatedVisibility
4+import androidx.compose.animation.scaleIn
5+import androidx.compose.animation.scaleOut
4import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Column
8import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.PaddingValues
9import androidx.compose.foundation.layout.PaddingValues
8import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxSize
9import androidx.compose.foundation.layout.padding
11import androidx.compose.foundation.layout.padding
10−import androidx.compose.foundation.layout.size
11import androidx.compose.foundation.lazy.LazyColumn
12import androidx.compose.foundation.lazy.LazyColumn
12import androidx.compose.foundation.lazy.items
13import androidx.compose.foundation.lazy.items
13import androidx.compose.foundation.lazy.rememberLazyListState
14import androidx.compose.foundation.lazy.rememberLazyListState
14import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.automirrored.filled.ArrowBack
16import androidx.compose.material.icons.automirrored.filled.ArrowBack
16import androidx.compose.material.icons.filled.KeyboardArrowDown
17import 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
17import androidx.compose.material3.ExperimentalMaterial3Api
22import androidx.compose.material3.ExperimentalMaterial3Api
18−import androidx.compose.material3.FloatingActionButton
23+import androidx.compose.material3.ExtendedFloatingActionButton
19import androidx.compose.material3.Icon
24import androidx.compose.material3.Icon
20import androidx.compose.material3.IconButton
25import androidx.compose.material3.IconButton
21import androidx.compose.material3.MaterialTheme
26import androidx.compose.material3.MaterialTheme
@@ -33,6 +38,8 @@ import androidx.compose.runtime.remember
33import androidx.compose.runtime.setValue
38import androidx.compose.runtime.setValue
34import androidx.compose.ui.Alignment
39import androidx.compose.ui.Alignment
35import androidx.compose.ui.Modifier
40import androidx.compose.ui.Modifier
41+import androidx.compose.ui.focus.FocusRequester
42+import androidx.compose.ui.focus.focusRequester
36import androidx.compose.ui.res.stringResource
43import androidx.compose.ui.res.stringResource
37import androidx.compose.ui.text.style.TextOverflow
44import androidx.compose.ui.text.style.TextOverflow
38import androidx.compose.ui.tooling.preview.Preview
45import androidx.compose.ui.tooling.preview.Preview
@@ -62,6 +69,8 @@ fun ChatScreen(core: Core) {
62 var inputText by remember { mutableStateOf("") }
69 var inputText by remember { mutableStateOf("") }
63 val listState = rememberLazyListState()
70 val listState = rememberLazyListState()
64 var scrollRequest by remember { mutableIntStateOf(0) }
71 var scrollRequest by remember { mutableIntStateOf(0) }
72+ var menuExpanded by remember { mutableStateOf(false) }
73+ val inputFocusRequester = remember { FocusRequester() }
66 // Track whether the user is parked at the bottom of the list. We only
75 // Track whether the user is parked at the bottom of the list. We only
67 // auto-scroll when they are, so reading history isn't yanked away as
76 // auto-scroll when they are, so reading history isn't yanked away as
@@ -90,6 +99,16 @@ fun ChatScreen(core: Core) {
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()
93 val errorHostState = ErrorHost(
112 val errorHostState = ErrorHost(
94 core = core,
113 core = core,
95 dismissLabel = stringResource(R.string.action_dismiss),
114 dismissLabel = stringResource(R.string.action_dismiss),
@@ -106,7 +125,6 @@ fun ChatScreen(core: Core) {
107 // Resolve nullable session fields into locals once per recomposition so we
126 // Resolve nullable session fields into locals once per recomposition so we
108 // never smart-cast a delegate-backed collected property across calls.
127 // never smart-cast a delegate-backed collected property across calls.
109− val sessionId = view.currentSessionId
110 val sessionTitle = view.currentSessionTitle
128 val sessionTitle = view.currentSessionTitle
111 val chatFallback = stringResource(R.string.top_bar_chat_fallback)
129 val chatFallback = stringResource(R.string.top_bar_chat_fallback)
112 val idShortTemplate = stringResource(R.string.chat_session_id_short)
130 val idShortTemplate = stringResource(R.string.chat_session_id_short)
@@ -126,6 +144,10 @@ fun ChatScreen(core: Core) {
126 } else null
144 } else null
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)
129 Scaffold(
151 Scaffold(
130 topBar = {
152 topBar = {
131 TopAppBar(
153 TopAppBar(
@@ -154,6 +176,34 @@ fun ChatScreen(core: Core) {
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.
184+ IconButton(onClick = { menuExpanded = true }) {
186+ Icons.Default.MoreVert,
187+ contentDescription = overflowLabel,
191+ expanded = menuExpanded,
192+ onDismissRequest = { menuExpanded = false },
195+ text = { Text(refreshLabel) },
197+ Icon(Icons.Default.Refresh, contentDescription = null)
200+ menuExpanded = false
201+ sessionId?.let { core.update(Event.LoadMessages(it)) }
159 bottomBar = {
209 bottomBar = {
@@ -171,6 +221,7 @@ fun ChatScreen(core: Core) {
171 enabled = !view.loading && !view.generating,
221 enabled = !view.loading && !view.generating,
172 placeholder = stringResource(R.string.chat_input_placeholder),
222 placeholder = stringResource(R.string.chat_input_placeholder),
173 sendContentDescription = stringResource(R.string.chat_cd_send),
223 sendContentDescription = stringResource(R.string.chat_cd_send),
224+ modifier = Modifier.focusRequester(inputFocusRequester),
176 snackbarHost = { DualSnackbarHost(errorHostState, infoHostState) },
227 snackbarHost = { DualSnackbarHost(errorHostState, infoHostState) },
@@ -196,25 +247,38 @@ fun ChatScreen(core: Core) {
196 LoadingPlaceholder()
247 LoadingPlaceholder()
198 view.messages.isEmpty() -> {
249 view.messages.isEmpty() -> {
199− EmptyState(message = stringResource(R.string.chat_empty))
250+ EmptyState(message = chatEmptyHint)
201 else -> {
252 else -> {
202− LazyColumn(
253+ // Pull-to-refresh lets the user manually reload
203− state = listState,
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)) } },
204− modifier = Modifier
259+ modifier = Modifier.fillMaxSize(),
206− .padding(horizontal = Dimens.listHorizontalPadding),
207− verticalArrangement = Arrangement.spacedBy(Dimens.listItemSpacing),
208− contentPadding = PaddingValues(vertical = Dimens.listVerticalPadding),
265+ .padding(horizontal = Dimens.listHorizontalPadding),
266+ verticalArrangement = Arrangement.spacedBy(Dimens.listItemSpacing),
267+ contentPadding = PaddingValues(vertical = Dimens.listVerticalPadding),
210− items(view.messages, key = { it.id }) { message ->
269+ items(view.messages, key = { it.id }) { message ->
211− MessageBubble(message)
270+ MessageBubble(message)
213− // "Thinking" indicator shown after the user sends, while
272+ // "Thinking" indicator shown after the user sends, while
214− // the assistant reply hasn't arrived yet.
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) {
215− item(key = "__generating__") {
279+ item(key = "__generating__") {
216− AnimatedVisibility(visible = view.generating) {
217− GeneratingIndicator()
280+ GeneratingIndicator()
@@ -224,24 +288,30 @@ fun ChatScreen(core: Core) {
225 // Scroll-to-bottom FAB: only when the user has scrolled up away
289 // Scroll-to-bottom FAB: only when the user has scrolled up away
226 // from the latest message and there's content to scroll to.
290 // from the latest message and there's content to scroll to.
291+ // Animated so it scales in/out rather than popping.
227− if (view.messages.isNotEmpty() && !isAtBottom) {
293+ visible = view.messages.isNotEmpty() && !isAtBottom,
228− FloatingActionButton(
294+ enter = scaleIn(),
296+ modifier = Modifier.align(Alignment.BottomEnd),
298+ ExtendedFloatingActionButton(
229 onClick = { scrollRequest++ },
299 onClick = { scrollRequest++ },
302+ Icons.Default.KeyboardArrowDown,
303+ contentDescription = stringResource(R.string.chat_cd_scroll_to_bottom),
306+ text = { Text(stringResource(R.string.chat_cd_scroll_to_bottom)) },
307+ containerColor = MaterialTheme.colorScheme.primaryContainer,
308+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
230 modifier = Modifier
309 modifier = Modifier
231− .align(Alignment.BottomEnd)
232 .padding(
310 .padding(
233 end = Dimens.fabEndMargin,
311 end = Dimens.fabEndMargin,
234 bottom = Dimens.fabBottomMargin,
312 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),