diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerReadinessTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerReadinessTest.kt index fc075cf0..cfd5c863 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerReadinessTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerReadinessTest.kt @@ -50,8 +50,8 @@ class ComposerReadinessTest { compose.runOnIdle { assertEquals(0, sends) } compose.runOnIdle { ready.value = true } compose.onNodeWithTag("chat_session_preparing").assertDoesNotExist() - // Ready + typing = an interruptible generation: Stop replaces the - // action glyph while queue-send stays available in the flat slot. + // Ready + typing = an interruptible generation: Send keeps the primary + // action slot and Stop moves to the flat slot beside it. compose.onNodeWithTag("stop_button").assertExists() compose.onNodeWithTag("send_button").assertIsEnabled().performClick() compose.runOnIdle { assertEquals(1, sends) } @@ -60,4 +60,28 @@ class ComposerReadinessTest { compose.onNodeWithTag("send_button").assertIsNotEnabled() compose.onNodeWithTag("chat_input").assertTextEquals("held draft") } + + @Test + fun emptyDraftWhileStreamingMakesStopThePrimaryAction() { + compose.setContent { + var input by remember { mutableStateOf(TextFieldValue("")) } + ChatInputBar( + inputFieldValue = input, + onInputChange = { input = it }, + onSend = {}, + onMicTap = {}, + isListening = false, + isAgentTyping = true, + canInterrupt = true, + isConnected = true, + commandCatalog = CommandCatalog(), + isSessionReady = true, + ) + } + compose.onNodeWithTag("stop_button").assertExists() + compose.onNodeWithTag("send_button").assertDoesNotExist() + compose.onNodeWithTag("chat_input").performTextInput("next") + compose.onNodeWithTag("send_button").assertExists() + compose.onNodeWithTag("stop_button").assertExists() + } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt index 6b47a49c..a2d03993 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt @@ -501,11 +501,12 @@ fun ComposerToolbar( } } - // Flat slot — mic while idle; while a generation can be interrupted it - // keeps queue-send available (the action button carries Stop). During - // session preparation the mic stays here and the action button is a - // disabled send: Stop must not appear when there is nothing to - // interrupt (review, PR #1250). + // Flat slot — mic while idle. While a generation can be interrupted and a + // draft exists, Stop moves here so the rightmost action button stays + // Send (desktop parity: a payload keeps Send primary mid-turn; an empty + // draft makes Stop primary). During session preparation the mic stays + // here and the action button is a disabled send: Stop must not appear + // when there is nothing to interrupt (review, PR #1250). AnimatedVisibility( visible = showSend, enter = fadeIn() + scaleIn(initialScale = 0.8f), @@ -513,17 +514,16 @@ fun ComposerToolbar( ) { if (canInterrupt) { FilledIconButton( - onClick = onSend, - enabled = canSend, + onClick = onStopGeneration, colors = flatIconButtonColors(palette), modifier = Modifier .size(ControlSize) - .testTag("send_button"), + .testTag("stop_button"), ) { Icon( - imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = stringResource(R.string.chat_send_desc), + imageVector = Icons.Default.Stop, + contentDescription = stringResource(R.string.chat_voice_stop_generating), ) } } else { @@ -560,6 +560,9 @@ fun ComposerToolbar( // While a recording is locked this button finishes the voice note, so // it must stay enabled even when the draft alone would not send // (review, PR #1280). + // Stop owns the action button only when there is no draft to send; + // with a draft it lives in the flat slot above. + val actionStops = canInterrupt && !showSend val actionEnabled = if (isVoiceNoteLocked) { true @@ -588,7 +591,7 @@ fun ComposerToolbar( // (review, PR #1280). isVoiceNoteLocked -> onMicTap() - canInterrupt -> onStopGeneration() + actionStops -> onStopGeneration() showSend -> onSend() @@ -598,7 +601,7 @@ fun ComposerToolbar( ).testTag( when { isVoiceNoteLocked -> "voice_note_send_button" - canInterrupt -> "stop_button" + actionStops -> "stop_button" showSend -> "send_button" isListening -> "mic_stop_button" else -> "mic_button" @@ -610,7 +613,7 @@ fun ComposerToolbar( targetState = when { isVoiceNoteLocked -> ActionGlyph.SEND - canInterrupt -> ActionGlyph.STOP + actionStops -> ActionGlyph.STOP showSend -> ActionGlyph.SEND isListening -> ActionGlyph.STOP else -> ActionGlyph.VOICE