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.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.material.icons.rounded.Bolt
|
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.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.ExpandMore
|
||||||
import androidx.compose.material.icons.rounded.Psychology
|
import androidx.compose.material.icons.rounded.Psychology
|
||||||
import androidx.compose.material.icons.rounded.Tune
|
import androidx.compose.material.icons.rounded.Tune
|
||||||
|
|
@ -337,13 +345,24 @@ fun TokenBadge(used: Int, contextLength: Int, modifier: Modifier = Modifier) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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 cs = MaterialTheme.colorScheme
|
||||||
val isDark = isSystemInDarkTheme()
|
val isDark = isSystemInDarkTheme()
|
||||||
val accent = LocalKaizenAccent.current
|
val accent = LocalKaizenAccent.current
|
||||||
val hasAttachments = message.attachments.isNotEmpty()
|
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) {
|
if (message.role == Role.User) {
|
||||||
|
Column(horizontalAlignment = Alignment.End, modifier = Modifier.fillMaxWidth()) {
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||||
val shape = KaizenShapes.lg
|
val shape = KaizenShapes.lg
|
||||||
val bgBrush = remember(accent, isDark) {
|
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 {
|
} else {
|
||||||
|
Column {
|
||||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
|
||||||
KaizenOrb(28.dp, streaming = message.streaming)
|
KaizenOrb(28.dp, streaming = message.streaming)
|
||||||
Spacer(Modifier.width(10.dp))
|
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),
|
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
|
// 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. */
|
/** POST /api/v1/conversations/[id]/title — fire-and-forget auto-title after the first exchange. */
|
||||||
suspend fun generateTitle(baseUrl: String, token: String, id: String) =
|
suspend fun generateTitle(baseUrl: String, token: String, id: String) =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue