From 32db7fc4e83815e6d63da25571ef2ecc02db4943 Mon Sep 17 00:00:00 2001 From: ARIA Date: Sun, 23 Aug 2026 13:08:44 +0200 Subject: [PATCH] feat(app): copy messages via bubble context menu + selection toolbar Long-press (touch) / right-click (desktop) on a finalized message now opens a context menu anchored to the bubble: Copy / Select messages / Delete. The multi-select toolbar gains a Copy button that joins the selected messages in display order. Copies raw markdown so formatting survives pasting into other markdown apps; blank text is a no-op. Cancelling a menu-opened delete confirm clears the staged selection so the bubble doesn't stay highlighted. --- .../kotlin/iris/ui/screens/ChatScreen.kt | 378 +++++++++++------- 1 file changed, 243 insertions(+), 135 deletions(-) diff --git a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt index 1dbfaa0..fb4e690 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt @@ -487,6 +487,22 @@ fun ChatScreen(controller: IrisController) { var selectionMode by remember { mutableStateOf(false) } var selectedIds by remember { mutableStateOf>(emptySet()) } 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) { selectionMode = true @@ -670,14 +686,30 @@ fun ChatScreen(controller: IrisController) { selected = selectable && item.id in selectedIds, onToggleSelect = { if (selectable) toggleSelect(item.id) }, onLongPress = { - if (selectable) { - if (selectionMode) { - toggleSelect(item.id) - } else { - enterSelection(item.id) - } - } + if (selectable && selectionMode) toggleSelect(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, runtimeFooterFields = runtimeFooterFields, ) @@ -913,7 +945,20 @@ fun ChatScreen(controller: IrisController) { SelectionToolbar( count = selectedIds.size, 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() + .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). if (showDeleteConfirm) { 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( - onDismissRequest = { showDeleteConfirm = false }, + onDismissRequest = { cancelDeleteConfirm() }, title = { Text(if (n == 1) "Delete message?" else "Delete $n messages?") }, text = { Text( @@ -1082,7 +1135,7 @@ fun ChatScreen(controller: IrisController) { }) { Text("Delete") } }, dismissButton = { - TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") } + TextButton(onClick = { cancelDeleteConfirm() }) { Text("Cancel") } }, ) } @@ -2460,6 +2513,9 @@ private fun MessageBubble( selected: Boolean = false, onToggleSelect: () -> Unit = {}, onLongPress: () -> Unit = {}, + onCopy: (() -> Unit)? = null, + onDelete: (() -> Unit)? = null, + onSelect: (() -> Unit)? = null, runtimeFooterEnabled: Boolean = false, runtimeFooterFields: List = emptyList(), ) { @@ -2489,141 +2545,178 @@ private fun MessageBubble( SelectionCheck(selected) Spacer(modifier = Modifier.width(6.dp)) } - Column( - modifier = - Modifier - .widthIn(max = maxWidth) - .clip(RoundedCornerShape(14.dp)) - .background(if (selected) IrisColors.chipSelected else bubbleColor) - .then( - // Long-press / right-click enters (or toggles within) message - // selection; a plain tap toggles while selecting, or retries a - // failed user send otherwise. Attached always so the long-press - // affordance exists even outside selection mode. - Modifier - .combinedClickable( - onClick = { - if (selectionMode) { - onToggleSelect() - } else if (isUser && msg.status == MsgStatus.Failed) { - onRetry() - } + // Context menu (long-press / right-click): copy / select / delete. + // Anchored to the bubble itself (Box), not the full-width row, so it + // pops up next to the bubble on both sides. + val hasMenu = onCopy != null || onDelete != null || onSelect != null + var menuOpen by remember { mutableStateOf(false) } + Box { + Column( + modifier = + Modifier + .widthIn(max = maxWidth) + .clip(RoundedCornerShape(14.dp)) + .background(if (selected) IrisColors.chipSelected else bubbleColor) + .then( + // Long-press / right-click opens the context menu (or + // toggles selection while selecting); a plain tap toggles + // while selecting, or retries a failed user send otherwise. + // Attached always so the long-press affordance exists even + // 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() }, - ).rightClick { onLongPress() }, - ).padding(horizontal = 12.dp, vertical = 8.dp), - ) { - // Reasoning block above the answer (assistant, non-commentary). - if (!isUser && !isCommentary && !msg.reasoning.isNullOrBlank()) { - 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, - ) + ).padding(horizontal = 12.dp, vertical = 8.dp), + ) { + // Reasoning block above the answer (assistant, non-commentary). + if (!isUser && !isCommentary && !msg.reasoning.isNullOrBlank()) { + ReasoningBlock(msg.reasoning!!, reasoningAutoCollapse, bubbleColor) + Spacer(modifier = Modifier.height(6.dp)) } - // M7: timestamp + delivery status on their own line at the - // bottom-right (Telegram look, same as agent bubbles). - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - if (time.isNotEmpty()) { - Text(time, fontSize = 10.sp, color = textColor.copy(alpha = 0.6f)) + 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, + ) } - 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) { + // M7: timestamp + delivery status on their own line at the + // bottom-right (Telegram look, same as agent bubbles). 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)) + 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( count: Int, onCancel: () -> Unit, + onCopy: () -> Unit, onDelete: () -> Unit, ) { Row( @@ -2720,6 +2814,20 @@ private fun SelectionToolbar( style = MaterialTheme.typography.bodyLarge, 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( modifier = Modifier -- 2.54.0