diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index eec5e413..7a1a0778 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -92,6 +92,7 @@ import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects import com.m57.hermescontrol.ui.chat.components.ChatLoadingOverlay import com.m57.hermescontrol.ui.chat.components.ChatResumeErrorOverlay import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab +import com.m57.hermescontrol.ui.chat.components.ChatTimelineNoPrefetchStrategy import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet import com.m57.hermescontrol.ui.chat.components.ContextUsageChip import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay @@ -158,7 +159,7 @@ fun ChatScreen( // read its fields recompose on search changes (bar, matched bubbles). val searchState = viewModel.searchState val lifecycleOwner = LocalLifecycleOwner.current - val listState = rememberLazyListState() + val listState = rememberLazyListState(prefetchStrategy = ChatTimelineNoPrefetchStrategy) val scrollScope = rememberCoroutineScope() val scrollController = rememberChatScrollController(listState, scrollScope) var isOlderPagingArmed by remember(state.currentSessionId) { mutableStateOf(false) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatTimelineNoPrefetchStrategy.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatTimelineNoPrefetchStrategy.kt new file mode 100644 index 00000000..a9513793 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatTimelineNoPrefetchStrategy.kt @@ -0,0 +1,25 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.lazy.LazyListLayoutInfo +import androidx.compose.foundation.lazy.LazyListPrefetchScope +import androidx.compose.foundation.lazy.LazyListPrefetchStrategy +import androidx.compose.foundation.lazy.layout.NestedPrefetchScope + +/** + * Chat messages can contain very large selectable Markdown blocks. Measuring a + * neighboring item during LazyColumn prefetch can monopolize the main thread + * long enough to trigger an input-dispatch ANR, even though that item is still + * off screen. Measure chat items only when they enter the viewport instead. + */ +@OptIn(ExperimentalFoundationApi::class) +internal object ChatTimelineNoPrefetchStrategy : LazyListPrefetchStrategy { + override fun LazyListPrefetchScope.onScroll( + delta: Float, + layoutInfo: LazyListLayoutInfo, + ) = Unit + + override fun LazyListPrefetchScope.onVisibleItemsUpdated(layoutInfo: LazyListLayoutInfo) = Unit + + override fun NestedPrefetchScope.onNestedPrefetch(firstVisibleItemIndex: Int) = Unit +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/components/ChatTimelinePrefetchStrategyTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/ChatTimelinePrefetchStrategyTest.kt new file mode 100644 index 00000000..d4260bf3 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/ChatTimelinePrefetchStrategyTest.kt @@ -0,0 +1,33 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.lazy.LazyListLayoutInfo +import androidx.compose.foundation.lazy.LazyListPrefetchScope +import androidx.compose.foundation.lazy.layout.NestedPrefetchScope +import io.mockk.mockk +import io.mockk.verify +import org.junit.Test + +@OptIn(ExperimentalFoundationApi::class) +class ChatTimelinePrefetchStrategyTest { + @Test + fun strategy_neverSchedulesOffscreenChatItems() { + val scope = mockk(relaxed = true) + val layoutInfo = mockk(relaxed = true) + val nestedScope = mockk(relaxed = true) + + with(ChatTimelineNoPrefetchStrategy) { + with(scope) { + onScroll(delta = 120f, layoutInfo) + onVisibleItemsUpdated(layoutInfo) + } + with(nestedScope) { + onNestedPrefetch(firstVisibleItemIndex = 10) + } + } + + verify(exactly = 0) { scope.schedulePrefetch(any(), any()) } + verify(exactly = 0) { nestedScope.schedulePrecomposition(any()) } + verify(exactly = 0) { nestedScope.schedulePrecompositionAndPremeasure(any(), any()) } + } +}