@@ -138,6 +138,10 @@ private fun MessageLongPressMenu(
138 onBranch: ((String) -> Unit)?,
138 onBranch: ((String) -> Unit)?,
139 onRevert: (() -> Unit)?,
139 onRevert: (() -> Unit)?,
140 onShare: (() -> Unit)?,
140 onShare: (() -> Unit)?,
141+ onEdit: (() -> Unit)? = null,
142+ onRegenerate: (() -> Unit)? = null,
143+ onSpeak: (() -> Unit)? = null,
144+ speakLabel: String? = null,
142 DropdownMenu(expanded = expanded, onDismissRequest = onDismiss, offset = offset) {
146 DropdownMenu(expanded = expanded, onDismissRequest = onDismiss, offset = offset) {
143 if (text != null && onCopy != null) {
147 if (text != null && onCopy != null) {
@@ -157,6 +161,33 @@ private fun MessageLongPressMenu(
157 onClick = { onDismiss(); onShare() },
161 onClick = { onDismiss(); onShare() },
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) {
168+ leadingIcon = { Icon(Icons.Filled.Edit, contentDescription = null) },
169+ text = { Text(stringResource(R.string.edit_message)) },
170+ onClick = { onDismiss(); onEdit() },
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) {
177+ leadingIcon = { Icon(Icons.Filled.Refresh, contentDescription = null) },
178+ text = { Text(stringResource(R.string.regenerate)) },
179+ onClick = { onDismiss(); onRegenerate() },
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) {
186+ leadingIcon = { Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = null) },
187+ text = { Text(speakLabel) },
188+ onClick = { onDismiss(); onSpeak() },
160 if (text != null && onQuote != null) {
191 if (text != null && onQuote != null) {
161 DropdownMenuItem(
192 DropdownMenuItem(
162 leadingIcon = { Icon(Icons.Filled.FormatQuote, contentDescription = null) },
193 leadingIcon = { Icon(Icons.Filled.FormatQuote, contentDescription = null) },
@@ -181,6 +212,44 @@ private fun MessageLongPressMenu(
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]. */
219+private fun UserMessageLongPressMenu(
221+ onDismiss: () -> Unit,
222+ offset: androidx.compose.ui.unit.DpOffset,
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,
234+ val editForMenu: (() -> Unit)? = if (onEdit != null && text != null) {
235+ { onEdit(text); if (hasAttachments) showToast(context, dropsAttachmentsMsg) }
239+ MessageLongPressMenu(
240+ expanded = expanded,
241+ onDismiss = onDismiss,
246+ onBranch = onBranch,
247+ onRevert = onRevert,
249+ onEdit = editForMenu,
184/** Overflow menu of secondary per-message actions (quote-reply, branch-a-new-session).
253/** Overflow menu of secondary per-message actions (quote-reply, branch-a-new-session).
185 * Shown when at least one action is available and there's text to act on. Keeps the
254 * Shown when at least one action is available and there's text to act on. Keeps the
186 * inline action row (copy/edit/revert/speak) uncluttered while still surfacing the extras. */
255 * inline action row (copy/edit/revert/speak) uncluttered while still surfacing the extras. */
@@ -313,6 +382,8 @@ private fun UserBubble(
313 val density = androidx.compose.ui.platform.LocalDensity.current
382 val density = androidx.compose.ui.platform.LocalDensity.current
314 val copyLabel = stringResource(R.string.copy)
383 val copyLabel = stringResource(R.string.copy)
315 val editLabel = stringResource(R.string.edit_message)
384 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 }
316 // Long-press context menu state: consolidates copy/quote/branch/revert into a standard
387 // Long-press context menu state: consolidates copy/quote/branch/revert into a standard
317 // Android long-press menu so the actions are discoverable without spotting the 18dp icons.
388 // Android long-press menu so the actions are discoverable without spotting the 18dp icons.
318 // The menu anchors at the actual touch point rather than a fixed corner.
389 // The menu anchors at the actual touch point rather than a fixed corner.
@@ -341,6 +412,22 @@ private fun UserBubble(
341 .widthIn(max = maxWidth * NetworkConfig.userBubbleWidthFraction)
412 .widthIn(max = maxWidth * NetworkConfig.userBubbleWidthFraction)
342 .clip(MaterialTheme.shapes.medium)
413 .clip(MaterialTheme.shapes.medium)
343 .background(MaterialTheme.colorScheme.primaryContainer)
414 .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() },
428+ longPressMenu = true
344 .padding(12.dp),
431 .padding(12.dp),
345 verticalArrangement = Arrangement.spacedBy(6.dp),
432 verticalArrangement = Arrangement.spacedBy(6.dp),
@@ -348,26 +435,10 @@ private fun UserBubble(
348 message.parts.forEachIndexed { index, part ->
435 message.parts.forEachIndexed { index, part ->
349 key(part.id, index) { PartView(part, imageContext = imageContext, onOpenFile = onOpenFile) }
436 key(part.id, index) { PartView(part, imageContext = imageContext, onOpenFile = onOpenFile) }
351− // Footer row with timestamp + inline actions. Long-press opens a context menu
438+ // Footer row with timestamp + inline actions.
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.
359− modifier = Modifier
441+ modifier = Modifier.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() },
368− longPressMenu = true
371 horizontalArrangement = Arrangement.SpaceBetween,
442 horizontalArrangement = Arrangement.SpaceBetween,
372 verticalAlignment = Alignment.CenterVertically,
443 verticalAlignment = Alignment.CenterVertically,
@@ -460,8 +531,6 @@ private fun UserBubble(
460 // conversation to before it so a Send replaces it. Only when there's text
531 // conversation to before it so a Send replaces it. Only when there's text
461 // to edit (an image-only prompt has nothing to reload).
532 // to edit (an image-only prompt has nothing to reload).
462 if (onEdit != null && textToCopy != null) {
533 if (onEdit != null && textToCopy != null) {
463− val dropsAttachmentsMsg = stringResource(R.string.edit_drops_attachments)
464− val hasAttachments = message.parts.any { it is FilePart }
465 IconButton(onClick = {
534 IconButton(onClick = {
466 onEdit(textToCopy)
535 onEdit(textToCopy)
467 // Editing reloads only the text; if this prompt carried images or
536 // Editing reloads only the text; if this prompt carried images or
@@ -503,7 +572,7 @@ private fun UserBubble(
506− MessageLongPressMenu(
575+ UserMessageLongPressMenu(
507 expanded = longPressMenu,
576 expanded = longPressMenu,
508 onDismiss = { longPressMenu = false },
577 onDismiss = { longPressMenu = false },
509 offset = longPressOffset,
578 offset = longPressOffset,
@@ -513,6 +582,10 @@ private fun UserBubble(
513 onBranch = onBranch,
582 onBranch = onBranch,
514 onRevert = onRevert,
583 onRevert = onRevert,
515 onShare = onShare,
584 onShare = onShare,
586+ hasAttachments = hasAttachments,
587+ dropsAttachmentsMsg = dropsAttachmentsMsg,
@@ -540,8 +613,26 @@ private fun AssistantBlock(
540 isEdited: Boolean = false,
613 isEdited: Boolean = false,
541 onShare: (() -> Unit)? = null,
614 onShare: (() -> Unit)? = null,
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)) }
544− modifier = modifier.fillMaxWidth(),
624+ modifier = modifier
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() },
633+ longPressMenu = true
545 verticalArrangement = Arrangement.spacedBy(8.dp),
636 verticalArrangement = Arrangement.spacedBy(8.dp),
547 val info = message.info
638 val info = message.info
@@ -593,9 +684,8 @@ private fun AssistantBlock(
593 val costSummary = remember(info.isComplete, tokens, cost, tokenFormat, reasoningFormat, cacheReadFormat, cacheWriteFormat, costShort, costLong) {
684 val costSummary = remember(info.isComplete, tokens, cost, tokenFormat, reasoningFormat, cacheReadFormat, cacheWriteFormat, costShort, costLong) {
594 buildCostSummary(info.isComplete, tokens, cost, tokenFormat, reasoningFormat, cacheReadFormat, cacheWriteFormat, costShort, costLong)
685 buildCostSummary(info.isComplete, tokens, cost, tokenFormat, reasoningFormat, cacheReadFormat, cacheWriteFormat, costShort, costLong)
596− // Long-press context menu state (see UserBubble for rationale).
687+ // Long-press context menu state is hoisted to the block scope (see the Column
597− var longPressMenu by remember { mutableStateOf(false) }
688+ // above) so the whole bubble is the long-press target.
598− var longPressOffset by remember { mutableStateOf(androidx.compose.ui.unit.DpOffset(0.dp, 0.dp)) }
599 // Collect text from all TextParts for copy/read-aloud. Memoized so a
689 // Collect text from all TextParts for copy/read-aloud. Memoized so a
600 // scroll-induced recomposition doesn't re-scan the parts list.
690 // scroll-induced recomposition doesn't re-scan the parts list.
601 val textToCopy = remember(message.parts) {
691 val textToCopy = remember(message.parts) {
@@ -604,9 +694,6 @@ private fun AssistantBlock(
604 .joinToString("\n\n") { it.text }
694 .joinToString("\n\n") { it.text }
605 .takeIf { it.isNotBlank() }
695 .takeIf { it.isNotBlank() }
607− val assistantHaptics = LocalHapticFeedback.current
608− val assistantContext = LocalContext.current
609− val assistantDensity = androidx.compose.ui.platform.LocalDensity.current
610 val onCopy: (() -> Unit)? = textToCopy?.let { text ->
697 val onCopy: (() -> Unit)? = textToCopy?.let { text ->
612 assistantHaptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
699 assistantHaptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
@@ -615,18 +702,7 @@ private fun AssistantBlock(
618− modifier = Modifier
705+ modifier = Modifier.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() },
627− longPressMenu = true
630 horizontalArrangement = Arrangement.SpaceBetween,
706 horizontalArrangement = Arrangement.SpaceBetween,
631 verticalAlignment = Alignment.CenterVertically,
707 verticalAlignment = Alignment.CenterVertically,
@@ -636,9 +712,10 @@ private fun AssistantBlock(
637 style = MaterialTheme.typography.labelSmall,
713 style = MaterialTheme.typography.labelSmall,
638 color = MaterialTheme.colorScheme.onSurfaceVariant,
714 color = MaterialTheme.colorScheme.onSurfaceVariant,
639− // Cap to a single line so a long "1.2k in · 540 out • $0.012"
715+ // Allow up to 2 lines so a multi-segment summary (in/out • reasoning
640− // summary doesn't wrap the "•" awkwardly at narrow widths.
716+ // • cache read • cost) wraps instead of ellipsizing away the cost —
717+ // the part users most want — on narrow screens.
641− maxLines = 1,
718+ maxLines = 2,
642 overflow = TextOverflow.Ellipsis,
719 overflow = TextOverflow.Ellipsis,
@@ -664,7 +741,7 @@ private fun AssistantBlock(
664 onRegenerate = onRegenerate,
741 onRegenerate = onRegenerate,
667− MessageLongPressMenu(
744+ AssistantMessageLongPressMenu(
668 expanded = longPressMenu,
745 expanded = longPressMenu,
669 onDismiss = { longPressMenu = false },
746 onDismiss = { longPressMenu = false },
670 offset = longPressOffset,
747 offset = longPressOffset,
@@ -674,6 +751,12 @@ private fun AssistantBlock(
674 onBranch = onBranch,
751 onBranch = onBranch,
675 onRevert = onRevert,
752 onRevert = onRevert,
676 onShare = onShare,
753 onShare = onShare,
754+ onRegenerate = onRegenerate,
756+ isSpeaking = isSpeaking,
757+ ttsState = ttsState,
759+ onResume = onResume,
@@ -682,6 +765,65 @@ private fun AssistantBlock(
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]. */
772+private fun AssistantMessageLongPressMenu(
774+ onDismiss: () -> Unit,
775+ offset: androidx.compose.ui.unit.DpOffset,
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,
786+ onPause: (() -> Unit)?,
787+ onResume: (() -> Unit)?,
789+ val speakForMenu: (() -> Unit)? = if (onSpeak != null && text != null) {
792+ isSpeaking && ttsState == TtsState.PAUSED -> onResume?.invoke()
793+ isSpeaking -> onPause?.invoke()
794+ else -> onSpeak(text)
800+ val speakLabel: String? = if (onSpeak != null && text != null) {
803+ isSpeaking && ttsState == TtsState.PAUSED -> R.string.resume_reading
804+ isSpeaking -> R.string.pause_reading
805+ else -> R.string.read_aloud
811+ MessageLongPressMenu(
812+ expanded = expanded,
813+ onDismiss = onDismiss,
818+ onBranch = onBranch,
819+ onRevert = onRevert,
821+ onRegenerate = onRegenerate,
822+ onSpeak = speakForMenu,
823+ speakLabel = speakLabel,
685/** Avatar + model label row for an assistant message. Extracted from [AssistantBlock] to keep
827/** Avatar + model label row for an assistant message. Extracted from [AssistantBlock] to keep
686 * that function's cyclomatic complexity under the detekt threshold. */
828 * that function's cyclomatic complexity under the detekt threshold. */
687@Composable
829@Composable