git.iko.soy/opencode-android
on master help sign in
Narrow chat recomposition scope and debounce session list SSE updates to reduce scroll jank
59feb55iko
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+4
@@ -57,6 +57,10 @@ object NetworkConfig {
57 * of SessionUpdated events (e.g. during active streaming) coalesces into one fetch
57 * of SessionUpdated events (e.g. during active streaming) coalesces into one fetch
58 * instead of launching one per event (each downloading the full message history). */
58 * instead of launching one per event (each downloading the full message history). */
59 const val previewDebounceMs = 500L
59 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
60
64
61 // --- In-memory message store (SessionRepository.MessageStore) ---
65 // --- In-memory message store (SessionRepository.MessageStore) ---
62
66
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+37−51
@@ -105,7 +105,7 @@ fun ChatScreen(
105 onBack: () -> Unit,
105 onBack: () -> Unit,
106) {
106) {
107 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
107 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()
109 val running by vm.running.collectAsStateWithLifecycle()
109 val running by vm.running.collectAsStateWithLifecycle()
110 val loading by vm.loading.collectAsStateWithLifecycle()
110 val loading by vm.loading.collectAsStateWithLifecycle()
111 val models by vm.models.collectAsStateWithLifecycle()
111 val models by vm.models.collectAsStateWithLifecycle()
@@ -151,11 +151,6 @@ fun ChatScreen(
151 if (sessionDeleted) onBack()
151 if (sessionDeleted) onBack()
152 }
152 }
153
153
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()
159 val snackbar = remember { SnackbarHostState() }
154 val snackbar = remember { SnackbarHostState() }
160 val retryLabel = stringResource(R.string.retry)
155 val retryLabel = stringResource(R.string.retry)
161 var showModelPicker by rememberSaveable { mutableStateOf(false) }
156 var showModelPicker by rememberSaveable { mutableStateOf(false) }
@@ -176,48 +171,6 @@ fun ChatScreen(
176 }
171 }
177 }
172 }
178
173
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− }
221 LaunchedEffect(Unit) {
174 LaunchedEffect(Unit) {
222 vm.errorEvents.collect { msg ->
175 vm.errorEvents.collect { msg ->
223 val result = if (vm.failedDraft.value != null) {
176 val result = if (vm.failedDraft.value != null) {
@@ -267,7 +220,7 @@ fun ChatScreen(
267 onClick = {
220 onClick = {
268 scope.launch {
221 scope.launch {
269 val md = withContext(Dispatchers.Default) {
222 val md = withContext(Dispatchers.Default) {
270− buildConversationMarkdown(messages, sessionTitle)
223+ buildConversationMarkdown(vm.messages.value, sessionTitle)
271 }
224 }
272 val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
225 val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
273 type = "text/markdown"
226 type = "text/markdown"
@@ -278,7 +231,7 @@ fun ChatScreen(
278 .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
231 .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
279 }
232 }
280 },
233 },
281− enabled = messages.isNotEmpty(),
234+ enabled = hasMessages,
282 ) {
235 ) {
283 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share_conversation))
236 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share_conversation))
284 }
237 }
@@ -306,6 +259,39 @@ fun ChatScreen(
306 )
259 )
307 },
260 },
308 ) { padding ->
261 ) { 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+
309 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
295 CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
310 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
296 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
311 if (activeConnection == null) {
297 if (activeConnection == null) {
@@ -383,7 +369,7 @@ fun ChatScreen(
383 ExtendedFloatingActionButton(
369 ExtendedFloatingActionButton(
384 onClick = {
370 onClick = {
385 if (messages.isNotEmpty()) {
371 if (messages.isNotEmpty()) {
386− scope.launch { runCatchingCancellable { listState.animateScrollToItem(messages.lastIndex) } }
372+ contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(messages.lastIndex) } }
387 }
373 }
388 },
374 },
389 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
375 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
37import kotlinx.coroutines.flow.debounce
37import kotlinx.coroutines.flow.debounce
38import kotlinx.coroutines.flow.drop
38import kotlinx.coroutines.flow.drop
39import kotlinx.coroutines.flow.first
39import kotlinx.coroutines.flow.first
40+import kotlinx.coroutines.flow.filterIsInstance
40import kotlinx.coroutines.flow.flatMapLatest
41import kotlinx.coroutines.flow.flatMapLatest
41import kotlinx.coroutines.flow.flowOf
42import kotlinx.coroutines.flow.flowOf
43+import kotlinx.coroutines.flow.map
42import kotlinx.coroutines.flow.merge
44import kotlinx.coroutines.flow.merge
43import kotlinx.coroutines.flow.onEach
45import kotlinx.coroutines.flow.onEach
44import kotlinx.coroutines.flow.retryWhen
46import kotlinx.coroutines.flow.retryWhen
@@ -97,6 +99,18 @@ class ChatViewModel(
97 initialValue = emptyList(),
99 initialValue = emptyList(),
98 )
100 )
99
101
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+
100 private val _running = MutableStateFlow(false)
114 private val _running = MutableStateFlow(false)
101 val running: StateFlow<Boolean> = _running.asStateFlow()
115 val running: StateFlow<Boolean> = _running.asStateFlow()
102
116
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+32−5
@@ -114,6 +114,12 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
114 private val livePreviewJobs = java.util.concurrent.ConcurrentHashMap<String, Job>()
114 private val livePreviewJobs = java.util.concurrent.ConcurrentHashMap<String, Job>()
115 private val previewLock = Any()
115 private val previewLock = Any()
116
116
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+
117 private val activeProfileId: String?
123 private val activeProfileId: String?
118 get() = container.activeConnection.value?.profile?.id
124 get() = container.activeConnection.value?.profile?.id
119
125
@@ -138,6 +144,8 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
138 livePreviewJobs.values.forEach { it.cancel() }
144 livePreviewJobs.values.forEach { it.cancel() }
139 livePreviewJobs.clear()
145 livePreviewJobs.clear()
140 }
146 }
147+ sessionUpdateJob?.cancel()
148+ pendingSessionUpdates.clear()
141 // Reload the session list when a connection becomes available so the
149 // Reload the session list when a connection becomes available so the
142 // initial load (which may have run before auto-reconnect finished and
150 // initial load (which may have run before auto-reconnect finished and
143 // found no connection) doesn't leave a stale "not connected" error.
151 // found no connection) doesn't leave a stale "not connected" error.
@@ -147,12 +155,13 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
147 when (event) {
155 when (event) {
148 is SessionUpdated -> {
156 is SessionUpdated -> {
149 val session = event.properties.info
157 val session = event.properties.info
150− _state.update { s ->
158+ pendingSessionUpdates[session.id] = session
151− val updated = (s.sessions.filterNot { it.id == session.id } + session)
159+ if (sessionUpdateJob?.isActive != true) {
152− .sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
160+ sessionUpdateJob = viewModelScope.launch {
153− s.copy(sessions = updated)
161+ delay(NetworkConfig.sessionUpdateDebounceMs)
162+ flushPendingSessionUpdates()
163+ }
154 }
164 }
155− loadPreview(session.id)
156 }
165 }
157 is SessionDeleted -> {
166 is SessionDeleted -> {
158 val id = event.properties.info?.id ?: event.properties.sessionID
167 val id = event.properties.info?.id ?: event.properties.sessionID
@@ -179,6 +188,24 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
179 }
188 }
180 }
189 }
181
190
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+
182 fun setQuery(query: String) {
209 fun setQuery(query: String) {
183 _state.update { it.copy(query = query) }
210 _state.update { it.copy(query = query) }
184 }
211 }