@@ -6,7 +6,12 @@ import androidx.compose.foundation.layout.Column
66import androidx.compose.foundation.layout.Row
77import androidx.compose.foundation.layout.fillMaxWidth
88import androidx.compose.foundation.layout.padding
9+import androidx.compose.foundation.layout.size
910import 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
1015import androidx.compose.material3.MaterialTheme
1116import androidx.compose.material3.Text
1217import androidx.compose.runtime.Composable
@@ -14,15 +19,18 @@ import androidx.compose.runtime.remember
1419import androidx.compose.ui.Alignment
1520import androidx.compose.ui.Modifier
1621import androidx.compose.ui.draw.clip
22+import androidx.compose.ui.platform.LocalContext
1723import androidx.compose.ui.res.stringResource
1824import androidx.compose.ui.unit.dp
1925import soy.iko.opencode.R
2026import soy.iko.opencode.data.model.AssistantMessage
2127import soy.iko.opencode.data.model.MessageWithParts
28+import soy.iko.opencode.data.model.TextPart
2229import soy.iko.opencode.data.model.Tokens
2330import soy.iko.opencode.data.model.UserMessage
2431import soy.iko.opencode.data.network.NetworkConfig
2532import soy.iko.opencode.ui.components.ImageLoadContext
33+import soy.iko.opencode.ui.components.copyToClipboard
2634import soy.iko.opencode.ui.components.rememberRelativeTime
2735
2836/** 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
8795 cost?.takeIf { it > 0 }?.let { add(formatCost(it)) }
8896 }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
8997 }
90− costSummary?.let {
91− Text(
92− it,
93− style = MaterialTheme.typography.labelSmall,
94− color = MaterialTheme.colorScheme.onSurfaceVariant,
95− )
98+ Row(
99+ modifier = Modifier.fillMaxWidth(),
100+ horizontalArrangement = Arrangement.SpaceBetween,
101+ verticalAlignment = Alignment.CenterVertically,
102+ ) {
103+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
104+ costSummary?.let {
105+ Text(
106+ it,
107+ style = MaterialTheme.typography.labelSmall,
108+ color = MaterialTheme.colorScheme.onSurfaceVariant,
109+ )
110+ }
111+ MessageTimestampText(message.info)
112+ }
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) {
118+ message.parts
119+ .filterIsInstance<TextPart>()
120+ .joinToString("\n\n") { it.text }
121+ .takeIf { it.isNotBlank() }
122+ }
123+ if (textToCopy != null) {
124+ IconButton(
125+ onClick = { copyToClipboard(context, "message", textToCopy) },
126+ modifier = Modifier.size(28.dp),
127+ ) {
128+ Icon(
129+ Icons.Filled.ContentCopy,
130+ contentDescription = copyLabel,
131+ modifier = Modifier.size(14.dp),
132+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
133+ )
134+ }
135+ }
96136 }
137+ } else {
138+ MessageTimestampText(message.info)
97139 }
98− MessageTimestamp(message.info)
99140 }
100141}
101142
102143@Composable
103−private fun MessageTimestamp(info: soy.iko.opencode.data.model.MessageInfo) {
144+private fun MessageTimestampText(info: soy.iko.opencode.data.model.MessageInfo) {
104145 val t = rememberRelativeTime(info.time?.created)
105146 if (t.isNotEmpty()) {
106147 Text(
@@ -111,6 +152,11 @@ private fun MessageTimestamp(info: soy.iko.opencode.data.model.MessageInfo) {
111152 }
112153}
113154
155+@Composable
156+private fun MessageTimestamp(info: soy.iko.opencode.data.model.MessageInfo) {
157+ MessageTimestampText(info)
158+}
159+
114160// NumberFormat.getNumberInstance performs an expensive ICU locale lookup + object
115161// construction on every call. Reuse a thread-local instance so repeated calls (e.g.
116162// when the message list re-seeds after a reconnect and every visible assistant bubble