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()
scope.launch { drawerState.close() }
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

View file

@ -363,20 +363,20 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
val isLast = idx == blocks.lastIndex
when (block) {
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)
Text(
text = rendered,
color = cs.onBackground,
fontSize = when (block.level) { 1 -> 22.sp; 2 -> 19.sp; else -> 17.sp },
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 -> {
if (idx > 0) Spacer(Modifier.height(6.dp))
if (idx > 0) Spacer(Modifier.height(12.dp))
Box(
Modifier
.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 -> {
if (idx > 0) Spacer(Modifier.height(4.dp))
if (idx > 0) Spacer(Modifier.height(8.dp))
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 ""
Text(
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),
color = cs.onBackground,
fontSize = 16.sp,
lineHeight = 23.sp,
lineHeight = 26.sp,
modifier = Modifier.weight(1f),
)
}
}
Spacer(Modifier.height(4.dp))
Spacer(Modifier.height(8.dp))
}
is MdBlock.HorizontalRule -> {
if (idx > 0) Spacer(Modifier.height(8.dp))
if (idx > 0) Spacer(Modifier.height(14.dp))
Box(
Modifier
.fillMaxWidth()
.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 -> {
if (idx > 0) Spacer(Modifier.height(6.dp))
if (idx > 0) Spacer(Modifier.height(12.dp))
Row(
Modifier
.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),
color = cs.onSurfaceVariant,
fontSize = 15.sp,
lineHeight = 22.sp,
lineHeight = 24.sp,
fontStyle = FontStyle.Italic,
modifier = Modifier.weight(1f),
)
}
Spacer(Modifier.height(6.dp))
Spacer(Modifier.height(12.dp))
}
is MdBlock.Paragraph -> {
if (idx > 0) Spacer(Modifier.height(6.dp))
if (idx > 0) Spacer(Modifier.height(12.dp))
Text(
text = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg),
color = cs.onBackground,
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.lazy.LazyColumn
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.automirrored.rounded.Logout
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit
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.Settings
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.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.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.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.sp
import dev.kaizen.app.net.ConversationSummary
@ -76,6 +94,13 @@ private fun groupConversations(conversations: List<ConversationSummary>, labels:
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
fun KaizenSidebar(
userName: String,
@ -86,18 +111,23 @@ fun KaizenSidebar(
onSelectConversation: (ConversationSummary) -> Unit,
onOpenSettings: () -> Unit,
onLogout: () -> Unit,
onAction: (SidebarAction) -> Unit = {},
modifier: Modifier = Modifier
) {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
var deleteTarget by remember { mutableStateOf<ConversationSummary?>(null) }
var renameTarget by remember { mutableStateOf<ConversationSummary?>(null) }
var renameText by remember { mutableStateOf("") }
GlassSurface(
modifier = modifier
.fillMaxHeight()
.width(300.dp)
.padding(start = 12.dp, top = 12.dp, end = 4.dp, bottom = 12.dp),
shape = KaizenShapes.lg,
tintAlpha = GlassTiers.sidebar(isDark),
tintAlpha = if (isDark) 0.88f else 0.92f,
shadowStack = KaizenShadows.level3,
cornerRadius = 24.dp,
) {
@ -113,46 +143,50 @@ fun KaizenSidebar(
horizontalArrangement = Arrangement.SpaceBetween,
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(
modifier = Modifier
.size(36.dp)
.size(34.dp)
.clip(KaizenShapes.circle)
.background(if (isDark) Color(0x1AFFFFFF) else Color(0x0A000000))
.clickable { onNewChat() },
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
Row(
modifier = Modifier
.fillMaxWidth()
.clip(KaizenShapes.sm)
.background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000))
.border(1.dp, if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.04f), KaizenShapes.sm)
.padding(horizontal = 12.dp, vertical = 10.dp),
.clip(KaizenShapes.pill)
.background(if (isDark) Color(0x0DFFFFFF) else Color(0x08000000))
.padding(horizontal = 14.dp, vertical = 9.dp),
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))
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
Box(Modifier.weight(1f).fillMaxWidth()) {
if (conversations.isEmpty()) {
Text(
stringResource(R.string.chat_no_conversations),
color = cs.onSurfaceVariant.copy(alpha = 0.5f),
fontSize = 14.sp,
modifier = Modifier.padding(start = 12.dp, top = 12.dp)
color = cs.onSurfaceVariant.copy(alpha = 0.4f),
fontSize = 13.sp,
modifier = Modifier.padding(start = 8.dp, top = 8.dp)
)
} else {
val labels = DateLabels(
@ -164,16 +198,16 @@ fun KaizenSidebar(
val grouped = remember(conversations, labels) { groupConversations(conversations, labels) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp)
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
grouped.forEach { (dateGroup, items) ->
item(key = "header_$dateGroup") {
Text(
dateGroup,
color = cs.onSurfaceVariant.copy(alpha = 0.45f),
color = cs.onSurfaceVariant.copy(alpha = 0.4f),
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 4.dp)
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(start = 8.dp, top = 14.dp, bottom = 6.dp)
)
}
items(items, key = { it.id }) { item ->
@ -181,6 +215,13 @@ fun KaizenSidebar(
item = item,
selected = item.id == currentId,
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(
modifier = Modifier
.fillMaxWidth()
.clip(KaizenShapes.md)
.background(if (isDark) Color(0x12FFFFFF) else Color(0x06000000))
.border(1.dp, if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.04f), KaizenShapes.md)
.background(if (isDark) Color(0x0DFFFFFF) else Color(0x06000000))
.clickable { onOpenSettings() }
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(40.dp)
.size(30.dp)
.clip(KaizenShapes.circle)
.background(if (isDark) Color(0xFF1E293B) else Color(0xFF0F172A)),
contentAlignment = Alignment.Center
) {
Text(
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))
Column(Modifier.weight(1f)) {
Text(userName, color = cs.onBackground, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
Text(
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))
// Logout
Row(
modifier = Modifier
.fillMaxWidth()
.clip(KaizenShapes.sm)
.clickable { onLogout() }
.padding(horizontal = 12.dp, vertical = 10.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))
Spacer(Modifier.width(10.dp))
Text(stringResource(R.string.sidebar_logout), color = cs.onSurfaceVariant.copy(alpha = 0.85f), fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
}
}
// 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))
}
},
)
}
// Rename dialog
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
.fillMaxWidth()
.clip(KaizenShapes.sm)
.background(if (isDark) Color(0x12FFFFFF) else Color(0x08000000))
.padding(horizontal = 14.dp, vertical = 12.dp),
)
},
confirmButton = {
TextButton(onClick = {
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
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 isDark = isSystemInDarkTheme()
val accent = LocalKaizenAccent.current
var showMenu by remember { mutableStateOf(false) }
val itemBg = when {
selected -> accent.primary.copy(alpha = 0.14f)
isDark -> Color(0x0AFFFFFF)
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)
selected -> accent.primary.copy(alpha = if (isDark) 0.14f else 0.10f)
else -> Color.Transparent
}
Row(
modifier = Modifier
.fillMaxWidth()
.clip(KaizenShapes.pill)
.background(itemBg)
.border(1.2.dp, itemBorder, KaizenShapes.pill)
.clickable { onClick() }
.padding(horizontal = 16.dp, vertical = 11.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (item.pinned) {
Icon(Icons.Rounded.Star, null, tint = accent.primary, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(8.dp))
Box {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(KaizenShapes.sm)
.background(itemBg)
.clickable { onClick() }
.padding(start = 10.dp, end = 4.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (item.pinned) {
Icon(Icons.Rounded.Star, null, tint = accent.primary.copy(alpha = 0.7f), modifier = Modifier.size(13.dp))
Spacer(Modifier.width(6.dp))
}
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,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
fontStyle = if (item.locked) FontStyle.Italic else FontStyle.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (item.locked) {
Icon(Icons.Rounded.Lock, null, tint = accent.primary.copy(alpha = 0.5f), modifier = Modifier.size(13.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),
)
}
}
Text(
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,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
fontStyle = if (item.locked) FontStyle.Italic else FontStyle.Normal,
modifier = Modifier.weight(1f)
)
if (item.locked) {
Spacer(Modifier.width(8.dp))
Icon(Icons.Rounded.Lock, stringResource(R.string.chat_locked_badge), tint = accent.primary, modifier = Modifier.size(15.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. */
suspend fun generateTitle(baseUrl: String, token: String, id: String) =
withContext(Dispatchers.IO) {

View file

@ -92,7 +92,7 @@ fun GlassSurface(
* Use these constants with [GlassSurface] instead of ad-hoc alpha values.
*/
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 pill(isDark: Boolean) = if (isDark) 0.80f else 0.82f
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_logout">Sign Out</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 -->
<string name="model_sheet_title">Select Model</string>

View file

@ -63,6 +63,18 @@
<string name="sidebar_earlier">FRÜHER</string>
<string name="sidebar_logout">Abmelden</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 -->
<string name="model_sheet_title">Modell auswählen</string>