@@ -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)) },