message action buttons: copy, delete, regenerate

Under each message (when not streaming), small action icons:

User messages: Copy (clipboard → green check) + Delete (red trash).
Delete calls DELETE /api/v1/conversations/[id]/messages and removes
from the in-memory list optimistically.

Assistant messages: Copy + Regenerate (refresh icon, resends the
preceding user message) + Delete. Regenerate removes the assistant
message and re-calls send() with the original user text.

30dp circular tap targets, 16dp icons, subtle 0.35 alpha — visible
but not distracting, matching the web's action button style.
This commit is contained in:
Bruno Deanoz 2026-06-22 00:45:18 +02:00
parent 4ebe812c42
commit f22838e99a
3 changed files with 126 additions and 2 deletions

View file

@ -89,7 +89,15 @@ import androidx.compose.material.icons.rounded.VideoFile
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Mic
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.ui.platform.LocalClipboard
import dev.kaizen.app.haptics.rememberHaptics
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Psychology
import androidx.compose.material.icons.rounded.Tune
@ -337,13 +345,24 @@ fun TokenBadge(used: Int, contextLength: Int, modifier: Modifier = Modifier) {
}
@Composable
fun MessageRow(message: Message) {
fun MessageRow(
message: Message,
onCopy: (String) -> Unit = {},
onDelete: ((String) -> Unit)? = null,
onRegenerate: ((String) -> Unit)? = null,
) {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
val accent = LocalKaizenAccent.current
val hasAttachments = message.attachments.isNotEmpty()
val clipboard = LocalClipboard.current
val haptics = rememberHaptics()
val scope = rememberCoroutineScope()
var copied by remember { mutableStateOf(false) }
if (message.role == Role.User) {
Column(horizontalAlignment = Alignment.End, modifier = Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
val shape = KaizenShapes.lg
val bgBrush = remember(accent, isDark) {
@ -391,7 +410,33 @@ fun MessageRow(message: Message) {
}
}
}
if (!message.streaming) {
Row(
Modifier.padding(top = 4.dp, end = 4.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
MessageActionButton(
icon = if (copied) Icons.Rounded.Check else Icons.Rounded.ContentCopy,
tint = if (copied) Color(0xFF10B981) else cs.onSurfaceVariant.copy(alpha = 0.35f),
) {
haptics.tick()
copied = true
scope.launch {
clipboard.setClipEntry(androidx.compose.ui.platform.ClipEntry(android.content.ClipData.newPlainText("msg", message.content)))
kotlinx.coroutines.delay(1500)
copied = false
}
}
onDelete?.let { del ->
MessageActionButton(Icons.Rounded.Delete, Color(0xFFEF4444).copy(alpha = 0.6f)) {
haptics.tick(); del(message.wireId)
}
}
}
}
}
} else {
Column {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
KaizenOrb(28.dp, streaming = message.streaming)
Spacer(Modifier.width(10.dp))
@ -418,6 +463,49 @@ fun MessageRow(message: Message) {
}
}
}
if (!message.streaming && !message.thinking && message.content.isNotBlank()) {
Row(
Modifier.padding(start = 38.dp, top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
MessageActionButton(
icon = if (copied) Icons.Rounded.Check else Icons.Rounded.ContentCopy,
tint = if (copied) Color(0xFF10B981) else cs.onSurfaceVariant.copy(alpha = 0.35f),
) {
haptics.tick()
copied = true
scope.launch {
clipboard.setClipEntry(androidx.compose.ui.platform.ClipEntry(android.content.ClipData.newPlainText("msg", message.content)))
kotlinx.coroutines.delay(1500)
copied = false
}
}
onRegenerate?.let { regen ->
MessageActionButton(Icons.Rounded.Refresh, cs.onSurfaceVariant.copy(alpha = 0.35f)) {
haptics.tick(); regen(message.wireId)
}
}
onDelete?.let { del ->
MessageActionButton(Icons.Rounded.Delete, Color(0xFFEF4444).copy(alpha = 0.6f)) {
haptics.tick(); del(message.wireId)
}
}
}
}
}
}
}
@Composable
private fun MessageActionButton(icon: ImageVector, tint: Color, onClick: () -> Unit) {
Box(
Modifier
.size(30.dp)
.clip(KaizenShapes.circle)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Icon(icon, null, tint = tint, modifier = Modifier.size(16.dp))
}
}

View file

@ -542,7 +542,26 @@ fun ChatScreen(
),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
items(messages, key = { it.id }) { message -> MessageRow(message) }
items(messages, key = { it.id }) { message ->
MessageRow(
message = message,
onDelete = if (conversationId != null && !isStreaming) { wireId ->
val cfg = session.config ?: return@MessageRow
messages.removeAll { it.wireId == wireId }
scope.launch {
KaizenApi.deleteMessage(cfg.baseUrl, cfg.token, conversationId!!, wireId)
}
} else null,
onRegenerate = if (message.role == Role.Assistant && !isStreaming) { _ ->
val lastUserIdx = messages.indexOfLast { it.role == Role.User && messages.indexOf(it) < messages.indexOf(message) }
if (lastUserIdx >= 0) {
val userContent = messages[lastUserIdx].content
messages.removeAt(messages.indexOf(message))
send(userContent)
}
} else null,
)
}
}
// Scroll buttons

View file

@ -411,6 +411,23 @@ object KaizenApi {
}
}
/** DELETE /api/v1/conversations/[id]/messages — delete a single message. */
suspend fun deleteMessage(baseUrl: String, token: String, conversationId: String, messageId: String): Boolean =
withContext(Dispatchers.IO) {
val body = """{"messageId":"$messageId"}""".toRequestBody(jsonMedia)
val req = Request.Builder()
.url("$baseUrl/api/v1/conversations/$conversationId/messages")
.delete(body)
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { it.isSuccessful }
} catch (e: Exception) {
Log.w(TAG, "deleteMessage: ${e.javaClass.simpleName}: ${e.message}")
false
}
}
/** POST /api/v1/conversations/[id]/title — fire-and-forget auto-title after the first exchange. */
suspend fun generateTitle(baseUrl: String, token: String, id: String) =
withContext(Dispatchers.IO) {