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:
parent
2f9ada646c
commit
558b1be675
8 changed files with 175 additions and 38 deletions
|
|
@ -681,11 +681,9 @@ fun ChatScreen(
|
||||||
}
|
}
|
||||||
is SidebarAction.ToggleLock -> {
|
is SidebarAction.ToggleLock -> {
|
||||||
if (action.locked) {
|
if (action.locked) {
|
||||||
// Locking — no auth needed
|
|
||||||
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("locked" to true))
|
KaizenApi.patchConversation(cfg.baseUrl, cfg.token, action.id, mapOf("locked" to true))
|
||||||
refreshConversations()
|
refreshConversations()
|
||||||
} else {
|
} else {
|
||||||
// Unlocking — require biometric or password
|
|
||||||
val summary = conversations.find { it.id == action.id }
|
val summary = conversations.find { it.id == action.id }
|
||||||
if (summary != null) {
|
if (summary != null) {
|
||||||
requestBiometricOrPassword(summary, forToggle = true) {
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ 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.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
|
@ -48,8 +49,9 @@ 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
|
||||||
import dev.kaizen.app.ui.effect.GlassSurface
|
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.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.ui.shape.KaizenShapes
|
||||||
import dev.kaizen.app.R
|
import dev.kaizen.app.R
|
||||||
import dev.kaizen.app.ui.theme.LocalKaizenAccent
|
import dev.kaizen.app.ui.theme.LocalKaizenAccent
|
||||||
|
|
@ -88,6 +90,8 @@ sealed interface SidebarAction {
|
||||||
data class Delete(val id: String) : SidebarAction
|
data class Delete(val id: String) : SidebarAction
|
||||||
data class TogglePin(val id: String, val pinned: Boolean) : SidebarAction
|
data class TogglePin(val id: String, val pinned: Boolean) : SidebarAction
|
||||||
data class ToggleLock(val id: String, val locked: 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
|
@Composable
|
||||||
|
|
@ -105,10 +109,12 @@ fun KaizenSidebar(
|
||||||
) {
|
) {
|
||||||
val cs = MaterialTheme.colorScheme
|
val cs = MaterialTheme.colorScheme
|
||||||
val isDark = isSystemInDarkTheme()
|
val isDark = isSystemInDarkTheme()
|
||||||
|
val accent = LocalKaizenAccent.current
|
||||||
|
|
||||||
var deleteTarget by remember { mutableStateOf<ConversationSummary?>(null) }
|
var deleteTarget by remember { mutableStateOf<ConversationSummary?>(null) }
|
||||||
var renameTarget by remember { mutableStateOf<ConversationSummary?>(null) }
|
var renameTarget by remember { mutableStateOf<ConversationSummary?>(null) }
|
||||||
var renameText by remember { mutableStateOf("") }
|
var renameText by remember { mutableStateOf("") }
|
||||||
|
var searchQuery by remember { mutableStateOf("") }
|
||||||
|
|
||||||
GlassSurface(
|
GlassSurface(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|
@ -116,7 +122,7 @@ fun KaizenSidebar(
|
||||||
.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 = if (isDark) 0.88f else 0.92f,
|
tintAlpha = if (isDark) 0.80f else 0.86f,
|
||||||
shadowStack = KaizenShadows.level3,
|
shadowStack = KaizenShadows.level3,
|
||||||
cornerRadius = 24.dp,
|
cornerRadius = 24.dp,
|
||||||
) {
|
) {
|
||||||
|
|
@ -126,7 +132,7 @@ fun KaizenSidebar(
|
||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
) {
|
) {
|
||||||
// Header
|
// ── Header ──
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
|
@ -152,25 +158,70 @@ fun KaizenSidebar(
|
||||||
|
|
||||||
Spacer(Modifier.height(14.dp))
|
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(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(KaizenShapes.pill)
|
.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),
|
.padding(horizontal = 14.dp, vertical = 9.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
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))
|
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))
|
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()) {
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
if (conversations.isEmpty()) {
|
if (filteredConversations.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.chat_no_conversations),
|
stringResource(R.string.chat_no_conversations),
|
||||||
color = cs.onSurfaceVariant.copy(alpha = 0.4f),
|
color = cs.onSurfaceVariant.copy(alpha = 0.4f),
|
||||||
|
|
@ -184,7 +235,7 @@ fun KaizenSidebar(
|
||||||
earlier = stringResource(R.string.sidebar_earlier),
|
earlier = stringResource(R.string.sidebar_earlier),
|
||||||
pinned = stringResource(R.string.sidebar_pinned),
|
pinned = stringResource(R.string.sidebar_pinned),
|
||||||
)
|
)
|
||||||
val grouped = remember(conversations, labels) { groupConversations(conversations, labels) }
|
val grouped = remember(filteredConversations, labels) { groupConversations(filteredConversations, labels) }
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
|
@ -193,9 +244,10 @@ fun KaizenSidebar(
|
||||||
item(key = "header_$dateGroup") {
|
item(key = "header_$dateGroup") {
|
||||||
Text(
|
Text(
|
||||||
dateGroup,
|
dateGroup,
|
||||||
color = cs.onSurfaceVariant.copy(alpha = 0.4f),
|
color = cs.onSurfaceVariant.copy(alpha = 0.35f),
|
||||||
fontSize = 11.sp,
|
fontSize = 10.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.Bold,
|
||||||
|
letterSpacing = 1.sp,
|
||||||
modifier = Modifier.padding(start = 8.dp, top = 14.dp, bottom = 6.dp)
|
modifier = Modifier.padding(start = 8.dp, top = 14.dp, bottom = 6.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -211,6 +263,8 @@ fun KaizenSidebar(
|
||||||
onDelete = { deleteTarget = item },
|
onDelete = { deleteTarget = item },
|
||||||
onTogglePin = { onAction(SidebarAction.TogglePin(item.id, !item.pinned)) },
|
onTogglePin = { onAction(SidebarAction.TogglePin(item.id, !item.pinned)) },
|
||||||
onToggleLock = { onAction(SidebarAction.ToggleLock(item.id, !item.locked)) },
|
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))
|
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(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(KaizenShapes.md)
|
.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() }
|
.clickable { onOpenSettings() }
|
||||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
|
@ -234,7 +298,11 @@ fun KaizenSidebar(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(30.dp)
|
.size(30.dp)
|
||||||
.clip(KaizenShapes.circle)
|
.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
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -253,8 +321,8 @@ fun KaizenSidebar(
|
||||||
Icon(
|
Icon(
|
||||||
KaizenIcons.Settings,
|
KaizenIcons.Settings,
|
||||||
stringResource(R.string.sidebar_settings),
|
stringResource(R.string.sidebar_settings),
|
||||||
tint = cs.onSurfaceVariant.copy(alpha = 0.5f),
|
tint = cs.onSurfaceVariant.copy(alpha = 0.45f),
|
||||||
modifier = Modifier.size(18.dp),
|
modifier = Modifier.size(17.dp),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(6.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
Box(
|
Box(
|
||||||
|
|
@ -267,8 +335,8 @@ fun KaizenSidebar(
|
||||||
Icon(
|
Icon(
|
||||||
KaizenIcons.LogOut,
|
KaizenIcons.LogOut,
|
||||||
stringResource(R.string.sidebar_logout),
|
stringResource(R.string.sidebar_logout),
|
||||||
tint = cs.onSurfaceVariant.copy(alpha = 0.5f),
|
tint = cs.onSurfaceVariant.copy(alpha = 0.45f),
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(15.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -356,6 +424,8 @@ private fun HistoryItemRow(
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
onTogglePin: () -> Unit,
|
onTogglePin: () -> Unit,
|
||||||
onToggleLock: () -> Unit,
|
onToggleLock: () -> Unit,
|
||||||
|
onGenerateTitle: () -> Unit,
|
||||||
|
onDuplicate: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val cs = MaterialTheme.colorScheme
|
val cs = MaterialTheme.colorScheme
|
||||||
val isDark = isSystemInDarkTheme()
|
val isDark = isSystemInDarkTheme()
|
||||||
|
|
@ -363,17 +433,31 @@ private fun HistoryItemRow(
|
||||||
|
|
||||||
var showMenu by remember { mutableStateOf(false) }
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val itemBg = when {
|
val itemBorder = if (selected) {
|
||||||
selected -> accent.primary.copy(alpha = if (isDark) 0.14f else 0.10f)
|
remember(isDark, accent) {
|
||||||
else -> Color.Transparent
|
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 {
|
Box {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(KaizenShapes.sm)
|
.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() }
|
.clickable { onClick() }
|
||||||
.padding(start = 10.dp, end = 4.dp, top = 8.dp, bottom = 8.dp),
|
.padding(start = 10.dp, end = 4.dp, top = 8.dp, bottom = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
|
@ -384,8 +468,8 @@ private fun HistoryItemRow(
|
||||||
}
|
}
|
||||||
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.45f) else cs.onBackground.copy(alpha = 0.85f),
|
color = if (item.locked) cs.onSurfaceVariant.copy(alpha = 0.45f) else cs.onBackground.copy(alpha = if (selected) 0.95f else 0.80f),
|
||||||
fontSize = 13.5.sp,
|
fontSize = 13.sp,
|
||||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
|
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,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|
@ -406,8 +490,8 @@ private fun HistoryItemRow(
|
||||||
Icon(
|
Icon(
|
||||||
KaizenIcons.MoreVertical,
|
KaizenIcons.MoreVertical,
|
||||||
stringResource(R.string.sidebar_more),
|
stringResource(R.string.sidebar_more),
|
||||||
tint = cs.onSurfaceVariant.copy(alpha = 0.35f),
|
tint = cs.onSurfaceVariant.copy(alpha = 0.30f),
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(15.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -416,18 +500,30 @@ private fun HistoryItemRow(
|
||||||
expanded = showMenu,
|
expanded = showMenu,
|
||||||
onDismissRequest = { showMenu = false },
|
onDismissRequest = { showMenu = false },
|
||||||
containerColor = if (isDark) Color(0xFF1A2030) else Color(0xFFF8F9FB),
|
containerColor = if (isDark) Color(0xFF1A2030) else Color(0xFFF8F9FB),
|
||||||
shadowElevation = 12.dp,
|
shadowElevation = 16.dp,
|
||||||
shape = KaizenShapes.md,
|
shape = KaizenShapes.md,
|
||||||
) {
|
) {
|
||||||
if (!item.locked) {
|
if (!item.locked) {
|
||||||
MenuRow(KaizenIcons.Pencil, stringResource(R.string.sidebar_rename), cs.onBackground) { showMenu = false; onRename() }
|
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.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(
|
MenuRow(
|
||||||
if (item.locked) KaizenIcons.LockOpen else KaizenIcons.Lock,
|
if (item.locked) KaizenIcons.LockOpen else KaizenIcons.Lock,
|
||||||
stringResource(if (item.locked) R.string.sidebar_unlock else R.string.sidebar_lock),
|
stringResource(if (item.locked) R.string.sidebar_unlock else R.string.sidebar_lock),
|
||||||
cs.onBackground,
|
cs.onBackground,
|
||||||
) { showMenu = false; onToggleLock() }
|
) { 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() }
|
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
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.padding(horizontal = 16.dp, vertical = 11.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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))
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ data class ConversationEntity(
|
||||||
val pinned: Boolean,
|
val pinned: Boolean,
|
||||||
val updatedAt: String?,
|
val updatedAt: String?,
|
||||||
val cachedAt: Long,
|
val cachedAt: Long,
|
||||||
|
val titleGenerated: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import androidx.room.TypeConverters
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [ConversationEntity::class, MessageEntity::class],
|
entities = [ConversationEntity::class, MessageEntity::class],
|
||||||
version = 3,
|
version = 4,
|
||||||
exportSchema = false,
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ fun ConversationSummary.toEntity() = ConversationEntity(
|
||||||
pinned = pinned,
|
pinned = pinned,
|
||||||
updatedAt = updatedAt,
|
updatedAt = updatedAt,
|
||||||
cachedAt = System.currentTimeMillis(),
|
cachedAt = System.currentTimeMillis(),
|
||||||
|
titleGenerated = titleGenerated,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun ConversationEntity.toSummary() = ConversationSummary(
|
fun ConversationEntity.toSummary() = ConversationSummary(
|
||||||
|
|
@ -18,6 +19,7 @@ fun ConversationEntity.toSummary() = ConversationSummary(
|
||||||
locked = locked,
|
locked = locked,
|
||||||
pinned = pinned,
|
pinned = pinned,
|
||||||
updatedAt = updatedAt,
|
updatedAt = updatedAt,
|
||||||
|
titleGenerated = titleGenerated,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun StoredMessage.toEntity(conversationId: String, sortOrder: Int) = MessageEntity(
|
fun StoredMessage.toEntity(conversationId: String, sortOrder: Int) = MessageEntity(
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ data class ConversationSummary(
|
||||||
val locked: Boolean = false,
|
val locked: Boolean = false,
|
||||||
val pinned: Boolean = false,
|
val pinned: Boolean = false,
|
||||||
val updatedAt: String? = null,
|
val updatedAt: String? = null,
|
||||||
|
val titleGenerated: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable private data class ConversationsResponse(val conversations: List<ConversationSummary> = emptyList())
|
@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. */
|
/** 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) {
|
withContext(Dispatchers.IO) {
|
||||||
|
val body = if (manual) """{"manual":true}""" else "{}"
|
||||||
val req = Request.Builder()
|
val req = Request.Builder()
|
||||||
.url("$baseUrl/api/v1/conversations/$id/title")
|
.url("$baseUrl/api/v1/conversations/$id/title")
|
||||||
.post("{}".toRequestBody(jsonMedia))
|
.post(body.toRequestBody(jsonMedia))
|
||||||
.header("Authorization", "Bearer $token")
|
.header("Authorization", "Bearer $token")
|
||||||
.build()
|
.build()
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,8 @@
|
||||||
<string name="sidebar_lock">Lock</string>
|
<string name="sidebar_lock">Lock</string>
|
||||||
<string name="sidebar_unlock">Unlock</string>
|
<string name="sidebar_unlock">Unlock</string>
|
||||||
<string name="sidebar_more">More</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_rename_title">Rename Chat</string>
|
||||||
<string name="sidebar_delete_confirm">Permanently delete this chat?</string>
|
<string name="sidebar_delete_confirm">Permanently delete this chat?</string>
|
||||||
<string name="sidebar_cancel">Cancel</string>
|
<string name="sidebar_cancel">Cancel</string>
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,8 @@
|
||||||
<string name="sidebar_lock">Sperren</string>
|
<string name="sidebar_lock">Sperren</string>
|
||||||
<string name="sidebar_unlock">Entsperren</string>
|
<string name="sidebar_unlock">Entsperren</string>
|
||||||
<string name="sidebar_more">Mehr</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_rename_title">Chat umbenennen</string>
|
||||||
<string name="sidebar_delete_confirm">Chat endgültig löschen?</string>
|
<string name="sidebar_delete_confirm">Chat endgültig löschen?</string>
|
||||||
<string name="sidebar_cancel">Abbrechen</string>
|
<string name="sidebar_cancel">Abbrechen</string>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue