@@ -58,6 +58,7 @@ import androidx.compose.runtime.remember
58import androidx.compose.runtime.rememberCoroutineScope
58import androidx.compose.runtime.rememberCoroutineScope
59import androidx.compose.runtime.saveable.rememberSaveable
59import androidx.compose.runtime.saveable.rememberSaveable
60import androidx.compose.runtime.setValue
60import androidx.compose.runtime.setValue
61+import androidx.compose.runtime.snapshotFlow
61import androidx.compose.foundation.text.KeyboardActions
62import androidx.compose.foundation.text.KeyboardActions
62import androidx.compose.foundation.text.KeyboardOptions
63import androidx.compose.foundation.text.KeyboardOptions
63import androidx.compose.ui.Alignment
64import androidx.compose.ui.Alignment
@@ -71,6 +72,7 @@ import androidx.compose.ui.input.key.type
71import androidx.compose.ui.platform.LocalContext
72import androidx.compose.ui.platform.LocalContext
72import androidx.compose.ui.platform.LocalHapticFeedback
73import androidx.compose.ui.platform.LocalHapticFeedback
73import androidx.compose.ui.platform.LocalView
74import androidx.compose.ui.platform.LocalView
75+import androidx.compose.ui.platform.testTag
74import androidx.compose.ui.res.stringResource
76import androidx.compose.ui.res.stringResource
75import androidx.compose.ui.semantics.Role
77import androidx.compose.ui.semantics.Role
76import androidx.compose.ui.semantics.contentDescription
78import androidx.compose.ui.semantics.contentDescription
@@ -80,15 +82,15 @@ import androidx.compose.ui.text.input.ImeAction
80import androidx.compose.ui.unit.dp
82import androidx.compose.ui.unit.dp
81import androidx.lifecycle.compose.collectAsStateWithLifecycle
83import androidx.lifecycle.compose.collectAsStateWithLifecycle
82import androidx.lifecycle.viewmodel.compose.viewModel
84import androidx.lifecycle.viewmodel.compose.viewModel
85+import kotlinx.coroutines.flow.collectLatest
83import kotlinx.coroutines.launch
86import kotlinx.coroutines.launch
84import soy.iko.opencode.data.network.EventStreamClient
87import soy.iko.opencode.data.network.EventStreamClient
85−import soy.iko.opencode.data.model.ReasoningPart
86−import soy.iko.opencode.data.model.TextPart
87import soy.iko.opencode.di.AppContainer
88import soy.iko.opencode.di.AppContainer
88import soy.iko.opencode.R
89import soy.iko.opencode.R
89import soy.iko.opencode.ui.components.ConnectionBanner
90import soy.iko.opencode.ui.components.ConnectionBanner
90import soy.iko.opencode.ui.components.toImageContext
91import soy.iko.opencode.ui.components.toImageContext
91import soy.iko.opencode.ui.vmFactory
92import soy.iko.opencode.ui.vmFactory
93+import soy.iko.opencode.util.runCatchingCancellable
93@OptIn(ExperimentalMaterial3Api::class)
95@OptIn(ExperimentalMaterial3Api::class)
94@Composable
96@Composable
@@ -174,19 +176,17 @@ fun ChatScreen(
176 // Keep the newest content in view as parts stream in — only if already at the bottom.
178 // 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
179+ // A single LaunchedEffect(Unit) + snapshotFlow avoids the rapid cancel/re-launch
178− // only when a brand-new message or part is added.
180+ // churn that keying on lastPartLen (which changes on every streaming token) caused.
179− val lastPartLen = messages.lastOrNull()?.parts?.lastOrNull()?.let { part ->
181+ // collectLatest cancels the in-flight scroll when a newer snapshot arrives, so
180− when (part) {
182+ // streaming stays smooth without dozens of competing animations per second.
181− is TextPart -> part.text.length
183+ LaunchedEffect(Unit) {
182− is ReasoningPart -> part.text.length
186− LaunchedEffect(messages.size, messages.lastOrNull()?.parts?.size, lastPartLen) {
184+ snapshotFlow { Triple(messages.size, messages.lastOrNull()?.parts?.size, isPinnedToBottom) }
185+ .collectLatest { (_, _, pinned) ->
187− if (messages.isNotEmpty() && isPinnedToBottom) {
186+ if (messages.isNotEmpty() && pinned) {
188− listState.animateScrollToItem(messages.lastIndex)
187+ listState.animateScrollToItem(messages.lastIndex)
191 val retryLabel = stringResource(R.string.retry)
191 val retryLabel = stringResource(R.string.retry)
192 LaunchedEffect(error) {
192 LaunchedEffect(error) {
@@ -337,7 +337,7 @@ fun ChatScreen(
337 ExtendedFloatingActionButton(
337 ExtendedFloatingActionButton(
338 onClick = {
338 onClick = {
339 if (messages.isNotEmpty()) {
339 if (messages.isNotEmpty()) {
340− scope.launch { listState.animateScrollToItem(messages.lastIndex) }
340+ scope.launch { runCatchingCancellable { listState.animateScrollToItem(messages.lastIndex) } }
343 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
343 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
@@ -432,6 +432,7 @@ private fun ChatInputBar(
432 onValueChange = onValueChange,
432 onValueChange = onValueChange,
433 modifier = Modifier
433 modifier = Modifier
434 .weight(1f)
434 .weight(1f)
435+ .testTag("chat_input")
435 .onPreviewKeyEvent { event ->
436 .onPreviewKeyEvent { event ->
436 if (event.type == KeyEventType.KeyDown &&
437 if (event.type == KeyEventType.KeyDown &&
437 event.key == Key.Enter &&
438 event.key == Key.Enter &&
@@ -445,20 +446,21 @@ private fun ChatInputBar(
447 placeholder = { Text(stringResource(R.string.message_placeholder)) },
448 placeholder = { Text(stringResource(R.string.message_placeholder)) },
449+ label = { Text(stringResource(R.string.message_placeholder)) },
448 enabled = enabled,
450 enabled = enabled,
449 maxLines = 6,
451 maxLines = 6,
450 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
452 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
451 keyboardActions = KeyboardActions(onSend = { if (enabled && value.isNotBlank()) onSend() }),
453 keyboardActions = KeyboardActions(onSend = { if (enabled && value.isNotBlank()) onSend() }),
453 if (running) {
455 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")) {
455 Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
457 Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
458 IconButton(
460 IconButton(
459 onClick = onSend,
461 onClick = onSend,
460 enabled = enabled && value.isNotBlank(),
462 enabled = enabled && value.isNotBlank(),
461− modifier = Modifier.padding(start = 4.dp),
463+ modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
463 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
465 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))