fix(chat): keep Send primary while streaming, Stop in the flat slot (#1412)

This commit is contained in:
m57
2026-09-30 14:33:08 +03:00
committed by GitHub
parent 3fb99f86ac
commit c5bd457da3
2 changed files with 42 additions and 15 deletions
@@ -50,8 +50,8 @@ class ComposerReadinessTest {
compose.runOnIdle { assertEquals(0, sends) } compose.runOnIdle { assertEquals(0, sends) }
compose.runOnIdle { ready.value = true } compose.runOnIdle { ready.value = true }
compose.onNodeWithTag("chat_session_preparing").assertDoesNotExist() compose.onNodeWithTag("chat_session_preparing").assertDoesNotExist()
// Ready + typing = an interruptible generation: Stop replaces the // Ready + typing = an interruptible generation: Send keeps the primary
// action glyph while queue-send stays available in the flat slot. // action slot and Stop moves to the flat slot beside it.
compose.onNodeWithTag("stop_button").assertExists() compose.onNodeWithTag("stop_button").assertExists()
compose.onNodeWithTag("send_button").assertIsEnabled().performClick() compose.onNodeWithTag("send_button").assertIsEnabled().performClick()
compose.runOnIdle { assertEquals(1, sends) } compose.runOnIdle { assertEquals(1, sends) }
@@ -60,4 +60,28 @@ class ComposerReadinessTest {
compose.onNodeWithTag("send_button").assertIsNotEnabled() compose.onNodeWithTag("send_button").assertIsNotEnabled()
compose.onNodeWithTag("chat_input").assertTextEquals("held draft") 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()
}
} }
@@ -501,11 +501,12 @@ fun ComposerToolbar(
} }
} }
// Flat slot — mic while idle; while a generation can be interrupted it // Flat slot — mic while idle. While a generation can be interrupted and a
// keeps queue-send available (the action button carries Stop). During // draft exists, Stop moves here so the rightmost action button stays
// session preparation the mic stays here and the action button is a // Send (desktop parity: a payload keeps Send primary mid-turn; an empty
// disabled send: Stop must not appear when there is nothing to // draft makes Stop primary). During session preparation the mic stays
// interrupt (review, PR #1250). // here and the action button is a disabled send: Stop must not appear
// when there is nothing to interrupt (review, PR #1250).
AnimatedVisibility( AnimatedVisibility(
visible = showSend, visible = showSend,
enter = fadeIn() + scaleIn(initialScale = 0.8f), enter = fadeIn() + scaleIn(initialScale = 0.8f),
@@ -513,17 +514,16 @@ fun ComposerToolbar(
) { ) {
if (canInterrupt) { if (canInterrupt) {
FilledIconButton( FilledIconButton(
onClick = onSend, onClick = onStopGeneration,
enabled = canSend,
colors = flatIconButtonColors(palette), colors = flatIconButtonColors(palette),
modifier = modifier =
Modifier Modifier
.size(ControlSize) .size(ControlSize)
.testTag("send_button"), .testTag("stop_button"),
) { ) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.Send, imageVector = Icons.Default.Stop,
contentDescription = stringResource(R.string.chat_send_desc), contentDescription = stringResource(R.string.chat_voice_stop_generating),
) )
} }
} else { } else {
@@ -560,6 +560,9 @@ fun ComposerToolbar(
// While a recording is locked this button finishes the voice note, so // While a recording is locked this button finishes the voice note, so
// it must stay enabled even when the draft alone would not send // it must stay enabled even when the draft alone would not send
// (review, PR #1280). // (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 = val actionEnabled =
if (isVoiceNoteLocked) { if (isVoiceNoteLocked) {
true true
@@ -588,7 +591,7 @@ fun ComposerToolbar(
// (review, PR #1280). // (review, PR #1280).
isVoiceNoteLocked -> onMicTap() isVoiceNoteLocked -> onMicTap()
canInterrupt -> onStopGeneration() actionStops -> onStopGeneration()
showSend -> onSend() showSend -> onSend()
@@ -598,7 +601,7 @@ fun ComposerToolbar(
).testTag( ).testTag(
when { when {
isVoiceNoteLocked -> "voice_note_send_button" isVoiceNoteLocked -> "voice_note_send_button"
canInterrupt -> "stop_button" actionStops -> "stop_button"
showSend -> "send_button" showSend -> "send_button"
isListening -> "mic_stop_button" isListening -> "mic_stop_button"
else -> "mic_button" else -> "mic_button"
@@ -610,7 +613,7 @@ fun ComposerToolbar(
targetState = targetState =
when { when {
isVoiceNoteLocked -> ActionGlyph.SEND isVoiceNoteLocked -> ActionGlyph.SEND
canInterrupt -> ActionGlyph.STOP actionStops -> ActionGlyph.STOP
showSend -> ActionGlyph.SEND showSend -> ActionGlyph.SEND
isListening -> ActionGlyph.STOP isListening -> ActionGlyph.STOP
else -> ActionGlyph.VOICE else -> ActionGlyph.VOICE