@@ -58,6 +58,7 @@ import androidx.compose.runtime.remember
5858import androidx.compose.runtime.rememberCoroutineScope
5959import androidx.compose.runtime.saveable.rememberSaveable
6060import androidx.compose.runtime.setValue
61+import androidx.compose.runtime.snapshotFlow
6162import androidx.compose.foundation.text.KeyboardActions
6263import androidx.compose.foundation.text.KeyboardOptions
6364import androidx.compose.ui.Alignment
@@ -71,6 +72,7 @@ import androidx.compose.ui.input.key.type
7172import androidx.compose.ui.platform.LocalContext
7273import androidx.compose.ui.platform.LocalHapticFeedback
7374import androidx.compose.ui.platform.LocalView
75+import androidx.compose.ui.platform.testTag
7476import androidx.compose.ui.res.stringResource
7577import androidx.compose.ui.semantics.Role
7678import androidx.compose.ui.semantics.contentDescription
@@ -80,15 +82,15 @@ import androidx.compose.ui.text.input.ImeAction
8082import androidx.compose.ui.unit.dp
8183import androidx.lifecycle.compose.collectAsStateWithLifecycle
8284import androidx.lifecycle.viewmodel.compose.viewModel
85+import kotlinx.coroutines.flow.collectLatest
8386import kotlinx.coroutines.launch
8487import soy.iko.opencode.data.network.EventStreamClient
85−import soy.iko.opencode.data.model.ReasoningPart
86−import soy.iko.opencode.data.model.TextPart
8788import soy.iko.opencode.di.AppContainer
8889import soy.iko.opencode.R
8990import soy.iko.opencode.ui.components.ConnectionBanner
9091import soy.iko.opencode.ui.components.toImageContext
9192import soy.iko.opencode.ui.vmFactory
93+import soy.iko.opencode.util.runCatchingCancellable
9294
9395@OptIn(ExperimentalMaterial3Api::class)
9496@Composable
@@ -174,19 +176,17 @@ fun ChatScreen(
174176 }
175177
176178 // Keep the newest content in view as parts stream in — only if already at the bottom.
177− // Include the growing text length so we follow streaming within a single part, not
178− // only when a brand-new message or part is added.
179− val lastPartLen = messages.lastOrNull()?.parts?.lastOrNull()?.let { part ->
180− when (part) {
181− is TextPart -> part.text.length
182− is ReasoningPart -> part.text.length
183− else -> 0
184− }
185− } ?: 0
186− LaunchedEffect(messages.size, messages.lastOrNull()?.parts?.size, lastPartLen) {
187− if (messages.isNotEmpty() && isPinnedToBottom) {
188− listState.animateScrollToItem(messages.lastIndex)
189− }
179+ // A single LaunchedEffect(Unit) + snapshotFlow avoids the rapid cancel/re-launch
180+ // churn that keying on lastPartLen (which changes on every streaming token) caused.
181+ // collectLatest cancels the in-flight scroll when a newer snapshot arrives, so
182+ // streaming stays smooth without dozens of competing animations per second.
183+ LaunchedEffect(Unit) {
184+ snapshotFlow { Triple(messages.size, messages.lastOrNull()?.parts?.size, isPinnedToBottom) }
185+ .collectLatest { (_, _, pinned) ->
186+ if (messages.isNotEmpty() && pinned) {
187+ listState.animateScrollToItem(messages.lastIndex)
188+ }
189+ }
190190 }
191191 val retryLabel = stringResource(R.string.retry)
192192 LaunchedEffect(error) {
@@ -337,7 +337,7 @@ fun ChatScreen(
337337 ExtendedFloatingActionButton(
338338 onClick = {
339339 if (messages.isNotEmpty()) {
340− scope.launch { listState.animateScrollToItem(messages.lastIndex) }
340+ scope.launch { runCatchingCancellable { listState.animateScrollToItem(messages.lastIndex) } }
341341 }
342342 },
343343 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
@@ -432,6 +432,7 @@ private fun ChatInputBar(
432432 onValueChange = onValueChange,
433433 modifier = Modifier
434434 .weight(1f)
435+ .testTag("chat_input")
435436 .onPreviewKeyEvent { event ->
436437 if (event.type == KeyEventType.KeyDown &&
437438 event.key == Key.Enter &&
@@ -445,20 +446,21 @@ private fun ChatInputBar(
445446 }
446447 },
447448 placeholder = { Text(stringResource(R.string.message_placeholder)) },
449+ label = { Text(stringResource(R.string.message_placeholder)) },
448450 enabled = enabled,
449451 maxLines = 6,
450452 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
451453 keyboardActions = KeyboardActions(onSend = { if (enabled && value.isNotBlank()) onSend() }),
452454 )
453455 if (running) {
454− IconButton(onClick = onAbort, modifier = Modifier.padding(start = 4.dp)) {
456+ IconButton(onClick = onAbort, modifier = Modifier.padding(start = 4.dp).testTag("stop_button")) {
455457 Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
456458 }
457459 } else {
458460 IconButton(
459461 onClick = onSend,
460462 enabled = enabled && value.isNotBlank(),
461− modifier = Modifier.padding(start = 4.dp),
463+ modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
462464 ) {
463465 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
464466 }