@@ -105,7 +105,7 @@ fun ChatScreen(
105 onBack: () -> Unit,
105 onBack: () -> Unit,
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()
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(
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 {
183− val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
184− lastVisible >= messages.lastIndex || messages.isEmpty()
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)
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).
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) {
208− messages.lastOrNull()?.parts?.sumOf { (it as? TextPart)?.text?.length ?: 0 } ?: 0,
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)
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)
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)) }
281− enabled = messages.isNotEmpty(),
234+ enabled = hasMessages,
283 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share_conversation))
236 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share_conversation))
@@ -306,6 +259,39 @@ fun ChatScreen(
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()
270+ val isPinnedToBottom by remember {
272+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
273+ lastVisible >= messages.lastIndex || messages.isEmpty()
277+ LaunchedEffect(messages.isNotEmpty()) {
278+ if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex)
281+ LaunchedEffect(Unit) {
285+ messages.lastOrNull()?.parts?.sumOf { (it as? TextPart)?.text?.length ?: 0 } ?: 0,
288+ }.collect { (_, _, pinned) ->
289+ if (messages.isNotEmpty() && pinned) {
290+ listState.scrollToItem(messages.lastIndex)
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) } }
389 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
375 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },