@@ -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(
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).
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,
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)
211 LaunchedEffect(Unit) {
221 LaunchedEffect(Unit) {
212 vm.errorEvents.collect { msg ->
222 vm.errorEvents.collect { msg ->
@@ -296,6 +306,7 @@ fun ChatScreen(
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) {
@@ -379,6 +390,7 @@ fun ChatScreen(
384 if (showExitConfirm) {
396 if (showExitConfirm) {