@@ -1,7 +1,9 @@
1package soy.iko.opencode.ui.chat
1package soy.iko.opencode.ui.chat
3import androidx.compose.foundation.background
3import androidx.compose.foundation.background
4+import androidx.compose.foundation.combinedClickable
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Arrangement
6+import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.BoxWithConstraints
7import androidx.compose.foundation.layout.BoxWithConstraints
6import androidx.compose.foundation.layout.Column
8import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.Row
@@ -18,6 +20,7 @@ import androidx.compose.material.icons.filled.Edit
18import androidx.compose.material.icons.filled.FormatQuote
20import androidx.compose.material.icons.filled.FormatQuote
19import androidx.compose.material.icons.filled.MoreVert
21import androidx.compose.material.icons.filled.MoreVert
20import androidx.compose.material.icons.filled.Restore
22import androidx.compose.material.icons.filled.Restore
23+import androidx.compose.material.icons.filled.SmartToy
21import androidx.compose.material.icons.filled.StopCircle
24import androidx.compose.material.icons.filled.StopCircle
22import androidx.compose.material3.DropdownMenu
25import androidx.compose.material3.DropdownMenu
23import androidx.compose.material3.DropdownMenuItem
26import androidx.compose.material3.DropdownMenuItem
@@ -38,6 +41,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
38import androidx.compose.ui.platform.LocalContext
41import androidx.compose.ui.platform.LocalContext
39import androidx.compose.ui.platform.LocalHapticFeedback
42import androidx.compose.ui.platform.LocalHapticFeedback
40import androidx.compose.ui.res.stringResource
43import androidx.compose.ui.res.stringResource
44+import androidx.compose.ui.semantics.Role
41import androidx.compose.ui.unit.dp
45import androidx.compose.ui.unit.dp
42import soy.iko.opencode.R
46import soy.iko.opencode.R
43import soy.iko.opencode.data.model.AssistantMessage
47import soy.iko.opencode.data.model.AssistantMessage
@@ -69,6 +73,7 @@ fun resolveModelLabel(info: AssistantMessage, models: List<ModelOption>): String
71/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
75/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
76+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
72@Composable
77@Composable
73fun MessageBubble(
78fun MessageBubble(
74 message: MessageWithParts,
79 message: MessageWithParts,
@@ -91,6 +96,54 @@ fun MessageBubble(
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. */
105+private fun MessageLongPressMenu(
107+ onDismiss: () -> Unit,
108+ offset: androidx.compose.ui.unit.DpOffset,
110+ onCopy: (() -> Unit)?,
111+ onQuote: ((String) -> Unit)?,
112+ onBranch: ((String) -> Unit)?,
113+ onRevert: (() -> Unit)?,
115+ DropdownMenu(expanded = expanded, onDismissRequest = onDismiss, offset = offset) {
116+ if (text != null && onCopy != null) {
118+ leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) },
119+ text = { Text(stringResource(R.string.copy)) },
120+ onClick = { onDismiss(); onCopy() },
123+ if (text != null && onQuote != null) {
125+ leadingIcon = { Icon(Icons.Filled.FormatQuote, contentDescription = null) },
126+ text = { Text(stringResource(R.string.quote_reply)) },
127+ onClick = { onDismiss(); onQuote(text) },
130+ if (text != null && onBranch != null) {
132+ leadingIcon = { Icon(Icons.Filled.CallSplit, contentDescription = null) },
133+ text = { Text(stringResource(R.string.branch_session)) },
134+ onClick = { onDismiss(); onBranch(text) },
137+ if (onRevert != null) {
139+ leadingIcon = { Icon(Icons.Filled.Restore, contentDescription = null) },
140+ text = { Text(stringResource(R.string.revert_to_here)) },
141+ onClick = { onDismiss(); onRevert() },
94/** Overflow menu of secondary per-message actions (quote-reply, branch-a-new-session).
147/** Overflow menu of secondary per-message actions (quote-reply, branch-a-new-session).
95 * Shown when at least one action is available and there's text to act on. Keeps the
148 * Shown when at least one action is available and there's text to act on. Keeps the
96 * inline action row (copy/edit/revert/speak) uncluttered while still surfacing the extras. */
149 * inline action row (copy/edit/revert/speak) uncluttered while still surfacing the extras. */
@@ -201,6 +254,7 @@ private fun UnknownMessageBlock(
257+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
204@Composable
258@Composable
205private fun UserBubble(
259private fun UserBubble(
206 message: MessageWithParts,
260 message: MessageWithParts,
@@ -216,6 +270,9 @@ private fun UserBubble(
216 val haptics = LocalHapticFeedback.current
270 val haptics = LocalHapticFeedback.current
217 val copyLabel = stringResource(R.string.copy)
271 val copyLabel = stringResource(R.string.copy)
218 val editLabel = stringResource(R.string.edit_message)
272 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) }
219 // Collect text from all TextParts for copying, so a user can reuse/repost their
276 // Collect text from all TextParts for copying, so a user can reuse/repost their
220 // own prompt. Memoized so a scroll-induced recomposition doesn't re-scan the list.
277 // own prompt. Memoized so a scroll-induced recomposition doesn't re-scan the list.
221 val textToCopy = remember(message.parts) {
278 val textToCopy = remember(message.parts) {
@@ -224,6 +281,12 @@ private fun UserBubble(
224 .joinToString("\n\n") { it.text }
281 .joinToString("\n\n") { it.text }
225 .takeIf { it.isNotBlank() }
282 .takeIf { it.isNotBlank() }
284+ val onCopy: (() -> Unit)? = textToCopy?.let { text ->
286+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
287+ copyToClipboard(context, context.getString(R.string.clip_label_message), text)
227 // Wrap-content bubble capped at the width fraction (end-aligned), so a one-word prompt is
290 // Wrap-content bubble capped at the width fraction (end-aligned), so a one-word prompt is
228 // snug instead of a wide empty box while long prompts still cap at the readable fraction.
291 // snug instead of a wide empty box while long prompts still cap at the readable fraction.
229 BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
292 BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
@@ -240,8 +303,19 @@ private fun UserBubble(
240 message.parts.forEachIndexed { index, part ->
303 message.parts.forEachIndexed { index, part ->
241 key(part.id, index) { PartView(part, imageContext = imageContext, onOpenFile = onOpenFile) }
304 key(part.id, index) { PartView(part, imageContext = imageContext, onOpenFile = onOpenFile) }
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.
244− modifier = Modifier.fillMaxWidth(),
311+ modifier = Modifier.fillMaxWidth().combinedClickable(
315+ haptics.performHapticFeedback(HapticFeedbackType.LongPress)
316+ longPressMenu = true
245 horizontalArrangement = Arrangement.SpaceBetween,
319 horizontalArrangement = Arrangement.SpaceBetween,
246 verticalAlignment = Alignment.CenterVertically,
320 verticalAlignment = Alignment.CenterVertically,
@@ -294,10 +368,22 @@ private fun UserBubble(
371+ MessageLongPressMenu(
372+ expanded = longPressMenu,
373+ onDismiss = { longPressMenu = false },
374+ offset = androidx.compose.ui.unit.DpOffset(0.dp, 0.dp),
378+ onBranch = onBranch,
379+ onRevert = onRevert,
386+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
301@Composable
387@Composable
302private fun AssistantBlock(
388private fun AssistantBlock(
303 message: MessageWithParts,
389 message: MessageWithParts,
@@ -318,12 +404,32 @@ private fun AssistantBlock(
318 val info = message.info
404 val info = message.info
319 if (info is AssistantMessage) {
405 if (info is AssistantMessage) {
320 val label = modelLabel ?: info.modelID
406 val label = modelLabel ?: info.modelID
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) {
414+ .clip(MaterialTheme.shapes.extraSmall)
415+ .background(MaterialTheme.colorScheme.secondaryContainer),
416+ contentAlignment = Alignment.Center,
419+ Icons.Filled.SmartToy,
420+ contentDescription = null,
421+ modifier = Modifier.size(14.dp),
422+ tint = MaterialTheme.colorScheme.onSecondaryContainer,
321− if (label != null) {
425+ if (label != null) {
324− style = MaterialTheme.typography.labelSmall,
428+ style = MaterialTheme.typography.labelSmall,
325− color = MaterialTheme.colorScheme.onSurfaceVariant,
429+ color = MaterialTheme.colorScheme.onSurfaceVariant,
326− )
430+ modifier = Modifier.padding(start = 6.dp),
329 message.parts.forEachIndexed { index, part ->
435 message.parts.forEachIndexed { index, part ->
@@ -357,8 +463,34 @@ private fun AssistantBlock(
357 cost?.takeIf { it > 0 }?.let { add(formatCost(it, costShort, costLong)) }
463 cost?.takeIf { it > 0 }?.let { add(formatCost(it, costShort, costLong)) }
358 }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
464 }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
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) {
472+ .filterIsInstance<TextPart>()
473+ .joinToString("\n\n") { it.text }
474+ .takeIf { it.isNotBlank() }
476+ val assistantHaptics = LocalHapticFeedback.current
477+ val assistantContext = LocalContext.current
478+ val onCopy: (() -> Unit)? = textToCopy?.let { text ->
480+ assistantHaptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
481+ copyToClipboard(assistantContext, assistantContext.getString(R.string.clip_label_message), text)
361− modifier = Modifier.fillMaxWidth(),
486+ modifier = Modifier.fillMaxWidth().combinedClickable(
490+ assistantHaptics.performHapticFeedback(HapticFeedbackType.LongPress)
491+ longPressMenu = true
362 horizontalArrangement = Arrangement.SpaceBetween,
494 horizontalArrangement = Arrangement.SpaceBetween,
363 verticalAlignment = Alignment.CenterVertically,
495 verticalAlignment = Alignment.CenterVertically,
@@ -372,14 +504,6 @@ private fun AssistantBlock(
373 MessageTimestampText(message.info)
505 MessageTimestampText(message.info)
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) {
379− .filterIsInstance<TextPart>()
380− .joinToString("\n\n") { it.text }
381− .takeIf { it.isNotBlank() }
383 AssistantActions(
507 AssistantActions(
384 textToCopy = textToCopy,
508 textToCopy = textToCopy,
385 isSpeaking = isSpeaking,
509 isSpeaking = isSpeaking,
@@ -388,6 +512,17 @@ private fun AssistantBlock(
388 onQuote = onQuote,
512 onQuote = onQuote,
515+ MessageLongPressMenu(
516+ expanded = longPressMenu,
517+ onDismiss = { longPressMenu = false },
518+ offset = androidx.compose.ui.unit.DpOffset(0.dp, 0.dp),
523+ onRevert = onRevert,
392 MessageTimestampText(message.info)
527 MessageTimestampText(message.info)