@@ -1,5 +1,9 @@
11package soy.iko.opencode.ui.screens
22
3+import android.content.ClipData
4+import android.content.ClipboardManager
5+import android.content.Context
6+import android.widget.Toast
37import androidx.compose.animation.AnimatedVisibility
48import androidx.compose.animation.core.RepeatMode
59import androidx.compose.animation.core.animateFloat
@@ -10,8 +14,10 @@ import androidx.compose.animation.fadeIn
1014import androidx.compose.animation.fadeOut
1115import androidx.compose.animation.scaleIn
1216import androidx.compose.animation.scaleOut
17+import androidx.compose.foundation.ExperimentalFoundationApi
1318import androidx.compose.foundation.background
1419import androidx.compose.foundation.clickable
20+import androidx.compose.foundation.combinedClickable
1521import androidx.compose.foundation.layout.Arrangement
1622import androidx.compose.foundation.layout.Box
1723import androidx.compose.foundation.layout.BoxScope
@@ -65,6 +71,7 @@ import androidx.compose.ui.Alignment
6571import androidx.compose.ui.Modifier
6672import androidx.compose.ui.draw.clip
6773import androidx.compose.ui.hapticfeedback.HapticFeedbackType
74+import androidx.compose.ui.platform.LocalContext
6875import androidx.compose.ui.platform.LocalDensity
6976import androidx.compose.ui.platform.LocalHapticFeedback
7077import androidx.compose.ui.text.font.FontStyle
@@ -77,11 +84,12 @@ import soy.iko.opencode.core.Event
7784import soy.iko.opencode.core.UiState
7885import soy.iko.opencode.ui.components.MessageBubble
7986
80−@OptIn(ExperimentalMaterial3Api::class)
87+@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
8188@Composable
8289fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
8390 val listState = rememberLazyListState()
8491 val coroutineScope = rememberCoroutineScope()
92+ val context = LocalContext.current
8593
8694 // Hide the jump-to-bottom FAB while the keyboard is up: it would otherwise
8795 // sit over the top of the IME. Reading the ime inset bottom during composition
@@ -134,7 +142,15 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
134142 text = if (state.messages.isNotEmpty()) "$title (${state.messages.size})" else title,
135143 maxLines = 1,
136144 overflow = TextOverflow.Ellipsis,
137− modifier = Modifier.clickable { dispatch(Event.NavigateToSessions) },
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+ ),
138154 )
139155 },
140156 navigationIcon = {
@@ -219,7 +235,13 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
219235 onClick = {
220236 coroutineScope.launch {
221237 if (state.messages.isNotEmpty()) {
222− listState.animateScrollToItem(state.messages.size - 1)
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)
223245 }
224246 }
225247 },
@@ -263,6 +285,11 @@ private fun BoxScope.ScrollToBottomButton(visible: Boolean, showBadge: Boolean,
263285 }
264286}
265287
288+private fun copyToClipboard(context: Context, text: String) {
289+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
290+ clipboard.setPrimaryClip(ClipData.newPlainText("session", text))
291+}
292+
266293private val dateSeparatorFormat = java.time.format.DateTimeFormatter.ofPattern("MMM d")
267294
268295/** Convert an epoch-millis timestamp to the local calendar day it falls on. */