@@ -1,7 +1,7 @@
11package soy.iko.opencode.ui.chat
22
33import androidx.compose.foundation.background
4−import androidx.compose.foundation.combinedClickable
4+import androidx.compose.foundation.gestures.detectTapGestures
55import androidx.compose.foundation.layout.Arrangement
66import androidx.compose.foundation.layout.Box
77import androidx.compose.foundation.layout.BoxWithConstraints
@@ -17,6 +17,7 @@ import androidx.compose.material.icons.automirrored.filled.VolumeUp
1717import androidx.compose.material.icons.filled.CallSplit
1818import androidx.compose.material.icons.filled.ContentCopy
1919import androidx.compose.material.icons.filled.Edit
20+import androidx.compose.material.icons.filled.Error
2021import androidx.compose.material.icons.filled.FormatQuote
2122import androidx.compose.material.icons.filled.MoreVert
2223import androidx.compose.material.icons.filled.Restore
@@ -24,6 +25,7 @@ import androidx.compose.material.icons.filled.SmartToy
2425import androidx.compose.material.icons.filled.StopCircle
2526import androidx.compose.material3.DropdownMenu
2627import androidx.compose.material3.DropdownMenuItem
28+import androidx.compose.material3.CircularProgressIndicator
2729import androidx.compose.material3.Icon
2830import androidx.compose.material3.IconButton
2931import androidx.compose.material3.MaterialTheme
@@ -38,10 +40,10 @@ import androidx.compose.ui.Alignment
3840import androidx.compose.ui.Modifier
3941import androidx.compose.ui.draw.clip
4042import androidx.compose.ui.hapticfeedback.HapticFeedbackType
43+import androidx.compose.ui.input.pointer.pointerInput
4144import androidx.compose.ui.platform.LocalContext
4245import androidx.compose.ui.platform.LocalHapticFeedback
4346import androidx.compose.ui.res.stringResource
44−import androidx.compose.ui.semantics.Role
4547import androidx.compose.ui.unit.dp
4648import soy.iko.opencode.R
4749import soy.iko.opencode.data.model.AssistantMessage
@@ -72,6 +74,9 @@ fun resolveModelLabel(info: AssistantMessage, models: List<ModelOption>): String
7274 ?: id
7375}
7476
77+/** Status of an outgoing message, shown as a small indicator on the user bubble. */
78+enum class MessageSendStatus { SENDING, FAILED }
79+
7580/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
7681@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
7782@Composable
@@ -88,11 +93,13 @@ fun MessageBubble(
8893 isSpeaking: Boolean = false,
8994 onQuote: ((String) -> Unit)? = null,
9095 onBranch: ((String) -> Unit)? = null,
96+ sendStatus: MessageSendStatus? = null,
97+ isEdited: Boolean = false,
9198) {
9299 when (message.info) {
93− is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit, onQuote, onBranch)
100+ is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit, onQuote, onBranch, sendStatus, isEdited)
94101 is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
95− else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile, onRevert, onSpeak, isSpeaking, onQuote)
102+ else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile, onRevert, onSpeak, isSpeaking, onQuote, onBranch, isEdited)
96103 }
97104}
98105
@@ -265,14 +272,19 @@ private fun UserBubble(
265272 onEdit: ((String) -> Unit)? = null,
266273 onQuote: ((String) -> Unit)? = null,
267274 onBranch: ((String) -> Unit)? = null,
275+ sendStatus: MessageSendStatus? = null,
276+ isEdited: Boolean = false,
268277) {
269278 val context = LocalContext.current
270279 val haptics = LocalHapticFeedback.current
280+ val density = androidx.compose.ui.platform.LocalDensity.current
271281 val copyLabel = stringResource(R.string.copy)
272282 val editLabel = stringResource(R.string.edit_message)
273283 // Long-press context menu state: consolidates copy/quote/branch/revert into a standard
274284 // Android long-press menu so the actions are discoverable without spotting the 18dp icons.
285+ // The menu anchors at the actual touch point rather than a fixed corner.
275286 var longPressMenu by remember { mutableStateOf(false) }
287+ var longPressOffset by remember { mutableStateOf(androidx.compose.ui.unit.DpOffset(0.dp, 0.dp)) }
276288 // Collect text from all TextParts for copying, so a user can reuse/repost their
277289 // own prompt. Memoized so a scroll-induced recomposition doesn't re-scan the list.
278290 val textToCopy = remember(message.parts) {
@@ -305,21 +317,65 @@ private fun UserBubble(
305317 }
306318 // Footer row with timestamp + inline actions. Long-press opens a context menu
307319 // consolidating copy/quote/branch/revert — the conventional Android pattern — so
308− // the actions are discoverable without spotting the 18dp inline icons.
320+ // the actions are discoverable without spotting the 18dp inline icons. Uses
321+ // pointerInput/detectTapGestures (not combinedClickable) so no misleading "Button"
322+ // semantics are announced to TalkBack for an area whose tap does nothing, and the
323+ // menu anchors at the actual long-press position instead of a fixed corner.
309324 Box {
310325 Row(
311− modifier = Modifier.fillMaxWidth().combinedClickable(
312− role = Role.Button,
313− onClick = {},
314− onLongClick = {
315− haptics.performHapticFeedback(HapticFeedbackType.LongPress)
316− longPressMenu = true
326+ modifier = Modifier
327+ .fillMaxWidth()
328+ .pointerInput(Unit) {
329+ detectTapGestures(onLongPress = { offset ->
330+ haptics.performHapticFeedback(HapticFeedbackType.LongPress)
331+ longPressOffset = androidx.compose.ui.unit.DpOffset(
332+ with(density) { offset.x.toDp() },
333+ with(density) { offset.y.toDp() },
334+ )
335+ longPressMenu = true
336+ })
317337 },
318− ),
319338 horizontalArrangement = Arrangement.SpaceBetween,
320339 verticalAlignment = Alignment.CenterVertically,
321340 ) {
322− MessageTimestampText(message.info)
341+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
342+ when (sendStatus) {
343+ MessageSendStatus.SENDING -> {
344+ CircularProgressIndicator(
345+ Modifier.size(12.dp),
346+ strokeWidth = 1.5.dp,
347+ color = MaterialTheme.colorScheme.onSurfaceVariant,
348+ )
349+ Text(
350+ stringResource(R.string.message_sending),
351+ style = MaterialTheme.typography.labelSmall,
352+ color = MaterialTheme.colorScheme.onSurfaceVariant,
353+ )
354+ }
355+ MessageSendStatus.FAILED -> {
356+ Icon(
357+ Icons.Filled.Error,
358+ contentDescription = stringResource(R.string.message_send_failed),
359+ modifier = Modifier.size(12.dp),
360+ tint = MaterialTheme.colorScheme.error,
361+ )
362+ Text(
363+ stringResource(R.string.message_send_failed),
364+ style = MaterialTheme.typography.labelSmall,
365+ color = MaterialTheme.colorScheme.error,
366+ )
367+ }
368+ null -> {}
369+ }
370+ if (isEdited && sendStatus == null) {
371+ Text(
372+ stringResource(R.string.message_edited),
373+ style = MaterialTheme.typography.labelSmall,
374+ color = MaterialTheme.colorScheme.onSurfaceVariant,
375+ )
376+ }
377+ MessageTimestampText(message.info)
378+ }
323379 Row(verticalAlignment = Alignment.CenterVertically) {
324380 // Edit affordance: reload this prompt into the composer, reverting the
325381 // conversation to before it so a Send replaces it. Only when there's text
@@ -371,7 +427,7 @@ private fun UserBubble(
371427 MessageLongPressMenu(
372428 expanded = longPressMenu,
373429 onDismiss = { longPressMenu = false },
374− offset = androidx.compose.ui.unit.DpOffset(0.dp, 0.dp),
430+ offset = longPressOffset,
375431 text = textToCopy,
376432 onCopy = onCopy,
377433 onQuote = onQuote,
@@ -396,6 +452,8 @@ private fun AssistantBlock(
396452 onSpeak: ((String) -> Unit)? = null,
397453 isSpeaking: Boolean = false,
398454 onQuote: ((String) -> Unit)? = null,
455+ onBranch: ((String) -> Unit)? = null,
456+ isEdited: Boolean = false,
399457) {
400458 Column(
401459 modifier = modifier.fillMaxWidth(),
@@ -465,6 +523,7 @@ private fun AssistantBlock(
465523 }
466524 // Long-press context menu state (see UserBubble for rationale).
467525 var longPressMenu by remember { mutableStateOf(false) }
526+ var longPressOffset by remember { mutableStateOf(androidx.compose.ui.unit.DpOffset(0.dp, 0.dp)) }
468527 // Collect text from all TextParts for copy/read-aloud. Memoized so a
469528 // scroll-induced recomposition doesn't re-scan the parts list.
470529 val textToCopy = remember(message.parts) {
@@ -475,6 +534,7 @@ private fun AssistantBlock(
475534 }
476535 val assistantHaptics = LocalHapticFeedback.current
477536 val assistantContext = LocalContext.current
537+ val assistantDensity = androidx.compose.ui.platform.LocalDensity.current
478538 val onCopy: (() -> Unit)? = textToCopy?.let { text ->
479539 {
480540 assistantHaptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
@@ -483,14 +543,18 @@ private fun AssistantBlock(
483543 }
484544 Box {
485545 Row(
486− modifier = Modifier.fillMaxWidth().combinedClickable(
487− role = Role.Button,
488− onClick = {},
489− onLongClick = {
490− assistantHaptics.performHapticFeedback(HapticFeedbackType.LongPress)
491− longPressMenu = true
546+ modifier = Modifier
547+ .fillMaxWidth()
548+ .pointerInput(Unit) {
549+ detectTapGestures(onLongPress = { offset ->
550+ assistantHaptics.performHapticFeedback(HapticFeedbackType.LongPress)
551+ longPressOffset = androidx.compose.ui.unit.DpOffset(
552+ with(assistantDensity) { offset.x.toDp() },
553+ with(assistantDensity) { offset.y.toDp() },
554+ )
555+ longPressMenu = true
556+ })
492557 },
493− ),
494558 horizontalArrangement = Arrangement.SpaceBetween,
495559 verticalAlignment = Alignment.CenterVertically,
496560 ) {
@@ -502,6 +566,13 @@ private fun AssistantBlock(
502566 color = MaterialTheme.colorScheme.onSurfaceVariant,
503567 )
504568 }
569+ if (isEdited) {
570+ Text(
571+ stringResource(R.string.message_edited),
572+ style = MaterialTheme.typography.labelSmall,
573+ color = MaterialTheme.colorScheme.onSurfaceVariant,
574+ )
575+ }
505576 MessageTimestampText(message.info)
506577 }
507578 AssistantActions(
@@ -510,16 +581,17 @@ private fun AssistantBlock(
510581 onSpeak = onSpeak,
511582 onRevert = onRevert,
512583 onQuote = onQuote,
584+ onBranch = onBranch,
513585 )
514586 }
515587 MessageLongPressMenu(
516588 expanded = longPressMenu,
517589 onDismiss = { longPressMenu = false },
518− offset = androidx.compose.ui.unit.DpOffset(0.dp, 0.dp),
590+ offset = longPressOffset,
519591 text = textToCopy,
520592 onCopy = onCopy,
521593 onQuote = onQuote,
522− onBranch = null,
594+ onBranch = onBranch,
523595 onRevert = onRevert,
524596 )
525597 }
@@ -538,6 +610,7 @@ private fun AssistantActions(
538610 onSpeak: ((String) -> Unit)?,
539611 onRevert: (() -> Unit)?,
540612 onQuote: ((String) -> Unit)? = null,
613+ onBranch: ((String) -> Unit)? = null,
541614) {
542615 val context = LocalContext.current
543616 val haptics = LocalHapticFeedback.current
@@ -574,10 +647,9 @@ private fun AssistantActions(
574647 )
575648 }
576649 }
577− // Assistant replies can be quoted into the composer (branching is offered only on
578− // user prompts, where seeding a new session with the prompt makes sense).
579− if (textToCopy != null && onQuote != null) {
580− MessageOverflow(text = textToCopy, onQuote = onQuote, onBranch = null)
650+ // Assistant replies can be quoted into the composer or branched into a new session.
651+ if (textToCopy != null && (onQuote != null || onBranch != null)) {
652+ MessageOverflow(text = textToCopy, onQuote = onQuote, onBranch = onBranch)
581653 }
582654 }
583655}