Compare commits
2
Commits
863ab34915
...
560d9c19b3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
560d9c19b3 | ||
|
|
32db7fc4e8 |
@@ -487,6 +487,22 @@ fun ChatScreen(controller: IrisController) {
|
||||
var selectionMode by remember { mutableStateOf(false) }
|
||||
var selectedIds by remember { mutableStateOf<Set<String>>(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,13 +686,29 @@ fun ChatScreen(controller: IrisController) {
|
||||
selected = selectable && item.id in selectedIds,
|
||||
onToggleSelect = { if (selectable) toggleSelect(item.id) },
|
||||
onLongPress = {
|
||||
if (selectable && selectionMode) toggleSelect(item.id)
|
||||
},
|
||||
onCopy =
|
||||
if (selectable) {
|
||||
if (selectionMode) {
|
||||
toggleSelect(item.id)
|
||||
{ copyText(item.text) }
|
||||
} else {
|
||||
enterSelection(item.id)
|
||||
}
|
||||
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<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).
|
||||
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<String> = emptyList(),
|
||||
) {
|
||||
@@ -2489,6 +2545,12 @@ private fun MessageBubble(
|
||||
SelectionCheck(selected)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
}
|
||||
// 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
|
||||
@@ -2496,10 +2558,11 @@ private fun MessageBubble(
|
||||
.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.
|
||||
// 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 = {
|
||||
@@ -2509,8 +2572,12 @@ private fun MessageBubble(
|
||||
onRetry()
|
||||
}
|
||||
},
|
||||
onLongClick = { onLongPress() },
|
||||
).rightClick { onLongPress() },
|
||||
onLongClick = {
|
||||
if (hasMenu && !selectionMode) menuOpen = true else onLongPress()
|
||||
},
|
||||
).rightClick {
|
||||
if (hasMenu && !selectionMode) menuOpen = true else onLongPress()
|
||||
},
|
||||
).padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
// Reasoning block above the answer (assistant, non-commentary).
|
||||
@@ -2628,6 +2695,32 @@ private fun MessageBubble(
|
||||
}
|
||||
}
|
||||
}
|
||||
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()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isUser && selectionMode) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
SelectionCheck(selected)
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user