From aea19c46aaea832beab887e2ba03b1b67b515c2c Mon Sep 17 00:00:00 2001 From: tdrkDev Date: Tue, 21 Jul 2026 14:28:58 +0700 Subject: [PATCH 1/2] fix(downloads): align file-download scheduling with TDLib Foreground media (opening an image full screen, a story, a just-opened chat) loaded slowly because the app-level FileDownloadQueue withheld requests from TDLib and ordered them opposite to how TDLib schedules. TDLib has no concurrent-download cap and, among equal priorities, serves the most recently requested file first (LIFO, per td_api.tl). The queue capped DEFAULT downloads at 4, sorted equal priorities FIFO, and inferred "user intent" from priority >= 32 -- which viewport auto-download also passed, so every scrolled-past thumbnail was marked manual and became un-evictable, silting up the slots. Changes: - Make user intent explicit (userInitiated) instead of inferring it from priority; thread it from the tap gesture down to the queue. - De-saturate calculatePriority so 32 is reserved for real user actions. - Invert the equal-priority tie-break to LIFO to match TDLib. - Raise the concurrency caps and remove the 160ms inter-start gap; TDLib paces part issuance itself. - Bypass the queue for user-initiated requests so they reach TDLib at once. - Re-issue DownloadFile on user action (free bump-to-front in TDLib). - Cancel stale in-flight downloads (scan activeRequests), not just pending. - Fix an indefinite story hang: bound the synchronous wait with a timeout and never silently drop a synchronous request. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../data/datasource/FileDataSource.kt | 13 ++- .../data/datasource/TdFileDataSource.kt | 23 ++++- .../monogram/data/infra/FileDownloadQueue.kt | 97 ++++++++++++++----- .../data/repository/MessageRepositoryImpl.kt | 11 ++- .../data/repository/StoryRepositoryImpl.kt | 6 +- .../domain/repository/FileRepository.kt | 3 +- .../chats/conversation/ChatComponent.kt | 2 +- .../features/chats/conversation/ChatStore.kt | 2 +- .../chats/conversation/ChatStoreFactory.kt | 2 +- .../conversation/DefaultChatComponent.kt | 4 +- .../logic/files/FileOperations.kt | 16 ++- .../ui/content/ChatContentBody.kt | 9 +- 12 files changed, 139 insertions(+), 49 deletions(-) diff --git a/data/src/main/java/org/monogram/data/datasource/FileDataSource.kt b/data/src/main/java/org/monogram/data/datasource/FileDataSource.kt index 5e2fce805..543f9d36c 100644 --- a/data/src/main/java/org/monogram/data/datasource/FileDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/FileDataSource.kt @@ -5,14 +5,23 @@ import org.drinkless.tdlib.TdApi import org.monogram.data.infra.FileDownloadQueue interface FileDataSource { - suspend fun downloadFile(fileId: Int, priority: Int, offset: Long, limit: Long, synchronous: Boolean): TdApi.File? suspend fun downloadFile( fileId: Int, priority: Int, offset: Long, limit: Long, synchronous: Boolean, - type: FileDownloadQueue.DownloadType + userInitiated: Boolean = false + ): TdApi.File? + + suspend fun downloadFile( + fileId: Int, + priority: Int, + offset: Long, + limit: Long, + synchronous: Boolean, + type: FileDownloadQueue.DownloadType, + userInitiated: Boolean = false ): TdApi.File? suspend fun cancelDownload(fileId: Int): TdApi.Ok? suspend fun getFile(fileId: Int): TdApi.File? diff --git a/data/src/main/java/org/monogram/data/datasource/TdFileDataSource.kt b/data/src/main/java/org/monogram/data/datasource/TdFileDataSource.kt index d688d66d5..664e831d5 100644 --- a/data/src/main/java/org/monogram/data/datasource/TdFileDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/TdFileDataSource.kt @@ -1,11 +1,14 @@ package org.monogram.data.datasource import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeout import org.drinkless.tdlib.TdApi import org.monogram.data.core.coRunCatching import org.monogram.data.gateway.TelegramGateway import org.monogram.data.infra.FileDownloadQueue +private const val SYNCHRONOUS_DOWNLOAD_TIMEOUT_MS = 60_000L + class TdFileDataSource( private val gateway: TelegramGateway, private val fileDownloadQueue: FileDownloadQueue @@ -15,7 +18,8 @@ class TdFileDataSource( priority: Int, offset: Long, limit: Long, - synchronous: Boolean + synchronous: Boolean, + userInitiated: Boolean ): TdApi.File? { return downloadFile( fileId = fileId, @@ -23,7 +27,8 @@ class TdFileDataSource( offset = offset, limit = limit, synchronous = synchronous, - type = FileDownloadQueue.DownloadType.DEFAULT + type = FileDownloadQueue.DownloadType.DEFAULT, + userInitiated = userInitiated ) } @@ -33,7 +38,8 @@ class TdFileDataSource( offset: Long, limit: Long, synchronous: Boolean, - type: FileDownloadQueue.DownloadType + type: FileDownloadQueue.DownloadType, + userInitiated: Boolean ): TdApi.File? { fileDownloadQueue.clearSuppression(fileId) fileDownloadQueue.enqueue( @@ -43,10 +49,17 @@ class TdFileDataSource( offset, limit, synchronous, - ignoreSuppression = true + ignoreSuppression = true, + userInitiated = userInitiated ) if (synchronous) { - coRunCatching { fileDownloadQueue.waitForDownload(fileId).await() } + // Bounded: the queue can legitimately decline to enqueue (cooldown, suppression), + // and an unbounded await here left story loads hanging forever when it did. + coRunCatching { + withTimeout(SYNCHRONOUS_DOWNLOAD_TIMEOUT_MS) { + fileDownloadQueue.waitForDownload(fileId).await() + } + } } return getFile(fileId) } diff --git a/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt b/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt index 0ab4bca79..8dc55fcf5 100644 --- a/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt +++ b/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt @@ -51,8 +51,11 @@ class FileDownloadQueue( if (this.isManual != other.isManual) return if (this.isManual) -1 else 1 val p = other.priority.compareTo(priority) if (p != 0) return p - val a = availableAt.compareTo(other.availableAt) - return if (a != 0) a else createdAt.compareTo(other.createdAt) + // Match TDLib: among equal priorities the most recent request wins (LIFO). + // `availableAt` is deliberately not a ranking key -- it expresses readiness + // (backoff/cooldown) and is already enforced as a filter in dispatchTasks(). + // Ranking by it would keep this ordering FIFO and defeat the recency rule. + return other.createdAt.compareTo(createdAt) } } @@ -76,17 +79,19 @@ class FileDownloadQueue( @Volatile private var activeChatId: Long = 0L - @Volatile - private var lastTaskStartAt: Long = 0L - private val startGapMs = 160L + // TDLib has no concurrent-download cap of its own: it self-regulates with a 2 MiB in-flight + // byte budget per (DC, size-class) and orders equal priorities LIFO. Keeping these caps tight + // only withholds requests from that scheduler, so they are sized to stay out of its way. private val notFoundCooldownMs = TimeUnit.MINUTES.toMillis(2) - private val maxTotalParallelDownloads = 10 - private val maxVideoParallelDownloads = 3 - private val maxGifParallelDownloads = 2 - private val maxDefaultParallelDownloads = 4 - private val maxPendingDefaultAutoDownloads = 10 - private val maxStickerParallelDownloads = 5 + private val maxTotalParallelDownloads = 48 + // Video stays low deliberately: a large file monopolises TDLib's byte window anyway, so + // extra video slots add queueing without throughput. + private val maxVideoParallelDownloads = 4 + private val maxGifParallelDownloads = 4 + private val maxDefaultParallelDownloads = 32 + private val maxPendingDefaultAutoDownloads = 64 + private val maxStickerParallelDownloads = 16 private val stickerStallMs = 20_000L private val defaultStallMs = 35_000L private val stalledRecoveryCooldownMs = 12_000L @@ -176,19 +181,22 @@ class FileDownloadQueue( } } + // No inter-start gap: awaiting one here serialised every start and, because `trigger` is + // CONFLATED, an urgent enqueue arriving mid-dispatch could have its wake-up coalesced + // away. TDLib already paces part issuance itself (DelayDispatcher, 50ms -> 3ms ramp). for (task in tasksToStart) { - throttleTaskStart() scope.launch(dispatcherProvider.io) { processDownload(task) } } - } - private suspend fun throttleTaskStart() { - val now = System.currentTimeMillis() - val waitMs = (lastTaskStartAt + startGapMs - now).coerceAtLeast(0L) - if (waitMs > 0) delay(waitMs) - lastTaskStartAt = System.currentTimeMillis() + // A conflated trigger can drop a wake-up that arrived while we were dispatching, which + // would strand ready work until the 15s stall sweep. Re-arm only when this pass actually + // started something and more is waiting -- gating on progress keeps this from spinning + // when the backlog is blocked purely by slot exhaustion. + if (tasksToStart.isNotEmpty() && pendingRequests.isNotEmpty()) { + trigger.trySend(Unit) + } } private suspend fun processDownload(req: DownloadRequest) { @@ -535,14 +543,18 @@ class FileDownloadQueue( offset: Long = 0, limit: Long = 0, synchronous: Boolean = false, - ignoreSuppression: Boolean = false + ignoreSuppression: Boolean = false, + userInitiated: Boolean = false ) { scope.launch(dispatcherProvider.default) { if (!ignoreSuppression && suppressedAutoDownloadIds.contains(fileId)) { return@launch } - val isManualRequest = priority >= 32 + // User intent is an explicit signal, not something inferred from a magic priority. + // It used to be `priority >= 32`, but viewport auto-download also passes 32, so every + // thumbnail scrolled past was marked "manual" and became un-evictable. + val isManualRequest = userInitiated if (isManualRequest) manualDownloadIds.add(fileId) val cooldownUntil = notFoundCooldownUntil[fileId] @@ -592,7 +604,13 @@ class FileDownloadQueue( val active = activeRequests[fileId] if (active != null) { val merged = mergeRequests(active, req) - val shouldKick = merged != active || cache.fileCache[fileId]?.local?.isDownloadingActive != true + // Always re-send on user action. FileManager::download_impl runs with + // force_update_priority=true and ResourceManager::add_node inserts before + // equals, so re-issuing DownloadFile -- even at an unchanged priority -- + // moves the file to the head of TDLib's queue. It is a free bump-to-front. + val shouldKick = userInitiated || + merged != active || + cache.fileCache[fileId]?.local?.isDownloadingActive != true if (shouldKick) { activeRequests[fileId] = merged scope.launch(dispatcherProvider.io) { @@ -613,15 +631,33 @@ class FileDownloadQueue( return@launch } + // A genuinely user-initiated request must reach TDLib immediately rather than + // queueing behind background work. TDLib imposes no concurrency cap and orders + // equal priorities LIFO, so handing it straight over makes the newest action win. + // This intentionally lets activeRequests exceed maxTotalParallelDownloads; + // dispatchTasks() recomputes its counters per pass and simply admits nothing new + // until the set drains. + if (userInitiated) { + pendingRequests.remove(fileId) + activeRequests[fileId] = req + scope.launch(dispatcherProvider.io) { + processDownload(req) + } + return@launch + } + val pending = pendingRequests[fileId] if (pending != null) { pendingRequests[fileId] = mergeRequests(pending, req) } else { - if (!isManual && resolvedType == DownloadType.DEFAULT) { + // Never silently drop a synchronous request: callers await waitForDownload(), + // and abandoning it here leaves that deferred uncompleted forever. + if (!isManual && !synchronous && resolvedType == DownloadType.DEFAULT) { val pendingDefaultCount = pendingRequests.values.count { !it.isManual && it.type == DownloadType.DEFAULT } if (req.priority < 32 && pendingDefaultCount >= maxPendingDefaultAutoDownloads) { + downloadWaiters.remove(fileId)?.cancel() return@launch } } @@ -714,8 +750,11 @@ class FileDownloadQueue( var max = 1 val type = fileDownloadTypes[fileId] + // Priority only does work when values differ. These used to saturate at 32 -- the same + // value as an explicit user action -- which left TDLib nothing to arbitrate and reduced + // every scheduling decision to the tie-break. 32 is now reserved for real user intent. if (type == DownloadType.STICKER || type == DownloadType.VIDEO_NOTE) { - max = 32 + max = 8 } messages.forEach { (chatId, msgId) -> @@ -724,9 +763,9 @@ class FileDownloadQueue( var p = 1 if (isVisible) { - p = if (chatId == activeChatId) 32 else 24 - } else if (isNearby) { p = if (chatId == activeChatId) 16 else 8 + } else if (isNearby) { + p = if (chatId == activeChatId) 4 else 2 } max = maxOf(max, p) @@ -755,11 +794,17 @@ class FileDownloadQueue( private fun cancelIrrelevantDownloads() { scope.launch(dispatcherProvider.default) { - val toCancel = mutableListOf() + // Scan active downloads too, not just pending ones. An in-flight download that the + // user has scrolled past used to hold its slot until completion or the 3-minute + // timeout, which is what let stale work block foreground requests. + val toCancel = LinkedHashSet() for ((fileId, _) in pendingRequests) { if (!isStillRelevant(fileId)) toCancel.add(fileId) } + for ((fileId, _) in activeRequests) { + if (!isStillRelevant(fileId)) toCancel.add(fileId) + } toCancel.forEach { fileId -> cancelDownload(fileId, force = false, suppress = false) } } diff --git a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt index 188fb67fd..9cc1a9561 100644 --- a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt @@ -896,9 +896,16 @@ internal class MessageRepositoryImpl( } } - override fun downloadFile(fileId: Int, priority: Int, offset: Long, limit: Long, synchronous: Boolean) { + override fun downloadFile( + fileId: Int, + priority: Int, + offset: Long, + limit: Long, + synchronous: Boolean, + userInitiated: Boolean + ) { scope.launch { - fileDataSource.downloadFile(fileId, priority, offset, limit, synchronous) + fileDataSource.downloadFile(fileId, priority, offset, limit, synchronous, userInitiated) } } diff --git a/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt index 467afe9ec..94c1ab322 100644 --- a/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/StoryRepositoryImpl.kt @@ -661,7 +661,11 @@ class StoryRepositoryImpl( priority = priority, offset = 0, limit = 0, - synchronous = synchronous + synchronous = synchronous, + // Opening a story is an explicit user action. Story files are not registered + // per-message, so calculatePriority() cannot lift them -- without this they rank + // below every scrolled-past thumbnail and are the only work the drop guard sheds. + userInitiated = true ) }.onFailure { Log.d( diff --git a/domain/src/main/java/org/monogram/domain/repository/FileRepository.kt b/domain/src/main/java/org/monogram/domain/repository/FileRepository.kt index 4de949c8c..7862e92f8 100644 --- a/domain/src/main/java/org/monogram/domain/repository/FileRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/FileRepository.kt @@ -14,7 +14,8 @@ interface FileRepository { priority: Int = 1, offset: Long = 0, limit: Long = 0, - synchronous: Boolean = false + synchronous: Boolean = false, + userInitiated: Boolean = false ) suspend fun cancelDownloadFile(fileId: Int) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt index d9c7a1d77..44caa3338 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt @@ -140,7 +140,7 @@ interface ChatComponent { fun onViewportSettled() fun onMessageViewportChanged(visibleMessageIds: Set, nearbyMessageIds: Set) fun onScrollToBottom() - fun onDownloadFile(fileId: Int) + fun onDownloadFile(fileId: Int, userInitiated: Boolean = false) fun onDownloadHighRes(messageId: Long) fun onCancelDownloadFile(fileId: Int) fun updateScrollPosition(messageId: Long) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatStore.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatStore.kt index 0a900ae96..2a6af9833 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatStore.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatStore.kt @@ -111,7 +111,7 @@ interface ChatStore : Store component.scrollToBottomInternal() - is Intent.DownloadFile -> component.handleDownloadFile(intent.fileId) + is Intent.DownloadFile -> component.handleDownloadFile(intent.fileId, intent.userInitiated) is Intent.DownloadHighRes -> component.handleDownloadHighRes(intent.messageId) is Intent.CancelDownloadFile -> component.handleCancelDownloadFile(intent.fileId) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt index 0ff15173d..3696cac34 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt @@ -876,9 +876,9 @@ class DefaultChatComponent( override fun onScrollToBottom() = store.accept(ChatStore.Intent.ScrollToBottom) - override fun onDownloadFile(fileId: Int) { + override fun onDownloadFile(fileId: Int, userInitiated: Boolean) { AutoDownloadSuppression.clear(fileId) - store.accept(ChatStore.Intent.DownloadFile(fileId)) + store.accept(ChatStore.Intent.DownloadFile(fileId, userInitiated)) } override fun onDownloadHighRes(messageId: Long) = store.accept(ChatStore.Intent.DownloadHighRes(messageId)) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/files/FileOperations.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/files/FileOperations.kt index 9fa77f863..f4e2abefb 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/files/FileOperations.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/files/FileOperations.kt @@ -6,8 +6,15 @@ import kotlinx.coroutines.launch import org.monogram.domain.models.MessageContent import org.monogram.presentation.features.chats.conversation.DefaultChatComponent -internal fun DefaultChatComponent.handleDownloadFile(fileId: Int) { - repositoryMessage.downloadFile(fileId, priority = 32) +internal fun DefaultChatComponent.handleDownloadFile(fileId: Int, userInitiated: Boolean = false) { + // Only an explicit gesture gets priority 32 and the user-initiated fast path. Viewport + // prefetch used to pass 32 as well, which marked every scrolled-past thumbnail as "manual" + // and left it un-evictable, silting up the download slots. + repositoryMessage.downloadFile( + fileId, + priority = if (userInitiated) 32 else 16, + userInitiated = userInitiated + ) } internal fun DefaultChatComponent.handleCancelDownloadFile(fileId: Int) { @@ -25,7 +32,10 @@ internal fun DefaultChatComponent.handleDownloadHighRes(messageId: Long) { val fileId = repositoryMessage.getHighResFileId(chatId, messageId) if (fileId != null) { updatePhotoOriginalFileId(messageId, fileId) - repositoryMessage.downloadFile(fileId, priority = 32) + // Opening a photo full screen is an unambiguous user action: this file is usually + // cold (only the "x" size was ever prefetched), so it needs the bypass to reach + // TDLib immediately rather than queueing behind background thumbnails. + repositoryMessage.downloadFile(fileId, priority = 32, userInitiated = true) } } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentBody.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentBody.kt index 0a2de506f..68eff5745 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentBody.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentBody.kt @@ -284,7 +284,8 @@ internal fun ChatContentBody( ) } } else { - content?.fileId?.takeIf { it != 0 }?.let(component::onDownloadFile) + content?.fileId?.takeIf { it != 0 } + ?.let { component.onDownloadFile(it, userInitiated = true) } } Unit } @@ -314,7 +315,7 @@ internal fun ChatContentBody( else -> 0 } if (fileId != 0) { - component.onDownloadFile(fileId) + component.onDownloadFile(fileId, userInitiated = true) } } } @@ -349,7 +350,7 @@ internal fun ChatContentBody( component.downloadUtils.openFile(validPath) } } else { - component.onDownloadFile(document.fileId) + component.onDownloadFile(document.fileId, userInitiated = true) } } Unit @@ -369,7 +370,7 @@ internal fun ChatContentBody( caption = audio.caption ) } else { - component.onDownloadFile(audio.fileId) + component.onDownloadFile(audio.fileId, userInitiated = true) } } Unit From 6d061b6a3e3aeec91938ca6d320348e451f44982 Mon Sep 17 00:00:00 2001 From: tdrkDev Date: Tue, 21 Jul 2026 14:29:07 +0700 Subject: [PATCH 2/2] fix(chat): stop forum topics getting stuck on a blank screen forever Tapping a topic while the forum root was still loading (~2s) silently dropped the load and left the screen permanently blank with nothing to retry it. handleTopicClick() clears `messages` and sets viewportPhase=Initializing, then calls loadMessages(force = true). But loadMessages returned early on `if (state.isLoading) return` -- checked before `force` was honoured -- so the forced load did nothing. With 0 messages and 0 topics the viewport never settles, so the content animates to alpha 0: no spinner, no error, no retry. - Honour force: `if (state.isLoading && !force) return`. cancelAllLoadingJobs() already tears down the in-flight load, so pre-empting is safe. - Guard the finally block with isActive so a pre-empted job (cancelled without join) can't clear isLoading after the replacement load set it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../logic/message-loading/MessageLoading.kt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-loading/MessageLoading.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-loading/MessageLoading.kt index deeec2e72..3470753e2 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-loading/MessageLoading.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-loading/MessageLoading.kt @@ -293,7 +293,12 @@ internal fun DefaultChatComponent.loadMessages( loadSource: String = "manual" ) { val state = _state.value - if (state.isLoading) return + // A forced load must be able to pre-empt one already in flight. handleTopicClick() clears + // `messages` and sets `viewportPhase = Initializing` before calling this, so dropping the + // call here left the screen permanently blank with nothing to re-trigger it: tapping a topic + // while the forum root was still loading (~2s) was silently ignored. + // cancelAllLoadingJobs() below tears down the in-flight load, so pre-empting is safe. + if (state.isLoading && !force) return if (!force && state.messages.size >= PAGE_SIZE && state.currentTopicId == null) return cancelAllLoadingJobs() @@ -432,7 +437,12 @@ internal fun DefaultChatComponent.loadMessages( phase = "chat_open_total_end", durationMs = System.currentTimeMillis() - openStartedAt ) - _state.update { it.copy(isLoading = false) } + // Only clear the flag if this job finished on its own. cancelAllLoadingJobs() cancels + // without joining, so a pre-empted job's finally runs *after* the replacement job has + // already set isLoading = true -- clearing it here would clobber the live load. + if (isActive) { + _state.update { it.copy(isLoading = false) } + } ChatConversationLog.logViewportState( event = "load_messages_finished", state = _state.value,