@@ -1,7 +1,9 @@
11package soy.iko.opencode.ui.chat
22
33import androidx.compose.foundation.background
4+import androidx.compose.foundation.combinedClickable
45import androidx.compose.foundation.layout.Arrangement
6+import androidx.compose.foundation.layout.Box
57import androidx.compose.foundation.layout.BoxWithConstraints
68import androidx.compose.foundation.layout.Column
79import androidx.compose.foundation.layout.Row
@@ -18,6 +20,7 @@ import androidx.compose.material.icons.filled.Edit
1820import androidx.compose.material.icons.filled.FormatQuote
1921import androidx.compose.material.icons.filled.MoreVert
2022import androidx.compose.material.icons.filled.Restore
23+import androidx.compose.material.icons.filled.SmartToy
2124import androidx.compose.material.icons.filled.StopCircle
2225import androidx.compose.material3.DropdownMenu
2326import androidx.compose.material3.DropdownMenuItem
@@ -38,6 +41,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
3841import androidx.compose.ui.platform.LocalContext
3942import androidx.compose.ui.platform.LocalHapticFeedback
4043import androidx.compose.ui.res.stringResource
44+import androidx.compose.ui.semantics.Role
4145import androidx.compose.ui.unit.dp
4246import soy.iko.opencode.R
4347import soy.iko.opencode.data.model.AssistantMessage
@@ -69,6 +73,7 @@ fun resolveModelLabel(info: AssistantMessage, models: List<ModelOption>): String
6973}
7074
7175/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
76+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
7277@Composable
7378fun MessageBubble(
7479 message: MessageWithParts,
@@ -91,6 +96,54 @@ fun MessageBubble(
9196 }
9297}
9398
99+/** A long-press context menu consolidating all per-message actions (copy, quote, branch,
100+ * revert). Surfacing these via long-press matches the conventional Android pattern so the
101+ * actions are discoverable without spotting the small 18dp inline icon row. The menu is
102+ * anchored to the long-press position via [offset], and only items with non-null callbacks
103+ * (and text to act on) are shown. */
104+@Composable
105+private fun MessageLongPressMenu(
106+ expanded: Boolean,
107+ onDismiss: () -> Unit,
108+ offset: androidx.compose.ui.unit.DpOffset,
109+ text: String?,
110+ onCopy: (() -> Unit)?,
111+ onQuote: ((String) -> Unit)?,
112+ onBranch: ((String) -> Unit)?,
113+ onRevert: (() -> Unit)?,
114+) {
115+ DropdownMenu(expanded = expanded, onDismissRequest = onDismiss, offset = offset) {
116+ if (text != null && onCopy != null) {
117+ DropdownMenuItem(
118+ leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) },
119+ text = { Text(stringResource(R.string.copy)) },
120+ onClick = { onDismiss(); onCopy() },
121+ )
122+ }
123+ if (text != null && onQuote != null) {
124+ DropdownMenuItem(
125+ leadingIcon = { Icon(Icons.Filled.FormatQuote, contentDescription = null) },
126+ text = { Text(stringResource(R.string.quote_reply)) },
127+ onClick = { onDismiss(); onQuote(text) },
128+ )
129+ }
130+ if (text != null && onBranch != null) {
131+ DropdownMenuItem(
132+ leadingIcon = { Icon(Icons.Filled.CallSplit, contentDescription = null) },
133+ text = { Text(stringResource(R.string.branch_session)) },
134+ onClick = { onDismiss(); onBranch(text) },
135+ )
136+ }
137+ if (onRevert != null) {
138+ DropdownMenuItem(
139+ leadingIcon = { Icon(Icons.Filled.Restore, contentDescription = null) },
140+ text = { Text(stringResource(R.string.revert_to_here)) },
141+ onClick = { onDismiss(); onRevert() },
142+ )
143+ }
144+ }
145+}
146+
94147/** Overflow menu of secondary per-message actions (quote-reply, branch-a-new-session).
95148 * Shown when at least one action is available and there's text to act on. Keeps the
96149 * inline action row (copy/edit/revert/speak) uncluttered while still surfacing the extras. */
@@ -201,6 +254,7 @@ private fun UnknownMessageBlock(
201254 }
202255}
203256
257+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
204258@Composable
205259private fun UserBubble(
206260 message: MessageWithParts,
@@ -216,6 +270,9 @@ private fun UserBubble(
216270 val haptics = LocalHapticFeedback.current
217271 val copyLabel = stringResource(R.string.copy)
218272 val editLabel = stringResource(R.string.edit_message)
273+ // Long-press context menu state: consolidates copy/quote/branch/revert into a standard
274+ // Android long-press menu so the actions are discoverable without spotting the 18dp icons.
275+ var longPressMenu by remember { mutableStateOf(false) }
219276 // Collect text from all TextParts for copying, so a user can reuse/repost their
220277 // own prompt. Memoized so a scroll-induced recomposition doesn't re-scan the list.
221278 val textToCopy = remember(message.parts) {
@@ -224,6 +281,12 @@ private fun UserBubble(
224281 .joinToString("\n\n") { it.text }
225282 .takeIf { it.isNotBlank() }
226283 }
284+ val onCopy: (() -> Unit)? = textToCopy?.let { text ->
285+ {
286+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
287+ copyToClipboard(context, context.getString(R.string.clip_label_message), text)
288+ }
289+ }
227290 // Wrap-content bubble capped at the width fraction (end-aligned), so a one-word prompt is
228291 // snug instead of a wide empty box while long prompts still cap at the readable fraction.
229292 BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
@@ -240,8 +303,19 @@ private fun UserBubble(
240303 message.parts.forEachIndexed { index, part ->
241304 key(part.id, index) { PartView(part, imageContext = imageContext, onOpenFile = onOpenFile) }
242305 }
306+ // Footer row with timestamp + inline actions. Long-press opens a context menu
307+ // consolidating copy/quote/branch/revert — the conventional Android pattern — so
308+ // the actions are discoverable without spotting the 18dp inline icons.
309+ Box {
243310 Row(
244− modifier = Modifier.fillMaxWidth(),
311+ modifier = Modifier.fillMaxWidth().combinedClickable(
312+ role = Role.Button,
313+ onClick = {},
314+ onLongClick = {
315+ haptics.performHapticFeedback(HapticFeedbackType.LongPress)
316+ longPressMenu = true
317+ },
318+ ),
245319 horizontalArrangement = Arrangement.SpaceBetween,
246320 verticalAlignment = Alignment.CenterVertically,
247321 ) {
@@ -294,10 +368,22 @@ private fun UserBubble(
294368 }
295369 }
296370 }
371+ MessageLongPressMenu(
372+ expanded = longPressMenu,
373+ onDismiss = { longPressMenu = false },
374+ offset = androidx.compose.ui.unit.DpOffset(0.dp, 0.dp),
375+ text = textToCopy,
376+ onCopy = onCopy,
377+ onQuote = onQuote,
378+ onBranch = onBranch,
379+ onRevert = onRevert,
380+ )
381+ }
297382 }
298383 }
299384}
300385
386+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
301387@Composable
302388private fun AssistantBlock(
303389 message: MessageWithParts,
@@ -318,12 +404,32 @@ private fun AssistantBlock(
318404 val info = message.info
319405 if (info is AssistantMessage) {
320406 val label = modelLabel ?: info.modelID
321− if (label != null) {
322− Text(
323− label,
324− style = MaterialTheme.typography.labelSmall,
325− color = MaterialTheme.colorScheme.onSurfaceVariant,
326− )
407+ // Avatar + model label row: the robot icon gives the assistant a consistent visual
408+ // identity (left-aligned, mirroring the user's right-aligned bubble) so on a long
409+ // scroll the speaker is unambiguous without reading the label.
410+ Row(verticalAlignment = Alignment.CenterVertically) {
411+ Box(
412+ modifier = Modifier
413+ .size(20.dp)
414+ .clip(MaterialTheme.shapes.extraSmall)
415+ .background(MaterialTheme.colorScheme.secondaryContainer),
416+ contentAlignment = Alignment.Center,
417+ ) {
418+ Icon(
419+ Icons.Filled.SmartToy,
420+ contentDescription = null,
421+ modifier = Modifier.size(14.dp),
422+ tint = MaterialTheme.colorScheme.onSecondaryContainer,
423+ )
424+ }
425+ if (label != null) {
426+ Text(
427+ label,
428+ style = MaterialTheme.typography.labelSmall,
429+ color = MaterialTheme.colorScheme.onSurfaceVariant,
430+ modifier = Modifier.padding(start = 6.dp),
431+ )
432+ }
327433 }
328434 }
329435 message.parts.forEachIndexed { index, part ->
@@ -357,8 +463,34 @@ private fun AssistantBlock(
357463 cost?.takeIf { it > 0 }?.let { add(formatCost(it, costShort, costLong)) }
358464 }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
359465 }
466+ // Long-press context menu state (see UserBubble for rationale).
467+ var longPressMenu by remember { mutableStateOf(false) }
468+ // Collect text from all TextParts for copy/read-aloud. Memoized so a
469+ // scroll-induced recomposition doesn't re-scan the parts list.
470+ val textToCopy = remember(message.parts) {
471+ message.parts
472+ .filterIsInstance<TextPart>()
473+ .joinToString("\n\n") { it.text }
474+ .takeIf { it.isNotBlank() }
475+ }
476+ val assistantHaptics = LocalHapticFeedback.current
477+ val assistantContext = LocalContext.current
478+ val onCopy: (() -> Unit)? = textToCopy?.let { text ->
479+ {
480+ assistantHaptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
481+ copyToClipboard(assistantContext, assistantContext.getString(R.string.clip_label_message), text)
482+ }
483+ }
484+ Box {
360485 Row(
361− modifier = Modifier.fillMaxWidth(),
486+ modifier = Modifier.fillMaxWidth().combinedClickable(
487+ role = Role.Button,
488+ onClick = {},
489+ onLongClick = {
490+ assistantHaptics.performHapticFeedback(HapticFeedbackType.LongPress)
491+ longPressMenu = true
492+ },
493+ ),
362494 horizontalArrangement = Arrangement.SpaceBetween,
363495 verticalAlignment = Alignment.CenterVertically,
364496 ) {
@@ -372,14 +504,6 @@ private fun AssistantBlock(
372504 }
373505 MessageTimestampText(message.info)
374506 }
375− // Collect text from all TextParts for copy/read-aloud. Memoized so a
376− // scroll-induced recomposition doesn't re-scan the parts list.
377− val textToCopy = remember(message.parts) {
378− message.parts
379− .filterIsInstance<TextPart>()
380− .joinToString("\n\n") { it.text }
381− .takeIf { it.isNotBlank() }
382− }
383507 AssistantActions(
384508 textToCopy = textToCopy,
385509 isSpeaking = isSpeaking,
@@ -388,6 +512,17 @@ private fun AssistantBlock(
388512 onQuote = onQuote,
389513 )
390514 }
515+ MessageLongPressMenu(
516+ expanded = longPressMenu,
517+ onDismiss = { longPressMenu = false },
518+ offset = androidx.compose.ui.unit.DpOffset(0.dp, 0.dp),
519+ text = textToCopy,
520+ onCopy = onCopy,
521+ onQuote = onQuote,
522+ onBranch = null,
523+ onRevert = onRevert,
524+ )
525+ }
391526 } else {
392527 MessageTimestampText(message.info)
393528 }