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)
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 {
104 val layoutInfo = listState.layoutInfo
105 val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
106 val totalItems = layoutInfo.totalItemsCount
107 totalItems > 0 && lastVisibleIndex < totalItems - 3
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
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
131 listState.animateScrollToItem(state.messages.size - 1)
140 val title = state.currentSessionTitle.ifEmpty { "Chat" }
142 text = if (state.messages.isNotEmpty()) "$title (${state.messages.size})" else title,
144 overflow = TextOverflow.Ellipsis,
145 modifier = Modifier.combinedClickable(
146 onClick = { dispatch(Event.NavigateToSessions) },
148 state.currentSessionId?.let { id ->
149 copyToClipboard(context, id)
150 Toast.makeText(context, "Session ID copied", Toast.LENGTH_SHORT).show()
157 IconButton(onClick = { dispatch(Event.NavigateToSessions) }) {
158 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to sessions")
162 if (state.generating) {
163 CircularProgressIndicator(
164 modifier = Modifier.padding(end = 16.dp).size(22.dp),
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) },
182 Column(Modifier.fillMaxSize().padding(padding)) {
183 if (!state.sseConnected && !state.generating) {
186 if (state.loading && state.messages.isEmpty()) {
187 LinearProgressIndicator(Modifier.fillMaxWidth())
189 if (state.error != null && state.messages.isNotEmpty()) {
191 message = state.error,
192 onDismiss = { dispatch(Event.DismissError) },
195 Box(Modifier.fillMaxWidth().weight(1f)) {
196 if (state.messages.isEmpty() && !state.loading) {
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),
206 modifier = Modifier.fillMaxSize(),
207 contentPadding = PaddingValues(vertical = 8.dp),
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)
216 val sameSenderAsPrevious = index > 0 && state.messages[index - 1].isUser == message.isUser
219 onRetry = { dispatch(Event.SendMessage(message.text)) },
220 compactSpacing = sameSenderAsPrevious,
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") {
232 ScrollToBottomButton(
233 visible = showScrollButton && !isImeVisible,
234 showBadge = hasUnseenMessages,
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 })
243 state.messages.size - 1
244 listState.animateScrollToItem(target)
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.
258private fun BoxScope.ScrollToBottomButton(visible: Boolean, showBadge: Boolean, onClick: () -> Unit) {
261 modifier = Modifier.align(Alignment.BottomEnd),
262 enter = scaleIn() + fadeIn(),
263 exit = scaleOut() + fadeOut(),
265 Box(Modifier.padding(16.dp)) {
266 FloatingActionButton(
268 modifier = Modifier.size(44.dp),
271 Icons.Filled.KeyboardArrowDown,
272 contentDescription = "Scroll to latest message",
278 .align(Alignment.TopEnd)
281 .background(MaterialTheme.colorScheme.error),
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())
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.
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()
317 today.minusDays(1) -> "Yesterday"
318 else -> day.format(dateSeparatorFormat)
323private fun DateSeparator(epochMillis: Long) {
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),
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.
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")
341 modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
342 horizontalArrangement = Arrangement.Start,
347 .background(MaterialTheme.colorScheme.surfaceVariant)
348 .padding(horizontal = 14.dp, vertical = 12.dp),
349 verticalAlignment = Alignment.CenterVertically,
350 horizontalArrangement = Arrangement.spacedBy(6.dp),
354 style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
355 color = MaterialTheme.colorScheme.onSurfaceVariant,
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(
363 animationSpec = infiniteRepeatable(
364 animation = tween(durationMillis = 600, delayMillis = staggerMs),
365 repeatMode = RepeatMode.Reverse,
373 .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha)),
382private fun ReconnectingBanner() {
384 modifier = Modifier.fillMaxWidth(),
385 color = MaterialTheme.colorScheme.tertiaryContainer,
388 modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
389 verticalAlignment = Alignment.CenterVertically,
390 horizontalArrangement = Arrangement.spacedBy(8.dp),
392 CircularProgressIndicator(
393 modifier = Modifier.size(14.dp),
395 color = MaterialTheme.colorScheme.onTertiaryContainer,
398 text = "Reconnecting…",
399 style = MaterialTheme.typography.bodySmall,
400 color = MaterialTheme.colorScheme.onTertiaryContainer,
407private fun ErrorBanner(message: String, onDismiss: () -> Unit) {
409 modifier = Modifier.fillMaxWidth(),
410 color = MaterialTheme.colorScheme.errorContainer,
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),
419 style = MaterialTheme.typography.bodySmall,
420 color = MaterialTheme.colorScheme.onErrorContainer,
421 modifier = Modifier.weight(1f),
423 IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) {
426 contentDescription = "Dismiss error",
427 tint = MaterialTheme.colorScheme.onErrorContainer,
428 modifier = Modifier.size(18.dp),
436private fun ChatInputBar(
440 onDraftChange: (String) -> Unit,
444 val haptic = LocalHapticFeedback.current
445 Surface(tonalElevation = 3.dp) {
447 modifier = Modifier.fillMaxWidth().imePadding().padding(horizontal = 12.dp, vertical = 8.dp),
448 verticalAlignment = Alignment.Bottom,
449 horizontalArrangement = Arrangement.spacedBy(8.dp),
453 onValueChange = onDraftChange,
454 modifier = Modifier.weight(1f),
456 placeholder = { Text("Message opencode…") },
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) {
463 text = "${draft.length} characters",
464 style = MaterialTheme.typography.labelSmall,
465 color = MaterialTheme.colorScheme.onSurfaceVariant,
466 textAlign = TextAlign.End,
467 modifier = Modifier.fillMaxWidth(),
473 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
474 keyboardActions = KeyboardActions(onSend = {
475 if (!generating && !loading && draft.isNotBlank()) onSend()
479 FilledTonalButton(onClick = onStop) {
482 contentDescription = null,
483 modifier = Modifier.size(18.dp),
485 Spacer(Modifier.size(6.dp))
491 haptic.performHapticFeedback(HapticFeedbackType.LongPress)
494 enabled = draft.isNotBlank() && !loading,
496 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list