@@ -1,7 +1,13 @@
11package soy.iko.opencode.ui.screens
22
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
38import androidx.compose.foundation.layout.Arrangement
49import androidx.compose.foundation.layout.Box
10+import androidx.compose.foundation.layout.BoxScope
511import androidx.compose.foundation.layout.Column
612import androidx.compose.foundation.layout.Row
713import androidx.compose.foundation.layout.fillMaxSize
@@ -13,6 +19,8 @@ import androidx.compose.foundation.layout.size
1319import androidx.compose.foundation.lazy.LazyColumn
1420import androidx.compose.foundation.lazy.items
1521import androidx.compose.foundation.lazy.rememberLazyListState
22+import androidx.compose.foundation.text.KeyboardActions
23+import androidx.compose.foundation.text.KeyboardOptions
1624import androidx.compose.material.icons.Icons
1725import androidx.compose.material.icons.automirrored.filled.ArrowBack
1826import androidx.compose.material.icons.automirrored.filled.Send
@@ -24,6 +32,7 @@ import androidx.compose.material3.FilledIconButton
2432import androidx.compose.material3.FloatingActionButton
2533import androidx.compose.material3.Icon
2634import androidx.compose.material3.IconButton
35+import androidx.compose.material3.LinearProgressIndicator
2736import androidx.compose.material3.MaterialTheme
2837import androidx.compose.material3.OutlinedTextField
2938import androidx.compose.material3.Scaffold
@@ -38,6 +47,7 @@ import androidx.compose.runtime.remember
3847import androidx.compose.runtime.rememberCoroutineScope
3948import androidx.compose.ui.Alignment
4049import androidx.compose.ui.Modifier
50+import androidx.compose.ui.text.input.ImeAction
4151import androidx.compose.ui.text.style.TextOverflow
4252import androidx.compose.ui.unit.dp
4353import kotlinx.coroutines.launch
@@ -117,6 +127,9 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
117127 if (!state.sseConnected && !state.generating) {
118128 ReconnectingBanner()
119129 }
130+ if (state.loading && state.messages.isEmpty()) {
131+ LinearProgressIndicator(Modifier.fillMaxWidth())
132+ }
120133 Box(Modifier.fillMaxWidth().weight(1f)) {
121134 if (state.messages.isEmpty() && !state.loading) {
122135 Text(
@@ -135,31 +148,46 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
135148 MessageBubble(message)
136149 }
137150 }
138− if (showScrollButton) {
139− FloatingActionButton(
140− onClick = {
141− coroutineScope.launch {
142− if (state.messages.isNotEmpty()) {
143− listState.animateScrollToItem(state.messages.size - 1)
144− }
151+ ScrollToBottomButton(
152+ visible = showScrollButton,
153+ onClick = {
154+ coroutineScope.launch {
155+ if (state.messages.isNotEmpty()) {
156+ listState.animateScrollToItem(state.messages.size - 1)
145157 }
146− },
147− modifier = Modifier
148− .align(Alignment.BottomEnd)
149− .padding(16.dp)
150− .size(44.dp),
151− ) {
152− Icon(
153− Icons.Filled.KeyboardArrowDown,
154− contentDescription = "Scroll to latest message",
155− )
156− }
157− }
158+ }
159+ },
160+ )
158161 }
159162 }
160163 }
161164}
162165
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.
169+@Composable
170+private fun BoxScope.ScrollToBottomButton(visible: Boolean, onClick: () -> Unit) {
171+ AnimatedVisibility(
172+ visible = visible,
173+ modifier = Modifier.align(Alignment.BottomEnd),
174+ enter = scaleIn() + fadeIn(),
175+ exit = scaleOut() + fadeOut(),
176+ ) {
177+ FloatingActionButton(
178+ onClick = onClick,
179+ modifier = Modifier
180+ .padding(16.dp)
181+ .size(44.dp),
182+ ) {
183+ Icon(
184+ Icons.Filled.KeyboardArrowDown,
185+ contentDescription = "Scroll to latest message",
186+ )
187+ }
188+ }
189+}
190+
163191@Composable
164192private fun ReconnectingBanner() {
165193 Surface(
@@ -205,6 +233,10 @@ private fun ChatInputBar(
205233 modifier = Modifier.weight(1f),
206234 placeholder = { Text("Message opencode…") },
207235 maxLines = 5,
236+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
237+ keyboardActions = KeyboardActions(onSend = {
238+ if (!generating && draft.isNotBlank()) onSend()
239+ }),
208240 )
209241 if (generating) {
210242 FilledIconButton(onClick = onStop) {