Merge pull request 'feat(app): copy messages via bubble context menu + selection toolbar' (#9) from feat/message-copy-context-menu into master
CI / Kotlin tests (android host + desktop) (push) Successful in 9m0s
CI / Gateway plugin tests (push) Failing after 9m18s

Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
Pakobbix committed 2026-08-23 11:10:30 +00:00
commit 560d9c19b3
1 file changed
+243 -135
@@ -487,6 +487,22 @@ fun ChatScreen(controller: IrisController) {
var selectionMode by remember { mutableStateOf(false) } var selectionMode by remember { mutableStateOf(false) }
var selectedIds by remember { mutableStateOf<Set<String>>(emptySet()) } var selectedIds by remember { mutableStateOf<Set<String>>(emptySet()) }
var showDeleteConfirm by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) }
// True when the confirm dialog was opened from a bubble's context menu
// (which stages selectedIds WITHOUT entering selection mode) — cancel
// must then clear the staged selection so the bubble doesn't stay
// highlighted.
var deleteConfirmFromMenu by remember { mutableStateOf(false) }
val clipboard = LocalClipboardManager.current
// Copy raw markdown (formatting survives pasting into other markdown
// apps) with a toast, since the clipboard write itself is silent.
// Blank text (e.g. a media-only message) is a no-op: writing an empty
// string would wipe the clipboard for no reason.
fun copyText(text: String) {
if (text.isBlank()) return
clipboard.setText(AnnotatedString(text))
toastMessage = "Copied"
}
fun enterSelection(id: String) { fun enterSelection(id: String) {
selectionMode = true selectionMode = true
@@ -670,14 +686,30 @@ fun ChatScreen(controller: IrisController) {
selected = selectable && item.id in selectedIds, selected = selectable && item.id in selectedIds,
onToggleSelect = { if (selectable) toggleSelect(item.id) }, onToggleSelect = { if (selectable) toggleSelect(item.id) },
onLongPress = { onLongPress = {
if (selectable) { if (selectable && selectionMode) toggleSelect(item.id)
if (selectionMode) {
toggleSelect(item.id)
} else {
enterSelection(item.id)
}
}
}, },
onCopy =
if (selectable) {
{ copyText(item.text) }
} else {
null
},
onDelete =
if (selectable) {
{
selectedIds = setOf(item.id)
deleteConfirmFromMenu = true
showDeleteConfirm = true
}
} else {
null
},
onSelect =
if (selectable) {
{ enterSelection(item.id) }
} else {
null
},
runtimeFooterEnabled = runtimeFooterEnabled, runtimeFooterEnabled = runtimeFooterEnabled,
runtimeFooterFields = runtimeFooterFields, runtimeFooterFields = runtimeFooterFields,
) )
@@ -913,7 +945,20 @@ fun ChatScreen(controller: IrisController) {
SelectionToolbar( SelectionToolbar(
count = selectedIds.size, count = selectedIds.size,
onCancel = { exitSelection() }, onCancel = { exitSelection() },
onDelete = { showDeleteConfirm = true }, // Multi-select copy: join the selected messages in
// display order, separated by a blank line.
onCopy = {
val text =
items
.filterIsInstance<MessageItem>()
.filter { it.id in selectedIds }
.joinToString("\n\n") { it.text }
copyText(text)
},
onDelete = {
deleteConfirmFromMenu = false
showDeleteConfirm = true
},
) )
} }
@@ -1062,8 +1107,16 @@ fun ChatScreen(controller: IrisController) {
// Message selection: confirm deleting the selected message(s). // Message selection: confirm deleting the selected message(s).
if (showDeleteConfirm) { if (showDeleteConfirm) {
val n = selectedIds.size val n = selectedIds.size
// Cancel: a menu-opened confirm staged selectedIds without
// entering selection mode — clear it so the bubble doesn't
// stay highlighted (toolbar path keeps its selection).
fun cancelDeleteConfirm() {
showDeleteConfirm = false
if (deleteConfirmFromMenu) selectedIds = emptySet()
}
AlertDialog( AlertDialog(
onDismissRequest = { showDeleteConfirm = false }, onDismissRequest = { cancelDeleteConfirm() },
title = { Text(if (n == 1) "Delete message?" else "Delete $n messages?") }, title = { Text(if (n == 1) "Delete message?" else "Delete $n messages?") },
text = { text = {
Text( Text(
@@ -1082,7 +1135,7 @@ fun ChatScreen(controller: IrisController) {
}) { Text("Delete") } }) { Text("Delete") }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") } TextButton(onClick = { cancelDeleteConfirm() }) { Text("Cancel") }
}, },
) )
} }
@@ -2460,6 +2513,9 @@ private fun MessageBubble(
selected: Boolean = false, selected: Boolean = false,
onToggleSelect: () -> Unit = {}, onToggleSelect: () -> Unit = {},
onLongPress: () -> Unit = {}, onLongPress: () -> Unit = {},
onCopy: (() -> Unit)? = null,
onDelete: (() -> Unit)? = null,
onSelect: (() -> Unit)? = null,
runtimeFooterEnabled: Boolean = false, runtimeFooterEnabled: Boolean = false,
runtimeFooterFields: List<String> = emptyList(), runtimeFooterFields: List<String> = emptyList(),
) { ) {
@@ -2489,141 +2545,178 @@ private fun MessageBubble(
SelectionCheck(selected) SelectionCheck(selected)
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
} }
Column( // Context menu (long-press / right-click): copy / select / delete.
modifier = // Anchored to the bubble itself (Box), not the full-width row, so it
Modifier // pops up next to the bubble on both sides.
.widthIn(max = maxWidth) val hasMenu = onCopy != null || onDelete != null || onSelect != null
.clip(RoundedCornerShape(14.dp)) var menuOpen by remember { mutableStateOf(false) }
.background(if (selected) IrisColors.chipSelected else bubbleColor) Box {
.then( Column(
// Long-press / right-click enters (or toggles within) message modifier =
// selection; a plain tap toggles while selecting, or retries a Modifier
// failed user send otherwise. Attached always so the long-press .widthIn(max = maxWidth)
// affordance exists even outside selection mode. .clip(RoundedCornerShape(14.dp))
Modifier .background(if (selected) IrisColors.chipSelected else bubbleColor)
.combinedClickable( .then(
onClick = { // Long-press / right-click opens the context menu (or
if (selectionMode) { // toggles selection while selecting); a plain tap toggles
onToggleSelect() // while selecting, or retries a failed user send otherwise.
} else if (isUser && msg.status == MsgStatus.Failed) { // Attached always so the long-press affordance exists even
onRetry() // outside selection mode.
} Modifier
.combinedClickable(
onClick = {
if (selectionMode) {
onToggleSelect()
} else if (isUser && msg.status == MsgStatus.Failed) {
onRetry()
}
},
onLongClick = {
if (hasMenu && !selectionMode) menuOpen = true else onLongPress()
},
).rightClick {
if (hasMenu && !selectionMode) menuOpen = true else onLongPress()
}, },
onLongClick = { onLongPress() }, ).padding(horizontal = 12.dp, vertical = 8.dp),
).rightClick { onLongPress() }, ) {
).padding(horizontal = 12.dp, vertical = 8.dp), // Reasoning block above the answer (assistant, non-commentary).
) { if (!isUser && !isCommentary && !msg.reasoning.isNullOrBlank()) {
// Reasoning block above the answer (assistant, non-commentary). ReasoningBlock(msg.reasoning!!, reasoningAutoCollapse, bubbleColor)
if (!isUser && !isCommentary && !msg.reasoning.isNullOrBlank()) { Spacer(modifier = Modifier.height(6.dp))
ReasoningBlock(msg.reasoning!!, reasoningAutoCollapse, bubbleColor)
Spacer(modifier = Modifier.height(6.dp))
}
if (isUser) {
// M7: user text rendered as markdown (bold / italic / inline
// code), so a pasted snippet or emphasis shows up the same as
// in replies.
if (msg.text.isNotBlank() || msg.streaming) {
MarkdownText(
text =
msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() +
if (msg.streaming) " ▉" else "",
color = textColor,
fontSize = 15.sp,
isStreaming = msg.streaming,
)
} }
// M7: timestamp + delivery status on their own line at the if (isUser) {
// bottom-right (Telegram look, same as agent bubbles). // M7: user text rendered as markdown (bold / italic / inline
Row( // code), so a pasted snippet or emphasis shows up the same as
modifier = Modifier.fillMaxWidth(), // in replies.
horizontalArrangement = Arrangement.End, if (msg.text.isNotBlank() || msg.streaming) {
verticalAlignment = Alignment.CenterVertically, MarkdownText(
) { text =
if (time.isNotEmpty()) { msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() +
Text(time, fontSize = 10.sp, color = textColor.copy(alpha = 0.6f)) if (msg.streaming) " ▉" else "",
color = textColor,
fontSize = 15.sp,
isStreaming = msg.streaming,
)
} }
when (msg.status) { // M7: timestamp + delivery status on their own line at the
MsgStatus.Pending -> { // bottom-right (Telegram look, same as agent bubbles).
Text(" sending…", fontSize = 10.sp, color = textColor.copy(alpha = 0.6f))
}
MsgStatus.Sent -> {
Text(" ✓", fontSize = 10.sp, color = textColor.copy(alpha = 0.8f))
}
MsgStatus.Read -> {
Text(" ✓✓", fontSize = 10.sp, color = textColor.copy(alpha = 0.8f))
}
MsgStatus.Failed -> {
Unit
}
}
}
if (msg.status == MsgStatus.Failed) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.End,
) {
Text("Failed to send — tap to retry", fontSize = 10.sp, color = IrisColors.errorText)
}
}
} else {
if (msg.text.isNotBlank() || msg.streaming) {
// M8: render the agent's reply as markdown (bold / italic /
// underscore, tables, highlighted code blocks). Leading
// newlines are stripped so the text hugs the top of the
// bubble; single newlines become hard breaks (models write
// status lines and wrapped text expecting a break per line,
// same as user input); the ▉ cursor is kept while streaming.
val displayText =
msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() +
if (msg.streaming) " ▉" else ""
MarkdownText(
text = displayText,
color = textColor,
fontSize = if (isCommentary) 13.sp else 15.sp,
modifier = Modifier.fillMaxWidth(),
isStreaming = msg.streaming,
)
}
}
// M4: media attachments (image / player / document chip).
if (msg.media.isNotEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
msg.media.forEach { m -> MediaAttachment(m) }
}
}
if (!isUser) {
// Runtime-metadata footer + timestamp on ONE line (footer left,
// time right) — Telegram-style. The app controls whether/what
// the footer shows via Settings → Runtime footer.
val footerText =
if (!isCommentary && !msg.streaming && runtimeFooterEnabled) {
buildRuntimeFooterText(msg, runtimeFooterFields)
} else {
""
}
if (footerText.isNotEmpty() || time.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (footerText.isNotEmpty()) {
Text(
footerText,
color = textColor.copy(alpha = 0.4f),
fontSize = 10.sp,
modifier = Modifier.weight(1f),
)
} else {
Spacer(modifier = Modifier.weight(1f))
}
if (time.isNotEmpty()) { if (time.isNotEmpty()) {
Text(time, fontSize = 10.sp, color = textColor.copy(alpha = 0.5f)) Text(time, fontSize = 10.sp, color = textColor.copy(alpha = 0.6f))
} }
when (msg.status) {
MsgStatus.Pending -> {
Text(" sending…", fontSize = 10.sp, color = textColor.copy(alpha = 0.6f))
}
MsgStatus.Sent -> {
Text(" ✓", fontSize = 10.sp, color = textColor.copy(alpha = 0.8f))
}
MsgStatus.Read -> {
Text(" ✓✓", fontSize = 10.sp, color = textColor.copy(alpha = 0.8f))
}
MsgStatus.Failed -> {
Unit
}
}
}
if (msg.status == MsgStatus.Failed) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
Text("Failed to send — tap to retry", fontSize = 10.sp, color = IrisColors.errorText)
}
}
} else {
if (msg.text.isNotBlank() || msg.streaming) {
// M8: render the agent's reply as markdown (bold / italic /
// underscore, tables, highlighted code blocks). Leading
// newlines are stripped so the text hugs the top of the
// bubble; single newlines become hard breaks (models write
// status lines and wrapped text expecting a break per line,
// same as user input); the ▉ cursor is kept while streaming.
val displayText =
msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() +
if (msg.streaming) " ▉" else ""
MarkdownText(
text = displayText,
color = textColor,
fontSize = if (isCommentary) 13.sp else 15.sp,
modifier = Modifier.fillMaxWidth(),
isStreaming = msg.streaming,
)
}
}
// M4: media attachments (image / player / document chip).
if (msg.media.isNotEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
msg.media.forEach { m -> MediaAttachment(m) }
}
}
if (!isUser) {
// Runtime-metadata footer + timestamp on ONE line (footer left,
// time right) — Telegram-style. The app controls whether/what
// the footer shows via Settings → Runtime footer.
val footerText =
if (!isCommentary && !msg.streaming && runtimeFooterEnabled) {
buildRuntimeFooterText(msg, runtimeFooterFields)
} else {
""
}
if (footerText.isNotEmpty() || time.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
if (footerText.isNotEmpty()) {
Text(
footerText,
color = textColor.copy(alpha = 0.4f),
fontSize = 10.sp,
modifier = Modifier.weight(1f),
)
} else {
Spacer(modifier = Modifier.weight(1f))
}
if (time.isNotEmpty()) {
Text(time, fontSize = 10.sp, color = textColor.copy(alpha = 0.5f))
}
}
}
}
}
if (hasMenu) {
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
onCopy?.let {
DropdownMenuItem(text = { Text("Copy") }, onClick = {
menuOpen = false
it()
})
}
onSelect?.let {
DropdownMenuItem(
text = { Text("Select messages") },
onClick = {
menuOpen = false
it()
},
)
}
onDelete?.let {
DropdownMenuItem(text = { Text("Delete") }, onClick = {
menuOpen = false
it()
})
} }
} }
} }
@@ -2699,6 +2792,7 @@ private fun SelectionCheck(selected: Boolean) {
private fun SelectionToolbar( private fun SelectionToolbar(
count: Int, count: Int,
onCancel: () -> Unit, onCancel: () -> Unit,
onCopy: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
) { ) {
Row( Row(
@@ -2720,6 +2814,20 @@ private fun SelectionToolbar(
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
// Copy the selection (secondary action → neutral chip, Delete stays
// the accent primary action).
Box(
modifier =
Modifier
.clip(RoundedCornerShape(20.dp))
.background(if (count > 0) IrisColors.chip else Color.Transparent)
.clickable(enabled = count > 0) { onCopy() }
.padding(horizontal = 16.dp, vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
Text("⧉ Copy", color = if (count > 0) IrisColors.textBright else IrisColors.textDim, fontSize = 14.sp)
}
Spacer(modifier = Modifier.width(8.dp))
Box( Box(
modifier = modifier =
Modifier Modifier