diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt index 465647b..45ec5e8 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt @@ -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)) } } diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt index 886badc..7eca559 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -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 diff --git a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt index 224ceb2..8a34ed5 100644 --- a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt +++ b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt @@ -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) {