Long-press message selection + delete (message.delete frame, outbox removal, all-device sync)
This commit is contained in:
1 parent
60ec2b44a7
commit
6f4bf3cb7f
11 files changed
+411
-12
No files matched your search
@@ -444,6 +444,26 @@ class ChatStore {
|
|||||||
if (changed) _lanes.value = map
|
if (changed) _lanes.value = map
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove messages by id from every lane (a `message.deleted` frame). The
|
||||||
|
* server is authoritative: the frame carries no lane, and a message id is
|
||||||
|
* unique, so scanning all lanes is both safe and idempotent (a no-op when the
|
||||||
|
* id is absent, e.g. a local-only id another device never had).
|
||||||
|
*/
|
||||||
|
fun removeMessages(messageIds: Set<String>) {
|
||||||
|
if (messageIds.isEmpty()) return
|
||||||
|
val map = _lanes.value.toMutableMap()
|
||||||
|
var changed = false
|
||||||
|
for ((lane, list) in map) {
|
||||||
|
val updated = list.filterNot { it.id in messageIds }
|
||||||
|
if (updated != list) {
|
||||||
|
map[lane] = updated
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) _lanes.value = map
|
||||||
|
}
|
||||||
|
|
||||||
/** M7: mark all pending user messages as failed (gateway error frame). */
|
/** M7: mark all pending user messages as failed (gateway error frame). */
|
||||||
fun failPending() {
|
fun failPending() {
|
||||||
val map = _lanes.value.toMutableMap()
|
val map = _lanes.value.toMutableMap()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package iris.protocol
|
|||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
@@ -41,6 +42,10 @@ const val TYPE_TYPING = "typing"
|
|||||||
const val TYPE_MESSAGE_START = "message.start"
|
const val TYPE_MESSAGE_START = "message.start"
|
||||||
const val TYPE_MESSAGE_UPDATE = "message.update"
|
const val TYPE_MESSAGE_UPDATE = "message.update"
|
||||||
const val TYPE_MESSAGE_STOP = "message.stop"
|
const val TYPE_MESSAGE_STOP = "message.stop"
|
||||||
|
|
||||||
|
// Message deletion (app requests; broadcast to all devices)
|
||||||
|
const val TYPE_MESSAGE_DELETE = "message.delete"
|
||||||
|
const val TYPE_MESSAGE_DELETED = "message.deleted"
|
||||||
const val TYPE_TOOL_START = "tool.start"
|
const val TYPE_TOOL_START = "tool.start"
|
||||||
const val TYPE_TOOL_PROGRESS = "tool.progress"
|
const val TYPE_TOOL_PROGRESS = "tool.progress"
|
||||||
const val TYPE_TOOL_END = "tool.end"
|
const val TYPE_TOOL_END = "tool.end"
|
||||||
@@ -385,6 +390,14 @@ data class HistoryPayload(
|
|||||||
@SerialName("oldest_message_id") val oldestMessageId: String? = null,
|
@SerialName("oldest_message_id") val oldestMessageId: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── message deletion ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** The message(s) removed from a chat/thread (response + broadcast). */
|
||||||
|
@Serializable
|
||||||
|
data class MessageDeletedPayload(
|
||||||
|
@SerialName("message_ids") val messageIds: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
// ── M5: push / notifications ────────────────────────────────────────────
|
// ── M5: push / notifications ────────────────────────────────────────────
|
||||||
|
|
||||||
/** Notification kinds (mirror of protocol.NOTIF_*). */
|
/** Notification kinds (mirror of protocol.NOTIF_*). */
|
||||||
@@ -550,6 +563,24 @@ fun historyFrame(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Request deletion of the given message(s) in a chat/thread. The server
|
||||||
|
* removes them from the outbox and broadcasts `message.deleted`. */
|
||||||
|
fun messageDeleteFrame(
|
||||||
|
id: Int,
|
||||||
|
chatId: String,
|
||||||
|
messageIds: List<String>,
|
||||||
|
threadId: String? = null,
|
||||||
|
): Frame =
|
||||||
|
Frame(
|
||||||
|
id = id,
|
||||||
|
type = TYPE_MESSAGE_DELETE,
|
||||||
|
chatId = chatId,
|
||||||
|
threadId = threadId,
|
||||||
|
payload = buildJsonObject {
|
||||||
|
put("message_ids", JsonArray(messageIds.map { JsonPrimitive(it) }))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// ── M4 frame builders ────────────────────────────────────────────────────
|
// ── M4 frame builders ────────────────────────────────────────────────────
|
||||||
|
|
||||||
fun mediaUploadStartFrame(
|
fun mediaUploadStartFrame(
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import iris.protocol.HIGH_PRIORITY_NOTIF_KINDS
|
|||||||
import iris.protocol.HistoryMessage
|
import iris.protocol.HistoryMessage
|
||||||
import iris.protocol.HistoryPayload
|
import iris.protocol.HistoryPayload
|
||||||
import iris.protocol.MediaOfferPayload
|
import iris.protocol.MediaOfferPayload
|
||||||
|
import iris.protocol.MessageDeletedPayload
|
||||||
import iris.protocol.MessagePayload
|
import iris.protocol.MessagePayload
|
||||||
import iris.protocol.MessageStopPayload
|
import iris.protocol.MessageStopPayload
|
||||||
import iris.protocol.NotificationPayload
|
import iris.protocol.NotificationPayload
|
||||||
@@ -38,6 +39,8 @@ import iris.protocol.TYPE_CHANNEL_RENAMED
|
|||||||
import iris.protocol.TYPE_COMMENTARY
|
import iris.protocol.TYPE_COMMENTARY
|
||||||
import iris.protocol.TYPE_MEDIA_OFFER
|
import iris.protocol.TYPE_MEDIA_OFFER
|
||||||
import iris.protocol.TYPE_MESSAGE
|
import iris.protocol.TYPE_MESSAGE
|
||||||
|
import iris.protocol.TYPE_MESSAGE_DELETE
|
||||||
|
import iris.protocol.TYPE_MESSAGE_DELETED
|
||||||
import iris.protocol.TYPE_MESSAGE_START
|
import iris.protocol.TYPE_MESSAGE_START
|
||||||
import iris.protocol.TYPE_MESSAGE_STOP
|
import iris.protocol.TYPE_MESSAGE_STOP
|
||||||
import iris.protocol.TYPE_MESSAGE_UPDATE
|
import iris.protocol.TYPE_MESSAGE_UPDATE
|
||||||
@@ -56,6 +59,7 @@ import iris.protocol.channelListFrame
|
|||||||
import iris.protocol.channelRenameFrame
|
import iris.protocol.channelRenameFrame
|
||||||
import iris.protocol.channelSetDefaultFrame
|
import iris.protocol.channelSetDefaultFrame
|
||||||
import iris.protocol.historyFrame
|
import iris.protocol.historyFrame
|
||||||
|
import iris.protocol.messageDeleteFrame
|
||||||
import iris.protocol.searchFrame
|
import iris.protocol.searchFrame
|
||||||
import iris.protocol.syncFrame
|
import iris.protocol.syncFrame
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -367,6 +371,13 @@ class IrisController(
|
|||||||
TYPE_READ_RECEIPT -> {
|
TYPE_READ_RECEIPT -> {
|
||||||
frame.payloadAs<ReadReceiptPayload>()?.let { chat.markRead(it.messageId) }
|
frame.payloadAs<ReadReceiptPayload>()?.let { chat.markRead(it.messageId) }
|
||||||
}
|
}
|
||||||
|
TYPE_MESSAGE_DELETED -> {
|
||||||
|
// A message was deleted (by this or another device).
|
||||||
|
// Idempotent: dropping an unknown id is a no-op.
|
||||||
|
frame.payloadAs<MessageDeletedPayload>()?.let {
|
||||||
|
chat.removeMessages(it.messageIds.toSet())
|
||||||
|
}
|
||||||
|
}
|
||||||
TYPE_STATUS -> {
|
TYPE_STATUS -> {
|
||||||
frame.payloadAs<StatusPayload>()?.let { _gatewayStatus.value = it.state }
|
frame.payloadAs<StatusPayload>()?.let { _gatewayStatus.value = it.state }
|
||||||
}
|
}
|
||||||
@@ -539,6 +550,18 @@ class IrisController(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Delete the given message(s) from the current lane (long-press select →
|
||||||
|
* delete). The server removes them from the outbox and broadcasts
|
||||||
|
* `message.deleted`; the local cache drops them on that frame (or
|
||||||
|
* immediately, below, for snappy UX). */
|
||||||
|
fun deleteMessages(messageIds: List<String>) {
|
||||||
|
if (messageIds.isEmpty()) return
|
||||||
|
val lane = chat.currentLane.value
|
||||||
|
val (chatId, threadId) = chat.parseLane(lane)
|
||||||
|
chat.removeMessages(messageIds.toSet())
|
||||||
|
client.sendFrame(messageDeleteFrame(0, chatId, messageIds, threadId))
|
||||||
|
}
|
||||||
|
|
||||||
// ── M4: attachments (pick -> upload -> send) ──────────────────────────
|
// ── M4: attachments (pick -> upload -> send) ──────────────────────────
|
||||||
|
|
||||||
/** Stage a picked file: upload it, then keep it as a pending attachment. */
|
/** Stage a picked file: upload it, then keep it as a pending attachment. */
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package iris.ui.screens
|
|||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
@@ -221,6 +222,34 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
// Topic context menu (long-press / right-click): rename / delete a thread.
|
// Topic context menu (long-press / right-click): rename / delete a thread.
|
||||||
var renameThread by remember { mutableStateOf<ChannelInfo?>(null) }
|
var renameThread by remember { mutableStateOf<ChannelInfo?>(null) }
|
||||||
var deleteThread by remember { mutableStateOf<ChannelInfo?>(null) }
|
var deleteThread by remember { mutableStateOf<ChannelInfo?>(null) }
|
||||||
|
// Message selection (long-press / right-click a bubble → multi-select →
|
||||||
|
// delete). The composer is replaced by a selection toolbar while active.
|
||||||
|
var selectionMode by remember { mutableStateOf(false) }
|
||||||
|
var selectedIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||||
|
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
fun enterSelection(id: String) {
|
||||||
|
selectionMode = true
|
||||||
|
selectedIds = setOf(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleSelect(id: String) {
|
||||||
|
selectedIds = if (id in selectedIds) selectedIds - id else selectedIds + id
|
||||||
|
if (selectedIds.isEmpty()) selectionMode = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun exitSelection() {
|
||||||
|
selectionMode = false
|
||||||
|
selectedIds = emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection is scoped to the lane the messages were viewed in; leaving
|
||||||
|
// that lane (channel / thread switch) clears any in-progress selection
|
||||||
|
// so a later delete can't target the wrong lane.
|
||||||
|
LaunchedEffect(currentLane) {
|
||||||
|
if (selectionMode) exitSelection()
|
||||||
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// Header (M7: avatar + two-line title pill, reference look). Wide
|
// Header (M7: avatar + two-line title pill, reference look). Wide
|
||||||
// panes keep everything on one row; narrow phones move the utility
|
// panes keep everything on one row; narrow phones move the utility
|
||||||
@@ -355,12 +384,27 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
when (row) {
|
when (row) {
|
||||||
is ChatRow.Day -> DaySeparator(row.label)
|
is ChatRow.Day -> DaySeparator(row.label)
|
||||||
is ChatRow.Item -> when (val item = row.item) {
|
is ChatRow.Item -> when (val item = row.item) {
|
||||||
is MessageItem -> MessageBubble(
|
is MessageItem -> {
|
||||||
|
// Only finalized messages are selectable: a
|
||||||
|
// streaming bubble has no final id yet and a
|
||||||
|
// pending echo isn't on the server to delete.
|
||||||
|
val selectable = !item.streaming && !item.pending
|
||||||
|
MessageBubble(
|
||||||
msg = item,
|
msg = item,
|
||||||
maxWidth = bubbleMaxWidth,
|
maxWidth = bubbleMaxWidth,
|
||||||
reasoningAutoCollapse = reasoningAutoCollapse,
|
reasoningAutoCollapse = reasoningAutoCollapse,
|
||||||
onRetry = { controller.retrySend(item.id) },
|
onRetry = { controller.retrySend(item.id) },
|
||||||
|
selectionMode = selectionMode && selectable,
|
||||||
|
selected = selectable && item.id in selectedIds,
|
||||||
|
onToggleSelect = { if (selectable) toggleSelect(item.id) },
|
||||||
|
onLongPress = {
|
||||||
|
if (selectable) {
|
||||||
|
if (selectionMode) toggleSelect(item.id)
|
||||||
|
else enterSelection(item.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
}
|
||||||
is ToolItem -> if (toolDetail != ToolDetail.NOTHING) ToolCard(item, toolDetail)
|
is ToolItem -> if (toolDetail != ToolDetail.NOTHING) ToolCard(item, toolDetail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -444,6 +488,15 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Selection toolbar (replaces the composer while messages are selected).
|
||||||
|
if (selectionMode) {
|
||||||
|
SelectionToolbar(
|
||||||
|
count = selectedIds.size,
|
||||||
|
onCancel = { exitSelection() },
|
||||||
|
onDelete = { showDeleteConfirm = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Composer (M7: rounded pill + accent circular send button). Sending is gated
|
// Composer (M7: rounded pill + accent circular send button). Sending is gated
|
||||||
// on a live socket: sendMessage is a no-op while disconnected, so an
|
// on a live socket: sendMessage is a no-op while disconnected, so an
|
||||||
// ungated send would show a pending bubble that never resolves.
|
// ungated send would show a pending bubble that never resolves.
|
||||||
@@ -452,7 +505,7 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
isConnected && (input.isNotBlank() || attachments.any { it.mediaRef != null && it.error == null })
|
isConnected && (input.isNotBlank() || attachments.any { it.mediaRef != null && it.error == null })
|
||||||
val layoutDensity = LocalDensity.current.density
|
val layoutDensity = LocalDensity.current.density
|
||||||
var textHeightPx by remember { mutableFloatStateOf(0f) }
|
var textHeightPx by remember { mutableFloatStateOf(0f) }
|
||||||
Row(
|
if (!selectionMode) Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
@@ -575,6 +628,30 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Message selection: confirm deleting the selected message(s).
|
||||||
|
if (showDeleteConfirm) {
|
||||||
|
val n = selectedIds.size
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showDeleteConfirm = false },
|
||||||
|
title = { Text(if (n == 1) "Delete message?" else "Delete $n messages?") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
if (n == 1) "This message will be deleted for all devices."
|
||||||
|
else "These messages will be deleted for all devices.",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
showDeleteConfirm = false
|
||||||
|
controller.deleteMessages(selectedIds.toList())
|
||||||
|
exitSelection()
|
||||||
|
}) { Text("Delete") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1418,8 +1495,18 @@ private fun NotificationBanner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun MessageBubble(msg: MessageItem, maxWidth: Dp, reasoningAutoCollapse: Boolean, onRetry: () -> Unit) {
|
private fun MessageBubble(
|
||||||
|
msg: MessageItem,
|
||||||
|
maxWidth: Dp,
|
||||||
|
reasoningAutoCollapse: Boolean,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
selectionMode: Boolean = false,
|
||||||
|
selected: Boolean = false,
|
||||||
|
onToggleSelect: () -> Unit = {},
|
||||||
|
onLongPress: () -> Unit = {},
|
||||||
|
) {
|
||||||
val isUser = msg.role == ROLE_USER
|
val isUser = msg.role == ROLE_USER
|
||||||
val isCommentary = msg.isCommentary
|
val isCommentary = msg.isCommentary
|
||||||
val bubbleColor = when {
|
val bubbleColor = when {
|
||||||
@@ -1437,15 +1524,31 @@ private fun MessageBubble(msg: MessageItem, maxWidth: Dp, reasoningAutoCollapse:
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
||||||
) {
|
) {
|
||||||
|
// Selection check sits on the side away from the bubble (user bubbles
|
||||||
|
// are right-aligned → check on the left; assistant on the left → right).
|
||||||
|
if (isUser && selectionMode) {
|
||||||
|
SelectionCheck(selected)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.widthIn(max = maxWidth)
|
.widthIn(max = maxWidth)
|
||||||
.clip(RoundedCornerShape(14.dp))
|
.clip(RoundedCornerShape(14.dp))
|
||||||
.background(bubbleColor)
|
.background(if (selected) IrisColors.chipSelected else bubbleColor)
|
||||||
.then(
|
.then(
|
||||||
if (isUser && msg.status == MsgStatus.Failed) {
|
// Long-press / right-click enters (or toggles within) message
|
||||||
Modifier.clickable { onRetry() }
|
// selection; a plain tap toggles while selecting, or retries a
|
||||||
} else Modifier
|
// 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 = { onLongPress() },
|
||||||
|
)
|
||||||
|
.rightClick { onLongPress() }
|
||||||
)
|
)
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
@@ -1532,6 +1635,63 @@ private fun MessageBubble(msg: MessageItem, maxWidth: Dp, reasoningAutoCollapse:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!isUser && selectionMode) {
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
SelectionCheck(selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Circular selection indicator shown beside a bubble in selection mode. */
|
||||||
|
@Composable
|
||||||
|
private fun SelectionCheck(selected: Boolean) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(22.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(if (selected) IrisColors.primary else Color.Transparent)
|
||||||
|
.border(
|
||||||
|
width = if (selected) 0.dp else 1.5.dp,
|
||||||
|
color = IrisColors.textDim,
|
||||||
|
shape = CircleShape,
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (selected) Text("✓", color = Color.White, fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toolbar shown in place of the composer while messages are selected. */
|
||||||
|
@Composable
|
||||||
|
private fun SelectionToolbar(count: Int, onCancel: () -> Unit, onDelete: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
|
.clip(RoundedCornerShape(28.dp))
|
||||||
|
.background(IrisColors.surface)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
HeaderIconButton(onClick = onCancel) {
|
||||||
|
Text("✕", fontSize = 16.sp)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
if (count == 1) "1 selected" else "$count selected",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(20.dp))
|
||||||
|
.background(if (count > 0) IrisColors.primary else IrisColors.primary.copy(alpha = 0.35f))
|
||||||
|
.clickable(enabled = count > 0) { onDelete() }
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text("🗑 Delete", color = Color.White, fontSize = 14.sp)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,16 @@ Streaming a bubble. `update` carries the **full** current text (app replaces).
|
|||||||
"reasoning":"…","model":"…","tokens":11}}
|
"reasoning":"…","model":"…","tokens":11}}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `message.deleted`
|
||||||
|
The given message(s) were deleted from a chat/thread. Response to a
|
||||||
|
`message.delete` request (`id` set) **and** broadcast to every device so all of
|
||||||
|
them drop the message(s) from their cache. Also outboxed, so a device that was
|
||||||
|
offline learns of the deletion on its next `sync`.
|
||||||
|
```json
|
||||||
|
{"type":"message.deleted","id":30,"chat_id":"android:default","thread_id":null,
|
||||||
|
"payload":{"message_ids":["m_9001","m_9002"]}}
|
||||||
|
```
|
||||||
|
|
||||||
### `commentary`
|
### `commentary`
|
||||||
Intermediate assistant beat (between tool iterations).
|
Intermediate assistant beat (between tool iterations).
|
||||||
```json
|
```json
|
||||||
@@ -300,6 +310,16 @@ Load a page of messages for a chat/thread (initial open, scroll-up pagination).
|
|||||||
`before_message_id` — return messages older than this (omit for newest page).
|
`before_message_id` — return messages older than this (omit for newest page).
|
||||||
`limit` — max messages (default 50, max 200).
|
`limit` — max messages (default 50, max 200).
|
||||||
|
|
||||||
|
### `message.delete`
|
||||||
|
Delete the given message(s) from a chat/thread. The server removes them from the
|
||||||
|
outbox (so `history`/`sync` no longer return them) and broadcasts
|
||||||
|
`message.deleted` to every device. Idempotent: a message already gone (pruned by
|
||||||
|
retention) still yields a `message.deleted` broadcast so live caches drop it.
|
||||||
|
```json
|
||||||
|
{"type":"message.delete","id":30,"chat_id":"android:default","thread_id":null,
|
||||||
|
"payload":{"message_ids":["m_9001","m_9002"]}}
|
||||||
|
```
|
||||||
|
|
||||||
### `commands.catalog`
|
### `commands.catalog`
|
||||||
Fetch the full slash-command catalog (for the `Menü` bottom sheet).
|
Fetch the full slash-command catalog (for the `Menü` bottom sheet).
|
||||||
```json
|
```json
|
||||||
@@ -346,10 +366,11 @@ Keepalive. `{"type":"ping","payload":{"ts":1724000000000}}` → `pong`.
|
|||||||
|
|
||||||
- Frames are ordered per connection (TCP/WS). Streaming `message.update` frames
|
- Frames are ordered per connection (TCP/WS). Streaming `message.update` frames
|
||||||
for a `message_id` are monotonic; the app may coalesce to the latest.
|
for a `message_id` are monotonic; the app may coalesce to the latest.
|
||||||
- Terminal frames (`message`, `message.stop`, `tool.end`, `notification`,
|
- Terminal frames (`message`, `message.stop`, `message.deleted`, `tool.end`,
|
||||||
`picker.*`, `channel.*`, `agent.busy`, `agent.idle`, `history`,
|
`notification`, `picker.*`, `channel.*`, `agent.busy`, `agent.idle`,
|
||||||
`commands.catalog`, `commands.complete`) are **never dropped** under
|
`history`, `commands.catalog`, `commands.complete`) are **never dropped**
|
||||||
backpressure; only intermediate `message.update`/`tool.progress` are coalesced.
|
under backpressure; only intermediate `message.update`/`tool.progress` are
|
||||||
|
coalesced.
|
||||||
- Anything not delivered live goes to the **outbox** and is replayed by `sync`.
|
- Anything not delivered live goes to the **outbox** and is replayed by `sync`.
|
||||||
- **Broadcast:** channel directory events (`channel.*`) and read-receipts are
|
- **Broadcast:** channel directory events (`channel.*`) and read-receipts are
|
||||||
pushed to **all** connected devices for that gateway (no subscribe step).
|
pushed to **all** connected devices for that gateway (no subscribe step).
|
||||||
|
|||||||
@@ -126,6 +126,20 @@ app/shared/src/
|
|||||||
### Intermediate messages
|
### Intermediate messages
|
||||||
- `commentary` frames → dimmed/smaller bubble, distinct from final answers.
|
- `commentary` frames → dimmed/smaller bubble, distinct from final answers.
|
||||||
|
|
||||||
|
### Message selection + delete
|
||||||
|
- **Long-press** a message bubble (touch) or **right-click** it (desktop mouse)
|
||||||
|
enters selection mode: the tapped message is selected (a circular check appears
|
||||||
|
beside each bubble) and the composer is replaced by a selection toolbar
|
||||||
|
(count + **Delete** + cancel ✕).
|
||||||
|
- While selecting, **tap** a bubble to toggle it; the ✕ (or deselecting the last
|
||||||
|
message) exits selection mode. Only finalized messages are selectable — a
|
||||||
|
streaming bubble has no final id yet and a pending echo isn't on the server.
|
||||||
|
- **Delete** → confirm dialog → `message.delete {message_ids:[…]}` for the
|
||||||
|
current lane. The server removes the message(s) from the outbox (so
|
||||||
|
`history`/`sync` no longer return them) and broadcasts `message.deleted` to
|
||||||
|
every device; each device drops them from its cache (the requesting device
|
||||||
|
also drops them locally for snappy UX). Deleting is idempotent.
|
||||||
|
|
||||||
### Agent busy / stop / steer
|
### Agent busy / stop / steer
|
||||||
- `agent.busy` → show "thinking…" indicator in chat header (animated dots).
|
- `agent.busy` → show "thinking…" indicator in chat header (animated dots).
|
||||||
- `agent.idle` → clear indicator.
|
- `agent.idle` → clear indicator.
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"message.start": { "payload": { "message_id": { "type": "string" }, "role": { "type": "string" } } },
|
"message.start": { "payload": { "message_id": { "type": "string" }, "role": { "type": "string" } } },
|
||||||
"message.update": { "payload": { "message_id": { "type": "string" }, "text": { "type": "string", "description": "Full current text (app replaces)." } } },
|
"message.update": { "payload": { "message_id": { "type": "string" }, "text": { "type": "string", "description": "Full current text (app replaces)." } } },
|
||||||
"message.stop": { "payload": { "message_id": { "type": "string" }, "final_text": { "type": "string" }, "reasoning": { "type": "string" }, "model": { "type": "string" }, "tokens": { "type": "integer" }, "ts": { "type": "integer" } } },
|
"message.stop": { "payload": { "message_id": { "type": "string" }, "final_text": { "type": "string" }, "reasoning": { "type": "string" }, "model": { "type": "string" }, "tokens": { "type": "integer" }, "ts": { "type": "integer" } } },
|
||||||
|
"message.deleted": { "description": "The given message(s) were deleted from a chat/thread. Response to a message.delete request (id set) and broadcast to every device so all drop them from their cache; also outboxed so an offline device learns of the deletion on its next sync.", "payload": { "message_ids": { "type": "array", "items": { "type": "string" } } } },
|
||||||
"commentary": { "description": "Intermediate assistant beat.", "payload": { "message_id": { "type": "string" }, "text": { "type": "string" } } },
|
"commentary": { "description": "Intermediate assistant beat.", "payload": { "message_id": { "type": "string" }, "text": { "type": "string" } } },
|
||||||
"tool.start": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "preview": { "type": "string" }, "args": { "type": "object" } } },
|
"tool.start": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "preview": { "type": "string" }, "args": { "type": "object" } } },
|
||||||
"tool.progress": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "note": { "type": "string" } } },
|
"tool.progress": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "note": { "type": "string" } } },
|
||||||
@@ -77,6 +78,7 @@
|
|||||||
"search": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "chat_id": { "type": "string" }, "thread_id": { "type": "string" }, "limit": { "type": "integer", "description": "Optional; server default 20." } } },
|
"search": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "chat_id": { "type": "string" }, "thread_id": { "type": "string" }, "limit": { "type": "integer", "description": "Optional; server default 20." } } },
|
||||||
"sync": { "description": "Reconnect catch-up; replays undelivered outbox frames only (not full history).", "payload": { "cursor": { "type": "integer" } } },
|
"sync": { "description": "Reconnect catch-up; replays undelivered outbox frames only (not full history).", "payload": { "cursor": { "type": "integer" } } },
|
||||||
"history": { "description": "Load a page of full message history for a chat/thread (initial open, scroll-up pagination).", "payload": { "before_message_id": { "type": "string", "description": "Return messages older than this (omit for newest page)." }, "limit": { "type": "integer", "description": "Max messages (default 50, max 200)." } } },
|
"history": { "description": "Load a page of full message history for a chat/thread (initial open, scroll-up pagination).", "payload": { "before_message_id": { "type": "string", "description": "Return messages older than this (omit for newest page)." }, "limit": { "type": "integer", "description": "Max messages (default 50, max 200)." } } },
|
||||||
|
"message.delete": { "description": "Delete the given message(s) from a chat/thread. The server removes them from the outbox (so history/sync no longer return them) and broadcasts message.deleted to every device. Idempotent: a message already gone (pruned) still yields a message.deleted broadcast.", "payload": { "message_ids": { "type": "array", "items": { "type": "string" }, "description": "One or more message_id values to delete." } } },
|
||||||
"fcm.register": { "payload": { "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } },
|
"fcm.register": { "payload": { "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } },
|
||||||
"ping": { "payload": { "ts": { "type": "integer" } } }
|
"ping": { "payload": { "ts": { "type": "integer" } } }
|
||||||
}
|
}
|
||||||
@@ -105,7 +107,7 @@
|
|||||||
],
|
],
|
||||||
"reliability": {
|
"reliability": {
|
||||||
"ordering": "Per-connection (TCP/WS). message.update for a message_id is monotonic; app may coalesce to latest.",
|
"ordering": "Per-connection (TCP/WS). message.update for a message_id is monotonic; app may coalesce to latest.",
|
||||||
"never_dropped": ["message", "message.stop", "tool.end", "notification", "channel.*", "search.results", "error"],
|
"never_dropped": ["message", "message.stop", "message.deleted", "tool.end", "notification", "channel.*", "search.results", "error"],
|
||||||
"coalescable_under_backpressure": ["message.update", "tool.progress"],
|
"coalescable_under_backpressure": ["message.update", "tool.progress"],
|
||||||
"offline": "Undelivered frames go to the outbox; replayed by sync. Terminal frames always outboxed."
|
"offline": "Undelivered frames go to the outbox; replayed by sync. Terminal frames always outboxed."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2216,6 +2216,52 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
)
|
)
|
||||||
await self._ws_server.send_to(device_id, resp)
|
await self._ws_server.send_to(device_id, resp)
|
||||||
|
|
||||||
|
# ── Message deletion (app -> agent) ───────────────────────────────────
|
||||||
|
|
||||||
|
async def on_message_delete(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
|
"""Handle an inbound ``message.delete`` request.
|
||||||
|
|
||||||
|
Removes the requested message(s) from the outbox (so ``history`` and
|
||||||
|
``sync`` no longer return them) and broadcasts ``message.deleted`` to
|
||||||
|
every device (outboxed too, so an offline device learns of the
|
||||||
|
deletion on its next ``sync``). Deleting is idempotent: a message that
|
||||||
|
is already gone (pruned by retention) simply yields 0 removed rows,
|
||||||
|
and the ``message.deleted`` broadcast is still emitted so live caches
|
||||||
|
drop it.
|
||||||
|
"""
|
||||||
|
payload = frame.payload
|
||||||
|
chat_id = frame.chat_id or payload.get("chat_id")
|
||||||
|
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||||
|
await self._ws_server.send_to(
|
||||||
|
device_id,
|
||||||
|
protocol.error(protocol.ERR_NOT_FOUND, "message.delete requires chat_id", id=frame.id),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
chat_id = chat_id.strip()
|
||||||
|
thread_id = frame.thread_id or payload.get("thread_id")
|
||||||
|
if not isinstance(thread_id, str) or not thread_id.strip():
|
||||||
|
thread_id = None
|
||||||
|
message_ids = payload.get("message_ids")
|
||||||
|
if not isinstance(message_ids, list):
|
||||||
|
message_ids = [payload.get("message_id")] if payload.get("message_id") else []
|
||||||
|
message_ids = [m for m in message_ids if isinstance(m, str) and m.strip()]
|
||||||
|
if not message_ids:
|
||||||
|
await self._ws_server.send_to(
|
||||||
|
device_id,
|
||||||
|
protocol.error(protocol.ERR_UNSUPPORTED, "message.delete requires message_ids", id=frame.id),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
removed = 0
|
||||||
|
for mid in message_ids:
|
||||||
|
removed += self._outbox.delete_message(chat_id, mid, thread_id=thread_id)
|
||||||
|
logger.info(
|
||||||
|
"android: message.delete from %s chat_id=%r thread_id=%r ids=%s removed=%s",
|
||||||
|
device_id, chat_id, thread_id, message_ids, removed,
|
||||||
|
)
|
||||||
|
resp = protocol.message_deleted(chat_id, message_ids, thread_id=thread_id)
|
||||||
|
resp.id = frame.id
|
||||||
|
await self._broadcast_or_log(chat_id, resp)
|
||||||
|
|
||||||
# ── M5: push token registration ───────────────────────────────────────
|
# ── M5: push token registration ───────────────────────────────────────
|
||||||
|
|
||||||
async def on_fcm_register(self, frame: protocol.Frame, device_id: str) -> None:
|
async def on_fcm_register(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
|
|||||||
@@ -277,6 +277,55 @@ class Outbox:
|
|||||||
"oldest_message_id": oldest_message_id,
|
"oldest_message_id": oldest_message_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── message deletion ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def delete_message(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
message_id: str,
|
||||||
|
thread_id: Optional[str] = None,
|
||||||
|
) -> int:
|
||||||
|
"""Remove every outbox frame belonging to *message_id* in *chat_id*.
|
||||||
|
|
||||||
|
A message can span several frames (``message`` / ``message.start`` /
|
||||||
|
``message.update`` / ``message.stop`` / ``media.offer`` /
|
||||||
|
``commentary``); all of them are removed so neither ``history`` nor a
|
||||||
|
``sync`` replay can resurrect the message. The delete is scoped to the
|
||||||
|
exact lane: a flat-lane delete (``thread_id=None``) matches only frames
|
||||||
|
with no ``thread_id``, and a thread delete matches only that thread's
|
||||||
|
frames (a ``message_id`` is unique to one lane, so this is a safety
|
||||||
|
net, not a filter that drops real frames). Returns the number of rows
|
||||||
|
removed (0 when the message is not in the outbox — e.g. already pruned
|
||||||
|
by retention).
|
||||||
|
"""
|
||||||
|
if not message_id:
|
||||||
|
return 0
|
||||||
|
with self._lock:
|
||||||
|
rows = self._conn.execute(
|
||||||
|
"SELECT cursor, frame FROM outbox WHERE chat_id = ?", (chat_id,)
|
||||||
|
).fetchall()
|
||||||
|
cursors: List[int] = []
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
frame = json.loads(r["frame"])
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
continue
|
||||||
|
if not isinstance(frame, dict):
|
||||||
|
continue
|
||||||
|
if frame.get("thread_id") != thread_id:
|
||||||
|
continue
|
||||||
|
payload = frame.get("payload")
|
||||||
|
if isinstance(payload, dict) and payload.get("message_id") == message_id:
|
||||||
|
cursors.append(int(r["cursor"]))
|
||||||
|
if not cursors:
|
||||||
|
return 0
|
||||||
|
placeholders = ",".join("?" * len(cursors))
|
||||||
|
self._conn.execute(
|
||||||
|
f"DELETE FROM outbox WHERE cursor IN ({placeholders})", cursors
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return len(cursors)
|
||||||
|
|
||||||
# ── retention ─────────────────────────────────────────────────────────
|
# ── retention ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _maybe_prune(self) -> None:
|
def _maybe_prune(self) -> None:
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ TYPE_MESSAGE_START = "message.start"
|
|||||||
TYPE_MESSAGE_UPDATE = "message.update"
|
TYPE_MESSAGE_UPDATE = "message.update"
|
||||||
TYPE_MESSAGE_STOP = "message.stop"
|
TYPE_MESSAGE_STOP = "message.stop"
|
||||||
|
|
||||||
|
# Message deletion (app requests; broadcast to all devices)
|
||||||
|
TYPE_MESSAGE_DELETE = "message.delete"
|
||||||
|
TYPE_MESSAGE_DELETED = "message.deleted"
|
||||||
|
|
||||||
# Tool activity (M2)
|
# Tool activity (M2)
|
||||||
TYPE_TOOL_START = "tool.start"
|
TYPE_TOOL_START = "tool.start"
|
||||||
TYPE_TOOL_PROGRESS = "tool.progress"
|
TYPE_TOOL_PROGRESS = "tool.progress"
|
||||||
@@ -528,6 +532,33 @@ def history(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Message deletion frames
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def message_deleted(
|
||||||
|
chat_id: str,
|
||||||
|
message_ids: List[str],
|
||||||
|
*,
|
||||||
|
thread_id: Optional[str] = None,
|
||||||
|
id: Optional[int] = None,
|
||||||
|
) -> Frame:
|
||||||
|
"""Broadcast: the given message(s) were deleted from a chat/thread.
|
||||||
|
|
||||||
|
Carried by the response to a ``message.delete`` request (``id`` set) and
|
||||||
|
broadcast to every device so all of them drop the message(s) from their
|
||||||
|
cache. Also outboxed, so a device that was offline learns of the deletion
|
||||||
|
on its next ``sync``.
|
||||||
|
"""
|
||||||
|
return Frame(
|
||||||
|
type=TYPE_MESSAGE_DELETED,
|
||||||
|
id=id,
|
||||||
|
chat_id=chat_id,
|
||||||
|
thread_id=thread_id,
|
||||||
|
payload={"message_ids": list(message_ids)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Push / notification frames (M5)
|
# Push / notification frames (M5)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -380,6 +380,8 @@ class WsServer:
|
|||||||
await self._adapter.on_sync(frame, device_id)
|
await self._adapter.on_sync(frame, device_id)
|
||||||
elif frame.type == protocol.TYPE_HISTORY:
|
elif frame.type == protocol.TYPE_HISTORY:
|
||||||
await self._adapter.on_history(frame, device_id)
|
await self._adapter.on_history(frame, device_id)
|
||||||
|
elif frame.type == protocol.TYPE_MESSAGE_DELETE:
|
||||||
|
await self._adapter.on_message_delete(frame, device_id)
|
||||||
elif frame.type == protocol.TYPE_MEDIA_UPLOAD_START:
|
elif frame.type == protocol.TYPE_MEDIA_UPLOAD_START:
|
||||||
await self._adapter.on_media_upload_start(frame, device_id)
|
await self._adapter.on_media_upload_start(frame, device_id)
|
||||||
elif frame.type == protocol.TYPE_MEDIA_UPLOAD_END:
|
elif frame.type == protocol.TYPE_MEDIA_UPLOAD_END:
|
||||||
|
|||||||
Reference in new issue
Block a user