git.iko.soy/opencode-android
on b9f3596 help sign in
242 lines 10 kB history raw
1package soy.iko.opencode.ui.components
3import android.content.ClipData
4import android.content.ClipboardManager
5import android.content.Context
6import android.widget.Toast
7import androidx.compose.foundation.ExperimentalFoundationApi
8import androidx.compose.foundation.background
9import androidx.compose.foundation.clickable
10import androidx.compose.foundation.combinedClickable
11import androidx.compose.foundation.layout.Arrangement
12import androidx.compose.foundation.layout.Column
13import androidx.compose.foundation.layout.Row
14import androidx.compose.foundation.layout.fillMaxWidth
15import androidx.compose.foundation.layout.padding
16import androidx.compose.foundation.layout.widthIn
17import androidx.compose.foundation.shape.RoundedCornerShape
18import androidx.compose.foundation.text.selection.SelectionContainer
19import androidx.compose.material.icons.Icons
20import androidx.compose.material.icons.filled.Build
21import androidx.compose.material.icons.filled.Check
22import androidx.compose.material.icons.filled.ErrorOutline
23import androidx.compose.material.icons.filled.ExpandLess
24import androidx.compose.material.icons.filled.ExpandMore
25import androidx.compose.material.icons.filled.Schedule
26import androidx.compose.material3.Icon
27import androidx.compose.material3.MaterialTheme
28import androidx.compose.material3.Text
29import androidx.compose.runtime.Composable
30import androidx.compose.runtime.getValue
31import androidx.compose.runtime.mutableStateOf
32import androidx.compose.runtime.remember
33import androidx.compose.runtime.setValue
34import androidx.compose.ui.Alignment
35import androidx.compose.ui.Modifier
36import androidx.compose.ui.draw.clip
37import androidx.compose.ui.graphics.Color
38import androidx.compose.ui.platform.LocalContext
39import androidx.compose.ui.text.SpanStyle
40import androidx.compose.ui.text.buildAnnotatedString
41import androidx.compose.ui.text.font.FontStyle
42import androidx.compose.ui.text.withStyle
43import androidx.compose.ui.text.style.TextOverflow
44import androidx.compose.ui.unit.dp
45import soy.iko.opencode.core.MessageStatus
46import soy.iko.opencode.core.MessageView
47import soy.iko.opencode.core.ToolView
48import soy.iko.opencode.ui.theme.MonoStyle
50private val timeFormat = java.time.format.DateTimeFormatter.ofPattern("HH:mm")
52@OptIn(ExperimentalFoundationApi::class)
53@Composable
54fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
55 val isUser = message.isUser
56 val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
57 val textColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
58 val mutedColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f) else MaterialTheme.colorScheme.onSurfaceVariant
59 val shape = RoundedCornerShape(
60 topStart = 16.dp,
61 topEnd = 16.dp,
62 bottomStart = if (isUser) 16.dp else 4.dp,
63 bottomEnd = if (isUser) 4.dp else 16.dp,
64 )
66 val context = LocalContext.current
68 Row(
69 modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
70 horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
71 ) {
72 Column(
73 modifier = Modifier
74 .fillMaxWidth(0.85f)
75 .widthIn(max = 320.dp)
76 .clip(shape)
77 .background(bubbleColor)
78 .combinedClickable(
79 onClick = {},
80 onLongClick = {
81 if (message.text.isNotEmpty()) {
82 copyToClipboard(context, message.text)
83 Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
84 }
85 },
86 )
87 .padding(horizontal = 14.dp, vertical = 10.dp),
88 ) {
89 message.reasoning?.let { ReasoningBlock(it, context) }
91 message.tools.forEach { ToolRow(it) }
93 SelectionContainer {
94 if (message.text.isNotEmpty()) {
95 Text(
96 text = message.text + if (message.streaming) " ▌" else "",
97 color = textColor,
98 style = MaterialTheme.typography.bodyLarge,
99 )
100 } else if (message.streaming && message.tools.isEmpty() && message.reasoning == null) {
101 Text("▌", color = textColor, style = MaterialTheme.typography.bodyLarge)
102 }
103 }
105 if (message.time > 0) {
106 Text(
107 text = timeFormat.format(java.time.Instant.ofEpochMilli(message.time).atZone(java.time.ZoneId.systemDefault()).toLocalTime()),
108 style = MaterialTheme.typography.labelSmall,
109 color = mutedColor,
110 modifier = Modifier.padding(top = 4.dp),
111 )
112 }
114 if (isUser && message.status != MessageStatus.Sent) {
115 StatusLine(message.status)
116 }
117 }
118 }
121private fun copyToClipboard(context: Context, text: String) {
122 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
123 clipboard.setPrimaryClip(ClipData.newPlainText("message", text))
126@Composable
127private fun ReasoningBlock(text: String, context: Context) {
128 var expanded by remember { mutableStateOf(false) }
129 Column(modifier = Modifier.padding(bottom = 6.dp)) {
130 Row(
131 verticalAlignment = Alignment.CenterVertically,
132 modifier = Modifier.clickable { expanded = !expanded },
133 ) {
134 Text(
135 text = if (expanded) "Show less" else "Reasoning",
136 style = MaterialTheme.typography.bodySmall,
137 color = MaterialTheme.colorScheme.onSurfaceVariant,
138 )
139 Icon(
140 imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
141 contentDescription = if (expanded) "Collapse reasoning" else "Expand reasoning",
142 tint = MaterialTheme.colorScheme.onSurfaceVariant,
143 modifier = Modifier.padding(start = 2.dp).widthIn(max = 18.dp),
144 )
145 }
146 if (expanded) {
147 Text(
148 text = text,
149 style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
150 color = MaterialTheme.colorScheme.onSurfaceVariant,
151 modifier = Modifier.padding(top = 4.dp),
152 )
153 Text(
154 text = "Copy",
155 style = MaterialTheme.typography.labelSmall,
156 color = MaterialTheme.colorScheme.primary,
157 modifier = Modifier
158 .padding(top = 4.dp)
159 .clickable {
160 copyToClipboard(context, text)
161 Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
162 },
163 )
164 }
165 }
168@Composable
169private fun ToolRow(tool: ToolView) {
170 var expanded by remember { mutableStateOf(false) }
171 val baseColor = MaterialTheme.colorScheme.onSurfaceVariant
172 val statusColor = when (tool.status) {
173 "completed", "success" -> MaterialTheme.colorScheme.primary
174 "error", "failed" -> MaterialTheme.colorScheme.error
175 "pending", "running", "in_progress" -> MaterialTheme.colorScheme.onSurfaceVariant
176 else -> MaterialTheme.colorScheme.onSurfaceVariant
177 }
178 Row(
179 verticalAlignment = Alignment.CenterVertically,
180 modifier = Modifier
181 .fillMaxWidth()
182 .clickable { expanded = !expanded }
183 .padding(bottom = 6.dp),
184 ) {
185 Icon(
186 Icons.Filled.Build,
187 contentDescription = null,
188 tint = MaterialTheme.colorScheme.primary,
189 modifier = Modifier.padding(end = 6.dp).widthIn(max = 16.dp),
190 )
191 Text(
192 text = buildAnnotatedString {
193 withStyle(SpanStyle(color = baseColor)) {
194 append(tool.name)
195 append(" · ")
196 }
197 withStyle(SpanStyle(color = statusColor)) {
198 append(tool.status)
199 }
200 if (expanded) tool.title?.let {
201 withStyle(SpanStyle(color = baseColor)) {
202 append(" — ")
203 append(it)
204 }
205 }
206 },
207 style = MonoStyle,
208 color = baseColor,
209 maxLines = if (expanded) Int.MAX_VALUE else 1,
210 overflow = TextOverflow.Ellipsis,
211 modifier = Modifier.weight(1f),
212 )
213 Icon(
214 imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
215 contentDescription = if (expanded) "Collapse tool details" else "Expand tool details",
216 tint = MaterialTheme.colorScheme.onSurfaceVariant,
217 modifier = Modifier.padding(start = 2.dp).widthIn(max = 18.dp),
218 )
219 }
222@Composable
223private fun StatusLine(status: MessageStatus) {
224 val (icon, label, tint) = when (status) {
225 MessageStatus.Pending -> Triple(Icons.Filled.Schedule, "Sending…", MaterialTheme.colorScheme.onPrimaryContainer)
226 MessageStatus.Failed -> Triple(Icons.Filled.ErrorOutline, "Failed to send", MaterialTheme.colorScheme.error)
227 MessageStatus.Sent -> Triple(Icons.Filled.Check, "", Color.Unspecified)
228 }
229 Row(
230 verticalAlignment = Alignment.CenterVertically,
231 horizontalArrangement = Arrangement.End,
232 modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
233 ) {
234 Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.widthIn(max = 14.dp))
235 Text(
236 text = label,
237 style = MaterialTheme.typography.labelSmall,
238 color = tint,
239 modifier = Modifier.padding(start = 4.dp),
240 )
241 }