@@ -16,10 +16,12 @@ import androidx.compose.foundation.lazy.rememberLazyListState
1616import androidx.compose.material.icons.Icons
1717import androidx.compose.material.icons.automirrored.filled.ArrowBack
1818import androidx.compose.material.icons.automirrored.filled.Send
19+import androidx.compose.material.icons.filled.KeyboardArrowDown
1920import androidx.compose.material.icons.filled.Stop
2021import androidx.compose.material3.CircularProgressIndicator
2122import androidx.compose.material3.ExperimentalMaterial3Api
2223import androidx.compose.material3.FilledIconButton
24+import androidx.compose.material3.FloatingActionButton
2325import androidx.compose.material3.Icon
2426import androidx.compose.material3.IconButton
2527import androidx.compose.material3.MaterialTheme
@@ -30,10 +32,15 @@ import androidx.compose.material3.Text
3032import androidx.compose.material3.TopAppBar
3133import androidx.compose.runtime.Composable
3234import androidx.compose.runtime.LaunchedEffect
35+import androidx.compose.runtime.derivedStateOf
36+import androidx.compose.runtime.getValue
37+import androidx.compose.runtime.remember
38+import androidx.compose.runtime.rememberCoroutineScope
3339import androidx.compose.ui.Alignment
3440import androidx.compose.ui.Modifier
3541import androidx.compose.ui.text.style.TextOverflow
3642import androidx.compose.ui.unit.dp
43+import kotlinx.coroutines.launch
3744import soy.iko.opencode.core.Event
3845import soy.iko.opencode.core.UiState
3946import soy.iko.opencode.ui.components.MessageBubble
@@ -42,6 +49,19 @@ import soy.iko.opencode.ui.components.MessageBubble
4249@Composable
4350fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
4451 val listState = rememberLazyListState()
52+ val coroutineScope = rememberCoroutineScope()
53+
54+ // Show a jump-to-bottom button once the user scrolls up away from the latest
55+ // message. derivedStateOf keeps this from recomposing on every pixel of scroll —
56+ // it only flips when the near-bottom threshold is actually crossed.
57+ val showScrollButton by remember {
58+ derivedStateOf {
59+ val layoutInfo = listState.layoutInfo
60+ val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
61+ val totalItems = layoutInfo.totalItemsCount
62+ totalItems > 0 && lastVisibleIndex < totalItems - 3
63+ }
64+ }
4565
4666 // Keep the newest content in view as it streams in, but only when the user is
4767 // already near the bottom. If they've scrolled up to read history, leave it alone.
@@ -93,28 +113,78 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
93113 )
94114 },
95115 ) { padding ->
96− Box(Modifier.fillMaxSize().padding(padding)) {
97− if (state.messages.isEmpty() && !state.loading) {
98− Text(
99− text = "Send a message to start the conversation.",
100− style = MaterialTheme.typography.bodyMedium,
101− color = MaterialTheme.colorScheme.onSurfaceVariant,
102− modifier = Modifier.align(Alignment.Center).padding(32.dp),
103− )
116+ Column(Modifier.fillMaxSize().padding(padding)) {
117+ if (!state.sseConnected && !state.generating) {
118+ ReconnectingBanner()
104119 }
105− LazyColumn(
106− state = listState,
107− modifier = Modifier.fillMaxSize(),
108− contentPadding = PaddingValues(vertical = 8.dp),
109− ) {
110− items(state.messages, key = { it.id }) { message ->
111− MessageBubble(message)
120+ Box(Modifier.fillMaxWidth().weight(1f)) {
121+ if (state.messages.isEmpty() && !state.loading) {
122+ Text(
123+ text = "Send a message to start the conversation.",
124+ style = MaterialTheme.typography.bodyMedium,
125+ color = MaterialTheme.colorScheme.onSurfaceVariant,
126+ modifier = Modifier.align(Alignment.Center).padding(32.dp),
127+ )
128+ }
129+ LazyColumn(
130+ state = listState,
131+ modifier = Modifier.fillMaxSize(),
132+ contentPadding = PaddingValues(vertical = 8.dp),
133+ ) {
134+ items(state.messages, key = { it.id }) { message ->
135+ MessageBubble(message)
136+ }
137+ }
138+ if (showScrollButton) {
139+ FloatingActionButton(
140+ onClick = {
141+ coroutineScope.launch {
142+ if (state.messages.isNotEmpty()) {
143+ listState.animateScrollToItem(state.messages.size - 1)
144+ }
145+ }
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+ }
112157 }
113158 }
114159 }
115160 }
116161}
117162
163+@Composable
164+private fun ReconnectingBanner() {
165+ Surface(
166+ modifier = Modifier.fillMaxWidth(),
167+ color = MaterialTheme.colorScheme.tertiaryContainer,
168+ ) {
169+ Row(
170+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
171+ verticalAlignment = Alignment.CenterVertically,
172+ horizontalArrangement = Arrangement.spacedBy(8.dp),
173+ ) {
174+ CircularProgressIndicator(
175+ modifier = Modifier.size(14.dp),
176+ strokeWidth = 2.dp,
177+ color = MaterialTheme.colorScheme.onTertiaryContainer,
178+ )
179+ Text(
180+ text = "Reconnecting…",
181+ style = MaterialTheme.typography.bodySmall,
182+ color = MaterialTheme.colorScheme.onTertiaryContainer,
183+ )
184+ }
185+ }
186+}
187+
118188@Composable
119189private fun ChatInputBar(
120190 draft: String,