feat: branch navigation for edit/regenerate instead of destructive delete

Edit creates a sibling branch (same parentId) instead of deleting messages.
Regenerate creates a new assistant branch. BranchNavigator UI shows chevron
arrows with "1/3" counter on messages with siblings. Tree state (activeRootId,
activeChild) synced to server via PATCH. New ChatTree data structure mirrors
the web frontend's tree.ts. StoredMessage.parentId parsed from API response.
This commit is contained in:
Bruno Deanoz 2026-06-24 01:29:02 +02:00
parent 7ba42338c5
commit 287cdb4c6b
7 changed files with 309 additions and 70 deletions

View file

@ -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

View file

@ -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),

View file

@ -42,6 +42,7 @@ data class Message(
val tools: List<ToolEvent> = emptyList(),
val query: String = "",
val sources: List<SearchSource> = emptyList(),
val branchInfo: BranchInfo? = null,
)
data class Suggestion(val labelRes: Int, val promptRes: Int, val icon: ImageVector)

View file

@ -123,6 +123,31 @@ fun ChatScreen(
var chatModel by remember { mutableStateOf<String?>(null) }
var streamJob by remember { mutableStateOf<Job?>(null) }
// Tree state for branch navigation
var chatTree by remember { mutableStateOf(ChatTree.EMPTY) }
val storedMessagesMap = remember { mutableMapOf<String, dev.kaizen.app.net.StoredMessage>() }
var editParentId by remember { mutableStateOf<String?>(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<MediaRecorder?>(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<Attachment> = 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,
)
}
}

View file

@ -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<String> = 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<String, ChatNode>,
val rootIds: List<String>,
val activeRootId: String?,
val activeChild: Map<String, String>,
) {
companion object {
val EMPTY = ChatTree(emptyMap(), emptyList(), null, emptyMap())
}
}
fun buildChatTree(
messages: List<StoredMessage>,
savedActiveRootId: String?,
savedActiveChild: Map<String, String>,
): ChatTree {
val nodes = mutableMapOf<String, ChatNode>()
val rootIds = mutableListOf<String>()
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<VisibleEntry> {
if (tree.rootIds.isEmpty()) return emptyList()
val activeRootId = tree.activeRootId ?: tree.rootIds.last()
val result = mutableListOf<VisibleEntry>()
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))
}
}

View file

@ -99,6 +99,7 @@ data class StoredMessage(
val id: String,
val role: String,
val content: String = "",
val parentId: String? = null,
val attachments: List<Attachment> = emptyList(),
val reasoning: String? = null,
val sources: List<SearchSource>? = null,
@ -107,7 +108,17 @@ data class StoredMessage(
val outputTokens: Int? = null,
)
@Serializable private data class ConversationDetail(val messages: List<StoredMessage> = emptyList())
@Serializable private data class ConversationDetail(
val messages: List<StoredMessage> = emptyList(),
val activeRootId: String? = null,
val activeChild: Map<String, String> = emptyMap(),
)
data class ConversationData(
val messages: List<StoredMessage> = emptyList(),
val activeRootId: String? = null,
val activeChild: Map<String, String> = 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<StoredMessage> =
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<ConversationDetail>(resp.body!!.string()).messages
val detail = json.decodeFromString<ConversationDetail>(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<String, Any?>): 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<StoredMessage> =
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<ConversationDetail>(resp.body!!.string()).messages
val detail = json.decodeFromString<ConversationDetail>(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()
}
}

View file

@ -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