feat: sidebar redesign — glasier look, functional search, generate title, duplicate

Design: lower tintAlpha (0.80/0.86) for more blob bleed-through, accent-tinted
gradient borders + level1 shadow on active items, specular borders on search bar
and user card, accent gradient avatar, separator line in context menu.

Features: live search filter, "Titel generieren" (Sparkles, manual POST with
{ manual: true }), "Duplizieren" (copies all messages with new IDs + parentId
chain), titleGenerated field on ConversationSummary/Entity (Room v4).
This commit is contained in:
Bruno Deanoz 2026-06-23 00:56:58 +02:00
parent 2f9ada646c
commit 558b1be675
8 changed files with 175 additions and 38 deletions

View file

@ -681,11 +681,9 @@ fun ChatScreen(
}
is SidebarAction.ToggleLock -> {
if (action.locked) {
// Locking — no auth needed
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("locked" to true))
refreshConversations()
} else {
// Unlocking — require biometric or password
val summary = conversations.find { it.id == action.id }
if (summary != null) {
requestBiometricOrPassword(summary, forToggle = true) {
@ -697,6 +695,40 @@ fun ChatScreen(
}
}
}
is SidebarAction.GenerateTitle -> {
val title = KaizenApi.generateTitle(cfg.baseUrl, cfg.token, action.id, manual = true)
if (title != null) {
chat.conversationRepo.updateTitle(action.id, title)
refreshConversations()
}
}
is SidebarAction.Duplicate -> {
val srcMessages = KaizenApi.fetchMessages(cfg.baseUrl, cfg.token, action.id)
if (srcMessages.isNotEmpty()) {
val srcTitle = conversations.find { it.id == action.id }?.title ?: ""
val newId = KaizenApi.createConversation(cfg.baseUrl, cfg.token, "$srcTitle (2)")
if (newId != null) {
val saveMessages = srcMessages.mapIndexed { i, m ->
dev.kaizen.app.net.SaveMessage(
id = java.util.UUID.randomUUID().toString(),
parentId = if (i == 0) null else null,
role = m.role,
content = m.content,
model = m.model,
attachments = m.attachments.takeIf { it.isNotEmpty() },
reasoning = m.reasoning,
sources = m.sources,
)
}
// Build parentId chain
val chained = saveMessages.mapIndexed { i, m ->
m.copy(parentId = if (i == 0) null else saveMessages[i - 1].id)
}
KaizenApi.saveMessages(cfg.baseUrl, cfg.token, newId, chained)
refreshConversations()
}
}
}
}
}
},

View file

@ -36,6 +36,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalFocusManager
@ -48,8 +49,9 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.kaizen.app.net.ConversationSummary
import dev.kaizen.app.ui.effect.GlassSurface
import dev.kaizen.app.ui.effect.GlassTiers
import dev.kaizen.app.ui.effect.KaizenShadows
import dev.kaizen.app.ui.effect.kaizenShadow
import dev.kaizen.app.ui.effect.innerTopHighlight
import dev.kaizen.app.ui.shape.KaizenShapes
import dev.kaizen.app.R
import dev.kaizen.app.ui.theme.LocalKaizenAccent
@ -88,6 +90,8 @@ sealed interface SidebarAction {
data class Delete(val id: String) : SidebarAction
data class TogglePin(val id: String, val pinned: Boolean) : SidebarAction
data class ToggleLock(val id: String, val locked: Boolean) : SidebarAction
data class GenerateTitle(val id: String) : SidebarAction
data class Duplicate(val id: String) : SidebarAction
}
@Composable
@ -105,10 +109,12 @@ fun KaizenSidebar(
) {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
val accent = LocalKaizenAccent.current
var deleteTarget by remember { mutableStateOf<ConversationSummary?>(null) }
var renameTarget by remember { mutableStateOf<ConversationSummary?>(null) }
var renameText by remember { mutableStateOf("") }
var searchQuery by remember { mutableStateOf("") }
GlassSurface(
modifier = modifier
@ -116,7 +122,7 @@ fun KaizenSidebar(
.width(300.dp)
.padding(start = 12.dp, top = 12.dp, end = 4.dp, bottom = 12.dp),
shape = KaizenShapes.lg,
tintAlpha = if (isDark) 0.88f else 0.92f,
tintAlpha = if (isDark) 0.80f else 0.86f,
shadowStack = KaizenShadows.level3,
cornerRadius = 24.dp,
) {
@ -126,7 +132,7 @@ fun KaizenSidebar(
.statusBarsPadding()
.padding(16.dp)
) {
// Header
// ── Header ──
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@ -152,25 +158,70 @@ fun KaizenSidebar(
Spacer(Modifier.height(14.dp))
// Search
// ── Search (functional) ──
val searchBorder = remember(isDark) {
if (isDark) Brush.verticalGradient(
listOf(Color.White.copy(alpha = 0.10f), Color.White.copy(alpha = 0.03f))
) else Brush.verticalGradient(
listOf(Color.White.copy(alpha = 0.50f), Color.Black.copy(alpha = 0.04f))
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.clip(KaizenShapes.pill)
.background(if (isDark) Color(0x0DFFFFFF) else Color(0x08000000))
.border(0.8.dp, searchBorder, KaizenShapes.pill)
.background(if (isDark) Color(0x12FFFFFF) else Color(0x08000000))
.padding(horizontal = 14.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(KaizenIcons.Search, null, tint = cs.onSurfaceVariant.copy(alpha = 0.45f), modifier = Modifier.size(16.dp))
Icon(KaizenIcons.Search, null, tint = cs.onSurfaceVariant.copy(alpha = 0.40f), modifier = Modifier.size(15.dp))
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.chat_search), color = cs.onSurfaceVariant.copy(alpha = 0.45f), fontSize = 13.sp)
BasicTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
singleLine = true,
textStyle = TextStyle(
color = cs.onBackground,
fontSize = 13.sp,
),
cursorBrush = SolidColor(cs.onSurfaceVariant),
decorationBox = { innerTextField ->
Box {
if (searchQuery.isEmpty()) {
Text(
stringResource(R.string.chat_search),
color = cs.onSurfaceVariant.copy(alpha = 0.40f),
fontSize = 13.sp,
)
}
innerTextField()
}
},
modifier = Modifier.weight(1f),
)
if (searchQuery.isNotEmpty()) {
Icon(
KaizenIcons.X,
null,
tint = cs.onSurfaceVariant.copy(alpha = 0.40f),
modifier = Modifier
.size(14.dp)
.clickable { searchQuery = "" },
)
}
}
Spacer(Modifier.height(18.dp))
// Conversation list
// ── Conversation list ──
val filteredConversations = remember(conversations, searchQuery) {
if (searchQuery.isBlank()) conversations
else conversations.filter { it.title.contains(searchQuery, ignoreCase = true) }
}
Box(Modifier.weight(1f).fillMaxWidth()) {
if (conversations.isEmpty()) {
if (filteredConversations.isEmpty()) {
Text(
stringResource(R.string.chat_no_conversations),
color = cs.onSurfaceVariant.copy(alpha = 0.4f),
@ -184,7 +235,7 @@ fun KaizenSidebar(
earlier = stringResource(R.string.sidebar_earlier),
pinned = stringResource(R.string.sidebar_pinned),
)
val grouped = remember(conversations, labels) { groupConversations(conversations, labels) }
val grouped = remember(filteredConversations, labels) { groupConversations(filteredConversations, labels) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(2.dp)
@ -193,9 +244,10 @@ fun KaizenSidebar(
item(key = "header_$dateGroup") {
Text(
dateGroup,
color = cs.onSurfaceVariant.copy(alpha = 0.4f),
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
color = cs.onSurfaceVariant.copy(alpha = 0.35f),
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.sp,
modifier = Modifier.padding(start = 8.dp, top = 14.dp, bottom = 6.dp)
)
}
@ -211,6 +263,8 @@ fun KaizenSidebar(
onDelete = { deleteTarget = item },
onTogglePin = { onAction(SidebarAction.TogglePin(item.id, !item.pinned)) },
onToggleLock = { onAction(SidebarAction.ToggleLock(item.id, !item.locked)) },
onGenerateTitle = { onAction(SidebarAction.GenerateTitle(item.id)) },
onDuplicate = { onAction(SidebarAction.Duplicate(item.id)) },
)
}
}
@ -220,12 +274,22 @@ fun KaizenSidebar(
Spacer(Modifier.height(12.dp))
// User card — compact footer with settings + logout
// ── User card ──
val cardBorder = remember(isDark) {
if (isDark) Brush.verticalGradient(
listOf(Color.White.copy(alpha = 0.12f), Color.White.copy(alpha = 0.03f))
) else Brush.verticalGradient(
listOf(Color.White.copy(alpha = 0.50f), Color.Black.copy(alpha = 0.04f))
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.clip(KaizenShapes.md)
.background(if (isDark) Color(0x0DFFFFFF) else Color(0x06000000))
.kaizenShadow(stack = KaizenShadows.level1, cornerRadius = 12.dp)
.background(if (isDark) Color(0x14FFFFFF) else Color(0x0A000000))
.border(0.8.dp, cardBorder, KaizenShapes.md)
.innerTopHighlight()
.clickable { onOpenSettings() }
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
@ -234,7 +298,11 @@ fun KaizenSidebar(
modifier = Modifier
.size(30.dp)
.clip(KaizenShapes.circle)
.background(if (isDark) Color(0xFF1E293B) else Color(0xFF0F172A)),
.background(
Brush.verticalGradient(
listOf(accent.primary.copy(alpha = 0.8f), accent.primary.copy(alpha = 0.6f))
)
),
contentAlignment = Alignment.Center
) {
Text(
@ -253,8 +321,8 @@ fun KaizenSidebar(
Icon(
KaizenIcons.Settings,
stringResource(R.string.sidebar_settings),
tint = cs.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(18.dp),
tint = cs.onSurfaceVariant.copy(alpha = 0.45f),
modifier = Modifier.size(17.dp),
)
Spacer(Modifier.width(6.dp))
Box(
@ -267,8 +335,8 @@ fun KaizenSidebar(
Icon(
KaizenIcons.LogOut,
stringResource(R.string.sidebar_logout),
tint = cs.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(16.dp),
tint = cs.onSurfaceVariant.copy(alpha = 0.45f),
modifier = Modifier.size(15.dp),
)
}
}
@ -356,6 +424,8 @@ private fun HistoryItemRow(
onDelete: () -> Unit,
onTogglePin: () -> Unit,
onToggleLock: () -> Unit,
onGenerateTitle: () -> Unit,
onDuplicate: () -> Unit,
) {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
@ -363,17 +433,31 @@ private fun HistoryItemRow(
var showMenu by remember { mutableStateOf(false) }
val itemBg = when {
selected -> accent.primary.copy(alpha = if (isDark) 0.14f else 0.10f)
else -> Color.Transparent
val itemBorder = if (selected) {
remember(isDark, accent) {
if (isDark) Brush.verticalGradient(
listOf(accent.primary.copy(alpha = 0.25f), accent.primary.copy(alpha = 0.06f))
) else Brush.verticalGradient(
listOf(accent.primary.copy(alpha = 0.20f), accent.primary.copy(alpha = 0.05f))
)
}
} else null
Box {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(KaizenShapes.sm)
.background(itemBg)
.then(
if (selected) {
Modifier
.kaizenShadow(stack = KaizenShadows.level1, cornerRadius = 10.dp)
.background(accent.primary.copy(alpha = if (isDark) 0.12f else 0.08f))
.border(0.8.dp, itemBorder!!, KaizenShapes.sm)
} else {
Modifier
}
)
.clickable { onClick() }
.padding(start = 10.dp, end = 4.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically
@ -384,8 +468,8 @@ private fun HistoryItemRow(
}
Text(
text = if (item.locked) stringResource(R.string.chat_locked) else item.title,
color = if (item.locked) cs.onSurfaceVariant.copy(alpha = 0.45f) else cs.onBackground.copy(alpha = 0.85f),
fontSize = 13.5.sp,
color = if (item.locked) cs.onSurfaceVariant.copy(alpha = 0.45f) else cs.onBackground.copy(alpha = if (selected) 0.95f else 0.80f),
fontSize = 13.sp,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
fontStyle = if (item.locked) FontStyle.Italic else FontStyle.Normal,
maxLines = 1,
@ -406,8 +490,8 @@ private fun HistoryItemRow(
Icon(
KaizenIcons.MoreVertical,
stringResource(R.string.sidebar_more),
tint = cs.onSurfaceVariant.copy(alpha = 0.35f),
modifier = Modifier.size(16.dp),
tint = cs.onSurfaceVariant.copy(alpha = 0.30f),
modifier = Modifier.size(15.dp),
)
}
}
@ -416,18 +500,30 @@ private fun HistoryItemRow(
expanded = showMenu,
onDismissRequest = { showMenu = false },
containerColor = if (isDark) Color(0xFF1A2030) else Color(0xFFF8F9FB),
shadowElevation = 12.dp,
shadowElevation = 16.dp,
shape = KaizenShapes.md,
) {
if (!item.locked) {
MenuRow(KaizenIcons.Pencil, stringResource(R.string.sidebar_rename), cs.onBackground) { showMenu = false; onRename() }
}
if (!item.titleGenerated) {
MenuRow(KaizenIcons.Sparkles, stringResource(R.string.sidebar_generate_title), cs.onBackground) { showMenu = false; onGenerateTitle() }
}
MenuRow(KaizenIcons.Pin, stringResource(if (item.pinned) R.string.sidebar_unpin else R.string.sidebar_pin), cs.onBackground) { showMenu = false; onTogglePin() }
MenuRow(KaizenIcons.Copy, stringResource(R.string.sidebar_duplicate), cs.onBackground) { showMenu = false; onDuplicate() }
MenuRow(
if (item.locked) KaizenIcons.LockOpen else KaizenIcons.Lock,
stringResource(if (item.locked) R.string.sidebar_unlock else R.string.sidebar_lock),
cs.onBackground,
) { showMenu = false; onToggleLock() }
// Separator
Box(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.height(0.5.dp)
.background(if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f))
)
MenuRow(KaizenIcons.Trash, stringResource(R.string.sidebar_delete), Color(0xFFEF4444)) { showMenu = false; onDelete() }
}
}
@ -439,11 +535,11 @@ private fun MenuRow(icon: ImageVector, label: String, tint: Color, onClick: () -
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
.padding(horizontal = 16.dp, vertical = 11.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, null, tint = tint.copy(alpha = 0.7f), modifier = Modifier.size(18.dp))
Icon(icon, null, tint = tint.copy(alpha = 0.6f), modifier = Modifier.size(16.dp))
Spacer(Modifier.width(12.dp))
Text(label, color = tint, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text(label, color = tint.copy(alpha = 0.9f), fontSize = 13.5.sp, fontWeight = FontWeight.Medium)
}
}

View file

@ -15,6 +15,7 @@ data class ConversationEntity(
val pinned: Boolean,
val updatedAt: String?,
val cachedAt: Long,
val titleGenerated: Boolean = false,
)
@Entity(

View file

@ -8,7 +8,7 @@ import androidx.room.TypeConverters
@Database(
entities = [ConversationEntity::class, MessageEntity::class],
version = 3,
version = 4,
exportSchema = false,
)
@TypeConverters(Converters::class)

View file

@ -10,6 +10,7 @@ fun ConversationSummary.toEntity() = ConversationEntity(
pinned = pinned,
updatedAt = updatedAt,
cachedAt = System.currentTimeMillis(),
titleGenerated = titleGenerated,
)
fun ConversationEntity.toSummary() = ConversationSummary(
@ -18,6 +19,7 @@ fun ConversationEntity.toSummary() = ConversationSummary(
locked = locked,
pinned = pinned,
updatedAt = updatedAt,
titleGenerated = titleGenerated,
)
fun StoredMessage.toEntity(conversationId: String, sortOrder: Int) = MessageEntity(

View file

@ -61,6 +61,7 @@ data class ConversationSummary(
val locked: Boolean = false,
val pinned: Boolean = false,
val updatedAt: String? = null,
val titleGenerated: Boolean = false,
)
@Serializable private data class ConversationsResponse(val conversations: List<ConversationSummary> = emptyList())
@ -446,11 +447,12 @@ object KaizenApi {
}
/** POST /api/v1/conversations/[id]/title — returns the generated title or null. */
suspend fun generateTitle(baseUrl: String, token: String, id: String): String? =
suspend fun generateTitle(baseUrl: String, token: String, id: String, manual: Boolean = false): String? =
withContext(Dispatchers.IO) {
val body = if (manual) """{"manual":true}""" else "{}"
val req = Request.Builder()
.url("$baseUrl/api/v1/conversations/$id/title")
.post("{}".toRequestBody(jsonMedia))
.post(body.toRequestBody(jsonMedia))
.header("Authorization", "Bearer $token")
.build()
try {

View file

@ -74,6 +74,8 @@
<string name="sidebar_lock">Lock</string>
<string name="sidebar_unlock">Unlock</string>
<string name="sidebar_more">More</string>
<string name="sidebar_generate_title">Generate Title</string>
<string name="sidebar_duplicate">Duplicate</string>
<string name="sidebar_rename_title">Rename Chat</string>
<string name="sidebar_delete_confirm">Permanently delete this chat?</string>
<string name="sidebar_cancel">Cancel</string>

View file

@ -76,6 +76,8 @@
<string name="sidebar_lock">Sperren</string>
<string name="sidebar_unlock">Entsperren</string>
<string name="sidebar_more">Mehr</string>
<string name="sidebar_generate_title">Titel generieren</string>
<string name="sidebar_duplicate">Duplizieren</string>
<string name="sidebar_rename_title">Chat umbenennen</string>
<string name="sidebar_delete_confirm">Chat endgültig löschen?</string>
<string name="sidebar_cancel">Abbrechen</string>