From 1362daee531ed2637f9b57f3bc3e35819f587497 Mon Sep 17 00:00:00 2001 From: Bruno Deanoz Date: Mon, 22 Jun 2026 13:57:52 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20snappy=20sidebar=20=E2=80=94=20optimisti?= =?UTF-8?q?c=20insert,=20instant=20title,=20smart=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../main/java/dev/kaizen/app/chat/ChatScreen.kt | 15 +++++++++++++-- .../java/dev/kaizen/app/db/ConversationDao.kt | 17 +++++++++++++++-- .../dev/kaizen/app/db/ConversationRepository.kt | 15 +++++++++++++++ .../main/java/dev/kaizen/app/net/KaizenApi.kt | 13 +++++++++---- 4 files changed, 52 insertions(+), 8 deletions(-) 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 175e41f..22bc549 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -429,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( @@ -464,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() } } diff --git a/app/src/main/java/dev/kaizen/app/db/ConversationDao.kt b/app/src/main/java/dev/kaizen/app/db/ConversationDao.kt index 01b5936..b1b6156 100644 --- a/app/src/main/java/dev/kaizen/app/db/ConversationDao.kt +++ b/app/src/main/java/dev/kaizen/app/db/ConversationDao.kt @@ -14,13 +14,26 @@ interface ConversationDao { @Upsert suspend fun upsertAll(conversations: List) + @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) + + @Query("UPDATE conversations SET title = :title WHERE id = :id") + suspend fun updateTitle(id: String, title: String) + @Transaction suspend fun replaceAll(conversations: List) { - deleteAll() - upsertAll(conversations) + if (conversations.isEmpty()) { + deleteAll() + } else { + upsertAll(conversations) + deleteExcept(conversations.map { it.id }) + } } @Query("SELECT cachedAt FROM conversations LIMIT 1") diff --git a/app/src/main/java/dev/kaizen/app/db/ConversationRepository.kt b/app/src/main/java/dev/kaizen/app/db/ConversationRepository.kt index 870074e..c935dcf 100644 --- a/app/src/main/java/dev/kaizen/app/db/ConversationRepository.kt +++ b/app/src/main/java/dev/kaizen/app/db/ConversationRepository.kt @@ -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 = dao.allUnlockedIds() 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 d054d12..92255e9 100644 --- a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt +++ b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt @@ -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(body).title + } } catch (e: Exception) { - // best-effort: a missing title is cosmetic + null } }