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.
This commit is contained in:
Bruno Deanoz 2026-06-22 13:57:52 +02:00
parent 1929073ad5
commit 1362daee53
4 changed files with 52 additions and 8 deletions

View file

@ -429,7 +429,13 @@ fun ChatScreen(
val finalSources = finalMsg?.sources?.takeIf { it.isNotEmpty() } val finalSources = finalMsg?.sources?.takeIf { it.isNotEmpty() }
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false) 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()) { if (convId != null && finalContent.isNotEmpty()) {
val saved = KaizenApi.saveMessages( val saved = KaizenApi.saveMessages(
@ -464,7 +470,12 @@ fun ChatScreen(
sources = finalSources ?: emptyList(), 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() refreshConversations()
} }
} }

View file

@ -14,13 +14,26 @@ interface ConversationDao {
@Upsert @Upsert
suspend fun upsertAll(conversations: List<ConversationEntity>) suspend fun upsertAll(conversations: List<ConversationEntity>)
@Upsert
suspend fun upsert(conversation: ConversationEntity)
@Query("DELETE FROM conversations") @Query("DELETE FROM conversations")
suspend fun deleteAll() 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 @Transaction
suspend fun replaceAll(conversations: List<ConversationEntity>) { suspend fun replaceAll(conversations: List<ConversationEntity>) {
deleteAll() if (conversations.isEmpty()) {
upsertAll(conversations) deleteAll()
} else {
upsertAll(conversations)
deleteExcept(conversations.map { it.id })
}
} }
@Query("SELECT cachedAt FROM conversations LIMIT 1") @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 lastCachedAt(): Long? = dao.lastCachedAt()
suspend fun allUnlockedIds(): List<String> = dao.allUnlockedIds() 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 UnlockResponse(val unlockToken: String = "")
@Serializable private data class CreateConvoRequest(val title: String? = null) @Serializable private data class CreateConvoRequest(val title: String? = null)
@Serializable private data class CreatedConversation(val id: String) @Serializable private data class CreatedConversation(val id: String)
@Serializable private data class TitleResponse(val title: String? = null)
@Serializable data class AppDevice( @Serializable data class AppDevice(
val id: String, val id: String,
@ -443,8 +444,8 @@ object KaizenApi {
} }
} }
/** POST /api/v1/conversations/[id]/title — fire-and-forget auto-title after the first exchange. */ /** POST /api/v1/conversations/[id]/title — returns the generated title or null. */
suspend fun generateTitle(baseUrl: String, token: String, id: String) = suspend fun generateTitle(baseUrl: String, token: String, id: String): String? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val req = Request.Builder() val req = Request.Builder()
.url("$baseUrl/api/v1/conversations/$id/title") .url("$baseUrl/api/v1/conversations/$id/title")
@ -452,9 +453,13 @@ object KaizenApi {
.header("Authorization", "Bearer $token") .header("Authorization", "Bearer $token")
.build() .build()
try { 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) { } catch (e: Exception) {
// best-effort: a missing title is cosmetic null
} }
} }