diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteGestureTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteGestureTest.kt index 5c9a80c1..86f7f60b 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteGestureTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VoiceNoteGestureTest.kt @@ -219,6 +219,10 @@ class VoiceNoteGestureTest { composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) } composeTestRule.mainClock.advanceTimeBy(600) + // The strip takes the field's decoration box mid-hold; the field must + // keep its focus so the keyboard never collapses when recording starts + // (device follow-up, #1247). + composeTestRule.onNodeWithTag("chat_input").assertIsFocused() composeTestRule.onNodeWithTag("mic_button").performTouchInput { up() } composeTestRule.waitForIdle() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt index 2a49d067..b73833f3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt @@ -52,6 +52,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -137,20 +138,31 @@ fun ChatInputBar( // Attachment tray state var showAttachmentTray by remember { mutableStateOf(false) } - // The voice-note strip replaces the input field, so the IME closes the - // moment recording starts. When the strip goes away (send, delete, or - // cancel), focus returns to the input and the keyboard re-opens if it was - // up before — the next message must not need an extra tap (device - // follow-up, #1247). + // The voice-note strip renders inside the input field's decoration box, so + // the field stays composed and focused through the whole hold and the + // keyboard no longer collapses when recording starts (device follow-up, + // #1247). The restore below is only a safety net for a focus that still + // got lost (IME quirk); when the field kept focus, its IME state is left + // exactly as the user left it. val inputFocusRequester = remember { FocusRequester() } val softwareKeyboard = LocalSoftwareKeyboardController.current - var inputWasFocused by remember { mutableStateOf(false) } + var inputFocused by remember { mutableStateOf(false) } var restoreInputFocus by remember { mutableStateOf(false) } - // Snapshot BEFORE the launcher flips isRecordingVoice — by the time the - // recomposition swaps the field out, focus is already on its way to the - // root, so the effect alone cannot read it reliably. + // While a voice note records, the composer's trailing slots must not + // morph: the send/queue AnimatedVisibility content would be disposed + // under the holding finger, dropping the pointer loop (and with it the + // release). The gesture loop already ignores these slots, so hold-start + // freezes the slot layout synchronously (an effect would land a frame + // late, after the slots already collapsed) and recording-end thaws them. + val holdShowSend = remember { mutableStateOf(false) } + val holdCanInterrupt = remember { mutableStateOf(false) } + // Snapshot at hold start: whether the keyboard was up (for the restore + // safety net) and the trailing slot layout (frozen while recording, see + // above). val handleMicHoldStart = { - restoreInputFocus = inputWasFocused + restoreInputFocus = inputFocused + holdShowSend.value = hasDraft + holdCanInterrupt.value = canInterrupt onMicHoldStart() } // A hold can arm and still fail to start the recorder (permission denied, @@ -194,6 +206,10 @@ fun ChatInputBar( .apply() } } + val recording = isRecordingVoice + val composerShowSend = if (recording) holdShowSend.value else hasDraft + val composerCanInterrupt = if (recording) holdCanInterrupt.value else canInterrupt + val palette = composerPalette() BackHandler(enabled = showAttachmentTray) { showAttachmentTray = false } @@ -347,70 +363,84 @@ fun ChatInputBar( } val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl - if (isRecordingVoice) { - VoiceNoteRecordingPanel( - slideProgress = voiceSlideProgress, - locked = isVoiceNoteLocked, - onCancel = onMicHoldCancel, - modifier = Modifier.weight(1f), - ) - } else { - CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) { - BasicTextField( - value = inputFieldValue, - onValueChange = onInputChange, - modifier = - Modifier - .weight(1f) - .heightIn(min = 42.dp, max = 200.dp) - .padding(vertical = 4.dp) - .focusRequester(inputFocusRequester) - .onFocusChanged { focusState -> - if (!isRecordingVoice) { - inputWasFocused = focusState.isFocused - } - }.testTag("chat_input"), - enabled = isConnected, - textStyle = - MaterialTheme.typography.bodyLarge.copy( - color = palette.text, - textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, - textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr, - ), - singleLine = false, - maxLines = 8, - cursorBrush = SolidColor(palette.text), - decorationBox = { innerTextField -> - CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) { - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = - if (isInputRtl) { - Alignment.CenterEnd - } else { - Alignment.CenterStart - }, - ) { - CompositionLocalProvider( - LocalLayoutDirection provides inputLayoutDirection, - ) { - if (inputFieldValue.text.isEmpty()) { - Text( - text = placeholderText, - style = MaterialTheme.typography.bodyLarge, - textAlign = - if (isInputRtl) TextAlign.Right else TextAlign.Left, - color = palette.placeholder, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - ) - } - innerTextField() - } + // The field stays composed while a voice note records — + // drawn transparent, with the strip overlaid on top — so + // focus and the keyboard survive the hold (device + // follow-up, #1247). The overlay is a sibling, not the + // field's decoration content: decorationBox content does + // not size the field, so the strip would be clipped to + // the empty field's line height and vanish (CI, PR + // #1351). Keystrokes mid-hold are swallowed, and the + // composer's send/stop slots stay frozen — flipping them + // under the finger would drop the hold. + Box( + modifier = + Modifier + .weight(1f) + .heightIn(min = 42.dp), + ) { + BasicTextField( + value = inputFieldValue, + onValueChange = { if (!isRecordingVoice) onInputChange(it) }, + modifier = + Modifier + .fillMaxWidth() + .alpha(if (isRecordingVoice) 0f else 1f) + .heightIn(min = 42.dp, max = 200.dp) + .padding(vertical = 4.dp) + .focusRequester(inputFocusRequester) + .onFocusChanged { focusState -> + inputFocused = focusState.isFocused + }.testTag("chat_input"), + enabled = isConnected, + textStyle = + MaterialTheme.typography.bodyLarge.copy( + color = palette.text, + textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, + textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr, + ), + singleLine = false, + maxLines = 8, + cursorBrush = SolidColor(palette.text), + decorationBox = { innerTextField -> + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = + if (isInputRtl) { + Alignment.CenterEnd + } else { + Alignment.CenterStart + }, + ) { + CompositionLocalProvider( + LocalLayoutDirection provides inputLayoutDirection, + ) { + if (inputFieldValue.text.isEmpty() && !isRecordingVoice) { + Text( + text = placeholderText, + style = MaterialTheme.typography.bodyLarge, + textAlign = + if (isInputRtl) TextAlign.Right else TextAlign.Left, + color = palette.placeholder, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) } + innerTextField() } - }, + } + }, + ) + if (isRecordingVoice) { + // On top of the transparent field: keeps the + // strip's real 42 dp+ geometry instead of being + // clipped to the empty field's line height. + VoiceNoteRecordingPanel( + slideProgress = voiceSlideProgress, + locked = isVoiceNoteLocked, + onCancel = onMicHoldCancel, + modifier = Modifier.matchParentSize(), ) } } @@ -423,9 +453,9 @@ fun ChatInputBar( reasoningLevel = reasoningLevel, isListening = isListening, canSend = canSend, - showSend = hasDraft, + showSend = composerShowSend, onSend = onSend, - canInterrupt = canInterrupt, + canInterrupt = composerCanInterrupt, onStopGeneration = onStopGeneration, onAttachTap = { showAttachmentTray = !showAttachmentTray }, onModelTap = onModelTap,