@@ -67,6 +67,7 @@ import androidx.compose.foundation.text.KeyboardActions
67import androidx.compose.foundation.text.KeyboardOptions
67import androidx.compose.foundation.text.KeyboardOptions
68import androidx.compose.ui.Alignment
68import androidx.compose.ui.Alignment
69import androidx.compose.ui.Modifier
69import androidx.compose.ui.Modifier
70+import androidx.compose.ui.focus.focusRequester
70import androidx.compose.ui.input.key.Key
71import androidx.compose.ui.input.key.Key
71import androidx.compose.ui.input.key.KeyEventType
72import androidx.compose.ui.input.key.KeyEventType
72import androidx.compose.ui.input.key.isCtrlPressed
73import androidx.compose.ui.input.key.isCtrlPressed
@@ -113,6 +114,7 @@ fun ChatScreen(
113 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
114 val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
114 val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
115 val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
115 val running by vm.running.collectAsStateWithLifecycle()
116 val running by vm.running.collectAsStateWithLifecycle()
117+ val aborting by vm.aborting.collectAsStateWithLifecycle()
116 val loading by vm.loading.collectAsStateWithLifecycle()
118 val loading by vm.loading.collectAsStateWithLifecycle()
117 val loadError by vm.loadError.collectAsStateWithLifecycle()
119 val loadError by vm.loadError.collectAsStateWithLifecycle()
118 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
120 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
@@ -177,6 +179,7 @@ fun ChatScreen(
178 val snackbar = remember { SnackbarHostState() }
180 val snackbar = remember { SnackbarHostState() }
179 val retryLabel = stringResource(R.string.retry)
181 val retryLabel = stringResource(R.string.retry)
182+ val inputFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
180 var showModelPicker by rememberSaveable { mutableStateOf(false) }
183 var showModelPicker by rememberSaveable { mutableStateOf(false) }
181 var showAgentPicker by rememberSaveable { mutableStateOf(false) }
184 var showAgentPicker by rememberSaveable { mutableStateOf(false) }
182 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
185 var showCommandPicker by rememberSaveable { mutableStateOf(false) }
@@ -352,6 +355,7 @@ fun ChatScreen(
352 value = draft,
355 value = draft,
353 onValueChange = vm::updateDraft,
356 onValueChange = vm::updateDraft,
354 running = running,
357 running = running,
358+ aborting = aborting,
355 enabled = activeConnection != null,
359 enabled = activeConnection != null,
356 sendOnEnter = sendOnEnter,
360 sendOnEnter = sendOnEnter,
357 onSend = ::doSend,
361 onSend = ::doSend,
@@ -359,6 +363,7 @@ fun ChatScreen(
359 queuedFollowUp = queuedFollowUp,
363 queuedFollowUp = queuedFollowUp,
360 onQueueFollowUp = vm::queueFollowUp,
364 onQueueFollowUp = vm::queueFollowUp,
361 onCancelQueue = { vm.queueFollowUp("") },
365 onCancelQueue = { vm.queueFollowUp("") },
366+ focusRequester = inputFocusRequester,
364 ) { padding ->
369 ) { padding ->
@@ -373,7 +378,13 @@ fun ChatScreen(
373 val isPinnedToBottom by remember {
378 val isPinnedToBottom by remember {
374 derivedStateOf {
379 derivedStateOf {
375 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
380 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
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
376− lastVisible >= messages.lastIndex || messages.isEmpty()
387+ lastVisible >= effectiveLast || messages.isEmpty()
@@ -392,7 +403,11 @@ fun ChatScreen(
392 AutoScrollSignal(messages.size, lastText?.text?.length ?: 0, isPinnedToBottom)
403 AutoScrollSignal(messages.size, lastText?.text?.length ?: 0, isPinnedToBottom)
393 }.collect { signal ->
404 }.collect { signal ->
394 if (signal.size > 0 && signal.pinned) {
405 if (signal.size > 0 && signal.pinned) {
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
395− listState.scrollToItem(messages.lastIndex)
410+ listState.scrollToItem(target)
@@ -477,7 +492,12 @@ fun ChatScreen(
478 } else if (messages.isEmpty()) {
493 } else if (messages.isEmpty()) {
479 EmptyConversation(
494 EmptyConversation(
480− onSuggestion = { vm.updateDraft(it) },
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() }
481 modifier = Modifier.align(Alignment.Center),
501 modifier = Modifier.align(Alignment.Center),
@@ -535,7 +555,10 @@ fun ChatScreen(
535 ExtendedFloatingActionButton(
555 ExtendedFloatingActionButton(
536 onClick = {
556 onClick = {
537 if (messages.isNotEmpty()) {
557 if (messages.isNotEmpty()) {
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
538− contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(messages.lastIndex) } }
561+ contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
541 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
564 icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
@@ -557,6 +580,7 @@ fun ChatScreen(
557 confirmButton = {
580 confirmButton = {
558 TextButton(onClick = {
581 TextButton(onClick = {
559 showExitConfirm = false
582 showExitConfirm = false
583+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
560 vm.abort()
584 vm.abort()
562 }) { Text(stringResource(R.string.stop_and_exit)) }
586 }) { Text(stringResource(R.string.stop_and_exit)) }
@@ -575,6 +599,7 @@ fun ChatScreen(
575 confirmButton = {
599 confirmButton = {
576 TextButton(onClick = {
600 TextButton(onClick = {
577 showStopConfirm = false
601 showStopConfirm = false
602+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
578 vm.abort()
603 vm.abort()
579 }) { Text(stringResource(R.string.stop), color = MaterialTheme.colorScheme.error) }
604 }) { Text(stringResource(R.string.stop), color = MaterialTheme.colorScheme.error) }
@@ -632,6 +657,7 @@ private fun ChatInputBar(
632 value: String,
657 value: String,
633 onValueChange: (String) -> Unit,
658 onValueChange: (String) -> Unit,
634 running: Boolean,
659 running: Boolean,
635 enabled: Boolean,
661 enabled: Boolean,
636 sendOnEnter: Boolean,
662 sendOnEnter: Boolean,
637 onSend: () -> Unit,
663 onSend: () -> Unit,
@@ -639,6 +665,7 @@ private fun ChatInputBar(
639 queuedFollowUp: String?,
665 queuedFollowUp: String?,
640 onQueueFollowUp: (String) -> Unit,
666 onQueueFollowUp: (String) -> Unit,
641 onCancelQueue: () -> Unit,
667 onCancelQueue: () -> Unit,
668+ focusRequester: androidx.compose.ui.focus.FocusRequester,
643 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
670 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
644 Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
671 Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
@@ -683,6 +710,7 @@ private fun ChatInputBar(
684 modifier = Modifier
711 modifier = Modifier
685 .weight(1f)
712 .weight(1f)
713+ .focusRequester(focusRequester)
686 .testTag("chat_input")
714 .testTag("chat_input")
687 .onPreviewKeyEvent { event ->
715 .onPreviewKeyEvent { event ->
688 if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
716 if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
@@ -730,8 +758,22 @@ private fun ChatInputBar(
732 if (running) {
760 if (running) {
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.
765+ enabled = !aborting,
733− IconButton(onClick = onAbort, modifier = Modifier.padding(start = 4.dp).testTag("stop_button")) {
766+ modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
769+ val stopLabel = stringResource(R.string.stop)
770+ CircularProgressIndicator(
771+ Modifier.size(18.dp).semantics { contentDescription = stopLabel },
734− Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
775+ Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
737 IconButton(
779 IconButton(