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:
parent
4ebe812c42
commit
f22838e99a
3 changed files with 126 additions and 2 deletions
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue