@@ -1,5 +1,9 @@
1package soy.iko.opencode.ui.screens
1package soy.iko.opencode.ui.screens
3+import android.content.ClipData
4+import android.content.ClipboardManager
5+import android.content.Context
6+import android.widget.Toast
3import androidx.compose.animation.AnimatedVisibility
7import androidx.compose.animation.AnimatedVisibility
4import androidx.compose.animation.core.RepeatMode
8import androidx.compose.animation.core.RepeatMode
5import androidx.compose.animation.core.animateFloat
9import androidx.compose.animation.core.animateFloat
@@ -10,8 +14,10 @@ import androidx.compose.animation.fadeIn
10import androidx.compose.animation.fadeOut
14import androidx.compose.animation.fadeOut
11import androidx.compose.animation.scaleIn
15import androidx.compose.animation.scaleIn
12import androidx.compose.animation.scaleOut
16import androidx.compose.animation.scaleOut
17+import androidx.compose.foundation.ExperimentalFoundationApi
13import androidx.compose.foundation.background
18import androidx.compose.foundation.background
14import androidx.compose.foundation.clickable
19import androidx.compose.foundation.clickable
20+import androidx.compose.foundation.combinedClickable
15import androidx.compose.foundation.layout.Arrangement
21import androidx.compose.foundation.layout.Arrangement
16import androidx.compose.foundation.layout.Box
22import androidx.compose.foundation.layout.Box
17import androidx.compose.foundation.layout.BoxScope
23import androidx.compose.foundation.layout.BoxScope
@@ -65,6 +71,7 @@ import androidx.compose.ui.Alignment
65import androidx.compose.ui.Modifier
71import androidx.compose.ui.Modifier
66import androidx.compose.ui.draw.clip
72import androidx.compose.ui.draw.clip
67import androidx.compose.ui.hapticfeedback.HapticFeedbackType
73import androidx.compose.ui.hapticfeedback.HapticFeedbackType
74+import androidx.compose.ui.platform.LocalContext
68import androidx.compose.ui.platform.LocalDensity
75import androidx.compose.ui.platform.LocalDensity
69import androidx.compose.ui.platform.LocalHapticFeedback
76import androidx.compose.ui.platform.LocalHapticFeedback
70import androidx.compose.ui.text.font.FontStyle
77import androidx.compose.ui.text.font.FontStyle
@@ -77,11 +84,12 @@ import soy.iko.opencode.core.Event
77import soy.iko.opencode.core.UiState
84import soy.iko.opencode.core.UiState
78import soy.iko.opencode.ui.components.MessageBubble
85import soy.iko.opencode.ui.components.MessageBubble
80−@OptIn(ExperimentalMaterial3Api::class)
87+@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
81@Composable
88@Composable
82fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
89fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
83 val listState = rememberLazyListState()
90 val listState = rememberLazyListState()
84 val coroutineScope = rememberCoroutineScope()
91 val coroutineScope = rememberCoroutineScope()
92+ val context = LocalContext.current
86 // Hide the jump-to-bottom FAB while the keyboard is up: it would otherwise
94 // Hide the jump-to-bottom FAB while the keyboard is up: it would otherwise
87 // sit over the top of the IME. Reading the ime inset bottom during composition
95 // 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) {
134 text = if (state.messages.isNotEmpty()) "$title (${state.messages.size})" else title,
142 text = if (state.messages.isNotEmpty()) "$title (${state.messages.size})" else title,
135 maxLines = 1,
143 maxLines = 1,
136 overflow = TextOverflow.Ellipsis,
144 overflow = TextOverflow.Ellipsis,
145+ modifier = Modifier.combinedClickable(
137− modifier = Modifier.clickable { dispatch(Event.NavigateToSessions) },
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()
140 navigationIcon = {
156 navigationIcon = {
@@ -219,7 +235,13 @@ fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
219 onClick = {
235 onClick = {
220 coroutineScope.launch {
236 coroutineScope.launch {
221 if (state.messages.isNotEmpty()) {
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 })
222− listState.animateScrollToItem(state.messages.size - 1)
243+ state.messages.size - 1
244+ listState.animateScrollToItem(target)
@@ -263,6 +285,11 @@ private fun BoxScope.ScrollToBottomButton(visible: Boolean, showBadge: Boolean,
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))
266private val dateSeparatorFormat = java.time.format.DateTimeFormatter.ofPattern("MMM d")
293private val dateSeparatorFormat = java.time.format.DateTimeFormatter.ofPattern("MMM d")
268/** Convert an epoch-millis timestamp to the local calendar day it falls on. */
295/** Convert an epoch-millis timestamp to the local calendar day it falls on. */