sidebar redesign + markdown typography overhaul

Sidebar: increase glass opacity (0.92/0.88), flat conversation rows
instead of pill shapes, 3-dot context menu (rename/delete/pin/lock),
smaller profile avatar (30dp), logout integrated into user card.
Add KaizenApi.patchConversation + deleteConversation for CRUD.

Markdown: increase line-height from 23sp to 26sp (×1.625 ratio),
double paragraph spacing (6dp→12dp), generous heading margins
(18dp top, 8dp bottom), wider list item spacing — matches Gemini-
level readability.
This commit is contained in:
Bruno Deanoz 2026-06-21 23:31:03 +02:00
parent 3986443a56
commit f3d8154052
7 changed files with 371 additions and 97 deletions

View file

@ -409,7 +409,32 @@ fun ChatScreen(
newChat() newChat()
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
session.logout() session.logout()
},
onAction = { action ->
val cfg = session.config ?: return@KaizenSidebar
haptics.tick()
scope.launch {
when (action) {
is SidebarAction.Rename -> {
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("title" to action.newTitle))
refreshConversations()
} }
is SidebarAction.Delete -> {
KaizenApi.deleteConversation(cfg.baseUrl, cfg.token, action.id)
if (conversationId == action.id) newChat()
refreshConversations()
}
is SidebarAction.TogglePin -> {
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("pinned" to action.pinned))
refreshConversations()
}
is SidebarAction.ToggleLock -> {
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("locked" to action.locked))
refreshConversations()
}
}
}
},
) )
}, },
gesturesEnabled = true // Allows swiping from the screen's left edge to open the drawer gesturesEnabled = true // Allows swiping from the screen's left edge to open the drawer

View file

@ -363,20 +363,20 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
val isLast = idx == blocks.lastIndex val isLast = idx == blocks.lastIndex
when (block) { when (block) {
is MdBlock.Heading -> { is MdBlock.Heading -> {
if (idx > 0) Spacer(Modifier.height(8.dp)) if (idx > 0) Spacer(Modifier.height(18.dp))
val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg) val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg)
Text( Text(
text = rendered, text = rendered,
color = cs.onBackground, color = cs.onBackground,
fontSize = when (block.level) { 1 -> 22.sp; 2 -> 19.sp; else -> 17.sp }, fontSize = when (block.level) { 1 -> 22.sp; 2 -> 19.sp; else -> 17.sp },
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
lineHeight = when (block.level) { 1 -> 28.sp; 2 -> 25.sp; else -> 23.sp }, lineHeight = when (block.level) { 1 -> 30.sp; 2 -> 27.sp; else -> 24.sp },
) )
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(8.dp))
} }
is MdBlock.CodeFence -> { is MdBlock.CodeFence -> {
if (idx > 0) Spacer(Modifier.height(6.dp)) if (idx > 0) Spacer(Modifier.height(12.dp))
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@ -416,13 +416,13 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
} }
} }
} }
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(12.dp))
} }
is MdBlock.ListBlock -> { is MdBlock.ListBlock -> {
if (idx > 0) Spacer(Modifier.height(4.dp)) if (idx > 0) Spacer(Modifier.height(8.dp))
block.items.forEachIndexed { itemIdx, item -> block.items.forEachIndexed { itemIdx, item ->
Row(Modifier.fillMaxWidth().padding(start = 4.dp, bottom = 3.dp)) { Row(Modifier.fillMaxWidth().padding(start = 6.dp, bottom = 6.dp)) {
val bullet = if (item.ordered) "${item.index}." else "" val bullet = if (item.ordered) "${item.index}." else ""
Text( Text(
bullet, bullet,
@ -435,27 +435,27 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
text = inlineMarkdown(item.text + if (appendCursor) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg), text = inlineMarkdown(item.text + if (appendCursor) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg),
color = cs.onBackground, color = cs.onBackground,
fontSize = 16.sp, fontSize = 16.sp,
lineHeight = 23.sp, lineHeight = 26.sp,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(8.dp))
} }
is MdBlock.HorizontalRule -> { is MdBlock.HorizontalRule -> {
if (idx > 0) Spacer(Modifier.height(8.dp)) if (idx > 0) Spacer(Modifier.height(14.dp))
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(1.dp) .height(1.dp)
.background(cs.onSurfaceVariant.copy(alpha = 0.25f)) .background(cs.onSurfaceVariant.copy(alpha = 0.20f))
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(14.dp))
} }
is MdBlock.Blockquote -> { is MdBlock.Blockquote -> {
if (idx > 0) Spacer(Modifier.height(6.dp)) if (idx > 0) Spacer(Modifier.height(12.dp))
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@ -474,21 +474,21 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
text = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onSurfaceVariant, inlineCodeColor, inlineCodeBg), text = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onSurfaceVariant, inlineCodeColor, inlineCodeBg),
color = cs.onSurfaceVariant, color = cs.onSurfaceVariant,
fontSize = 15.sp, fontSize = 15.sp,
lineHeight = 22.sp, lineHeight = 24.sp,
fontStyle = FontStyle.Italic, fontStyle = FontStyle.Italic,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(12.dp))
} }
is MdBlock.Paragraph -> { is MdBlock.Paragraph -> {
if (idx > 0) Spacer(Modifier.height(6.dp)) if (idx > 0) Spacer(Modifier.height(12.dp))
Text( Text(
text = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg), text = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg),
color = cs.onBackground, color = cs.onBackground,
fontSize = 16.sp, fontSize = 16.sp,
lineHeight = 23.sp, lineHeight = 26.sp,
) )
} }

View file

@ -19,25 +19,43 @@ import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Logout import androidx.compose.material.icons.automirrored.rounded.Logout
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Lock import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material.icons.rounded.LockOpen
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.PushPin
import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Star import androidx.compose.material.icons.rounded.Star
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import dev.kaizen.app.net.ConversationSummary import dev.kaizen.app.net.ConversationSummary
@ -76,6 +94,13 @@ private fun groupConversations(conversations: List<ConversationSummary>, labels:
return groups return groups
} }
sealed interface SidebarAction {
data class Rename(val id: String, val newTitle: String) : 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
}
@Composable @Composable
fun KaizenSidebar( fun KaizenSidebar(
userName: String, userName: String,
@ -86,18 +111,23 @@ fun KaizenSidebar(
onSelectConversation: (ConversationSummary) -> Unit, onSelectConversation: (ConversationSummary) -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onAction: (SidebarAction) -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
var deleteTarget by remember { mutableStateOf<ConversationSummary?>(null) }
var renameTarget by remember { mutableStateOf<ConversationSummary?>(null) }
var renameText by remember { mutableStateOf("") }
GlassSurface( GlassSurface(
modifier = modifier modifier = modifier
.fillMaxHeight() .fillMaxHeight()
.width(300.dp) .width(300.dp)
.padding(start = 12.dp, top = 12.dp, end = 4.dp, bottom = 12.dp), .padding(start = 12.dp, top = 12.dp, end = 4.dp, bottom = 12.dp),
shape = KaizenShapes.lg, shape = KaizenShapes.lg,
tintAlpha = GlassTiers.sidebar(isDark), tintAlpha = if (isDark) 0.88f else 0.92f,
shadowStack = KaizenShadows.level3, shadowStack = KaizenShadows.level3,
cornerRadius = 24.dp, cornerRadius = 24.dp,
) { ) {
@ -113,46 +143,50 @@ fun KaizenSidebar(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text(stringResource(R.string.sidebar_brand), color = cs.onBackground, fontSize = 20.sp, fontWeight = FontWeight.Bold) Text(
stringResource(R.string.sidebar_brand),
color = cs.onBackground,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
)
Box( Box(
modifier = Modifier modifier = Modifier
.size(36.dp) .size(34.dp)
.clip(KaizenShapes.circle) .clip(KaizenShapes.circle)
.background(if (isDark) Color(0x1AFFFFFF) else Color(0x0A000000)) .background(if (isDark) Color(0x1AFFFFFF) else Color(0x0A000000))
.clickable { onNewChat() }, .clickable { onNewChat() },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon(Icons.Rounded.Edit, stringResource(R.string.chat_new), tint = cs.onBackground, modifier = Modifier.size(18.dp)) Icon(Icons.Rounded.Edit, stringResource(R.string.chat_new), tint = cs.onBackground, modifier = Modifier.size(17.dp))
} }
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(14.dp))
// Search // Search
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(KaizenShapes.sm) .clip(KaizenShapes.pill)
.background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000)) .background(if (isDark) Color(0x0DFFFFFF) else Color(0x08000000))
.border(1.dp, if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.04f), KaizenShapes.sm) .padding(horizontal = 14.dp, vertical = 9.dp),
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon(Icons.Rounded.Search, null, tint = cs.onSurfaceVariant.copy(alpha = 0.6f), modifier = Modifier.size(18.dp)) Icon(Icons.Rounded.Search, null, tint = cs.onSurfaceVariant.copy(alpha = 0.45f), modifier = Modifier.size(16.dp))
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.chat_search), color = cs.onSurfaceVariant.copy(alpha = 0.6f), fontSize = 14.sp) Text(stringResource(R.string.chat_search), color = cs.onSurfaceVariant.copy(alpha = 0.45f), fontSize = 13.sp)
} }
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(18.dp))
// Conversation list // Conversation list
Box(Modifier.weight(1f).fillMaxWidth()) { Box(Modifier.weight(1f).fillMaxWidth()) {
if (conversations.isEmpty()) { if (conversations.isEmpty()) {
Text( Text(
stringResource(R.string.chat_no_conversations), stringResource(R.string.chat_no_conversations),
color = cs.onSurfaceVariant.copy(alpha = 0.5f), color = cs.onSurfaceVariant.copy(alpha = 0.4f),
fontSize = 14.sp, fontSize = 13.sp,
modifier = Modifier.padding(start = 12.dp, top = 12.dp) modifier = Modifier.padding(start = 8.dp, top = 8.dp)
) )
} else { } else {
val labels = DateLabels( val labels = DateLabels(
@ -164,16 +198,16 @@ fun KaizenSidebar(
val grouped = remember(conversations, labels) { groupConversations(conversations, labels) } val grouped = remember(conversations, labels) { groupConversations(conversations, labels) }
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
grouped.forEach { (dateGroup, items) -> grouped.forEach { (dateGroup, items) ->
item(key = "header_$dateGroup") { item(key = "header_$dateGroup") {
Text( Text(
dateGroup, dateGroup,
color = cs.onSurfaceVariant.copy(alpha = 0.45f), color = cs.onSurfaceVariant.copy(alpha = 0.4f),
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 4.dp) modifier = Modifier.padding(start = 8.dp, top = 14.dp, bottom = 6.dp)
) )
} }
items(items, key = { it.id }) { item -> items(items, key = { it.id }) { item ->
@ -181,6 +215,13 @@ fun KaizenSidebar(
item = item, item = item,
selected = item.id == currentId, selected = item.id == currentId,
onClick = { onSelectConversation(item) }, onClick = { onSelectConversation(item) },
onRename = {
renameTarget = item
renameText = item.title
},
onDelete = { deleteTarget = item },
onTogglePin = { onAction(SidebarAction.TogglePin(item.id, !item.pinned)) },
onToggleLock = { onAction(SidebarAction.ToggleLock(item.id, !item.locked)) },
) )
} }
} }
@ -188,99 +229,242 @@ fun KaizenSidebar(
} }
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(12.dp))
// User card // User card — compact footer with settings + logout
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(KaizenShapes.md) .clip(KaizenShapes.md)
.background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000)) .background(if (isDark) Color(0x0DFFFFFF) else Color(0x06000000))
.border(1.dp, if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.04f), KaizenShapes.md)
.clickable { onOpenSettings() } .clickable { onOpenSettings() }
.padding(horizontal = 12.dp, vertical = 10.dp), .padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(40.dp) .size(30.dp)
.clip(KaizenShapes.circle) .clip(KaizenShapes.circle)
.background(if (isDark) Color(0xFF1E293B) else Color(0xFF0F172A)), .background(if (isDark) Color(0xFF1E293B) else Color(0xFF0F172A)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
userName.firstOrNull()?.toString()?.uppercase() ?: "A", userName.firstOrNull()?.toString()?.uppercase() ?: "A",
color = Color.White, fontSize = 17.sp, fontWeight = FontWeight.Bold color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.Bold
) )
} }
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
Column(Modifier.weight(1f)) { Text(
Text(userName, color = cs.onBackground, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) userName,
color = cs.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
)
Icon(
Icons.Rounded.Settings,
stringResource(R.string.sidebar_settings),
tint = cs.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(6.dp))
Box(
modifier = Modifier
.size(28.dp)
.clip(KaizenShapes.circle)
.clickable { onLogout() },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.AutoMirrored.Rounded.Logout,
stringResource(R.string.sidebar_logout),
tint = cs.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(16.dp),
)
}
}
} }
Icon(Icons.Rounded.Settings, stringResource(R.string.sidebar_settings), tint = cs.onSurfaceVariant.copy(alpha = 0.7f), modifier = Modifier.size(20.dp))
} }
Spacer(Modifier.height(10.dp)) // Delete confirmation dialog
deleteTarget?.let { target ->
AlertDialog(
onDismissRequest = { deleteTarget = null },
title = { Text(stringResource(R.string.sidebar_delete)) },
text = { Text(stringResource(R.string.sidebar_delete_confirm)) },
confirmButton = {
TextButton(onClick = {
onAction(SidebarAction.Delete(target.id))
deleteTarget = null
}) {
Text(stringResource(R.string.sidebar_confirm), color = Color(0xFFEF4444))
}
},
dismissButton = {
TextButton(onClick = { deleteTarget = null }) {
Text(stringResource(R.string.sidebar_cancel))
}
},
)
}
// Logout // Rename dialog
Row( renameTarget?.let { target ->
val focusManager = LocalFocusManager.current
AlertDialog(
onDismissRequest = { renameTarget = null },
title = { Text(stringResource(R.string.sidebar_rename_title)) },
text = {
BasicTextField(
value = renameText,
onValueChange = { renameText = it },
singleLine = true,
textStyle = TextStyle(
color = cs.onBackground,
fontSize = 15.sp,
),
cursorBrush = SolidColor(cs.onBackground),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {
focusManager.clearFocus()
if (renameText.isNotBlank()) {
onAction(SidebarAction.Rename(target.id, renameText.trim()))
}
renameTarget = null
}),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(KaizenShapes.sm) .clip(KaizenShapes.sm)
.clickable { onLogout() } .background(if (isDark) Color(0x12FFFFFF) else Color(0x08000000))
.padding(horizontal = 12.dp, vertical = 10.dp), .padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically )
) { },
Icon(Icons.AutoMirrored.Rounded.Logout, stringResource(R.string.sidebar_logout), tint = cs.onSurfaceVariant.copy(alpha = 0.7f), modifier = Modifier.size(18.dp)) confirmButton = {
Spacer(Modifier.width(10.dp)) TextButton(onClick = {
Text(stringResource(R.string.sidebar_logout), color = cs.onSurfaceVariant.copy(alpha = 0.85f), fontSize = 14.sp, fontWeight = FontWeight.Medium) if (renameText.isNotBlank()) {
onAction(SidebarAction.Rename(target.id, renameText.trim()))
} }
renameTarget = null
}) {
Text(stringResource(R.string.sidebar_save))
} }
},
dismissButton = {
TextButton(onClick = { renameTarget = null }) {
Text(stringResource(R.string.sidebar_cancel))
}
},
)
} }
} }
@Composable @Composable
private fun HistoryItemRow(item: ConversationSummary, selected: Boolean, onClick: () -> Unit) { private fun HistoryItemRow(
item: ConversationSummary,
selected: Boolean,
onClick: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
onTogglePin: () -> Unit,
onToggleLock: () -> Unit,
) {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val accent = LocalKaizenAccent.current val accent = LocalKaizenAccent.current
var showMenu by remember { mutableStateOf(false) }
val itemBg = when { val itemBg = when {
selected -> accent.primary.copy(alpha = 0.14f) selected -> accent.primary.copy(alpha = if (isDark) 0.14f else 0.10f)
isDark -> Color(0x0AFFFFFF) else -> Color.Transparent
else -> Color(0x05000000)
}
val itemBorder = when {
selected -> accent.primary.copy(alpha = 0.35f)
isDark -> Color.White.copy(alpha = 0.04f)
else -> Color.Black.copy(alpha = 0.03f)
} }
Box {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(KaizenShapes.pill) .clip(KaizenShapes.sm)
.background(itemBg) .background(itemBg)
.border(1.2.dp, itemBorder, KaizenShapes.pill)
.clickable { onClick() } .clickable { onClick() }
.padding(horizontal = 16.dp, vertical = 11.dp), .padding(start = 10.dp, end = 4.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (item.pinned) { if (item.pinned) {
Icon(Icons.Rounded.Star, null, tint = accent.primary, modifier = Modifier.size(14.dp)) Icon(Icons.Rounded.Star, null, tint = accent.primary.copy(alpha = 0.7f), modifier = Modifier.size(13.dp))
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(6.dp))
} }
Text( Text(
text = if (item.locked) stringResource(R.string.chat_locked) else item.title, text = if (item.locked) stringResource(R.string.chat_locked) else item.title,
color = if (item.locked) cs.onSurfaceVariant.copy(alpha = 0.5f) else cs.onBackground, color = if (item.locked) cs.onSurfaceVariant.copy(alpha = 0.45f) else cs.onBackground.copy(alpha = 0.85f),
fontSize = 14.sp, fontSize = 13.5.sp,
fontWeight = FontWeight.Medium, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
fontStyle = if (item.locked) FontStyle.Italic else FontStyle.Normal, fontStyle = if (item.locked) FontStyle.Italic else FontStyle.Normal,
modifier = Modifier.weight(1f) maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
) )
if (item.locked) { if (item.locked) {
Spacer(Modifier.width(8.dp)) Icon(Icons.Rounded.Lock, null, tint = accent.primary.copy(alpha = 0.5f), modifier = Modifier.size(13.dp))
Icon(Icons.Rounded.Lock, stringResource(R.string.chat_locked_badge), tint = accent.primary, modifier = Modifier.size(15.dp)) Spacer(Modifier.width(2.dp))
}
Box(
modifier = Modifier
.size(28.dp)
.clip(KaizenShapes.circle)
.clickable { showMenu = true },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Rounded.MoreVert,
stringResource(R.string.sidebar_more),
tint = cs.onSurfaceVariant.copy(alpha = 0.35f),
modifier = Modifier.size(16.dp),
)
}
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
if (!item.locked) {
DropdownMenuItem(
text = { Text(stringResource(R.string.sidebar_rename), fontSize = 14.sp) },
onClick = { showMenu = false; onRename() },
leadingIcon = { Icon(Icons.Rounded.Edit, null, modifier = Modifier.size(18.dp)) },
)
}
DropdownMenuItem(
text = {
Text(
stringResource(if (item.pinned) R.string.sidebar_unpin else R.string.sidebar_pin),
fontSize = 14.sp,
)
},
onClick = { showMenu = false; onTogglePin() },
leadingIcon = { Icon(Icons.Rounded.PushPin, null, modifier = Modifier.size(18.dp)) },
)
DropdownMenuItem(
text = {
Text(
stringResource(if (item.locked) R.string.sidebar_unlock else R.string.sidebar_lock),
fontSize = 14.sp,
)
},
onClick = { showMenu = false; onToggleLock() },
leadingIcon = {
Icon(
if (item.locked) Icons.Rounded.LockOpen else Icons.Rounded.Lock,
null,
modifier = Modifier.size(18.dp),
)
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.sidebar_delete), fontSize = 14.sp, color = Color(0xFFEF4444)) },
onClick = { showMenu = false; onDelete() },
leadingIcon = { Icon(Icons.Rounded.Delete, null, tint = Color(0xFFEF4444), modifier = Modifier.size(18.dp)) },
)
} }
} }
} }

View file

@ -316,6 +316,47 @@ object KaizenApi {
} }
} }
/** PATCH /api/v1/conversations/[id] — update title, locked, pinned. */
suspend fun patchConversation(baseUrl: String, token: String, id: String, body: Map<String, Any?>): Boolean =
withContext(Dispatchers.IO) {
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
}
}
)).toRequestBody(jsonMedia)
val req = Request.Builder()
.url("$baseUrl/api/v1/conversations/$id")
.patch(payload)
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { it.isSuccessful }
} catch (e: Exception) {
Log.w(TAG, "patchConversation($id): ${e.javaClass.simpleName}: ${e.message}")
false
}
}
/** DELETE /api/v1/conversations/[id] — permanently remove a conversation. */
suspend fun deleteConversation(baseUrl: String, token: String, id: String): Boolean =
withContext(Dispatchers.IO) {
val req = Request.Builder()
.url("$baseUrl/api/v1/conversations/$id")
.delete()
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { it.isSuccessful }
} catch (e: Exception) {
Log.w(TAG, "deleteConversation($id): ${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) {

View file

@ -92,7 +92,7 @@ fun GlassSurface(
* Use these constants with [GlassSurface] instead of ad-hoc alpha values. * Use these constants with [GlassSurface] instead of ad-hoc alpha values.
*/ */
object GlassTiers { object GlassTiers {
fun sidebar(isDark: Boolean) = if (isDark) 0.72f else 0.68f fun sidebar(isDark: Boolean) = if (isDark) 0.88f else 0.92f
fun input(isDark: Boolean) = if (isDark) 0.75f else 0.74f fun input(isDark: Boolean) = if (isDark) 0.75f else 0.74f
fun pill(isDark: Boolean) = if (isDark) 0.80f else 0.82f fun pill(isDark: Boolean) = if (isDark) 0.80f else 0.82f
fun chip(isDark: Boolean) = if (isDark) 0.85f else 0.90f fun chip(isDark: Boolean) = if (isDark) 0.85f else 0.90f

View file

@ -61,6 +61,18 @@
<string name="sidebar_earlier">EARLIER</string> <string name="sidebar_earlier">EARLIER</string>
<string name="sidebar_logout">Sign Out</string> <string name="sidebar_logout">Sign Out</string>
<string name="sidebar_settings">Settings</string> <string name="sidebar_settings">Settings</string>
<string name="sidebar_rename">Rename</string>
<string name="sidebar_delete">Delete</string>
<string name="sidebar_pin">Pin</string>
<string name="sidebar_unpin">Unpin</string>
<string name="sidebar_lock">Lock</string>
<string name="sidebar_unlock">Unlock</string>
<string name="sidebar_more">More</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>
<string name="sidebar_confirm">Confirm</string>
<string name="sidebar_save">Save</string>
<!-- Model sheet --> <!-- Model sheet -->
<string name="model_sheet_title">Select Model</string> <string name="model_sheet_title">Select Model</string>

View file

@ -63,6 +63,18 @@
<string name="sidebar_earlier">FRÜHER</string> <string name="sidebar_earlier">FRÜHER</string>
<string name="sidebar_logout">Abmelden</string> <string name="sidebar_logout">Abmelden</string>
<string name="sidebar_settings">Einstellungen</string> <string name="sidebar_settings">Einstellungen</string>
<string name="sidebar_rename">Umbenennen</string>
<string name="sidebar_delete">Löschen</string>
<string name="sidebar_pin">Anheften</string>
<string name="sidebar_unpin">Lösen</string>
<string name="sidebar_lock">Sperren</string>
<string name="sidebar_unlock">Entsperren</string>
<string name="sidebar_more">Mehr</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>
<string name="sidebar_confirm">Bestätigen</string>
<string name="sidebar_save">Speichern</string>
<!-- Model sheet --> <!-- Model sheet -->
<string name="model_sheet_title">Modell auswählen</string> <string name="model_sheet_title">Modell auswählen</string>