@@ -1,7 +1,13 @@
1package soy.iko.opencode.ui.screens
1package soy.iko.opencode.ui.screens
3+import androidx.compose.animation.AnimatedVisibility
4+import androidx.compose.animation.fadeIn
5+import androidx.compose.animation.fadeOut
6+import androidx.compose.animation.scaleIn
7+import androidx.compose.animation.scaleOut
3import androidx.compose.foundation.layout.Arrangement
8import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Box
9import androidx.compose.foundation.layout.Box
10+import androidx.compose.foundation.layout.BoxScope
5import androidx.compose.foundation.layout.Column
11import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Row
12import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.fillMaxSize
13import androidx.compose.foundation.layout.fillMaxSize
@@ -13,6 +19,8 @@ import androidx.compose.foundation.layout.size
13import androidx.compose.foundation.lazy.LazyColumn
19import androidx.compose.foundation.lazy.LazyColumn
14import androidx.compose.foundation.lazy.items
20import androidx.compose.foundation.lazy.items
15import androidx.compose.foundation.lazy.rememberLazyListState
21import androidx.compose.foundation.lazy.rememberLazyListState
22+import androidx.compose.foundation.text.KeyboardActions
23+import androidx.compose.foundation.text.KeyboardOptions
16import androidx.compose.material.icons.Icons
24import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.automirrored.filled.ArrowBack
25import androidx.compose.material.icons.automirrored.filled.ArrowBack
18import androidx.compose.material.icons.automirrored.filled.Send
26import androidx.compose.material.icons.automirrored.filled.Send
@@ -24,6 +32,7 @@ import androidx.compose.material3.FilledIconButton
24import androidx.compose.material3.FloatingActionButton
32import androidx.compose.material3.FloatingActionButton
25import androidx.compose.material3.Icon
33import androidx.compose.material3.Icon
26import androidx.compose.material3.IconButton
34import androidx.compose.material3.IconButton
35+import androidx.compose.material3.LinearProgressIndicator
27import androidx.compose.material3.MaterialTheme
36import androidx.compose.material3.MaterialTheme
28import androidx.compose.material3.OutlinedTextField
37import androidx.compose.material3.OutlinedTextField
29import androidx.compose.material3.Scaffold
38import androidx.compose.material3.Scaffold
@@ -38,6 +47,7 @@ import androidx.compose.runtime.remember
38import androidx.compose.runtime.rememberCoroutineScope
47import androidx.compose.runtime.rememberCoroutineScope
39import androidx.compose.ui.Alignment
48import androidx.compose.ui.Alignment
40import androidx.compose.ui.Modifier
49import androidx.compose.ui.Modifier
50+import androidx.compose.ui.text.input.ImeAction
41import androidx.compose.ui.text.style.TextOverflow
51import androidx.compose.ui.text.style.TextOverflow
42import androidx.compose.ui.unit.dp
52import androidx.compose.ui.unit.dp
43import kotlinx.coroutines.launch
53import kotlinx.coroutines.launch
@@ -117,6 +127,9 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
117 if (!state.sseConnected && !state.generating) {
127 if (!state.sseConnected && !state.generating) {
118 ReconnectingBanner()
128 ReconnectingBanner()
130+ if (state.loading && state.messages.isEmpty()) {
131+ LinearProgressIndicator(Modifier.fillMaxWidth())
120 Box(Modifier.fillMaxWidth().weight(1f)) {
133 Box(Modifier.fillMaxWidth().weight(1f)) {
121 if (state.messages.isEmpty() && !state.loading) {
134 if (state.messages.isEmpty() && !state.loading) {
@@ -135,31 +148,46 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
135 MessageBubble(message)
148 MessageBubble(message)
151+ ScrollToBottomButton(
138− if (showScrollButton) {
152+ visible = showScrollButton,
139− FloatingActionButton(
140− onClick = {
153+ onClick = {
141− coroutineScope.launch {
154+ coroutineScope.launch {
142− if (state.messages.isNotEmpty()) {
155+ if (state.messages.isNotEmpty()) {
143− listState.animateScrollToItem(state.messages.size - 1)
156+ listState.animateScrollToItem(state.messages.size - 1)
147− modifier = Modifier
159+ },
148− .align(Alignment.BottomEnd)
160+ )
153− Icons.Filled.KeyboardArrowDown,
154− contentDescription = "Scroll to latest message",
166+// Extracted into a BoxScope extension so the top-level AnimatedVisibility overload
167+// resolves cleanly. Called inline inside a Column, the enclosing ColumnScope receiver
168+// would otherwise capture the ColumnScope.AnimatedVisibility overload and fail to compile.
170+private fun BoxScope.ScrollToBottomButton(visible: Boolean, onClick: () -> Unit) {
173+ modifier = Modifier.align(Alignment.BottomEnd),
174+ enter = scaleIn() + fadeIn(),
175+ exit = scaleOut() + fadeOut(),
177+ FloatingActionButton(
184+ Icons.Filled.KeyboardArrowDown,
185+ contentDescription = "Scroll to latest message",
163@Composable
191@Composable
164private fun ReconnectingBanner() {
192private fun ReconnectingBanner() {
@@ -205,6 +233,10 @@ private fun ChatInputBar(
205 modifier = Modifier.weight(1f),
233 modifier = Modifier.weight(1f),
206 placeholder = { Text("Message opencode…") },
234 placeholder = { Text("Message opencode…") },
207 maxLines = 5,
235 maxLines = 5,
236+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
237+ keyboardActions = KeyboardActions(onSend = {
238+ if (!generating && draft.isNotBlank()) onSend()
209 if (generating) {
241 if (generating) {
210 FilledIconButton(onClick = onStop) {
242 FilledIconButton(onClick = onStop) {