@@ -6,7 +6,12 @@ import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Row
6import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.fillMaxWidth
8import androidx.compose.foundation.layout.padding
8import androidx.compose.foundation.layout.padding
9+import androidx.compose.foundation.layout.size
9import androidx.compose.foundation.shape.RoundedCornerShape
10import androidx.compose.foundation.shape.RoundedCornerShape
11+import androidx.compose.material.icons.Icons
12+import androidx.compose.material.icons.filled.ContentCopy
13+import androidx.compose.material3.Icon
14+import androidx.compose.material3.IconButton
10import androidx.compose.material3.MaterialTheme
15import androidx.compose.material3.MaterialTheme
11import androidx.compose.material3.Text
16import androidx.compose.material3.Text
12import androidx.compose.runtime.Composable
17import androidx.compose.runtime.Composable
@@ -14,15 +19,18 @@ import androidx.compose.runtime.remember
14import androidx.compose.ui.Alignment
19import androidx.compose.ui.Alignment
15import androidx.compose.ui.Modifier
20import androidx.compose.ui.Modifier
16import androidx.compose.ui.draw.clip
21import androidx.compose.ui.draw.clip
22+import androidx.compose.ui.platform.LocalContext
17import androidx.compose.ui.res.stringResource
23import androidx.compose.ui.res.stringResource
18import androidx.compose.ui.unit.dp
24import androidx.compose.ui.unit.dp
19import soy.iko.opencode.R
25import soy.iko.opencode.R
20import soy.iko.opencode.data.model.AssistantMessage
26import soy.iko.opencode.data.model.AssistantMessage
21import soy.iko.opencode.data.model.MessageWithParts
27import soy.iko.opencode.data.model.MessageWithParts
28+import soy.iko.opencode.data.model.TextPart
22import soy.iko.opencode.data.model.Tokens
29import soy.iko.opencode.data.model.Tokens
23import soy.iko.opencode.data.model.UserMessage
30import soy.iko.opencode.data.model.UserMessage
24import soy.iko.opencode.data.network.NetworkConfig
31import soy.iko.opencode.data.network.NetworkConfig
25import soy.iko.opencode.ui.components.ImageLoadContext
32import soy.iko.opencode.ui.components.ImageLoadContext
33+import soy.iko.opencode.ui.components.copyToClipboard
26import soy.iko.opencode.ui.components.rememberRelativeTime
34import soy.iko.opencode.ui.components.rememberRelativeTime
28/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
36/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
@@ -87,20 +95,53 @@ private fun AssistantBlock(message: MessageWithParts, isRunning: Boolean, imageC
87 cost?.takeIf { it > 0 }?.let { add(formatCost(it)) }
95 cost?.takeIf { it > 0 }?.let { add(formatCost(it)) }
88 }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
96 }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
99+ modifier = Modifier.fillMaxWidth(),
100+ horizontalArrangement = Arrangement.SpaceBetween,
101+ verticalAlignment = Alignment.CenterVertically,
103+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
90− costSummary?.let {
104+ costSummary?.let {
93− style = MaterialTheme.typography.labelSmall,
107+ style = MaterialTheme.typography.labelSmall,
94− color = MaterialTheme.colorScheme.onSurfaceVariant,
108+ color = MaterialTheme.colorScheme.onSurfaceVariant,
111+ MessageTimestampText(message.info)
113+ val context = LocalContext.current
114+ val copyLabel = stringResource(R.string.copy)
115+ // Collect text from all TextParts for copying. Memoized so a
116+ // scroll-induced recomposition doesn't re-scan the parts list.
117+ val textToCopy = remember(message.parts) {
119+ .filterIsInstance<TextPart>()
120+ .joinToString("\n\n") { it.text }
121+ .takeIf { it.isNotBlank() }
123+ if (textToCopy != null) {
125+ onClick = { copyToClipboard(context, "message", textToCopy) },
126+ modifier = Modifier.size(28.dp),
129+ Icons.Filled.ContentCopy,
130+ contentDescription = copyLabel,
131+ modifier = Modifier.size(14.dp),
132+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
138+ MessageTimestampText(message.info)
98− MessageTimestamp(message.info)
102@Composable
143@Composable
103−private fun MessageTimestamp(info: soy.iko.opencode.data.model.MessageInfo) {
144+private fun MessageTimestampText(info: soy.iko.opencode.data.model.MessageInfo) {
104 val t = rememberRelativeTime(info.time?.created)
145 val t = rememberRelativeTime(info.time?.created)
105 if (t.isNotEmpty()) {
146 if (t.isNotEmpty()) {
@@ -111,6 +152,11 @@ private fun MessageTimestamp(info: soy.iko.opencode.data.model.MessageInfo) {
156+private fun MessageTimestamp(info: soy.iko.opencode.data.model.MessageInfo) {
157+ MessageTimestampText(info)
114// NumberFormat.getNumberInstance performs an expensive ICU locale lookup + object
160// NumberFormat.getNumberInstance performs an expensive ICU locale lookup + object
115// construction on every call. Reuse a thread-local instance so repeated calls (e.g.
161// construction on every call. Reuse a thread-local instance so repeated calls (e.g.
116// when the message list re-seeds after a reconnect and every visible assistant bubble
162// when the message list re-seeds after a reconnect and every visible assistant bubble