@@ -1,6 +1,13 @@
1package soy.iko.opencode.ui.components
1package soy.iko.opencode.ui.components
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
3import androidx.compose.foundation.background
8import androidx.compose.foundation.background
9+import androidx.compose.foundation.clickable
10+import androidx.compose.foundation.combinedClickable
4import androidx.compose.foundation.layout.Arrangement
11import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Column
12import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Row
13import androidx.compose.foundation.layout.Row
@@ -11,27 +18,41 @@ import androidx.compose.foundation.shape.RoundedCornerShape
11import androidx.compose.material.icons.Icons
18import androidx.compose.material.icons.Icons
12import androidx.compose.material.icons.filled.Build
19import androidx.compose.material.icons.filled.Build
13import androidx.compose.material.icons.filled.ErrorOutline
20import androidx.compose.material.icons.filled.ErrorOutline
21+import androidx.compose.material.icons.filled.ExpandLess
22+import androidx.compose.material.icons.filled.ExpandMore
14import androidx.compose.material.icons.filled.Schedule
23import androidx.compose.material.icons.filled.Schedule
15import androidx.compose.material3.Icon
24import androidx.compose.material3.Icon
16import androidx.compose.material3.MaterialTheme
25import androidx.compose.material3.MaterialTheme
17import androidx.compose.material3.Text
26import androidx.compose.material3.Text
18import androidx.compose.runtime.Composable
27import 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
19import androidx.compose.ui.Alignment
32import androidx.compose.ui.Alignment
20import androidx.compose.ui.Modifier
33import androidx.compose.ui.Modifier
21import androidx.compose.ui.draw.clip
34import androidx.compose.ui.draw.clip
22import androidx.compose.ui.graphics.Color
35import androidx.compose.ui.graphics.Color
36+import androidx.compose.ui.platform.LocalContext
23import androidx.compose.ui.text.font.FontStyle
37import androidx.compose.ui.text.font.FontStyle
24import androidx.compose.ui.unit.dp
38import androidx.compose.ui.unit.dp
39+import java.text.SimpleDateFormat
41+import java.util.Locale
25import soy.iko.opencode.core.MessageStatus
42import soy.iko.opencode.core.MessageStatus
26import soy.iko.opencode.core.MessageView
43import soy.iko.opencode.core.MessageView
27import soy.iko.opencode.core.ToolView
44import soy.iko.opencode.core.ToolView
28import soy.iko.opencode.ui.theme.MonoStyle
45import soy.iko.opencode.ui.theme.MonoStyle
47+private val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
49+@OptIn(ExperimentalFoundationApi::class)
30@Composable
50@Composable
31fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
51fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
32 val isUser = message.isUser
52 val isUser = message.isUser
33 val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
53 val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
34 val textColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
54 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
35 val shape = RoundedCornerShape(
56 val shape = RoundedCornerShape(
36 topStart = 16.dp,
57 topStart = 16.dp,
37 topEnd = 16.dp,
58 topEnd = 16.dp,
@@ -39,6 +60,8 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
39 bottomEnd = if (isUser) 4.dp else 16.dp,
60 bottomEnd = if (isUser) 4.dp else 16.dp,
63+ val context = LocalContext.current
43 modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
66 modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
44 horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
67 horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
@@ -48,6 +71,15 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
48 .widthIn(max = 320.dp)
71 .widthIn(max = 320.dp)
49 .clip(shape)
72 .clip(shape)
50 .background(bubbleColor)
73 .background(bubbleColor)
77+ if (message.text.isNotEmpty()) {
78+ copyToClipboard(context, message.text)
79+ Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
51 .padding(horizontal = 14.dp, vertical = 10.dp),
83 .padding(horizontal = 14.dp, vertical = 10.dp),
53 message.reasoning?.let { ReasoningBlock(it) }
85 message.reasoning?.let { ReasoningBlock(it) }
@@ -64,6 +96,15 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
64 Text("▌", color = textColor, style = MaterialTheme.typography.bodyLarge)
96 Text("▌", color = textColor, style = MaterialTheme.typography.bodyLarge)
99+ if (message.time > 0) {
101+ text = timeFormat.format(Date(message.time)),
102+ style = MaterialTheme.typography.labelSmall,
104+ modifier = Modifier.padding(top = 4.dp),
67 if (isUser && message.status != MessageStatus.Sent) {
108 if (isUser && message.status != MessageStatus.Sent) {
68 StatusLine(message.status)
109 StatusLine(message.status)
@@ -71,14 +112,40 @@ fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
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))
74@Composable
120@Composable
75private fun ReasoningBlock(text: String) {
121private fun ReasoningBlock(text: String) {
122+ var expanded by remember { mutableStateOf(false) }
123+ Column(modifier = Modifier.padding(bottom = 6.dp)) {
125+ verticalAlignment = Alignment.CenterVertically,
126+ modifier = Modifier.clickable { expanded = !expanded },
129+ text = if (expanded) "Show less" else "Reasoning",
130+ style = MaterialTheme.typography.bodySmall,
131+ color = MaterialTheme.colorScheme.onSurfaceVariant,
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),
77− text = text,
142+ text = text,
78− style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
143+ style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
79− color = MaterialTheme.colorScheme.onSurfaceVariant,
144+ color = MaterialTheme.colorScheme.onSurfaceVariant,
80− modifier = Modifier.padding(bottom = 6.dp),
145+ modifier = Modifier.padding(top = 4.dp),
84@Composable
151@Composable