@@ -1,7 +1,7 @@
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
4+import androidx.compose.foundation.gestures.detectTapGestures
5import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.BoxWithConstraints
7import androidx.compose.foundation.layout.BoxWithConstraints
@@ -17,6 +17,7 @@ import androidx.compose.material.icons.automirrored.filled.VolumeUp
17import androidx.compose.material.icons.filled.CallSplit
17import androidx.compose.material.icons.filled.CallSplit
18import androidx.compose.material.icons.filled.ContentCopy
18import androidx.compose.material.icons.filled.ContentCopy
19import androidx.compose.material.icons.filled.Edit
19import androidx.compose.material.icons.filled.Edit
20+import androidx.compose.material.icons.filled.Error
20import androidx.compose.material.icons.filled.FormatQuote
21import androidx.compose.material.icons.filled.FormatQuote
21import androidx.compose.material.icons.filled.MoreVert
22import androidx.compose.material.icons.filled.MoreVert
22import androidx.compose.material.icons.filled.Restore
23import androidx.compose.material.icons.filled.Restore
@@ -24,6 +25,7 @@ import androidx.compose.material.icons.filled.SmartToy
24import androidx.compose.material.icons.filled.StopCircle
25import androidx.compose.material.icons.filled.StopCircle
25import androidx.compose.material3.DropdownMenu
26import androidx.compose.material3.DropdownMenu
26import androidx.compose.material3.DropdownMenuItem
27import androidx.compose.material3.DropdownMenuItem
28+import androidx.compose.material3.CircularProgressIndicator
27import androidx.compose.material3.Icon
29import androidx.compose.material3.Icon
28import androidx.compose.material3.IconButton
30import androidx.compose.material3.IconButton
29import androidx.compose.material3.MaterialTheme
31import androidx.compose.material3.MaterialTheme
@@ -38,10 +40,10 @@ import androidx.compose.ui.Alignment
38import androidx.compose.ui.Modifier
40import androidx.compose.ui.Modifier
39import androidx.compose.ui.draw.clip
41import androidx.compose.ui.draw.clip
40import androidx.compose.ui.hapticfeedback.HapticFeedbackType
42import androidx.compose.ui.hapticfeedback.HapticFeedbackType
43+import androidx.compose.ui.input.pointer.pointerInput
41import androidx.compose.ui.platform.LocalContext
44import androidx.compose.ui.platform.LocalContext
42import androidx.compose.ui.platform.LocalHapticFeedback
45import androidx.compose.ui.platform.LocalHapticFeedback
43import androidx.compose.ui.res.stringResource
46import androidx.compose.ui.res.stringResource
44−import androidx.compose.ui.semantics.Role
45import androidx.compose.ui.unit.dp
47import androidx.compose.ui.unit.dp
46import soy.iko.opencode.R
48import soy.iko.opencode.R
47import soy.iko.opencode.data.model.AssistantMessage
49import soy.iko.opencode.data.model.AssistantMessage
@@ -72,6 +74,9 @@ fun resolveModelLabel(info: AssistantMessage, models: List<ModelOption>): String
77+/** Status of an outgoing message, shown as a small indicator on the user bubble. */
78+enum class MessageSendStatus { SENDING, FAILED }
75/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
80/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
76@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
81@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
77@Composable
82@Composable
@@ -88,11 +93,13 @@ fun MessageBubble(
88 isSpeaking: Boolean = false,
93 isSpeaking: Boolean = false,
89 onQuote: ((String) -> Unit)? = null,
94 onQuote: ((String) -> Unit)? = null,
90 onBranch: ((String) -> Unit)? = null,
95 onBranch: ((String) -> Unit)? = null,
96+ sendStatus: MessageSendStatus? = null,
97+ isEdited: Boolean = false,
92 when (message.info) {
99 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)
94 is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
101 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)
@@ -265,14 +272,19 @@ private fun UserBubble(
265 onEdit: ((String) -> Unit)? = null,
272 onEdit: ((String) -> Unit)? = null,
266 onQuote: ((String) -> Unit)? = null,
273 onQuote: ((String) -> Unit)? = null,
267 onBranch: ((String) -> Unit)? = null,
274 onBranch: ((String) -> Unit)? = null,
275+ sendStatus: MessageSendStatus? = null,
276+ isEdited: Boolean = false,
269 val context = LocalContext.current
278 val context = LocalContext.current
270 val haptics = LocalHapticFeedback.current
279 val haptics = LocalHapticFeedback.current
280+ val density = androidx.compose.ui.platform.LocalDensity.current
271 val copyLabel = stringResource(R.string.copy)
281 val copyLabel = stringResource(R.string.copy)
272 val editLabel = stringResource(R.string.edit_message)
282 val editLabel = stringResource(R.string.edit_message)
273 // Long-press context menu state: consolidates copy/quote/branch/revert into a standard
283 // 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.
284 // 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.
275 var longPressMenu by remember { mutableStateOf(false) }
286 var longPressMenu by remember { mutableStateOf(false) }
287+ var longPressOffset by remember { mutableStateOf(androidx.compose.ui.unit.DpOffset(0.dp, 0.dp)) }
276 // Collect text from all TextParts for copying, so a user can reuse/repost their
288 // Collect text from all TextParts for copying, so a user can reuse/repost their
277 // own prompt. Memoized so a scroll-induced recomposition doesn't re-scan the list.
289 // own prompt. Memoized so a scroll-induced recomposition doesn't re-scan the list.
278 val textToCopy = remember(message.parts) {
290 val textToCopy = remember(message.parts) {
@@ -305,21 +317,65 @@ private fun UserBubble(
306 // Footer row with timestamp + inline actions. Long-press opens a context menu
318 // Footer row with timestamp + inline actions. Long-press opens a context menu
307 // consolidating copy/quote/branch/revert — the conventional Android pattern — so
319 // 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.
311− modifier = Modifier.fillMaxWidth().combinedClickable(
326+ modifier = Modifier
312− role = Role.Button,
327+ .fillMaxWidth()
313− onClick = {},
328+ .pointerInput(Unit) {
314− onLongClick = {
329+ detectTapGestures(onLongPress = { offset ->
315− haptics.performHapticFeedback(HapticFeedbackType.LongPress)
330+ haptics.performHapticFeedback(HapticFeedbackType.LongPress)
331+ longPressOffset = androidx.compose.ui.unit.DpOffset(
332+ with(density) { offset.x.toDp() },
333+ with(density) { offset.y.toDp() },
316− longPressMenu = true
335+ longPressMenu = true
319 horizontalArrangement = Arrangement.SpaceBetween,
338 horizontalArrangement = Arrangement.SpaceBetween,
320 verticalAlignment = Alignment.CenterVertically,
339 verticalAlignment = Alignment.CenterVertically,
341+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
343+ MessageSendStatus.SENDING -> {
344+ CircularProgressIndicator(
345+ Modifier.size(12.dp),
346+ strokeWidth = 1.5.dp,
347+ color = MaterialTheme.colorScheme.onSurfaceVariant,
350+ stringResource(R.string.message_sending),
351+ style = MaterialTheme.typography.labelSmall,
352+ color = MaterialTheme.colorScheme.onSurfaceVariant,
355+ MessageSendStatus.FAILED -> {
358+ contentDescription = stringResource(R.string.message_send_failed),
359+ modifier = Modifier.size(12.dp),
360+ tint = MaterialTheme.colorScheme.error,
363+ stringResource(R.string.message_send_failed),
364+ style = MaterialTheme.typography.labelSmall,
365+ color = MaterialTheme.colorScheme.error,
370+ if (isEdited && sendStatus == null) {
372+ stringResource(R.string.message_edited),
373+ style = MaterialTheme.typography.labelSmall,
374+ color = MaterialTheme.colorScheme.onSurfaceVariant,
322− MessageTimestampText(message.info)
377+ MessageTimestampText(message.info)
323 Row(verticalAlignment = Alignment.CenterVertically) {
379 Row(verticalAlignment = Alignment.CenterVertically) {
324 // Edit affordance: reload this prompt into the composer, reverting the
380 // Edit affordance: reload this prompt into the composer, reverting the
325 // conversation to before it so a Send replaces it. Only when there's text
381 // conversation to before it so a Send replaces it. Only when there's text
@@ -371,7 +427,7 @@ private fun UserBubble(
371 MessageLongPressMenu(
427 MessageLongPressMenu(
372 expanded = longPressMenu,
428 expanded = longPressMenu,
373 onDismiss = { longPressMenu = false },
429 onDismiss = { longPressMenu = false },
374− offset = androidx.compose.ui.unit.DpOffset(0.dp, 0.dp),
430+ offset = longPressOffset,
375 text = textToCopy,
431 text = textToCopy,
376 onCopy = onCopy,
432 onCopy = onCopy,
377 onQuote = onQuote,
433 onQuote = onQuote,
@@ -396,6 +452,8 @@ private fun AssistantBlock(
396 onSpeak: ((String) -> Unit)? = null,
452 onSpeak: ((String) -> Unit)? = null,
397 isSpeaking: Boolean = false,
453 isSpeaking: Boolean = false,
398 onQuote: ((String) -> Unit)? = null,
454 onQuote: ((String) -> Unit)? = null,
455+ onBranch: ((String) -> Unit)? = null,
456+ isEdited: Boolean = false,
401 modifier = modifier.fillMaxWidth(),
459 modifier = modifier.fillMaxWidth(),
@@ -465,6 +523,7 @@ private fun AssistantBlock(
466 // Long-press context menu state (see UserBubble for rationale).
524 // Long-press context menu state (see UserBubble for rationale).
467 var longPressMenu by remember { mutableStateOf(false) }
525 var longPressMenu by remember { mutableStateOf(false) }
526+ var longPressOffset by remember { mutableStateOf(androidx.compose.ui.unit.DpOffset(0.dp, 0.dp)) }
468 // Collect text from all TextParts for copy/read-aloud. Memoized so a
527 // Collect text from all TextParts for copy/read-aloud. Memoized so a
469 // scroll-induced recomposition doesn't re-scan the parts list.
528 // scroll-induced recomposition doesn't re-scan the parts list.
470 val textToCopy = remember(message.parts) {
529 val textToCopy = remember(message.parts) {
@@ -475,6 +534,7 @@ private fun AssistantBlock(
476 val assistantHaptics = LocalHapticFeedback.current
535 val assistantHaptics = LocalHapticFeedback.current
477 val assistantContext = LocalContext.current
536 val assistantContext = LocalContext.current
537+ val assistantDensity = androidx.compose.ui.platform.LocalDensity.current
478 val onCopy: (() -> Unit)? = textToCopy?.let { text ->
538 val onCopy: (() -> Unit)? = textToCopy?.let { text ->
480 assistantHaptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
540 assistantHaptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
@@ -483,14 +543,18 @@ private fun AssistantBlock(
486− modifier = Modifier.fillMaxWidth().combinedClickable(
546+ modifier = Modifier
487− role = Role.Button,
547+ .fillMaxWidth()
488− onClick = {},
548+ .pointerInput(Unit) {
489− onLongClick = {
549+ detectTapGestures(onLongPress = { offset ->
490− assistantHaptics.performHapticFeedback(HapticFeedbackType.LongPress)
550+ assistantHaptics.performHapticFeedback(HapticFeedbackType.LongPress)
551+ longPressOffset = androidx.compose.ui.unit.DpOffset(
552+ with(assistantDensity) { offset.x.toDp() },
553+ with(assistantDensity) { offset.y.toDp() },
491− longPressMenu = true
555+ longPressMenu = true
494 horizontalArrangement = Arrangement.SpaceBetween,
558 horizontalArrangement = Arrangement.SpaceBetween,
495 verticalAlignment = Alignment.CenterVertically,
559 verticalAlignment = Alignment.CenterVertically,
@@ -502,6 +566,13 @@ private fun AssistantBlock(
502 color = MaterialTheme.colorScheme.onSurfaceVariant,
566 color = MaterialTheme.colorScheme.onSurfaceVariant,
571+ stringResource(R.string.message_edited),
572+ style = MaterialTheme.typography.labelSmall,
573+ color = MaterialTheme.colorScheme.onSurfaceVariant,
505 MessageTimestampText(message.info)
576 MessageTimestampText(message.info)
507 AssistantActions(
578 AssistantActions(
@@ -510,16 +581,17 @@ private fun AssistantBlock(
510 onSpeak = onSpeak,
581 onSpeak = onSpeak,
511 onRevert = onRevert,
582 onRevert = onRevert,
512 onQuote = onQuote,
583 onQuote = onQuote,
584+ onBranch = onBranch,
515 MessageLongPressMenu(
587 MessageLongPressMenu(
516 expanded = longPressMenu,
588 expanded = longPressMenu,
517 onDismiss = { longPressMenu = false },
589 onDismiss = { longPressMenu = false },
518− offset = androidx.compose.ui.unit.DpOffset(0.dp, 0.dp),
590+ offset = longPressOffset,
519 text = textToCopy,
591 text = textToCopy,
520 onCopy = onCopy,
592 onCopy = onCopy,
521 onQuote = onQuote,
593 onQuote = onQuote,
522− onBranch = null,
594+ onBranch = onBranch,
523 onRevert = onRevert,
595 onRevert = onRevert,
@@ -538,6 +610,7 @@ private fun AssistantActions(
538 onSpeak: ((String) -> Unit)?,
610 onSpeak: ((String) -> Unit)?,
539 onRevert: (() -> Unit)?,
611 onRevert: (() -> Unit)?,
540 onQuote: ((String) -> Unit)? = null,
612 onQuote: ((String) -> Unit)? = null,
613+ onBranch: ((String) -> Unit)? = null,
542 val context = LocalContext.current
615 val context = LocalContext.current
543 val haptics = LocalHapticFeedback.current
616 val haptics = LocalHapticFeedback.current
@@ -574,10 +647,9 @@ private fun AssistantActions(
577− // Assistant replies can be quoted into the composer (branching is offered only on
650+ // Assistant replies can be quoted into the composer or branched into a new session.
578− // user prompts, where seeding a new session with the prompt makes sense).
579− if (textToCopy != null && onQuote != null) {
651+ if (textToCopy != null && (onQuote != null || onBranch != null)) {
580− MessageOverflow(text = textToCopy, onQuote = onQuote, onBranch = null)
652+ MessageOverflow(text = textToCopy, onQuote = onQuote, onBranch = onBranch)