diff --git a/CLAUDE.md b/CLAUDE.md index 9450ffa..00902df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -280,7 +280,7 @@ All committed on `main`. Compile-verified on Mac (`./gradlew :app:compileDebugKo | `fb6bc98` | **Web search provider picker** — for Vertex models: Google vs Enterprise Web Search dropdown pill, `webSearchProvider` sent in chat request | | `1899f2c` | **Voice message display** — audio-only user messages render as compact "🎤 Sprachnachricht" pill instead of ugly filename chip | | `44ff82a` | **Voice message ordering fix** — caches user + assistant messages in Room with correct `sortOrder` after server save (was missing, caused wrong order on reload) | -| `8f72afd` | **Edit and resend user messages** — SquarePen icon on user messages, removes message + all after it, puts text back in input field | +| `8f72afd` | **Edit and resend user messages** — SquarePen icon on user messages, creates branch (old messages preserved), text back in input field | | `b2df7e9` | **Modern message action icons** — ClipboardCopy (copy), SquarePen (edit), RotateCcw (regenerate), 36dp touch target, 20dp icons | | `421b29e` | **ReasoningBlock + SourceCards redesign** — accent-tinted background + gradient borders, KaizenShapes.md (16dp radius), domain instead of full URL in source cards | | `75ca8c0` | **Visual refresh** — body text W400→W300 (airier), headings scaled up (H1 26sp, H2 22sp, H3 18sp), `animateItem()` on messages (350ms fade-in, spring placement), blobs 20% larger + better distribution, higher alpha on OLED, light-mode factor 0.65→0.78, distinct `primaryDeep` per theme | @@ -292,6 +292,7 @@ All committed on `main`. Compile-verified on Mac (`./gradlew :app:compileDebugKo | `1c9491e` | **Text selection** — `SelectionContainer` on user + assistant messages. Long-press to select, copy, share | | `1c9491e` | **Markdown spacing** — heading spacing 18→24/20dp, list items 8dp gap + muted bullets, blockquote 12dp inset | | `430d5b6` | **Flat sidebar user row** — removed heavy card (background, border, shadow), replaced with flat row + thin separator | +| — | **Branch navigation (tree support)** — `ChatTree` data structure mirrors web frontend's tree. Edit creates a branch (sibling node with same parentId) instead of deleting messages. Regenerate creates a new assistant branch. `BranchNavigator` UI (chevron arrows + "1/3" counter) on messages with siblings. `activeRootId`/`activeChild` synced to server via PATCH. `StoredMessage.parentId` parsed from API. `patchConversation` handles JSON object values for `activeChild` | **Earlier UI/feel work (Phase 0 prototype → feel-first):** - Liquid glass styling, floating panels, glassmorphic sidebar 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 9386fd0..61b0354 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt @@ -442,6 +442,49 @@ fun TokenBadge(used: Int, contextLength: Int, modifier: Modifier = Modifier) { } } +@Composable +fun BranchNavigator( + branchInfo: BranchInfo, + onNavigate: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val cs = MaterialTheme.colorScheme + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + Box( + Modifier.size(28.dp).clip(KaizenShapes.circle) + .clickable(enabled = branchInfo.index > 0) { onNavigate(-1) }, + contentAlignment = Alignment.Center, + ) { + Icon( + KaizenIcons.ChevronLeft, null, + tint = cs.onSurfaceVariant.copy(alpha = if (branchInfo.index > 0) 0.65f else 0.20f), + modifier = Modifier.size(16.dp), + ) + } + Text( + "${branchInfo.index + 1}/${branchInfo.total}", + color = cs.onSurfaceVariant.copy(alpha = 0.55f), + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + Box( + Modifier.size(28.dp).clip(KaizenShapes.circle) + .clickable(enabled = branchInfo.index < branchInfo.total - 1) { onNavigate(1) }, + contentAlignment = Alignment.Center, + ) { + Icon( + KaizenIcons.ChevronRight, null, + tint = cs.onSurfaceVariant.copy(alpha = if (branchInfo.index < branchInfo.total - 1) 0.65f else 0.20f), + modifier = Modifier.size(16.dp), + ) + } + } +} + @Composable fun MessageRow( message: Message, @@ -451,6 +494,8 @@ fun MessageRow( onEdit: ((String, String) -> Unit)? = null, onReadAloud: ((String) -> Unit)? = null, isPlayingTts: Boolean = false, + branchInfo: BranchInfo? = null, + onNavigateBranch: ((String, Int) -> Unit)? = null, ) { val cs = MaterialTheme.colorScheme val isDark = isSystemInDarkTheme() @@ -535,7 +580,12 @@ fun MessageRow( Row( Modifier.padding(top = 6.dp, end = 4.dp), horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, ) { + if (branchInfo != null && onNavigateBranch != null) { + BranchNavigator(branchInfo, onNavigate = { dir -> onNavigateBranch(message.wireId, dir) }) + Spacer(Modifier.width(4.dp)) + } MessageActionButton( icon = if (copied) KaizenIcons.Check else KaizenIcons.ClipboardCopy, tint = if (copied) KaizenSemanticColors.success else cs.onSurfaceVariant.copy(alpha = 0.55f), @@ -597,7 +647,12 @@ fun MessageRow( Row( Modifier.padding(start = 38.dp, top = 6.dp), horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, ) { + if (branchInfo != null && onNavigateBranch != null) { + BranchNavigator(branchInfo, onNavigate = { dir -> onNavigateBranch(message.wireId, dir) }) + Spacer(Modifier.width(4.dp)) + } MessageActionButton( icon = if (copied) KaizenIcons.Check else KaizenIcons.ClipboardCopy, tint = if (copied) KaizenSemanticColors.success else cs.onSurfaceVariant.copy(alpha = 0.55f), diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt b/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt index 6251b46..b51ecbc 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt @@ -42,6 +42,7 @@ data class Message( val tools: List = emptyList(), val query: String = "", val sources: List = emptyList(), + val branchInfo: BranchInfo? = null, ) data class Suggestion(val labelRes: Int, val promptRes: Int, val icon: ImageVector) 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 03eedf3..7790a00 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -123,6 +123,31 @@ fun ChatScreen( var chatModel by remember { mutableStateOf(null) } var streamJob by remember { mutableStateOf(null) } + // Tree state for branch navigation + var chatTree by remember { mutableStateOf(ChatTree.EMPTY) } + val storedMessagesMap = remember { mutableMapOf() } + var editParentId by remember { mutableStateOf(null) } + + fun rebuildVisibleMessages() { + val path = getVisiblePath(chatTree) + messages.clear() + for (entry in path) { + val sm = storedMessagesMap[entry.messageId] ?: continue + messages.add( + Message( + id = nextId++, + role = if (sm.role == "assistant") Role.Assistant else Role.User, + content = sm.content, + wireId = sm.id, + attachments = sm.attachments, + reasoning = sm.reasoning ?: "", + sources = sm.sources ?: emptyList(), + branchInfo = entry.branchInfo, + ), + ) + } + } + // Audio recording state var isRecording by remember { mutableStateOf(false) } val recorderRef = remember { mutableStateOf(null) } @@ -309,6 +334,17 @@ fun ChatScreen( sources = finalSources?.map { dev.kaizen.app.net.SearchSource(it.title, it.url, it.snippet) } ?: emptyList(), ), )) + storedMessagesMap[userMsg.wireId] = dev.kaizen.app.net.StoredMessage( + id = userMsg.wireId, role = "user", content = "", + parentId = userParentId, attachments = listOf(att), + ) + storedMessagesMap[assistantWireId] = dev.kaizen.app.net.StoredMessage( + id = assistantWireId, role = "assistant", content = finalContent, + parentId = userMsg.wireId, model = cfg.model, + reasoning = finalReasoning, + sources = finalSources?.map { dev.kaizen.app.net.SearchSource(it.title, it.url, it.snippet) }, + ) + chatTree = buildChatTree(storedMessagesMap.values.toList(), chatTree.activeRootId, chatTree.activeChild) } if (convIdDeferred != null) { chat.conversationRepo.insertOptimistic(cid, "(Voice)") @@ -443,6 +479,8 @@ fun ChatScreen( viewingLockedChat = false usedTokens = 0 chatModel = null + chatTree = ChatTree.EMPTY + storedMessagesMap.clear() } fun unlockAndOpen(summary: ConversationSummary, password: String? = null) { @@ -462,22 +500,12 @@ fun ChatScreen( passwordUnlockTarget = null passwordUnlockError = null passwordUnlockForToggle = false - val stored = KaizenApi.fetchLockedMessages(cfg.baseUrl, cfg.token, summary.id, unlockToken) - if (stored.isNotEmpty()) { - messages.clear() - stored.forEach { m -> - messages.add( - Message( - id = nextId++, - role = if (m.role == "assistant") Role.Assistant else Role.User, - content = m.content, - wireId = m.id, - attachments = m.attachments, - reasoning = m.reasoning ?: "", - sources = m.sources ?: emptyList(), - ), - ) - } + val data = KaizenApi.fetchLockedConversationData(cfg.baseUrl, cfg.token, summary.id, unlockToken) + if (data.messages.isNotEmpty()) { + storedMessagesMap.clear() + data.messages.forEach { storedMessagesMap[it.id] = it } + chatTree = buildChatTree(data.messages, data.activeRootId, data.activeChild) + rebuildVisibleMessages() conversationId = summary.id viewingLockedChat = true } @@ -546,46 +574,36 @@ fun ChatScreen( ) } conversationId = summary.id - val stored = KaizenApi.fetchMessages(cfg.baseUrl, cfg.token, summary.id) - if (stored.isNotEmpty()) { - val entities = stored.mapIndexed { i, m -> m.toEntity(summary.id, i) } + val data = KaizenApi.fetchConversationData(cfg.baseUrl, cfg.token, summary.id) + if (data.messages.isNotEmpty()) { + val entities = data.messages.mapIndexed { i, m -> m.toEntity(summary.id, i) } chat.messageRepo.cacheMessages(summary.id, entities) - val lastAssistant = stored.lastOrNull { it.role == "assistant" } + val lastAssistant = data.messages.lastOrNull { it.role == "assistant" } lastAssistant?.model?.let { chatModel = it } - usedTokens = stored.sumOf { (it.inputTokens ?: 0) + (it.outputTokens ?: 0) } - if (stored.size != cachedList.size || stored.zip(cachedList).any { (s, c) -> s.id != c.id || s.content != c.content }) { - messages.clear() - stored.forEach { m -> - messages.add( - Message( - id = nextId++, - role = if (m.role == "assistant") Role.Assistant else Role.User, - content = m.content, - wireId = m.id, - attachments = m.attachments, - reasoning = m.reasoning ?: "", - sources = m.sources ?: emptyList(), - ), - ) - } - } + usedTokens = data.messages.sumOf { (it.inputTokens ?: 0) + (it.outputTokens ?: 0) } + + storedMessagesMap.clear() + data.messages.forEach { storedMessagesMap[it.id] = it } + chatTree = buildChatTree(data.messages, data.activeRootId, data.activeChild) + rebuildVisibleMessages() } } } - fun send(text: String) { + fun send(text: String, branchParentId: String? = null, branchAttachments: List = emptyList()) { val trimmed = text.trim() - val uploadedAtts = pendingFiles.mapNotNull { it.uploaded } + val uploadedAtts = if (branchAttachments.isNotEmpty()) branchAttachments else pendingFiles.mapNotNull { it.uploaded } if (trimmed.isEmpty() && uploadedAtts.isEmpty()) return if (isStreaming) return val cfg = session.config ?: return haptics.click() - val userParentId = messages.lastOrNull()?.wireId + val userParentId = editParentId ?: branchParentId ?: messages.lastOrNull()?.wireId + editParentId = null val userMsg = Message(nextId++, Role.User, trimmed, attachments = uploadedAtts) messages.add(userMsg) input = "" - pendingFiles.clear() + if (branchAttachments.isEmpty()) pendingFiles.clear() val assistantId = nextId++ val assistantWireId = java.util.UUID.randomUUID().toString() messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId)) @@ -681,6 +699,24 @@ fun ChatScreen( sources = finalSources ?: emptyList(), ), )) + + // Update tree with the new nodes + storedMessagesMap[userMsg.wireId] = dev.kaizen.app.net.StoredMessage( + id = userMsg.wireId, role = "user", content = trimmed, + parentId = userParentId, attachments = uploadedAtts, + ) + storedMessagesMap[assistantWireId] = dev.kaizen.app.net.StoredMessage( + id = assistantWireId, role = "assistant", content = finalContent, + parentId = userMsg.wireId, model = cfg.model, + reasoning = finalReasoning, + sources = finalSources?.map { dev.kaizen.app.net.SearchSource(it.title, it.url, it.snippet) }, + ) + chatTree = buildChatTree( + storedMessagesMap.values.toList(), + chatTree.activeRootId, + chatTree.activeChild, + ) + if (wasNewConversation) { val title = KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId) if (title != null) { @@ -888,24 +924,22 @@ fun ChatScreen( 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) + val userMsg = messages[lastUserIdx] + val parentId = chatTree.nodes[userMsg.wireId]?.parentId + val msgIdx = messages.indexOf(message) + if (msgIdx >= 0) { + messages.subList(msgIdx, messages.size).clear() + } + send(userMsg.content, branchParentId = parentId, branchAttachments = userMsg.attachments) } } else null, onEdit = if (message.role == Role.User && !isStreaming) { wireId, content -> + val node = chatTree.nodes[wireId] + val parentId = node?.parentId val idx = messages.indexOfFirst { it.wireId == wireId } if (idx >= 0) { - val cfg = session.config ?: return@MessageRow - val toRemove = messages.subList(idx, messages.size).map { it.wireId } - messages.removeAll { it.wireId in toRemove } - if (conversationId != null) { - scope.launch { - toRemove.forEach { id -> - KaizenApi.deleteMessage(cfg.baseUrl, cfg.token, conversationId!!, id) - } - } - } + messages.subList(idx, messages.size).clear() + editParentId = parentId input = content } } else null, @@ -913,6 +947,24 @@ fun ChatScreen( readAloud(content, message.id, message.wireId) } else null, isPlayingTts = playingTtsForId == message.id, + branchInfo = message.branchInfo, + onNavigateBranch = if (!isStreaming) { wireId, direction -> + val newTree = treeNavigate(chatTree, wireId, direction) + chatTree = newTree + rebuildVisibleMessages() + if (conversationId != null) { + val cfg = session.config ?: return@MessageRow + scope.launch { + KaizenApi.patchConversation( + cfg.baseUrl, cfg.token, conversationId!!, + mapOf( + "activeRootId" to newTree.activeRootId, + "activeChild" to newTree.activeChild, + ), + ) + } + } + } else null, ) } } diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatTree.kt b/app/src/main/java/dev/kaizen/app/chat/ChatTree.kt new file mode 100644 index 0000000..dc5a2bc --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/chat/ChatTree.kt @@ -0,0 +1,106 @@ +package dev.kaizen.app.chat + +import dev.kaizen.app.net.StoredMessage + +data class ChatNode( + val id: String, + val role: String, + val parentId: String?, + val childIds: List = emptyList(), +) + +data class BranchInfo(val index: Int, val total: Int) + +data class VisibleEntry( + val messageId: String, + val branchInfo: BranchInfo?, +) + +data class ChatTree( + val nodes: Map, + val rootIds: List, + val activeRootId: String?, + val activeChild: Map, +) { + companion object { + val EMPTY = ChatTree(emptyMap(), emptyList(), null, emptyMap()) + } +} + +fun buildChatTree( + messages: List, + savedActiveRootId: String?, + savedActiveChild: Map, +): ChatTree { + val nodes = mutableMapOf() + val rootIds = mutableListOf() + + for (msg in messages) { + nodes[msg.id] = ChatNode( + id = msg.id, + role = msg.role, + parentId = msg.parentId, + ) + } + + for (msg in messages) { + val pid = msg.parentId + if (pid != null && nodes.containsKey(pid)) { + val parent = nodes[pid]!! + nodes[pid] = parent.copy(childIds = parent.childIds + msg.id) + } else if (pid == null) { + rootIds.add(msg.id) + } + } + + val activeRootId = if (savedActiveRootId != null && nodes.containsKey(savedActiveRootId)) + savedActiveRootId + else rootIds.lastOrNull() + + return ChatTree(nodes, rootIds, activeRootId, savedActiveChild) +} + +fun getVisiblePath(tree: ChatTree): List { + if (tree.rootIds.isEmpty()) return emptyList() + val activeRootId = tree.activeRootId ?: tree.rootIds.last() + val result = mutableListOf() + var currentId: String? = activeRootId + + while (currentId != null) { + val node = tree.nodes[currentId] ?: break + val siblings = if (node.parentId == null) { + tree.rootIds + } else { + tree.nodes[node.parentId]?.childIds ?: listOf(node.id) + } + val branchInfo = if (siblings.size > 1) { + BranchInfo(index = siblings.indexOf(node.id), total = siblings.size) + } else null + + result.add(VisibleEntry(node.id, branchInfo)) + + if (node.childIds.isEmpty()) break + currentId = tree.activeChild[node.id] ?: node.childIds.last() + } + + return result +} + +fun treeNavigate(tree: ChatTree, nodeId: String, direction: Int): ChatTree { + val node = tree.nodes[nodeId] ?: return tree + val siblings = if (node.parentId == null) { + tree.rootIds + } else { + tree.nodes[node.parentId]?.childIds ?: return tree + } + val idx = siblings.indexOf(nodeId) + val newIdx = idx + direction + if (newIdx < 0 || newIdx >= siblings.size) return tree + val newActiveId = siblings[newIdx] + + return if (node.parentId == null) { + tree.copy(activeRootId = newActiveId) + } else { + tree.copy(activeChild = tree.activeChild + (node.parentId to newActiveId)) + } +} 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 8075a79..34a8266 100644 --- a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt +++ b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt @@ -99,6 +99,7 @@ data class StoredMessage( val id: String, val role: String, val content: String = "", + val parentId: String? = null, val attachments: List = emptyList(), val reasoning: String? = null, val sources: List? = null, @@ -107,7 +108,17 @@ data class StoredMessage( val outputTokens: Int? = null, ) -@Serializable private data class ConversationDetail(val messages: List = emptyList()) +@Serializable private data class ConversationDetail( + val messages: List = emptyList(), + val activeRootId: String? = null, + val activeChild: Map = emptyMap(), +) + +data class ConversationData( + val messages: List = emptyList(), + val activeRootId: String? = null, + val activeChild: Map = emptyMap(), +) /** A message to persist. `parentId` chains the linear history; `kind` is always "chat" for the app. */ @Serializable @@ -309,6 +320,9 @@ object KaizenApi { /** GET /api/v1/conversations/[id] — the stored messages, in order (empty on failure/locked). */ suspend fun fetchMessages(baseUrl: String, token: String, id: String): List = + fetchConversationData(baseUrl, token, id).messages + + suspend fun fetchConversationData(baseUrl: String, token: String, id: String): ConversationData = withContext(Dispatchers.IO) { val req = Request.Builder() .url("$baseUrl/api/v1/conversations/$id") @@ -318,13 +332,14 @@ object KaizenApi { client.newCall(req).execute().use { resp -> if (!resp.isSuccessful) { Log.w(TAG, "fetchMessages($id): HTTP ${resp.code}") - return@use emptyList() + return@use ConversationData() } - json.decodeFromString(resp.body!!.string()).messages + val detail = json.decodeFromString(resp.body!!.string()) + ConversationData(detail.messages, detail.activeRootId, detail.activeChild) } } catch (e: Exception) { Log.w(TAG, "fetchMessages($id): ${e.javaClass.simpleName}: ${e.message}") - emptyList() + ConversationData() } } @@ -348,17 +363,20 @@ object KaizenApi { } } - /** PATCH /api/v1/conversations/[id] — update title, locked, pinned. */ + /** PATCH /api/v1/conversations/[id] — update title, locked, pinned, activeRootId, activeChild. */ suspend fun patchConversation(baseUrl: String, token: String, id: String, body: Map): Boolean = withContext(Dispatchers.IO) { + fun toJsonElement(v: Any?): kotlinx.serialization.json.JsonElement = when (v) { + is String -> kotlinx.serialization.json.JsonPrimitive(v) + is Boolean -> kotlinx.serialization.json.JsonPrimitive(v) + is Map<*, *> -> kotlinx.serialization.json.JsonObject( + v.entries.associate { (k, v2) -> k.toString() to toJsonElement(v2) } + ) + null -> kotlinx.serialization.json.JsonNull + else -> kotlinx.serialization.json.JsonNull + } val payload = json.encodeToString(kotlinx.serialization.json.JsonObject( - body.mapValues { (_, v) -> - when (v) { - is String -> kotlinx.serialization.json.JsonPrimitive(v) - is Boolean -> kotlinx.serialization.json.JsonPrimitive(v) - else -> kotlinx.serialization.json.JsonNull - } - } + body.mapValues { (_, v) -> toJsonElement(v) } )).toRequestBody(jsonMedia) val req = Request.Builder() .url("$baseUrl/api/v1/conversations/$id") @@ -414,6 +432,9 @@ object KaizenApi { /** GET /api/v1/conversations/[id] with unlock token — fetch messages of a locked conversation. */ suspend fun fetchLockedMessages(baseUrl: String, token: String, id: String, unlockToken: String): List = + fetchLockedConversationData(baseUrl, token, id, unlockToken).messages + + suspend fun fetchLockedConversationData(baseUrl: String, token: String, id: String, unlockToken: String): ConversationData = withContext(Dispatchers.IO) { val req = Request.Builder() .url("$baseUrl/api/v1/conversations/$id") @@ -424,13 +445,14 @@ object KaizenApi { client.newCall(req).execute().use { resp -> if (!resp.isSuccessful) { Log.w(TAG, "fetchLockedMessages($id): HTTP ${resp.code}") - return@use emptyList() + return@use ConversationData() } - json.decodeFromString(resp.body!!.string()).messages + val detail = json.decodeFromString(resp.body!!.string()) + ConversationData(detail.messages, detail.activeRootId, detail.activeChild) } } catch (e: Exception) { Log.w(TAG, "fetchLockedMessages($id): ${e.javaClass.simpleName}: ${e.message}") - emptyList() + ConversationData() } } diff --git a/app/src/main/java/dev/kaizen/app/ui/icon/KaizenIcons.kt b/app/src/main/java/dev/kaizen/app/ui/icon/KaizenIcons.kt index fe96f6a..945facf 100644 --- a/app/src/main/java/dev/kaizen/app/ui/icon/KaizenIcons.kt +++ b/app/src/main/java/dev/kaizen/app/ui/icon/KaizenIcons.kt @@ -22,6 +22,8 @@ object KaizenIcons { // ── Navigation ────────────────────────────────────────────────────────── val ArrowLeft: ImageVector get() = Lucide.ArrowLeft val ChevronDown: ImageVector get() = Lucide.ChevronDown + val ChevronLeft: ImageVector get() = Lucide.ChevronLeft + val ChevronRight: ImageVector get() = Lucide.ChevronRight val ChevronUp: ImageVector get() = Lucide.ChevronUp val Menu: ImageVector get() = KaizenCustomIcons.Menu val MoreVertical: ImageVector get() = Lucide.EllipsisVertical