git.iko.soy/opencode-android
on master help sign in
Narrow chat recomposition scope and debounce session list SSE updates to reduce scroll jank
commit59feb55d04690ceed71b0cac966afef2f41f1fd7 authoriko <mail@iko.soy> dateWed 1 Jul 2026, 05:53 (3 months ago) parent278b084
4 files changed+87−56
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+4 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+37−51 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+14 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+32−5
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+4
@@ -57,6 +57,10 @@ object NetworkConfig {
5757 * of SessionUpdated events (e.g. during active streaming) coalesces into one fetch
5858 * instead of launching one per event (each downloading the full message history). */
5959 const val previewDebounceMs = 500L
60+ /** Debounce for applying SessionUpdated SSE events to the session list state. During
61+ * active streaming the server emits these frequently; without debouncing each one
62+ * triggers a full list filter + sort + recomposition, causing scroll jank. */
63+ const val sessionUpdateDebounceMs = 300L
6064
6165 // --- In-memory message store (SessionRepository.MessageStore) ---
6266
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+37−51
@@ -105,7 +105,7 @@ fun ChatScreen(
105105 onBack: () -> Unit,
106106) {
107107 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
108− val messages by vm.messages.collectAsStateWithLifecycle()
108+ val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
109109 val running by vm.running.collectAsStateWithLifecycle()
110110 val loading by vm.loading.collectAsStateWithLifecycle()
111111 val models by vm.models.collectAsStateWithLifecycle()
@@ -151,11 +151,6 @@ fun ChatScreen(
151151 if (sessionDeleted) onBack()
152152 }
153153
154− val listState = rememberLazyListState()
155− // One shared timer drives every relative-time label in the list (see MessageTimestamp)
156− // instead of each bubble spinning up its own coroutine + lifecycle observer, which
157− // churns as items scroll in and out of the viewport.
158− val timeTick = rememberRelativeTimeTick()
159154 val snackbar = remember { SnackbarHostState() }
160155 val retryLabel = stringResource(R.string.retry)
161156 var showModelPicker by rememberSaveable { mutableStateOf(false) }
@@ -176,48 +171,6 @@ fun ChatScreen(
176171 }
177172 }
178173
179− // Track whether the list is pinned to the bottom so streaming auto-scroll
180− // doesn't fight a user who scrolled up to read earlier output.
181− val isPinnedToBottom by remember {
182− derivedStateOf {
183− val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
184− lastVisible >= messages.lastIndex || messages.isEmpty()
185− }
186− }
187−
188− // Jump to the latest message when the conversation first loads so the user
189− // lands on recent context, not the oldest.
190− LaunchedEffect(messages.isNotEmpty()) {
191− if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex)
192− }
193−
194− // Keep the newest content in view as parts stream in — only if already at the bottom.
195− // A single LaunchedEffect(Unit) + snapshotFlow avoids the rapid cancel/re-launch
196− // churn that keying on lastPartLen (which changes on every streaming token) caused.
197− // Snapping (not animating) to the bottom keeps the newest content in view: animating
198− // per token cancels/relaunches the spring constantly and stutters (see collect below).
199− //
200− // The second Triple component tracks the total text length of the last message's
201− // parts. When a streaming token updates an existing TextPart (same part ID, longer
202− // text), messages.size and parts.size don't change — without the text length, the
203− // snapshotFlow wouldn't emit and the view wouldn't auto-scroll to follow the stream.
204− LaunchedEffect(Unit) {
205− snapshotFlow {
206− Triple(
207− messages.size,
208− messages.lastOrNull()?.parts?.sumOf { (it as? TextPart)?.text?.length ?: 0 } ?: 0,
209− isPinnedToBottom,
210− )
211− }.collect { (_, _, pinned) ->
212− // Snap (don't animate) to the latest content as it streams in. Animating on
213− // every token cancels and relaunches the spring dozens of times per second,
214− // which never settles and visibly stutters. An instant scroll is one cheap
215− // layout pass per coalesced snapshot and tracks the stream smoothly.
216− if (messages.isNotEmpty() && pinned) {
217− listState.scrollToItem(messages.lastIndex)
218− }
219− }
220− }
221174 LaunchedEffect(Unit) {
222175 vm.errorEvents.collect { msg ->
223176 val result = if (vm.failedDraft.value != null) {
@@ -267,7 +220,7 @@ fun ChatScreen(
267220 onClick = {
268221 scope.launch {
269222 val md = withContext(Dispatchers.Default) {
270− buildConversationMarkdown(messages, sessionTitle)
223+ buildConversationMarkdown(vm.messages.value, sessionTitle)
271224 }
272225 val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
273226 type = "text/markdown"
@@ -278,7 +231,7 @@ fun ChatScreen(
278231 .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
279232 }
280233 },
281− enabled = messages.isNotEmpty(),
234+ enabled = hasMessages,
282235 ) {
283236 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share_conversation))
284237 }
@@ -306,6 +259,39 @@ fun ChatScreen(
306259 )
307260 },
308261 ) { padding ->
262+ // Collect messages inside the content lambda so streaming token updates
263+ // recompose only this subtree, not the top bar / input bar / 20+ other
264+ // state reads in the ChatScreen body.
265+ val messages by vm.messages.collectAsStateWithLifecycle()
266+ val listState = rememberLazyListState()
267+ val contentScope = rememberCoroutineScope()
268+ val timeTick = rememberRelativeTimeTick()
269+
270+ val isPinnedToBottom by remember {
271+ derivedStateOf {
272+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
273+ lastVisible >= messages.lastIndex || messages.isEmpty()
274+ }
275+ }
276+
277+ LaunchedEffect(messages.isNotEmpty()) {
278+ if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex)
279+ }
280+
281+ LaunchedEffect(Unit) {
282+ snapshotFlow {
283+ Triple(
284+ messages.size,
285+ messages.lastOrNull()?.parts?.sumOf { (it as? TextPart)?.text?.length ?: 0 } ?: 0,
286+ isPinnedToBottom,
287+ )
288+ }.collect { (_, _, pinned) ->
289+ if (messages.isNotEmpty() && pinned) {
290+ listState.scrollToItem(messages.lastIndex)
291+ }
292+ }
293+ }
294+
309295 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
310296 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
311297 if (activeConnection == null) {
@@ -383,7 +369,7 @@ fun ChatScreen(
383369 ExtendedFloatingActionButton(
384370 onClick = {
385371 if (messages.isNotEmpty()) {
386− scope.launch { runCatchingCancellable { listState.animateScrollToItem(messages.lastIndex) } }
372+ contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(messages.lastIndex) } }
387373 }
388374 },
389375 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+14
@@ -37,8 +37,10 @@ import kotlinx.coroutines.flow.collectLatest
3737import kotlinx.coroutines.flow.debounce
3838import kotlinx.coroutines.flow.drop
3939import kotlinx.coroutines.flow.first
40+import kotlinx.coroutines.flow.filterIsInstance
4041import kotlinx.coroutines.flow.flatMapLatest
4142import kotlinx.coroutines.flow.flowOf
43+import kotlinx.coroutines.flow.map
4244import kotlinx.coroutines.flow.merge
4345import kotlinx.coroutines.flow.onEach
4446import kotlinx.coroutines.flow.retryWhen
@@ -97,6 +99,18 @@ class ChatViewModel(
9799 initialValue = emptyList(),
98100 )
99101
102+ /** Whether the conversation has any messages. Derived separately so the top bar
103+ * (share button enabled state) can observe this cheap boolean instead of the
104+ * full messages list, avoiding per-token recomposition of the app bar during
105+ * streaming. */
106+ val hasMessages: StateFlow<Boolean> = messages
107+ .map { it.isNotEmpty() }
108+ .stateIn(
109+ scope = viewModelScope,
110+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
111+ initialValue = false,
112+ )
113+
100114 private val _running = MutableStateFlow(false)
101115 val running: StateFlow<Boolean> = _running.asStateFlow()
102116
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+32−5
@@ -114,6 +114,12 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
114114 private val livePreviewJobs = java.util.concurrent.ConcurrentHashMap<String, Job>()
115115 private val previewLock = Any()
116116
117+ /** Accumulates SessionUpdated events between debounce flushes so a burst of SSE
118+ * updates during active streaming coalesces into one list rebuild + recomposition
119+ * instead of one per event. */
120+ private val pendingSessionUpdates = java.util.concurrent.ConcurrentHashMap<String, Session>()
121+ private var sessionUpdateJob: Job? = null
122+
117123 private val activeProfileId: String?
118124 get() = container.activeConnection.value?.profile?.id
119125
@@ -138,6 +144,8 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
138144 livePreviewJobs.values.forEach { it.cancel() }
139145 livePreviewJobs.clear()
140146 }
147+ sessionUpdateJob?.cancel()
148+ pendingSessionUpdates.clear()
141149 // Reload the session list when a connection becomes available so the
142150 // initial load (which may have run before auto-reconnect finished and
143151 // found no connection) doesn't leave a stale "not connected" error.
@@ -147,12 +155,13 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
147155 when (event) {
148156 is SessionUpdated -> {
149157 val session = event.properties.info
150− _state.update { s ->
151− val updated = (s.sessions.filterNot { it.id == session.id } + session)
152− .sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
153− s.copy(sessions = updated)
158+ pendingSessionUpdates[session.id] = session
159+ if (sessionUpdateJob?.isActive != true) {
160+ sessionUpdateJob = viewModelScope.launch {
161+ delay(NetworkConfig.sessionUpdateDebounceMs)
162+ flushPendingSessionUpdates()
163+ }
154164 }
155− loadPreview(session.id)
156165 }
157166 is SessionDeleted -> {
158167 val id = event.properties.info?.id ?: event.properties.sessionID
@@ -179,6 +188,24 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
179188 }
180189 }
181190
191+ /** Apply all buffered SessionUpdated events at once: upsert into the sessions list,
192+ * re-sort by recency, and kick off preview fetches. Called after the debounce delay. */
193+ private fun flushPendingSessionUpdates() {
194+ val batch = pendingSessionUpdates.toMap()
195+ if (batch.isEmpty()) return
196+ pendingSessionUpdates.clear()
197+ _state.update { s ->
198+ val byId = s.sessions.associateBy { it.id }.toMutableMap()
199+ batch.forEach { (id, session) -> byId[id] = session }
200+ s.copy(
201+ sessions = byId.values
202+ .sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
203+ .toList(),
204+ )
205+ }
206+ batch.keys.forEach { loadPreview(it) }
207+ }
208+
182209 fun setQuery(query: String) {
183210 _state.update { it.copy(query = query) }
184211 }