@@ -67,6 +67,7 @@ import androidx.compose.foundation.text.KeyboardActions
6767import androidx.compose.foundation.text.KeyboardOptions
6868import androidx.compose.ui.Alignment
6969import androidx.compose.ui.Modifier
70+import androidx.compose.ui.focus.focusRequester
7071import androidx.compose.ui.input.key.Key
7172import androidx.compose.ui.input.key.KeyEventType
7273import androidx.compose.ui.input.key.isCtrlPressed
@@ -113,6 +114,7 @@ fun ChatScreen(
113114 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
114115 val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
115116 val running by vm.running.collectAsStateWithLifecycle()
117+ val aborting by vm.aborting.collectAsStateWithLifecycle()
116118 val loading by vm.loading.collectAsStateWithLifecycle()
117119 val loadError by vm.loadError.collectAsStateWithLifecycle()
118120 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
@@ -177,6 +179,7 @@ fun ChatScreen(
177179
178180 val snackbar = remember { SnackbarHostState() }
179181 val retryLabel = stringResource(R.string.retry)
182+ val inputFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
180183 var showModelPicker by rememberSaveable { mutableStateOf(false) }
181184 var showAgentPicker by rememberSaveable { mutableStateOf(false) }
182185 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
@@ -352,6 +355,7 @@ fun ChatScreen(
352355 value = draft,
353356 onValueChange = vm::updateDraft,
354357 running = running,
358+ aborting = aborting,
355359 enabled = activeConnection != null,
356360 sendOnEnter = sendOnEnter,
357361 onSend = ::doSend,
@@ -359,6 +363,7 @@ fun ChatScreen(
359363 queuedFollowUp = queuedFollowUp,
360364 onQueueFollowUp = vm::queueFollowUp,
361365 onCancelQueue = { vm.queueFollowUp("") },
366+ focusRequester = inputFocusRequester,
362367 )
363368 },
364369 ) { padding ->
@@ -373,7 +378,13 @@ fun ChatScreen(
373378 val isPinnedToBottom by remember {
374379 derivedStateOf {
375380 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
376− lastVisible >= messages.lastIndex || messages.isEmpty()
381+ // When a run is active, the LazyColumn has one trailing "__typing"
382+ // item after the last message. Account for it so "pinned to bottom"
383+ // includes the working indicator — otherwise the pin check targets
384+ // messages.lastIndex, the typing row sits just below the viewport,
385+ // and the user never sees the progress indicator even when pinned.
386+ val effectiveLast = if (running) messages.size else messages.lastIndex
387+ lastVisible >= effectiveLast || messages.isEmpty()
377388 }
378389 }
379390
@@ -392,7 +403,11 @@ fun ChatScreen(
392403 AutoScrollSignal(messages.size, lastText?.text?.length ?: 0, isPinnedToBottom)
393404 }.collect { signal ->
394405 if (signal.size > 0 && signal.pinned) {
395− listState.scrollToItem(messages.lastIndex)
406+ // Scroll to the effective last index, including the trailing
407+ // "__typing" row when a run is active so the working indicator
408+ // is brought into view (not just the last message).
409+ val target = if (running) messages.size else messages.lastIndex
410+ listState.scrollToItem(target)
396411 }
397412 }
398413 }
@@ -477,7 +492,12 @@ fun ChatScreen(
477492 }
478493 } else if (messages.isEmpty()) {
479494 EmptyConversation(
480− onSuggestion = { vm.updateDraft(it) },
495+ onSuggestion = {
496+ vm.updateDraft(it)
497+ // Focus the input so the user can edit the suggestion before
498+ // sending, instead of having to manually tap the field.
499+ runCatching { inputFocusRequester.requestFocus() }
500+ },
481501 modifier = Modifier.align(Alignment.Center),
482502 )
483503 } else {
@@ -535,7 +555,10 @@ fun ChatScreen(
535555 ExtendedFloatingActionButton(
536556 onClick = {
537557 if (messages.isNotEmpty()) {
538− contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(messages.lastIndex) } }
558+ // Scroll to the effective last index, including the
559+ // trailing "__typing" row when a run is active.
560+ val target = if (running) messages.size else messages.lastIndex
561+ contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
539562 }
540563 },
541564 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
@@ -557,6 +580,7 @@ fun ChatScreen(
557580 confirmButton = {
558581 TextButton(onClick = {
559582 showExitConfirm = false
583+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
560584 vm.abort()
561585 onBack()
562586 }) { Text(stringResource(R.string.stop_and_exit)) }
@@ -575,6 +599,7 @@ fun ChatScreen(
575599 confirmButton = {
576600 TextButton(onClick = {
577601 showStopConfirm = false
602+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
578603 vm.abort()
579604 }) { Text(stringResource(R.string.stop), color = MaterialTheme.colorScheme.error) }
580605 },
@@ -632,6 +657,7 @@ private fun ChatInputBar(
632657 value: String,
633658 onValueChange: (String) -> Unit,
634659 running: Boolean,
660+ aborting: Boolean,
635661 enabled: Boolean,
636662 sendOnEnter: Boolean,
637663 onSend: () -> Unit,
@@ -639,6 +665,7 @@ private fun ChatInputBar(
639665 queuedFollowUp: String?,
640666 onQueueFollowUp: (String) -> Unit,
641667 onCancelQueue: () -> Unit,
668+ focusRequester: androidx.compose.ui.focus.FocusRequester,
642669) {
643670 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
644671 Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
@@ -683,6 +710,7 @@ private fun ChatInputBar(
683710 },
684711 modifier = Modifier
685712 .weight(1f)
713+ .focusRequester(focusRequester)
686714 .testTag("chat_input")
687715 .onPreviewKeyEvent { event ->
688716 if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
@@ -730,8 +758,22 @@ private fun ChatInputBar(
730758 }),
731759 )
732760 if (running) {
733− IconButton(onClick = onAbort, modifier = Modifier.padding(start = 4.dp).testTag("stop_button")) {
734− Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
761+ // Show a spinner while the abort REST call is in flight so the user
762+ // sees the stop was sent, and disable to prevent a double-tap.
763+ IconButton(
764+ onClick = onAbort,
765+ enabled = !aborting,
766+ modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
767+ ) {
768+ if (aborting) {
769+ val stopLabel = stringResource(R.string.stop)
770+ CircularProgressIndicator(
771+ Modifier.size(18.dp).semantics { contentDescription = stopLabel },
772+ strokeWidth = 2.dp,
773+ )
774+ } else {
775+ Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
776+ }
735777 }
736778 } else {
737779 IconButton(