@@ -1,6 +1,11 @@
11package soy.iko.opencode.ui.screens
22
33import 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
49import androidx.compose.animation.fadeIn
510import androidx.compose.animation.fadeOut
611import androidx.compose.animation.scaleIn
@@ -23,6 +28,7 @@ import androidx.compose.foundation.lazy.LazyColumn
2328import androidx.compose.foundation.lazy.items
2429import androidx.compose.foundation.lazy.rememberLazyListState
2530import androidx.compose.foundation.shape.CircleShape
31+import androidx.compose.foundation.shape.RoundedCornerShape
2632import androidx.compose.foundation.text.KeyboardActions
2733import androidx.compose.foundation.text.KeyboardOptions
2834import androidx.compose.material.icons.Icons
@@ -58,6 +64,7 @@ import androidx.compose.ui.Modifier
5864import androidx.compose.ui.draw.clip
5965import androidx.compose.ui.hapticfeedback.HapticFeedbackType
6066import androidx.compose.ui.platform.LocalHapticFeedback
67+import androidx.compose.ui.text.font.FontStyle
6168import androidx.compose.ui.text.input.ImeAction
6269import androidx.compose.ui.text.style.TextOverflow
6370import androidx.compose.ui.unit.dp
@@ -174,7 +181,18 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
174181 contentPadding = PaddingValues(vertical = 8.dp),
175182 ) {
176183 items(state.messages, key = { it.id }) { message ->
177− MessageBubble(message)
184+ MessageBubble(
185+ message,
186+ onRetry = { dispatch(Event.SendMessage(message.text)) },
187+ )
188+ }
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") {
194+ TypingIndicator()
195+ }
178196 }
179197 }
180198 ScrollToBottomButton(
@@ -227,6 +245,54 @@ private fun BoxScope.ScrollToBottomButton(visible: Boolean, showBadge: Boolean,
227245 }
228246}
229247
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.
251+@Composable
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")
255+ Row(
256+ modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
257+ horizontalArrangement = Arrangement.Start,
258+ ) {
259+ Row(
260+ modifier = Modifier
261+ .clip(shape)
262+ .background(MaterialTheme.colorScheme.surfaceVariant)
263+ .padding(horizontal = 14.dp, vertical = 12.dp),
264+ verticalAlignment = Alignment.CenterVertically,
265+ horizontalArrangement = Arrangement.spacedBy(6.dp),
266+ ) {
267+ Text(
268+ text = "Thinking",
269+ style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
270+ color = MaterialTheme.colorScheme.onSurfaceVariant,
271+ )
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,
277+ targetValue = 1f,
278+ animationSpec = infiniteRepeatable(
279+ animation = tween(durationMillis = 600, delayMillis = staggerMs),
280+ repeatMode = RepeatMode.Reverse,
281+ ),
282+ label = "dot",
283+ )
284+ Box(
285+ modifier = Modifier
286+ .size(6.dp)
287+ .clip(CircleShape)
288+ .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha)),
289+ )
290+ }
291+ }
292+ }
293+ }
294+}
295+
230296@Composable
231297private fun ReconnectingBanner() {
232298 Surface(