git.iko.soy/opencode-android
on master help sign in
Reduce scroll jank from streaming auto-scroll, per-bubble timers, and large diff composition
commitdce783d3fed7ab7230631733fcc035523a0c0d41 authoriko <mail@iko.soy> dateTue 30 Jun 2026, 23:45 (3 months ago) parent6a5c9f0
5 files changed+99−28
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+19−7 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+4−1 app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+1−1 app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt+67−19 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+8
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+19−7
@@ -49,6 +49,7 @@ import androidx.compose.material3.Text
4949import androidx.compose.material3.TextButton
5050import androidx.compose.material3.TopAppBar
5151import androidx.compose.runtime.Composable
52+import androidx.compose.runtime.CompositionLocalProvider
5253import androidx.compose.runtime.DisposableEffect
5354import androidx.compose.runtime.LaunchedEffect
5455import androidx.compose.runtime.derivedStateOf
@@ -82,7 +83,6 @@ import androidx.compose.ui.unit.dp
8283import androidx.lifecycle.compose.collectAsStateWithLifecycle
8384import androidx.lifecycle.viewmodel.compose.viewModel
8485import kotlinx.coroutines.Dispatchers
85−import kotlinx.coroutines.flow.collectLatest
8686import kotlinx.coroutines.launch
8787import kotlinx.coroutines.withContext
8888import soy.iko.opencode.data.model.TextPart
@@ -90,6 +90,8 @@ import soy.iko.opencode.data.network.EventStreamClient
9090import soy.iko.opencode.di.AppContainer
9191import soy.iko.opencode.R
9292import soy.iko.opencode.ui.components.ConnectionBanner
93+import soy.iko.opencode.ui.components.LocalRelativeTimeTick
94+import soy.iko.opencode.ui.components.rememberRelativeTimeTick
9395import soy.iko.opencode.ui.components.showToast
9496import soy.iko.opencode.ui.components.toImageContext
9597import soy.iko.opencode.ui.vmFactory
@@ -150,6 +152,10 @@ fun ChatScreen(
150152 }
151153
152154 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()
153159 val snackbar = remember { SnackbarHostState() }
154160 val retryLabel = stringResource(R.string.retry)
155161 var showModelPicker by rememberSaveable { mutableStateOf(false) }
@@ -188,8 +194,8 @@ fun ChatScreen(
188194 // Keep the newest content in view as parts stream in — only if already at the bottom.
189195 // A single LaunchedEffect(Unit) + snapshotFlow avoids the rapid cancel/re-launch
190196 // churn that keying on lastPartLen (which changes on every streaming token) caused.
191− // collectLatest cancels the in-flight scroll when a newer snapshot arrives, so
192− // streaming stays smooth without dozens of competing animations per second.
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).
193199 //
194200 // The second Triple component tracks the total text length of the last message's
195201 // parts. When a streaming token updates an existing TextPart (same part ID, longer
@@ -202,11 +208,15 @@ fun ChatScreen(
202208 messages.lastOrNull()?.parts?.sumOf { (it as? TextPart)?.text?.length ?: 0 } ?: 0,
203209 isPinnedToBottom,
204210 )
205− }.collectLatest { (_, _, pinned) ->
206− if (messages.isNotEmpty() && pinned) {
207− listState.animateScrollToItem(messages.lastIndex)
208− }
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)
209218 }
219+ }
210220 }
211221 LaunchedEffect(Unit) {
212222 vm.errorEvents.collect { msg ->
@@ -296,6 +306,7 @@ fun ChatScreen(
296306 )
297307 },
298308 ) { padding ->
309+ CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
299310 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
300311 if (activeConnection == null) {
301312 Column(
@@ -379,6 +390,7 @@ fun ChatScreen(
379390 }
380391 }
381392 }
393+ }
382394 }
383395
384396 if (showExitConfirm) {
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+4−1
@@ -180,7 +180,10 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
180180 val expandedState = stringResource(R.string.state_expanded)
181181 val collapsedState = stringResource(R.string.state_collapsed)
182182 val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail else collapsed
183− if (looksLikeDiff(display)) {
183+ // looksLikeDiff scans the whole string; memoize so a recomposition that doesn't
184+ // change `display` (e.g. an unrelated state flip) doesn't re-scan on every pass.
185+ val isDiff = remember(display) { looksLikeDiff(display) }
186+ if (isDiff) {
184187 DiffView(display)
185188 } else {
186189 Text(
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+1−1
@@ -89,7 +89,7 @@ fun looksLikeDiff(text: String): Boolean {
8989 return false
9090}
9191
92−private const val COLLAPSED_DIFF_LINES = 500
92+private const val COLLAPSED_DIFF_LINES = 200
9393
9494/**
9595 * Renders a parsed unified diff. Implemented as a plain (non-lazy) [Column] so it is
app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt+67−19
@@ -1,6 +1,7 @@
11package soy.iko.opencode.ui.components
22
33import androidx.compose.runtime.Composable
4+import androidx.compose.runtime.compositionLocalOf
45import androidx.compose.runtime.derivedStateOf
56import androidx.compose.runtime.getValue
67import androidx.compose.runtime.mutableLongStateOf
@@ -8,7 +9,6 @@ import androidx.compose.runtime.remember
89import androidx.compose.runtime.setValue
910import androidx.lifecycle.Lifecycle
1011import androidx.lifecycle.compose.LocalLifecycleOwner
11−import androidx.lifecycle.compose.LifecycleResumeEffect
1212import androidx.lifecycle.repeatOnLifecycle
1313import kotlinx.coroutines.delay
1414import java.util.concurrent.TimeUnit
@@ -33,42 +33,90 @@ fun relativeTime(epochMillis: Long?): String {
3333 return "${years}y"
3434}
3535
36+/** Re-evaluation interval for relative-time labels — coarse enough for battery, fine
37+ * enough for the smallest unit we show (minutes). */
38+private const val RELATIVE_TIME_INTERVAL_MS = 30_000L
39+
40+/**
41+ * A shared, screen-level "tick" that advances periodically so every relative-time label
42+ * in the subtree refreshes together. Provide it once near the top of a scrollable screen:
43+ *
44+ * val tick = rememberRelativeTimeTick()
45+ * CompositionLocalProvider(LocalRelativeTimeTick provides tick) { ... }
46+ *
47+ * [rememberRelativeTime] then reads this single source instead of each timestamp spinning
48+ * up its own coroutine + lifecycle observer — a list with many timestamped rows avoids a
49+ * timer churn as items enter and leave the viewport.
50+ *
51+ * Defaults to 0 (no provider); [rememberRelativeTime] falls back to a self-managed timer
52+ * then, so it keeps auto-refreshing even without a provider.
53+ */
54+val LocalRelativeTimeTick = compositionLocalOf<Long> { 0L }
55+
3656/**
37− * Returns a relative-time string for [epochMillis] that auto-refreshes so the label
38− * doesn't go stale (e.g. "3m" → "4m" → "5m" while the screen is visible). Re-evaluates
39− * every [intervalMs] (default 30 s — coarse enough for battery, fine enough for the
40− * smallest unit we show, which is minutes). Pauses the timer when the lifecycle is
41− * not at least resumed (screen off / app backgrounded) to avoid battery drain.
57+ * Advances a tick counter while the lifecycle is at least resumed (immediately on resume,
58+ * then every [intervalMs]), and suspends while the screen isn't visible to avoid battery
59+ * drain. Provides one shared time source for an entire subtree via [LocalRelativeTimeTick].
4260 */
4361@Composable
44−fun rememberRelativeTime(epochMillis: Long?, intervalMs: Long = 30_000L): String {
62+fun rememberRelativeTimeTick(intervalMs: Long = RELATIVE_TIME_INTERVAL_MS): Long {
4563 var tick by remember { mutableLongStateOf(0L) }
4664 val lifecycleOwner = LocalLifecycleOwner.current
4765
48− // Tick immediately on resume so a stale label is refreshed without waiting for
49− // the first delay interval to elapse.
50− LifecycleResumeEffect(epochMillis, lifecycleOwner) {
51− tick++
52− onPauseOrDispose { /* nothing to clean up */ }
66+ androidx.compose.runtime.LaunchedEffect(lifecycleOwner) {
67+ lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
68+ // Refresh immediately on resume (so a stale label doesn't wait an interval),
69+ // then periodically so labels stay current.
70+ tick++
71+ while (true) {
72+ delay(intervalMs)
73+ tick++
74+ }
75+ }
76+ }
77+ return tick
78+}
79+
80+/**
81+ * Formats [epochMillis] relative to now, recomputing as time passes. Prefers the shared
82+ * screen-level tick from [LocalRelativeTimeTick] (one timer for the whole subtree); when
83+ * no provider is present it falls back to its own lifecycle-bound timer, so callers don't
84+ * need a provider to get auto-refreshing labels.
85+ */
86+@Composable
87+fun rememberRelativeTime(epochMillis: Long?): String {
88+ val sharedTick = LocalRelativeTimeTick.current
89+ return if (sharedTick != 0L) {
90+ // Reading sharedTick above registers a recomposition dependency on the shared
91+ // ticker, so the label recomputes (cheaply) whenever the screen-level tick advances.
92+ relativeTime(epochMillis)
93+ } else {
94+ rememberRelativeTimeStandalone(epochMillis)
5395 }
96+}
97+
98+/** Self-contained auto-refreshing label used when no [LocalRelativeTimeTick] provider exists. */
99+@Composable
100+private fun rememberRelativeTimeStandalone(epochMillis: Long?): String {
101+ var tick by remember { mutableLongStateOf(0L) }
102+ val lifecycleOwner = LocalLifecycleOwner.current
54103
55− // Drive the periodic refresh with repeatOnLifecycle so the delay loop is
56− // suspended (not running) when the screen isn't at least resumed. This avoids
57− // unnecessary CPU usage from timer coroutines while the app is backgrounded.
58104 androidx.compose.runtime.LaunchedEffect(epochMillis, lifecycleOwner) {
59105 if (epochMillis == null) return@LaunchedEffect
60106 lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
107+ // Tick immediately on resume so a stale label refreshes without waiting for
108+ // the first interval, then periodically.
109+ tick++
61110 while (true) {
62− delay(intervalMs)
111+ delay(RELATIVE_TIME_INTERVAL_MS)
63112 tick++
64113 }
65114 }
66115 }
67116
68117 // derivedStateOf reads tick so Compose recomposes when it changes, and recomputes
69− // the relative-time string from the current wall clock on each tick.
70− // Keying remember on epochMillis ensures a new derivedStateOf is created when the
71− // timestamp changes, so the new value is reflected immediately.
118+ // the relative-time string from the current wall clock on each tick. Keying remember
119+ // on epochMillis ensures a new derivedStateOf is created when the timestamp changes.
72120 val formatted by remember(epochMillis) {
73121 derivedStateOf {
74122 // Read tick to create a recomposition dependency on the timer.
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+8
@@ -50,6 +50,7 @@ import androidx.compose.material3.TopAppBar
5050import androidx.compose.material3.pulltorefresh.PullToRefreshBox
5151import androidx.compose.material3.rememberSwipeToDismissBoxState
5252import androidx.compose.runtime.Composable
53+import androidx.compose.runtime.CompositionLocalProvider
5354import androidx.compose.runtime.LaunchedEffect
5455import androidx.compose.runtime.getValue
5556import androidx.compose.runtime.mutableStateOf
@@ -74,7 +75,9 @@ import soy.iko.opencode.data.model.Session
7475import soy.iko.opencode.di.AppContainer
7576import soy.iko.opencode.R
7677import soy.iko.opencode.ui.components.ConnectionBanner
78+import soy.iko.opencode.ui.components.LocalRelativeTimeTick
7779import soy.iko.opencode.ui.components.rememberRelativeTime
80+import soy.iko.opencode.ui.components.rememberRelativeTimeTick
7881import soy.iko.opencode.ui.vmFactory
7982
8083@OptIn(ExperimentalMaterial3Api::class)
@@ -96,6 +99,9 @@ fun SessionListScreen(
9699 val unread by vm.unread.collectAsStateWithLifecycle()
97100 val haptics = LocalHapticFeedback.current
98101 val snackbar = remember { SnackbarHostState() }
102+ // One shared timer drives every relative-time label in the session list instead of
103+ // each card spinning up its own coroutine + lifecycle observer while scrolling.
104+ val timeTick = rememberRelativeTimeTick()
99105 var showServerMenu by rememberSaveable { mutableStateOf(false) }
100106 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
101107 var pendingRenameId by rememberSaveable { mutableStateOf<String?>(null) }
@@ -194,6 +200,7 @@ fun SessionListScreen(
194200 )
195201 },
196202 ) { padding ->
203+ CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
197204 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
198205 ConnectionBanner(
199206 state = connectionState,
@@ -263,6 +270,7 @@ fun SessionListScreen(
263270 }
264271 }
265272 }
273+ }
266274 }
267275
268276 pendingDelete?.let { session ->