@@ -16,10 +16,12 @@ import androidx.compose.foundation.lazy.rememberLazyListState
16import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.automirrored.filled.ArrowBack
17import androidx.compose.material.icons.automirrored.filled.ArrowBack
18import androidx.compose.material.icons.automirrored.filled.Send
18import androidx.compose.material.icons.automirrored.filled.Send
19+import androidx.compose.material.icons.filled.KeyboardArrowDown
19import androidx.compose.material.icons.filled.Stop
20import androidx.compose.material.icons.filled.Stop
20import androidx.compose.material3.CircularProgressIndicator
21import androidx.compose.material3.CircularProgressIndicator
21import androidx.compose.material3.ExperimentalMaterial3Api
22import androidx.compose.material3.ExperimentalMaterial3Api
22import androidx.compose.material3.FilledIconButton
23import androidx.compose.material3.FilledIconButton
24+import androidx.compose.material3.FloatingActionButton
23import androidx.compose.material3.Icon
25import androidx.compose.material3.Icon
24import androidx.compose.material3.IconButton
26import androidx.compose.material3.IconButton
25import androidx.compose.material3.MaterialTheme
27import androidx.compose.material3.MaterialTheme
@@ -30,10 +32,15 @@ import androidx.compose.material3.Text
30import androidx.compose.material3.TopAppBar
32import androidx.compose.material3.TopAppBar
31import androidx.compose.runtime.Composable
33import androidx.compose.runtime.Composable
32import androidx.compose.runtime.LaunchedEffect
34import 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
33import androidx.compose.ui.Alignment
39import androidx.compose.ui.Alignment
34import androidx.compose.ui.Modifier
40import androidx.compose.ui.Modifier
35import androidx.compose.ui.text.style.TextOverflow
41import androidx.compose.ui.text.style.TextOverflow
36import androidx.compose.ui.unit.dp
42import androidx.compose.ui.unit.dp
43+import kotlinx.coroutines.launch
37import soy.iko.opencode.core.Event
44import soy.iko.opencode.core.Event
38import soy.iko.opencode.core.UiState
45import soy.iko.opencode.core.UiState
39import soy.iko.opencode.ui.components.MessageBubble
46import soy.iko.opencode.ui.components.MessageBubble
@@ -42,6 +49,19 @@ import soy.iko.opencode.ui.components.MessageBubble
42@Composable
49@Composable
43fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
50fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
44 val listState = rememberLazyListState()
51 val listState = rememberLazyListState()
52+ val coroutineScope = rememberCoroutineScope()
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 {
59+ val layoutInfo = listState.layoutInfo
60+ val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
61+ val totalItems = layoutInfo.totalItemsCount
62+ totalItems > 0 && lastVisibleIndex < totalItems - 3
46 // Keep the newest content in view as it streams in, but only when the user is
66 // Keep the newest content in view as it streams in, but only when the user is
47 // already near the bottom. If they've scrolled up to read history, leave it alone.
67 // 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) {
95 ) { padding ->
115 ) { padding ->
96− Box(Modifier.fillMaxSize().padding(padding)) {
116+ Column(Modifier.fillMaxSize().padding(padding)) {
97− if (state.messages.isEmpty() && !state.loading) {
117+ if (!state.sseConnected && !state.generating) {
98− Text(
118+ ReconnectingBanner()
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),
120+ Box(Modifier.fillMaxWidth().weight(1f)) {
121+ if (state.messages.isEmpty() && !state.loading) {
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),
105− LazyColumn(
129+ LazyColumn(
106− state = listState,
130+ state = listState,
107− modifier = Modifier.fillMaxSize(),
131+ modifier = Modifier.fillMaxSize(),
108− contentPadding = PaddingValues(vertical = 8.dp),
132+ contentPadding = PaddingValues(vertical = 8.dp),
110− items(state.messages, key = { it.id }) { message ->
134+ items(state.messages, key = { it.id }) { message ->
111− MessageBubble(message)
135+ MessageBubble(message)
138+ if (showScrollButton) {
139+ FloatingActionButton(
141+ coroutineScope.launch {
142+ if (state.messages.isNotEmpty()) {
143+ listState.animateScrollToItem(state.messages.size - 1)
148+ .align(Alignment.BottomEnd)
153+ Icons.Filled.KeyboardArrowDown,
154+ contentDescription = "Scroll to latest message",
164+private fun ReconnectingBanner() {
166+ modifier = Modifier.fillMaxWidth(),
167+ color = MaterialTheme.colorScheme.tertiaryContainer,
170+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
171+ verticalAlignment = Alignment.CenterVertically,
172+ horizontalArrangement = Arrangement.spacedBy(8.dp),
174+ CircularProgressIndicator(
175+ modifier = Modifier.size(14.dp),
177+ color = MaterialTheme.colorScheme.onTertiaryContainer,
180+ text = "Reconnecting…",
181+ style = MaterialTheme.typography.bodySmall,
182+ color = MaterialTheme.colorScheme.onTertiaryContainer,
118@Composable
188@Composable
119private fun ChatInputBar(
189private fun ChatInputBar(
120 draft: String,
190 draft: String,