Compare commits

..

2 commits

Author SHA1 Message Date
Bruno Deanoz
1362daee53 fix: snappy sidebar — optimistic insert, instant title, smart merge
Three fixes for sluggish conversation list:

1. Optimistic sidebar insert: new conversation appears in the sidebar
   immediately after creation (with first-line preview as title),
   before the stream finishes.

2. generateTitle now returns the title from the server response and
   updates Room directly via updateTitle() — no blind refresh that
   races with server-side generation.

3. replaceAll uses upsert + deleteExcept instead of deleteAll + insert,
   eliminating the empty-list flash that caused sidebar flicker.
2026-06-22 13:57:52 +02:00
Bruno Deanoz
1929073ad5 redesign top bar — unified glass pill for sidebar + model
Merge the separate hamburger circle and ModelPill into one cohesive
GlassSurface pill. Menu icon (17dp, muted) sits left, subtle 3dp dot
divider, then model name (13sp Medium, 180dp max with ellipsis).
No more chunky ExpandMore chevron. Matches the TokenBadge's glass
language on the right.
2026-06-22 13:54:08 +02:00
4 changed files with 100 additions and 27 deletions

View file

@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
@ -428,7 +429,13 @@ fun ChatScreen(
val finalSources = finalMsg?.sources?.takeIf { it.isNotEmpty() }
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
val convId = convIdDeferred?.await()?.also { conversationId = it } ?: conversationId
val convId = convIdDeferred?.await()?.also { cid ->
conversationId = cid
scope.launch {
val preview = trimmed.take(60).lines().first()
chat.conversationRepo.insertOptimistic(cid, preview)
}
} ?: conversationId
if (convId != null && finalContent.isNotEmpty()) {
val saved = KaizenApi.saveMessages(
@ -463,7 +470,12 @@ fun ChatScreen(
sources = finalSources ?: emptyList(),
),
))
if (wasNewConversation) KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId)
if (wasNewConversation) {
val title = KaizenApi.generateTitle(cfg.baseUrl, cfg.token, convId)
if (title != null) {
chat.conversationRepo.updateTitle(convId, title)
}
}
refreshConversations()
}
}
@ -694,33 +706,61 @@ fun ChatScreen(
.statusBarsPadding()
) {
Row(
modifier = Modifier.padding(start = 16.dp, top = 10.dp, end = 16.dp, bottom = 20.dp),
modifier = Modifier.padding(start = 14.dp, top = 10.dp, end = 14.dp, bottom = 20.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val displayModel = chatModel ?: session.config?.model ?: ""
// Unified top bar: sidebar trigger + model name in one glass pill
GlassSurface(
shape = KaizenShapes.circle,
shape = KaizenShapes.pill,
tintAlpha = GlassTiers.pill(isSystemInDarkTheme()),
shadowStack = KaizenShadows.level2,
cornerRadius = 22.dp,
modifier = Modifier
.size(44.dp)
.clickable { haptics.tick(); scope.launch { drawerState.open() } },
shadowStack = KaizenShadows.level1,
cornerRadius = 20.dp,
) {
Row(
Modifier.heightIn(min = 38.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Sidebar trigger area
Box(
Modifier
.size(38.dp)
.clip(KaizenShapes.circle)
.clickable { haptics.tick(); scope.launch { drawerState.open() } },
contentAlignment = Alignment.Center,
) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Rounded.Menu,
contentDescription = context.getString(R.string.chat_open_menu),
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.size(20.dp)
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(17.dp),
)
}
// Divider dot
Box(
Modifier
.size(3.dp)
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.20f), KaizenShapes.circle)
)
// Model name — tap opens picker
Box(
Modifier
.clip(KaizenShapes.pill)
.clickable { haptics.tick(); showModelSheet = true }
.padding(start = 10.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
) {
Text(
modelDisplayName(displayModel, models),
color = MaterialTheme.colorScheme.onBackground,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
modifier = Modifier.widthIn(max = 180.dp),
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
)
}
}
Spacer(Modifier.width(8.dp))
val displayModel = chatModel ?: session.config?.model ?: ""
ModelPill(
label = modelDisplayName(displayModel, models),
onClick = { haptics.tick(); showModelSheet = true },
)
}
Spacer(Modifier.weight(1f))
val ctxLen = models.find { it.id == displayModel }?.context_length ?: 1_000_000
TokenBadge(used = usedTokens, contextLength = ctxLen)

View file

@ -14,13 +14,26 @@ interface ConversationDao {
@Upsert
suspend fun upsertAll(conversations: List<ConversationEntity>)
@Upsert
suspend fun upsert(conversation: ConversationEntity)
@Query("DELETE FROM conversations")
suspend fun deleteAll()
@Query("DELETE FROM conversations WHERE id NOT IN (:keepIds)")
suspend fun deleteExcept(keepIds: List<String>)
@Query("UPDATE conversations SET title = :title WHERE id = :id")
suspend fun updateTitle(id: String, title: String)
@Transaction
suspend fun replaceAll(conversations: List<ConversationEntity>) {
if (conversations.isEmpty()) {
deleteAll()
} else {
upsertAll(conversations)
deleteExcept(conversations.map { it.id })
}
}
@Query("SELECT cachedAt FROM conversations LIMIT 1")

View file

@ -21,6 +21,21 @@ class ConversationRepository(private val dao: ConversationDao) {
}
}
suspend fun insertOptimistic(id: String, title: String) {
dao.upsert(ConversationEntity(
id = id,
title = title,
locked = false,
pinned = false,
updatedAt = java.time.Instant.now().toString(),
cachedAt = System.currentTimeMillis(),
))
}
suspend fun updateTitle(id: String, title: String) {
dao.updateTitle(id, title)
}
suspend fun lastCachedAt(): Long? = dao.lastCachedAt()
suspend fun allUnlockedIds(): List<String> = dao.allUnlockedIds()

View file

@ -67,6 +67,7 @@ data class ConversationSummary(
@Serializable private data class UnlockResponse(val unlockToken: String = "")
@Serializable private data class CreateConvoRequest(val title: String? = null)
@Serializable private data class CreatedConversation(val id: String)
@Serializable private data class TitleResponse(val title: String? = null)
@Serializable data class AppDevice(
val id: String,
@ -443,8 +444,8 @@ object KaizenApi {
}
}
/** POST /api/v1/conversations/[id]/title — fire-and-forget auto-title after the first exchange. */
suspend fun generateTitle(baseUrl: String, token: String, id: String) =
/** POST /api/v1/conversations/[id]/title — returns the generated title or null. */
suspend fun generateTitle(baseUrl: String, token: String, id: String): String? =
withContext(Dispatchers.IO) {
val req = Request.Builder()
.url("$baseUrl/api/v1/conversations/$id/title")
@ -452,9 +453,13 @@ object KaizenApi {
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { }
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) return@use null
val body = resp.body?.string() ?: return@use null
json.decodeFromString<TitleResponse>(body).title
}
} catch (e: Exception) {
// best-effort: a missing title is cosmetic
null
}
}