@@ -1,6 +1,11 @@
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.core.RepeatMode
5+import androidx.compose.animation.core.animateFloat
6+import androidx.compose.animation.core.infiniteRepeatable
7+import androidx.compose.animation.core.rememberInfiniteTransition
8+import androidx.compose.animation.core.tween
4import androidx.compose.animation.fadeIn
9import androidx.compose.animation.fadeIn
5import androidx.compose.animation.fadeOut
10import androidx.compose.animation.fadeOut
6import androidx.compose.animation.scaleIn
11import androidx.compose.animation.scaleIn
@@ -23,6 +28,7 @@ import androidx.compose.foundation.lazy.LazyColumn
23import androidx.compose.foundation.lazy.items
28import androidx.compose.foundation.lazy.items
24import androidx.compose.foundation.lazy.rememberLazyListState
29import androidx.compose.foundation.lazy.rememberLazyListState
25import androidx.compose.foundation.shape.CircleShape
30import androidx.compose.foundation.shape.CircleShape
31+import androidx.compose.foundation.shape.RoundedCornerShape
26import androidx.compose.foundation.text.KeyboardActions
32import androidx.compose.foundation.text.KeyboardActions
27import androidx.compose.foundation.text.KeyboardOptions
33import androidx.compose.foundation.text.KeyboardOptions
28import androidx.compose.material.icons.Icons
34import androidx.compose.material.icons.Icons
@@ -58,6 +64,7 @@ import androidx.compose.ui.Modifier
58import androidx.compose.ui.draw.clip
64import androidx.compose.ui.draw.clip
59import androidx.compose.ui.hapticfeedback.HapticFeedbackType
65import androidx.compose.ui.hapticfeedback.HapticFeedbackType
60import androidx.compose.ui.platform.LocalHapticFeedback
66import androidx.compose.ui.platform.LocalHapticFeedback
67+import androidx.compose.ui.text.font.FontStyle
61import androidx.compose.ui.text.input.ImeAction
68import androidx.compose.ui.text.input.ImeAction
62import androidx.compose.ui.text.style.TextOverflow
69import androidx.compose.ui.text.style.TextOverflow
63import androidx.compose.ui.unit.dp
70import androidx.compose.ui.unit.dp
@@ -174,7 +181,18 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
174 contentPadding = PaddingValues(vertical = 8.dp),
181 contentPadding = PaddingValues(vertical = 8.dp),
176 items(state.messages, key = { it.id }) { message ->
183 items(state.messages, key = { it.id }) { message ->
177− MessageBubble(message)
184+ MessageBubble(
186+ onRetry = { dispatch(Event.SendMessage(message.text)) },
189+ // While the assistant is spinning up but hasn't emitted a streaming
190+ // message yet (only the user's message is present), show a typing
191+ // indicator so the wait doesn't look like a stall.
192+ if (state.generating && state.messages.none { it.streaming }) {
193+ item(key = "typing-indicator") {
180 ScrollToBottomButton(
198 ScrollToBottomButton(
@@ -227,6 +245,54 @@ private fun BoxScope.ScrollToBottomButton(visible: Boolean, showBadge: Boolean,
248+// Assistant-style bubble with three sequentially-pulsing dots, shown as a stand-in
249+// until the real streaming reply starts. Mirrors MessageBubble's left-aligned,
250+// surfaceVariant look so it reads as an incoming message.
252+private fun TypingIndicator() {
253+ val shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 4.dp, bottomEnd = 16.dp)
254+ val transition = rememberInfiniteTransition(label = "typing")
256+ modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
257+ horizontalArrangement = Arrangement.Start,
262+ .background(MaterialTheme.colorScheme.surfaceVariant)
263+ .padding(horizontal = 14.dp, vertical = 12.dp),
264+ verticalAlignment = Alignment.CenterVertically,
265+ horizontalArrangement = Arrangement.spacedBy(6.dp),
269+ style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
270+ color = MaterialTheme.colorScheme.onSurfaceVariant,
272+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
273+ // Stagger the three dots so they fade in and out in sequence.
274+ listOf(0, 150, 300).forEach { staggerMs ->
275+ val alpha by transition.animateFloat(
276+ initialValue = 0.2f,
278+ animationSpec = infiniteRepeatable(
279+ animation = tween(durationMillis = 600, delayMillis = staggerMs),
280+ repeatMode = RepeatMode.Reverse,
288+ .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha)),
230@Composable
296@Composable
231private fun ReconnectingBanner() {
297private fun ReconnectingBanner() {