@@ -138,6 +138,10 @@ private fun MessageLongPressMenu(
138138 onBranch: ((String) -> Unit)?,
139139 onRevert: (() -> Unit)?,
140140 onShare: (() -> Unit)?,
141+ onEdit: (() -> Unit)? = null,
142+ onRegenerate: (() -> Unit)? = null,
143+ onSpeak: (() -> Unit)? = null,
144+ speakLabel: String? = null,
141145) {
142146 DropdownMenu(expanded = expanded, onDismissRequest = onDismiss, offset = offset) {
143147 if (text != null && onCopy != null) {
@@ -157,6 +161,33 @@ private fun MessageLongPressMenu(
157161 onClick = { onDismiss(); onShare() },
158162 )
159163 }
164+ // Edit (user prompts): reload the prompt into the composer. Surfaced here as well as
165+ // inline so the action is discoverable without spotting the 18dp icon.
166+ if (text != null && onEdit != null) {
167+ DropdownMenuItem(
168+ leadingIcon = { Icon(Icons.Filled.Edit, contentDescription = null) },
169+ text = { Text(stringResource(R.string.edit_message)) },
170+ onClick = { onDismiss(); onEdit() },
171+ )
172+ }
173+ // Regenerate (assistant): re-run the preceding user prompt. A flagship action that was
174+ // previously only reachable via the tiny inline icon.
175+ if (onRegenerate != null) {
176+ DropdownMenuItem(
177+ leadingIcon = { Icon(Icons.Filled.Refresh, contentDescription = null) },
178+ text = { Text(stringResource(R.string.regenerate)) },
179+ onClick = { onDismiss(); onRegenerate() },
180+ )
181+ }
182+ // Read aloud / Stop reading (assistant): toggles TTS. The label is supplied by the caller
183+ // so it reflects the current speaking state (play/pause/resume).
184+ if (text != null && onSpeak != null && speakLabel != null) {
185+ DropdownMenuItem(
186+ leadingIcon = { Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = null) },
187+ text = { Text(speakLabel) },
188+ onClick = { onDismiss(); onSpeak() },
189+ )
190+ }
160191 if (text != null && onQuote != null) {
161192 DropdownMenuItem(
162193 leadingIcon = { Icon(Icons.Filled.FormatQuote, contentDescription = null) },
@@ -181,6 +212,44 @@ private fun MessageLongPressMenu(
181212 }
182213}
183214
215+/** Wraps [MessageLongPressMenu] for a user prompt, building the Edit callback (which reloads
216+ * the prompt into the composer and warns when the original carried attachments) so the
217+ * branch count stays out of [UserBubble]. */
218+@Composable
219+private fun UserMessageLongPressMenu(
220+ expanded: Boolean,
221+ onDismiss: () -> Unit,
222+ offset: androidx.compose.ui.unit.DpOffset,
223+ text: String?,
224+ onCopy: (() -> Unit)?,
225+ onQuote: ((String) -> Unit)?,
226+ onBranch: ((String) -> Unit)?,
227+ onRevert: (() -> Unit)?,
228+ onShare: (() -> Unit)?,
229+ onEdit: ((String) -> Unit)?,
230+ hasAttachments: Boolean,
231+ dropsAttachmentsMsg: String,
232+ context: android.content.Context,
233+) {
234+ val editForMenu: (() -> Unit)? = if (onEdit != null && text != null) {
235+ { onEdit(text); if (hasAttachments) showToast(context, dropsAttachmentsMsg) }
236+ } else {
237+ null
238+ }
239+ MessageLongPressMenu(
240+ expanded = expanded,
241+ onDismiss = onDismiss,
242+ offset = offset,
243+ text = text,
244+ onCopy = onCopy,
245+ onQuote = onQuote,
246+ onBranch = onBranch,
247+ onRevert = onRevert,
248+ onShare = onShare,
249+ onEdit = editForMenu,
250+ )
251+}
252+
184253/** Overflow menu of secondary per-message actions (quote-reply, branch-a-new-session).
185254 * Shown when at least one action is available and there's text to act on. Keeps the
186255 * inline action row (copy/edit/revert/speak) uncluttered while still surfacing the extras. */
@@ -313,6 +382,8 @@ private fun UserBubble(
313382 val density = androidx.compose.ui.platform.LocalDensity.current
314383 val copyLabel = stringResource(R.string.copy)
315384 val editLabel = stringResource(R.string.edit_message)
385+ val dropsAttachmentsMsg = stringResource(R.string.edit_drops_attachments)
386+ val hasAttachments = message.parts.any { it is FilePart }
316387 // Long-press context menu state: consolidates copy/quote/branch/revert into a standard
317388 // Android long-press menu so the actions are discoverable without spotting the 18dp icons.
318389 // The menu anchors at the actual touch point rather than a fixed corner.
@@ -341,6 +412,22 @@ private fun UserBubble(
341412 .widthIn(max = maxWidth * NetworkConfig.userBubbleWidthFraction)
342413 .clip(MaterialTheme.shapes.medium)
343414 .background(MaterialTheme.colorScheme.primaryContainer)
415+ // Long-press anywhere on the bubble (body or footer) opens the context menu —
416+ // the conventional Android pattern — so the actions are discoverable without
417+ // spotting the 18dp inline icons. detectTapGestures with only onLongPress does
418+ // not consume taps, so text selection and inline-icon clicks still work; a long
419+ // press on selectable text is consumed by the SelectionContainer first, so the
420+ // menu only opens from non-text areas (the intended behavior).
421+ .pointerInput(Unit) {
422+ detectTapGestures(onLongPress = { offset ->
423+ haptics.performHapticFeedback(HapticFeedbackType.LongPress)
424+ longPressOffset = androidx.compose.ui.unit.DpOffset(
425+ with(density) { offset.x.toDp() },
426+ with(density) { offset.y.toDp() },
427+ )
428+ longPressMenu = true
429+ })
430+ }
344431 .padding(12.dp),
345432 verticalArrangement = Arrangement.spacedBy(6.dp),
346433 ) {
@@ -348,26 +435,10 @@ private fun UserBubble(
348435 message.parts.forEachIndexed { index, part ->
349436 key(part.id, index) { PartView(part, imageContext = imageContext, onOpenFile = onOpenFile) }
350437 }
351− // Footer row with timestamp + inline actions. Long-press opens a context menu
352− // consolidating copy/quote/branch/revert — the conventional Android pattern — so
353− // the actions are discoverable without spotting the 18dp inline icons. Uses
354− // pointerInput/detectTapGestures (not combinedClickable) so no misleading "Button"
355− // semantics are announced to TalkBack for an area whose tap does nothing, and the
356− // menu anchors at the actual long-press position instead of a fixed corner.
438+ // Footer row with timestamp + inline actions.
357439 Box {
358440 Row(
359− modifier = Modifier
360− .fillMaxWidth()
361− .pointerInput(Unit) {
362− detectTapGestures(onLongPress = { offset ->
363− haptics.performHapticFeedback(HapticFeedbackType.LongPress)
364− longPressOffset = androidx.compose.ui.unit.DpOffset(
365− with(density) { offset.x.toDp() },
366− with(density) { offset.y.toDp() },
367− )
368− longPressMenu = true
369− })
370− },
441+ modifier = Modifier.fillMaxWidth(),
371442 horizontalArrangement = Arrangement.SpaceBetween,
372443 verticalAlignment = Alignment.CenterVertically,
373444 ) {
@@ -460,8 +531,6 @@ private fun UserBubble(
460531 // conversation to before it so a Send replaces it. Only when there's text
461532 // to edit (an image-only prompt has nothing to reload).
462533 if (onEdit != null && textToCopy != null) {
463− val dropsAttachmentsMsg = stringResource(R.string.edit_drops_attachments)
464− val hasAttachments = message.parts.any { it is FilePart }
465534 IconButton(onClick = {
466535 onEdit(textToCopy)
467536 // Editing reloads only the text; if this prompt carried images or
@@ -503,7 +572,7 @@ private fun UserBubble(
503572 }
504573 }
505574 }
506− MessageLongPressMenu(
575+ UserMessageLongPressMenu(
507576 expanded = longPressMenu,
508577 onDismiss = { longPressMenu = false },
509578 offset = longPressOffset,
@@ -513,6 +582,10 @@ private fun UserBubble(
513582 onBranch = onBranch,
514583 onRevert = onRevert,
515584 onShare = onShare,
585+ onEdit = onEdit,
586+ hasAttachments = hasAttachments,
587+ dropsAttachmentsMsg = dropsAttachmentsMsg,
588+ context = context,
516589 )
517590 }
518591 }
@@ -540,8 +613,26 @@ private fun AssistantBlock(
540613 isEdited: Boolean = false,
541614 onShare: (() -> Unit)? = null,
542615) {
616+ // Long-press context menu state, hoisted to the block scope so the whole bubble (body and
617+ // footer) is the long-press target — see UserBubble for rationale.
618+ val assistantHaptics = LocalHapticFeedback.current
619+ val assistantContext = LocalContext.current
620+ val assistantDensity = androidx.compose.ui.platform.LocalDensity.current
621+ var longPressMenu by remember { mutableStateOf(false) }
622+ var longPressOffset by remember { mutableStateOf(androidx.compose.ui.unit.DpOffset(0.dp, 0.dp)) }
543623 Column(
544− modifier = modifier.fillMaxWidth(),
624+ modifier = modifier
625+ .fillMaxWidth()
626+ .pointerInput(Unit) {
627+ detectTapGestures(onLongPress = { offset ->
628+ assistantHaptics.performHapticFeedback(HapticFeedbackType.LongPress)
629+ longPressOffset = androidx.compose.ui.unit.DpOffset(
630+ with(assistantDensity) { offset.x.toDp() },
631+ with(assistantDensity) { offset.y.toDp() },
632+ )
633+ longPressMenu = true
634+ })
635+ },
545636 verticalArrangement = Arrangement.spacedBy(8.dp),
546637 ) {
547638 val info = message.info
@@ -593,9 +684,8 @@ private fun AssistantBlock(
593684 val costSummary = remember(info.isComplete, tokens, cost, tokenFormat, reasoningFormat, cacheReadFormat, cacheWriteFormat, costShort, costLong) {
594685 buildCostSummary(info.isComplete, tokens, cost, tokenFormat, reasoningFormat, cacheReadFormat, cacheWriteFormat, costShort, costLong)
595686 }
596− // Long-press context menu state (see UserBubble for rationale).
597− var longPressMenu by remember { mutableStateOf(false) }
598− var longPressOffset by remember { mutableStateOf(androidx.compose.ui.unit.DpOffset(0.dp, 0.dp)) }
687+ // Long-press context menu state is hoisted to the block scope (see the Column
688+ // above) so the whole bubble is the long-press target.
599689 // Collect text from all TextParts for copy/read-aloud. Memoized so a
600690 // scroll-induced recomposition doesn't re-scan the parts list.
601691 val textToCopy = remember(message.parts) {
@@ -604,9 +694,6 @@ private fun AssistantBlock(
604694 .joinToString("\n\n") { it.text }
605695 .takeIf { it.isNotBlank() }
606696 }
607− val assistantHaptics = LocalHapticFeedback.current
608− val assistantContext = LocalContext.current
609− val assistantDensity = androidx.compose.ui.platform.LocalDensity.current
610697 val onCopy: (() -> Unit)? = textToCopy?.let { text ->
611698 {
612699 assistantHaptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
@@ -615,18 +702,7 @@ private fun AssistantBlock(
615702 }
616703 Box {
617704 Row(
618− modifier = Modifier
619− .fillMaxWidth()
620− .pointerInput(Unit) {
621− detectTapGestures(onLongPress = { offset ->
622− assistantHaptics.performHapticFeedback(HapticFeedbackType.LongPress)
623− longPressOffset = androidx.compose.ui.unit.DpOffset(
624− with(assistantDensity) { offset.x.toDp() },
625− with(assistantDensity) { offset.y.toDp() },
626− )
627− longPressMenu = true
628− })
629− },
705+ modifier = Modifier.fillMaxWidth(),
630706 horizontalArrangement = Arrangement.SpaceBetween,
631707 verticalAlignment = Alignment.CenterVertically,
632708 ) {
@@ -636,9 +712,10 @@ private fun AssistantBlock(
636712 it,
637713 style = MaterialTheme.typography.labelSmall,
638714 color = MaterialTheme.colorScheme.onSurfaceVariant,
639− // Cap to a single line so a long "1.2k in · 540 out • $0.012"
640− // summary doesn't wrap the "•" awkwardly at narrow widths.
641− maxLines = 1,
715+ // Allow up to 2 lines so a multi-segment summary (in/out • reasoning
716+ // • cache read • cost) wraps instead of ellipsizing away the cost —
717+ // the part users most want — on narrow screens.
718+ maxLines = 2,
642719 overflow = TextOverflow.Ellipsis,
643720 )
644721 }
@@ -664,7 +741,7 @@ private fun AssistantBlock(
664741 onRegenerate = onRegenerate,
665742 )
666743 }
667− MessageLongPressMenu(
744+ AssistantMessageLongPressMenu(
668745 expanded = longPressMenu,
669746 onDismiss = { longPressMenu = false },
670747 offset = longPressOffset,
@@ -674,6 +751,12 @@ private fun AssistantBlock(
674751 onBranch = onBranch,
675752 onRevert = onRevert,
676753 onShare = onShare,
754+ onRegenerate = onRegenerate,
755+ onSpeak = onSpeak,
756+ isSpeaking = isSpeaking,
757+ ttsState = ttsState,
758+ onPause = onPause,
759+ onResume = onResume,
677760 )
678761 }
679762 } else {
@@ -682,6 +765,65 @@ private fun AssistantBlock(
682765 }
683766}
684767
768+/** Wraps [MessageLongPressMenu] for an assistant reply, building the Read-aloud toggle
769+ * callback and label from the current speaking state so the branch count stays out of
770+ * [AssistantBlock]. */
771+@Composable
772+private fun AssistantMessageLongPressMenu(
773+ expanded: Boolean,
774+ onDismiss: () -> Unit,
775+ offset: androidx.compose.ui.unit.DpOffset,
776+ text: String?,
777+ onCopy: (() -> Unit)?,
778+ onQuote: ((String) -> Unit)?,
779+ onBranch: ((String) -> Unit)?,
780+ onRevert: (() -> Unit)?,
781+ onShare: (() -> Unit)?,
782+ onRegenerate: (() -> Unit)?,
783+ onSpeak: ((String) -> Unit)?,
784+ isSpeaking: Boolean,
785+ ttsState: TtsState,
786+ onPause: (() -> Unit)?,
787+ onResume: (() -> Unit)?,
788+) {
789+ val speakForMenu: (() -> Unit)? = if (onSpeak != null && text != null) {
790+ {
791+ when {
792+ isSpeaking && ttsState == TtsState.PAUSED -> onResume?.invoke()
793+ isSpeaking -> onPause?.invoke()
794+ else -> onSpeak(text)
795+ }
796+ }
797+ } else {
798+ null
799+ }
800+ val speakLabel: String? = if (onSpeak != null && text != null) {
801+ stringResource(
802+ when {
803+ isSpeaking && ttsState == TtsState.PAUSED -> R.string.resume_reading
804+ isSpeaking -> R.string.pause_reading
805+ else -> R.string.read_aloud
806+ },
807+ )
808+ } else {
809+ null
810+ }
811+ MessageLongPressMenu(
812+ expanded = expanded,
813+ onDismiss = onDismiss,
814+ offset = offset,
815+ text = text,
816+ onCopy = onCopy,
817+ onQuote = onQuote,
818+ onBranch = onBranch,
819+ onRevert = onRevert,
820+ onShare = onShare,
821+ onRegenerate = onRegenerate,
822+ onSpeak = speakForMenu,
823+ speakLabel = speakLabel,
824+ )
825+}
826+
685827/** Avatar + model label row for an assistant message. Extracted from [AssistantBlock] to keep
686828 * that function's cyclomatic complexity under the detekt threshold. */
687829@Composable