git.iko.soy/opencode-android
on 29df003 help sign in
501 lines 22 kB history raw
1package soy.iko.opencode.ui.screens
3import android.content.ClipData
4import android.content.ClipboardManager
5import android.content.Context
6import android.widget.Toast
7import androidx.compose.animation.AnimatedVisibility
8import androidx.compose.animation.core.RepeatMode
9import androidx.compose.animation.core.animateFloat
10import androidx.compose.animation.core.infiniteRepeatable
11import androidx.compose.animation.core.rememberInfiniteTransition
12import androidx.compose.animation.core.tween
13import androidx.compose.animation.fadeIn
14import androidx.compose.animation.fadeOut
15import androidx.compose.animation.scaleIn
16import androidx.compose.animation.scaleOut
17import androidx.compose.foundation.ExperimentalFoundationApi
18import androidx.compose.foundation.background
19import androidx.compose.foundation.clickable
20import androidx.compose.foundation.combinedClickable
21import androidx.compose.foundation.layout.Arrangement
22import androidx.compose.foundation.layout.Box
23import androidx.compose.foundation.layout.BoxScope
24import androidx.compose.foundation.layout.Column
25import androidx.compose.foundation.layout.Row
26import androidx.compose.foundation.layout.Spacer
27import androidx.compose.foundation.layout.fillMaxSize
28import androidx.compose.foundation.layout.fillMaxWidth
29import androidx.compose.foundation.layout.PaddingValues
30import androidx.compose.foundation.layout.WindowInsets
31import androidx.compose.foundation.layout.ime
32import androidx.compose.foundation.layout.imePadding
33import androidx.compose.foundation.layout.padding
34import androidx.compose.foundation.layout.size
35import androidx.compose.foundation.lazy.LazyColumn
36import androidx.compose.foundation.lazy.itemsIndexed
37import androidx.compose.foundation.lazy.rememberLazyListState
38import androidx.compose.foundation.shape.CircleShape
39import androidx.compose.foundation.shape.RoundedCornerShape
40import androidx.compose.foundation.text.KeyboardActions
41import androidx.compose.foundation.text.KeyboardOptions
42import androidx.compose.material.icons.Icons
43import androidx.compose.material.icons.automirrored.filled.ArrowBack
44import androidx.compose.material.icons.automirrored.filled.Send
45import androidx.compose.material.icons.filled.Close
46import androidx.compose.material.icons.filled.KeyboardArrowDown
47import androidx.compose.material.icons.filled.Stop
48import androidx.compose.material3.CircularProgressIndicator
49import androidx.compose.material3.ExperimentalMaterial3Api
50import androidx.compose.material3.FilledIconButton
51import androidx.compose.material3.FilledTonalButton
52import androidx.compose.material3.FloatingActionButton
53import androidx.compose.material3.Icon
54import androidx.compose.material3.IconButton
55import androidx.compose.material3.LinearProgressIndicator
56import androidx.compose.material3.MaterialTheme
57import androidx.compose.material3.OutlinedTextField
58import androidx.compose.material3.Scaffold
59import androidx.compose.material3.Surface
60import androidx.compose.material3.Text
61import androidx.compose.material3.TopAppBar
62import androidx.compose.runtime.Composable
63import androidx.compose.runtime.LaunchedEffect
64import androidx.compose.runtime.derivedStateOf
65import androidx.compose.runtime.getValue
66import androidx.compose.runtime.mutableStateOf
67import androidx.compose.runtime.remember
68import androidx.compose.runtime.rememberCoroutineScope
69import androidx.compose.runtime.setValue
70import androidx.compose.ui.Alignment
71import androidx.compose.ui.Modifier
72import androidx.compose.ui.draw.clip
73import androidx.compose.ui.hapticfeedback.HapticFeedbackType
74import androidx.compose.ui.platform.LocalContext
75import androidx.compose.ui.platform.LocalDensity
76import androidx.compose.ui.platform.LocalHapticFeedback
77import androidx.compose.ui.text.font.FontStyle
78import androidx.compose.ui.text.input.ImeAction
79import androidx.compose.ui.text.style.TextAlign
80import androidx.compose.ui.text.style.TextOverflow
81import androidx.compose.ui.unit.dp
82import kotlinx.coroutines.launch
83import soy.iko.opencode.core.Event
84import soy.iko.opencode.core.UiState
85import soy.iko.opencode.ui.components.MessageBubble
87@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
88@Composable
89fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
90 val listState = rememberLazyListState()
91 val coroutineScope = rememberCoroutineScope()
92 val context = LocalContext.current
94 // Hide the jump-to-bottom FAB while the keyboard is up: it would otherwise
95 // sit over the top of the IME. Reading the ime inset bottom during composition
96 // observes snapshot state, so this flips as the keyboard opens/closes.
97 val isImeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0
99 // Show a jump-to-bottom button once the user scrolls up away from the latest
100 // message. derivedStateOf keeps this from recomposing on every pixel of scroll —
101 // it only flips when the near-bottom threshold is actually crossed.
102 val showScrollButton by remember {
103 derivedStateOf {
104 val layoutInfo = listState.layoutInfo
105 val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
106 val totalItems = layoutInfo.totalItemsCount
107 totalItems > 0 && lastVisibleIndex < totalItems - 3
108 }
109 }
111 // Track how many messages the user has "seen" (i.e. was at the bottom for). While
112 // they're scrolled up, any messages beyond that count are unseen — flag the FAB.
113 var lastSeenCount by remember { mutableStateOf(0) }
114 LaunchedEffect(showScrollButton, state.messages.size) {
115 if (!showScrollButton) {
116 lastSeenCount = state.messages.size
117 }
118 }
119 val hasUnseenMessages = showScrollButton && state.messages.size > lastSeenCount
121 // Keep the newest content in view as it streams in, but only when the user is
122 // already near the bottom. If they've scrolled up to read history, leave it alone.
123 val lastLen = state.messages.lastOrNull()?.text?.length ?: 0
124 LaunchedEffect(state.messages.size, lastLen, state.generating) {
125 if (state.messages.isNotEmpty()) {
126 val layoutInfo = listState.layoutInfo
127 val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
128 val totalItems = layoutInfo.totalItemsCount
129 val nearBottom = totalItems == 0 || lastVisibleIndex >= totalItems - 3
130 if (nearBottom) {
131 listState.animateScrollToItem(state.messages.size - 1)
132 }
133 }
134 }
136 Scaffold(
137 topBar = {
138 TopAppBar(
139 title = {
140 val title = state.currentSessionTitle.ifEmpty { "Chat" }
141 Text(
142 text = if (state.messages.isNotEmpty()) "$title (${state.messages.size})" else title,
143 maxLines = 1,
144 overflow = TextOverflow.Ellipsis,
145 modifier = Modifier.combinedClickable(
146 onClick = { dispatch(Event.NavigateToSessions) },
147 onLongClick = {
148 state.currentSessionId?.let { id ->
149 copyToClipboard(context, id)
150 Toast.makeText(context, "Session ID copied", Toast.LENGTH_SHORT).show()
151 }
152 },
153 ),
154 )
155 },
156 navigationIcon = {
157 IconButton(onClick = { dispatch(Event.NavigateToSessions) }) {
158 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to sessions")
159 }
160 },
161 actions = {
162 if (state.generating) {
163 CircularProgressIndicator(
164 modifier = Modifier.padding(end = 16.dp).size(22.dp),
165 strokeWidth = 2.dp,
166 )
167 }
168 },
169 )
170 },
171 bottomBar = {
172 ChatInputBar(
173 draft = state.draft,
174 generating = state.generating,
175 loading = state.loading && state.messages.isEmpty(),
176 onDraftChange = { dispatch(Event.DraftChanged(it)) },
177 onSend = { dispatch(Event.SendMessage(state.draft)) },
178 onStop = { dispatch(Event.CancelGeneration) },
179 )
180 },
181 ) { padding ->
182 Column(Modifier.fillMaxSize().padding(padding)) {
183 if (!state.sseConnected && !state.generating) {
184 ReconnectingBanner()
185 }
186 if (state.loading && state.messages.isEmpty()) {
187 LinearProgressIndicator(Modifier.fillMaxWidth())
188 }
189 if (state.error != null && state.messages.isNotEmpty()) {
190 ErrorBanner(
191 message = state.error,
192 onDismiss = { dispatch(Event.DismissError) },
193 )
194 }
195 Box(Modifier.fillMaxWidth().weight(1f)) {
196 if (state.messages.isEmpty() && !state.loading) {
197 Text(
198 text = "Send a message to start the conversation.",
199 style = MaterialTheme.typography.bodyMedium,
200 color = MaterialTheme.colorScheme.onSurfaceVariant,
201 modifier = Modifier.align(Alignment.Center).padding(32.dp),
202 )
203 }
204 LazyColumn(
205 state = listState,
206 modifier = Modifier.fillMaxSize(),
207 contentPadding = PaddingValues(vertical = 8.dp),
208 ) {
209 itemsIndexed(state.messages, key = { _, message -> message.id }) { index, message ->
210 // Insert a date separator whenever a message falls on a different
211 // calendar day than the one before it (and above the first message).
212 val previous = state.messages.getOrNull(index - 1)
213 if (shouldShowDateSeparator(previous, message)) {
214 DateSeparator(message.time)
215 }
216 val sameSenderAsPrevious = index > 0 && state.messages[index - 1].isUser == message.isUser
217 MessageBubble(
218 message,
219 onRetry = { dispatch(Event.SendMessage(message.text)) },
220 compactSpacing = sameSenderAsPrevious,
221 )
222 }
223 // While the assistant is spinning up but hasn't emitted a streaming
224 // message yet (only the user's message is present), show a typing
225 // indicator so the wait doesn't look like a stall.
226 if (state.generating && state.messages.none { it.streaming }) {
227 item(key = "typing-indicator") {
228 TypingIndicator()
229 }
230 }
231 }
232 ScrollToBottomButton(
233 visible = showScrollButton && !isImeVisible,
234 showBadge = hasUnseenMessages,
235 onClick = {
236 coroutineScope.launch {
237 if (state.messages.isNotEmpty()) {
238 // When the typing indicator is showing it occupies the item
239 // just past the last message, so scroll one further to reach it.
240 val target = if (state.generating && state.messages.none { it.streaming })
241 state.messages.size
242 else
243 state.messages.size - 1
244 listState.animateScrollToItem(target)
245 }
246 }
247 },
248 )
249 }
250 }
251 }
254// Extracted into a BoxScope extension so the top-level AnimatedVisibility overload
255// resolves cleanly. Called inline inside a Column, the enclosing ColumnScope receiver
256// would otherwise capture the ColumnScope.AnimatedVisibility overload and fail to compile.
257@Composable
258private fun BoxScope.ScrollToBottomButton(visible: Boolean, showBadge: Boolean, onClick: () -> Unit) {
259 AnimatedVisibility(
260 visible = visible,
261 modifier = Modifier.align(Alignment.BottomEnd),
262 enter = scaleIn() + fadeIn(),
263 exit = scaleOut() + fadeOut(),
264 ) {
265 Box(Modifier.padding(16.dp)) {
266 FloatingActionButton(
267 onClick = onClick,
268 modifier = Modifier.size(44.dp),
269 ) {
270 Icon(
271 Icons.Filled.KeyboardArrowDown,
272 contentDescription = "Scroll to latest message",
273 )
274 }
275 if (showBadge) {
276 Box(
277 modifier = Modifier
278 .align(Alignment.TopEnd)
279 .size(8.dp)
280 .clip(CircleShape)
281 .background(MaterialTheme.colorScheme.error),
282 )
283 }
284 }
285 }
288private fun copyToClipboard(context: Context, text: String) {
289 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
290 clipboard.setPrimaryClip(ClipData.newPlainText("session", text))
293private val dateSeparatorFormat = java.time.format.DateTimeFormatter.ofPattern("MMM d")
295/** Convert an epoch-millis timestamp to the local calendar day it falls on. */
296private fun localDayOf(epochMillis: Long): java.time.LocalDate =
297 java.time.Instant.ofEpochMilli(epochMillis)
298 .atZone(java.time.ZoneId.systemDefault())
299 .toLocalDate()
301/**
302 * A separator is shown above a message when it starts a new calendar day — i.e. the
303 * first timestamped message, or one whose day differs from the previous message's.
304 * Messages without a valid time (time <= 0) never get one.
305 */
306private fun shouldShowDateSeparator(previous: soy.iko.opencode.core.MessageView?, current: soy.iko.opencode.core.MessageView): Boolean {
307 if (current.time <= 0) return false
308 if (previous == null || previous.time <= 0) return true
309 return localDayOf(previous.time) != localDayOf(current.time)
312/** Human-friendly day label: "Today", "Yesterday", or "MMM d" (e.g. "Jul 8"). */
313private fun dateSeparatorLabel(day: java.time.LocalDate): String {
314 val today = java.time.LocalDate.now()
315 return when (day) {
316 today -> "Today"
317 today.minusDays(1) -> "Yesterday"
318 else -> day.format(dateSeparatorFormat)
319 }
322@Composable
323private fun DateSeparator(epochMillis: Long) {
324 Text(
325 text = dateSeparatorLabel(localDayOf(epochMillis)),
326 style = MaterialTheme.typography.labelSmall,
327 color = MaterialTheme.colorScheme.onSurfaceVariant,
328 textAlign = TextAlign.Center,
329 modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
330 )
333// Assistant-style bubble with three sequentially-pulsing dots, shown as a stand-in
334// until the real streaming reply starts. Mirrors MessageBubble's left-aligned,
335// surfaceVariant look so it reads as an incoming message.
336@Composable
337private fun TypingIndicator() {
338 val shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 4.dp, bottomEnd = 16.dp)
339 val transition = rememberInfiniteTransition(label = "typing")
340 Row(
341 modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
342 horizontalArrangement = Arrangement.Start,
343 ) {
344 Row(
345 modifier = Modifier
346 .clip(shape)
347 .background(MaterialTheme.colorScheme.surfaceVariant)
348 .padding(horizontal = 14.dp, vertical = 12.dp),
349 verticalAlignment = Alignment.CenterVertically,
350 horizontalArrangement = Arrangement.spacedBy(6.dp),
351 ) {
352 Text(
353 text = "Thinking",
354 style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
355 color = MaterialTheme.colorScheme.onSurfaceVariant,
356 )
357 Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
358 // Stagger the three dots so they fade in and out in sequence.
359 listOf(0, 150, 300).forEach { staggerMs ->
360 val alpha by transition.animateFloat(
361 initialValue = 0.2f,
362 targetValue = 1f,
363 animationSpec = infiniteRepeatable(
364 animation = tween(durationMillis = 600, delayMillis = staggerMs),
365 repeatMode = RepeatMode.Reverse,
366 ),
367 label = "dot",
368 )
369 Box(
370 modifier = Modifier
371 .size(6.dp)
372 .clip(CircleShape)
373 .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha)),
374 )
375 }
376 }
377 }
378 }
381@Composable
382private fun ReconnectingBanner() {
383 Surface(
384 modifier = Modifier.fillMaxWidth(),
385 color = MaterialTheme.colorScheme.tertiaryContainer,
386 ) {
387 Row(
388 modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
389 verticalAlignment = Alignment.CenterVertically,
390 horizontalArrangement = Arrangement.spacedBy(8.dp),
391 ) {
392 CircularProgressIndicator(
393 modifier = Modifier.size(14.dp),
394 strokeWidth = 2.dp,
395 color = MaterialTheme.colorScheme.onTertiaryContainer,
396 )
397 Text(
398 text = "Reconnecting…",
399 style = MaterialTheme.typography.bodySmall,
400 color = MaterialTheme.colorScheme.onTertiaryContainer,
401 )
402 }
403 }
406@Composable
407private fun ErrorBanner(message: String, onDismiss: () -> Unit) {
408 Surface(
409 modifier = Modifier.fillMaxWidth(),
410 color = MaterialTheme.colorScheme.errorContainer,
411 ) {
412 Row(
413 modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 6.dp, bottom = 6.dp, end = 4.dp),
414 verticalAlignment = Alignment.CenterVertically,
415 horizontalArrangement = Arrangement.spacedBy(8.dp),
416 ) {
417 Text(
418 text = message,
419 style = MaterialTheme.typography.bodySmall,
420 color = MaterialTheme.colorScheme.onErrorContainer,
421 modifier = Modifier.weight(1f),
422 )
423 IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) {
424 Icon(
425 Icons.Filled.Close,
426 contentDescription = "Dismiss error",
427 tint = MaterialTheme.colorScheme.onErrorContainer,
428 modifier = Modifier.size(18.dp),
429 )
430 }
431 }
432 }
435@Composable
436private fun ChatInputBar(
437 draft: String,
438 generating: Boolean,
439 loading: Boolean,
440 onDraftChange: (String) -> Unit,
441 onSend: () -> Unit,
442 onStop: () -> Unit,
443) {
444 val haptic = LocalHapticFeedback.current
445 Surface(tonalElevation = 3.dp) {
446 Row(
447 modifier = Modifier.fillMaxWidth().imePadding().padding(horizontal = 12.dp, vertical = 8.dp),
448 verticalAlignment = Alignment.Bottom,
449 horizontalArrangement = Arrangement.spacedBy(8.dp),
450 ) {
451 OutlinedTextField(
452 value = draft,
453 onValueChange = onDraftChange,
454 modifier = Modifier.weight(1f),
455 enabled = !loading,
456 placeholder = { Text("Message opencode…") },
457 maxLines = 5,
458 // Surface a running count once the draft gets long, so the user has a
459 // sense of scale before sending a wall of text.
460 supportingText = if (draft.length > 500) {
461 {
462 Text(
463 text = "${draft.length} characters",
464 style = MaterialTheme.typography.labelSmall,
465 color = MaterialTheme.colorScheme.onSurfaceVariant,
466 textAlign = TextAlign.End,
467 modifier = Modifier.fillMaxWidth(),
468 )
469 }
470 } else {
471 null
472 },
473 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
474 keyboardActions = KeyboardActions(onSend = {
475 if (!generating && !loading && draft.isNotBlank()) onSend()
476 }),
477 )
478 if (generating) {
479 FilledTonalButton(onClick = onStop) {
480 Icon(
481 Icons.Filled.Stop,
482 contentDescription = null,
483 modifier = Modifier.size(18.dp),
484 )
485 Spacer(Modifier.size(6.dp))
486 Text("Stop")
487 }
488 } else {
489 FilledIconButton(
490 onClick = {
491 haptic.performHapticFeedback(HapticFeedbackType.LongPress)
492 onSend()
493 },
494 enabled = draft.isNotBlank() && !loading,
495 ) {
496 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
497 }
498 }
499 }
500 }