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 dd33555..7f23ce2 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -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() + } + } + } } } }, diff --git a/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt b/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt index 79e7ac0..55d6fa3 100644 --- a/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt +++ b/app/src/main/java/dev/kaizen/app/chat/Sidebar.kt @@ -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(null) } var renameTarget by remember { mutableStateOf(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) } } diff --git a/app/src/main/java/dev/kaizen/app/db/Entities.kt b/app/src/main/java/dev/kaizen/app/db/Entities.kt index 07f5242..8cd6915 100644 --- a/app/src/main/java/dev/kaizen/app/db/Entities.kt +++ b/app/src/main/java/dev/kaizen/app/db/Entities.kt @@ -15,6 +15,7 @@ data class ConversationEntity( val pinned: Boolean, val updatedAt: String?, val cachedAt: Long, + val titleGenerated: Boolean = false, ) @Entity( diff --git a/app/src/main/java/dev/kaizen/app/db/KaizenDatabase.kt b/app/src/main/java/dev/kaizen/app/db/KaizenDatabase.kt index 8405c68..24ef0f3 100644 --- a/app/src/main/java/dev/kaizen/app/db/KaizenDatabase.kt +++ b/app/src/main/java/dev/kaizen/app/db/KaizenDatabase.kt @@ -8,7 +8,7 @@ import androidx.room.TypeConverters @Database( entities = [ConversationEntity::class, MessageEntity::class], - version = 3, + version = 4, exportSchema = false, ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/dev/kaizen/app/db/Mappers.kt b/app/src/main/java/dev/kaizen/app/db/Mappers.kt index 7320dc7..c1e8c8f 100644 --- a/app/src/main/java/dev/kaizen/app/db/Mappers.kt +++ b/app/src/main/java/dev/kaizen/app/db/Mappers.kt @@ -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( 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 91b5ac8..b47e745 100644 --- a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt +++ b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt @@ -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 = 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 { diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 7049add..b28c744 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -74,6 +74,8 @@ Lock Unlock More + Generate Title + Duplicate Rename Chat Permanently delete this chat? Cancel diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5d682c5..5301c71 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -76,6 +76,8 @@ Sperren Entsperren Mehr + Titel generieren + Duplizieren Chat umbenennen Chat endgültig löschen? Abbrechen