@@ -1,6 +1,13 @@
11package soy.iko.opencode.ui.components
22
3+import android.content.ClipData
4+import android.content.ClipboardManager
5+import android.content.Context
6+import android.widget.Toast
7+import androidx.compose.foundation.ExperimentalFoundationApi
38import androidx.compose.foundation.background
9+import androidx.compose.foundation.clickable
10+import androidx.compose.foundation.combinedClickable
411import androidx.compose.foundation.layout.Arrangement
512import androidx.compose.foundation.layout.Column
613import androidx.compose.foundation.layout.Row
@@ -11,27 +18,41 @@ import androidx.compose.foundation.shape.RoundedCornerShape
1118import androidx.compose.material.icons.Icons
1219import androidx.compose.material.icons.filled.Build
1320import androidx.compose.material.icons.filled.ErrorOutline
21+import androidx.compose.material.icons.filled.ExpandLess
22+import androidx.compose.material.icons.filled.ExpandMore
1423import androidx.compose.material.icons.filled.Schedule
1524import androidx.compose.material3.Icon
1625import androidx.compose.material3.MaterialTheme
1726import androidx.compose.material3.Text
1827import androidx.compose.runtime.Composable
28+import androidx.compose.runtime.getValue
29+import androidx.compose.runtime.mutableStateOf
30+import androidx.compose.runtime.remember
31+import androidx.compose.runtime.setValue
1932import androidx.compose.ui.Alignment
2033import androidx.compose.ui.Modifier
2134import androidx.compose.ui.draw.clip
2235import androidx.compose.ui.graphics.Color
36+import androidx.compose.ui.platform.LocalContext
2337import androidx.compose.ui.text.font.FontStyle
2438import androidx.compose.ui.unit.dp
39+import java.text.SimpleDateFormat
40+import java.util.Date
41+import java.util.Locale
2542import soy.iko.opencode.core.MessageStatus
2643import soy.iko.opencode.core.MessageView
2744import soy.iko.opencode.core.ToolView
2845import soy.iko.opencode.ui.theme.MonoStyle
2946
47+private val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
48+
49+@OptIn(ExperimentalFoundationApi::class)
3050@Composable
3151fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
3252 val isUser = message.isUser
3353 val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
3454 val textColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
55+ val mutedColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f) else MaterialTheme.colorScheme.onSurfaceVariant
3556 val shape = RoundedCornerShape(
3657 topStart = 16.dp,
3758 topEnd = 16.dp,
@@ -39,6 +60,8 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
3960 bottomEnd = if (isUser) 4.dp else 16.dp,
4061 )
4162
63+ val context = LocalContext.current
64+
4265 Row(
4366 modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
4467 horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
@@ -48,6 +71,15 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
4871 .widthIn(max = 320.dp)
4972 .clip(shape)
5073 .background(bubbleColor)
74+ .combinedClickable(
75+ onClick = {},
76+ onLongClick = {
77+ if (message.text.isNotEmpty()) {
78+ copyToClipboard(context, message.text)
79+ Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
80+ }
81+ },
82+ )
5183 .padding(horizontal = 14.dp, vertical = 10.dp),
5284 ) {
5385 message.reasoning?.let { ReasoningBlock(it) }
@@ -64,6 +96,15 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
6496 Text("▌", color = textColor, style = MaterialTheme.typography.bodyLarge)
6597 }
6698
99+ if (message.time > 0) {
100+ Text(
101+ text = timeFormat.format(Date(message.time)),
102+ style = MaterialTheme.typography.labelSmall,
103+ color = mutedColor,
104+ modifier = Modifier.padding(top = 4.dp),
105+ )
106+ }
107+
67108 if (isUser && message.status != MessageStatus.Sent) {
68109 StatusLine(message.status)
69110 }
@@ -71,14 +112,40 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
71112 }
72113}
73114
115+private fun copyToClipboard(context: Context, text: String) {
116+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
117+ clipboard.setPrimaryClip(ClipData.newPlainText("message", text))
118+}
119+
74120@Composable
75121private fun ReasoningBlock(text: String) {
76− Text(
77− text = text,
78− style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
79− color = MaterialTheme.colorScheme.onSurfaceVariant,
80− modifier = Modifier.padding(bottom = 6.dp),
81− )
122+ var expanded by remember { mutableStateOf(false) }
123+ Column(modifier = Modifier.padding(bottom = 6.dp)) {
124+ Row(
125+ verticalAlignment = Alignment.CenterVertically,
126+ modifier = Modifier.clickable { expanded = !expanded },
127+ ) {
128+ Text(
129+ text = if (expanded) "Show less" else "Reasoning",
130+ style = MaterialTheme.typography.bodySmall,
131+ color = MaterialTheme.colorScheme.onSurfaceVariant,
132+ )
133+ Icon(
134+ imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
135+ contentDescription = if (expanded) "Collapse reasoning" else "Expand reasoning",
136+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
137+ modifier = Modifier.padding(start = 2.dp).widthIn(max = 18.dp),
138+ )
139+ }
140+ if (expanded) {
141+ Text(
142+ text = text,
143+ style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
144+ color = MaterialTheme.colorScheme.onSurfaceVariant,
145+ modifier = Modifier.padding(top = 4.dp),
146+ )
147+ }
148+ }
82149}
83150
84151@Composable