git.iko.soy/opencode-android
on master help sign in
Reduce scroll jank from streaming auto-scroll, per-bubble timers, and large diff composition
dce783diko
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+19−7
@@ -49,6 +49,7 @@ import androidx.compose.material3.Text
49import androidx.compose.material3.TextButton
49import androidx.compose.material3.TextButton
50import androidx.compose.material3.TopAppBar
50import androidx.compose.material3.TopAppBar
51import androidx.compose.runtime.Composable
51import androidx.compose.runtime.Composable
52+import androidx.compose.runtime.CompositionLocalProvider
52import androidx.compose.runtime.DisposableEffect
53import androidx.compose.runtime.DisposableEffect
53import androidx.compose.runtime.LaunchedEffect
54import androidx.compose.runtime.LaunchedEffect
54import androidx.compose.runtime.derivedStateOf
55import androidx.compose.runtime.derivedStateOf
@@ -82,7 +83,6 @@ import androidx.compose.ui.unit.dp
82import androidx.lifecycle.compose.collectAsStateWithLifecycle
83import androidx.lifecycle.compose.collectAsStateWithLifecycle
83import androidx.lifecycle.viewmodel.compose.viewModel
84import androidx.lifecycle.viewmodel.compose.viewModel
84import kotlinx.coroutines.Dispatchers
85import kotlinx.coroutines.Dispatchers
85−import kotlinx.coroutines.flow.collectLatest
86import kotlinx.coroutines.launch
86import kotlinx.coroutines.launch
87import kotlinx.coroutines.withContext
87import kotlinx.coroutines.withContext
88import soy.iko.opencode.data.model.TextPart
88import soy.iko.opencode.data.model.TextPart
@@ -90,6 +90,8 @@ import soy.iko.opencode.data.network.EventStreamClient
90import soy.iko.opencode.di.AppContainer
90import soy.iko.opencode.di.AppContainer
91import soy.iko.opencode.R
91import soy.iko.opencode.R
92import soy.iko.opencode.ui.components.ConnectionBanner
92import soy.iko.opencode.ui.components.ConnectionBanner
93+import soy.iko.opencode.ui.components.LocalRelativeTimeTick
94+import soy.iko.opencode.ui.components.rememberRelativeTimeTick
93import soy.iko.opencode.ui.components.showToast
95import soy.iko.opencode.ui.components.showToast
94import soy.iko.opencode.ui.components.toImageContext
96import soy.iko.opencode.ui.components.toImageContext
95import soy.iko.opencode.ui.vmFactory
97import soy.iko.opencode.ui.vmFactory
@@ -150,6 +152,10 @@ fun ChatScreen(
150 }
152 }
151
153
152 val listState = rememberLazyListState()
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()
153 val snackbar = remember { SnackbarHostState() }
159 val snackbar = remember { SnackbarHostState() }
154 val retryLabel = stringResource(R.string.retry)
160 val retryLabel = stringResource(R.string.retry)
155 var showModelPicker by rememberSaveable { mutableStateOf(false) }
161 var showModelPicker by rememberSaveable { mutableStateOf(false) }
@@ -188,8 +194,8 @@ fun ChatScreen(
188 // Keep the newest content in view as parts stream in — only if already at the bottom.
194 // Keep the newest content in view as parts stream in — only if already at the bottom.
189 // A single LaunchedEffect(Unit) + snapshotFlow avoids the rapid cancel/re-launch
195 // A single LaunchedEffect(Unit) + snapshotFlow avoids the rapid cancel/re-launch
190 // churn that keying on lastPartLen (which changes on every streaming token) caused.
196 // 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
197+ // Snapping (not animating) to the bottom keeps the newest content in view: animating
192− // streaming stays smooth without dozens of competing animations per second.
198+ // per token cancels/relaunches the spring constantly and stutters (see collect below).
193 //
199 //
194 // The second Triple component tracks the total text length of the last message's
200 // The second Triple component tracks the total text length of the last message's
195 // parts. When a streaming token updates an existing TextPart (same part ID, longer
201 // parts. When a streaming token updates an existing TextPart (same part ID, longer
@@ -202,11 +208,15 @@ fun ChatScreen(
202 messages.lastOrNull()?.parts?.sumOf { (it as? TextPart)?.text?.length ?: 0 } ?: 0,
208 messages.lastOrNull()?.parts?.sumOf { (it as? TextPart)?.text?.length ?: 0 } ?: 0,
203 isPinnedToBottom,
209 isPinnedToBottom,
204 )
210 )
205− }.collectLatest { (_, _, pinned) ->
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.
206− if (messages.isNotEmpty() && pinned) {
216+ if (messages.isNotEmpty() && pinned) {
207− listState.animateScrollToItem(messages.lastIndex)
217+ listState.scrollToItem(messages.lastIndex)
208− }
209 }
218 }
219+ }
210 }
220 }
211 LaunchedEffect(Unit) {
221 LaunchedEffect(Unit) {
212 vm.errorEvents.collect { msg ->
222 vm.errorEvents.collect { msg ->
@@ -296,6 +306,7 @@ fun ChatScreen(
296 )
306 )
297 },
307 },
298 ) { padding ->
308 ) { padding ->
309+ CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
299 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
310 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
300 if (activeConnection == null) {
311 if (activeConnection == null) {
301 Column(
312 Column(
@@ -379,6 +390,7 @@ fun ChatScreen(
379 }
390 }
380 }
391 }
381 }
392 }
393+ }
382 }
394 }
383
395
384 if (showExitConfirm) {
396 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) {
180 val expandedState = stringResource(R.string.state_expanded)
180 val expandedState = stringResource(R.string.state_expanded)
181 val collapsedState = stringResource(R.string.state_collapsed)
181 val collapsedState = stringResource(R.string.state_collapsed)
182 val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail else collapsed
182 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) {
184 DiffView(display)
187 DiffView(display)
185 } else {
188 } else {
186 Text(
189 Text(
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+1−1
@@ -89,7 +89,7 @@ fun looksLikeDiff(text: String): Boolean {
89 return false
89 return false
90}
90}
91
91
92−private const val COLLAPSED_DIFF_LINES = 500
92+private const val COLLAPSED_DIFF_LINES = 200
93
93
94/**
94/**
95 * Renders a parsed unified diff. Implemented as a plain (non-lazy) [Column] so it is
95 * 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 @@
1package soy.iko.opencode.ui.components
1package soy.iko.opencode.ui.components
2
2
3import androidx.compose.runtime.Composable
3import androidx.compose.runtime.Composable
4+import androidx.compose.runtime.compositionLocalOf
4import androidx.compose.runtime.derivedStateOf
5import androidx.compose.runtime.derivedStateOf
5import androidx.compose.runtime.getValue
6import androidx.compose.runtime.getValue
6import androidx.compose.runtime.mutableLongStateOf
7import androidx.compose.runtime.mutableLongStateOf
@@ -8,7 +9,6 @@ import androidx.compose.runtime.remember
8import androidx.compose.runtime.setValue
9import androidx.compose.runtime.setValue
9import androidx.lifecycle.Lifecycle
10import androidx.lifecycle.Lifecycle
10import androidx.lifecycle.compose.LocalLifecycleOwner
11import androidx.lifecycle.compose.LocalLifecycleOwner
11−import androidx.lifecycle.compose.LifecycleResumeEffect
12import androidx.lifecycle.repeatOnLifecycle
12import androidx.lifecycle.repeatOnLifecycle
13import kotlinx.coroutines.delay
13import kotlinx.coroutines.delay
14import java.util.concurrent.TimeUnit
14import java.util.concurrent.TimeUnit
@@ -33,42 +33,90 @@ fun relativeTime(epochMillis: Long?): String {
33 return "${years}y"
33 return "${years}y"
34}
34}
35
35
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+
36/**
56/**
37− * Returns a relative-time string for [epochMillis] that auto-refreshes so the label
57+ * Advances a tick counter while the lifecycle is at least resumed (immediately on resume,
38− * doesn't go stale (e.g. "3m" → "4m" → "5m" while the screen is visible). Re-evaluates
58+ * then every [intervalMs]), and suspends while the screen isn't visible to avoid battery
39− * every [intervalMs] (default 30 s — coarse enough for battery, fine enough for the
59+ * drain. Provides one shared time source for an entire subtree via [LocalRelativeTimeTick].
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.
42 */
60 */
43@Composable
61@Composable
44−fun rememberRelativeTime(epochMillis: Long?, intervalMs: Long = 30_000L): String {
62+fun rememberRelativeTimeTick(intervalMs: Long = RELATIVE_TIME_INTERVAL_MS): Long {
45 var tick by remember { mutableLongStateOf(0L) }
63 var tick by remember { mutableLongStateOf(0L) }
46 val lifecycleOwner = LocalLifecycleOwner.current
64 val lifecycleOwner = LocalLifecycleOwner.current
47
65
66+ androidx.compose.runtime.LaunchedEffect(lifecycleOwner) {
67+ lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
48− // Tick immediately on resume so a stale label is refreshed without waiting for
68+ // Refresh immediately on resume (so a stale label doesn't wait an interval),
49− // the first delay interval to elapse.
69+ // then periodically so labels stay current.
50− LifecycleResumeEffect(epochMillis, lifecycleOwner) {
70+ tick++
51− tick++
71+ while (true) {
52− onPauseOrDispose { /* nothing to clean up */ }
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)
53 }
95 }
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
54
103
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.
58 androidx.compose.runtime.LaunchedEffect(epochMillis, lifecycleOwner) {
104 androidx.compose.runtime.LaunchedEffect(epochMillis, lifecycleOwner) {
59 if (epochMillis == null) return@LaunchedEffect
105 if (epochMillis == null) return@LaunchedEffect
60 lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
106 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++
61 while (true) {
110 while (true) {
62− delay(intervalMs)
111+ delay(RELATIVE_TIME_INTERVAL_MS)
63 tick++
112 tick++
64 }
113 }
65 }
114 }
66 }
115 }
67
116
68 // derivedStateOf reads tick so Compose recomposes when it changes, and recomputes
117 // derivedStateOf reads tick so Compose recomposes when it changes, and recomputes
69− // the relative-time string from the current wall clock on each tick.
118+ // the relative-time string from the current wall clock on each tick. Keying remember
70− // Keying remember on epochMillis ensures a new derivedStateOf is created when the
119+ // on epochMillis ensures a new derivedStateOf is created when the timestamp changes.
71− // timestamp changes, so the new value is reflected immediately.
72 val formatted by remember(epochMillis) {
120 val formatted by remember(epochMillis) {
73 derivedStateOf {
121 derivedStateOf {
74 // Read tick to create a recomposition dependency on the timer.
122 // 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
50import androidx.compose.material3.pulltorefresh.PullToRefreshBox
50import androidx.compose.material3.pulltorefresh.PullToRefreshBox
51import androidx.compose.material3.rememberSwipeToDismissBoxState
51import androidx.compose.material3.rememberSwipeToDismissBoxState
52import androidx.compose.runtime.Composable
52import androidx.compose.runtime.Composable
53+import androidx.compose.runtime.CompositionLocalProvider
53import androidx.compose.runtime.LaunchedEffect
54import androidx.compose.runtime.LaunchedEffect
54import androidx.compose.runtime.getValue
55import androidx.compose.runtime.getValue
55import androidx.compose.runtime.mutableStateOf
56import androidx.compose.runtime.mutableStateOf
@@ -74,7 +75,9 @@ import soy.iko.opencode.data.model.Session
74import soy.iko.opencode.di.AppContainer
75import soy.iko.opencode.di.AppContainer
75import soy.iko.opencode.R
76import soy.iko.opencode.R
76import soy.iko.opencode.ui.components.ConnectionBanner
77import soy.iko.opencode.ui.components.ConnectionBanner
78+import soy.iko.opencode.ui.components.LocalRelativeTimeTick
77import soy.iko.opencode.ui.components.rememberRelativeTime
79import soy.iko.opencode.ui.components.rememberRelativeTime
80+import soy.iko.opencode.ui.components.rememberRelativeTimeTick
78import soy.iko.opencode.ui.vmFactory
81import soy.iko.opencode.ui.vmFactory
79
82
80@OptIn(ExperimentalMaterial3Api::class)
83@OptIn(ExperimentalMaterial3Api::class)
@@ -96,6 +99,9 @@ fun SessionListScreen(
96 val unread by vm.unread.collectAsStateWithLifecycle()
99 val unread by vm.unread.collectAsStateWithLifecycle()
97 val haptics = LocalHapticFeedback.current
100 val haptics = LocalHapticFeedback.current
98 val snackbar = remember { SnackbarHostState() }
101 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()
99 var showServerMenu by rememberSaveable { mutableStateOf(false) }
105 var showServerMenu by rememberSaveable { mutableStateOf(false) }
100 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
106 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
101 var pendingRenameId by rememberSaveable { mutableStateOf<String?>(null) }
107 var pendingRenameId by rememberSaveable { mutableStateOf<String?>(null) }
@@ -194,6 +200,7 @@ fun SessionListScreen(
194 )
200 )
195 },
201 },
196 ) { padding ->
202 ) { padding ->
203+ CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
197 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
204 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
198 ConnectionBanner(
205 ConnectionBanner(
199 state = connectionState,
206 state = connectionState,
@@ -263,6 +270,7 @@ fun SessionListScreen(
263 }
270 }
264 }
271 }
265 }
272 }
273+ }
266 }
274 }
267
275
268 pendingDelete?.let { session ->
276 pendingDelete?.let { session ->