Keep the composer's trailing action in place while typing (#9151)

The waveform and Send decided "is there content" separately: Send read
the input's live text, while the waveform was gated on the draft store,
whose web write lands after paint for typing performance. Between the two
commits the toolbar showed both discs, moved the mic and context ring, or
painted a disabled Send.

MessageInput now picks voice mode, Send or stop from its live text. The
composer only says whether voice mode is on offer. The focus hint reads
the same live text, since the composer no longer rerenders when the draft
gains or loses text.
This commit is contained in:
Mohamed Boudra
2026-11-09 10:20:11 +07:00
committed by GitHub
parent 28c188513e
commit fdee02bdb6
4 changed files with 185 additions and 29 deletions
+4 -20
View File
@@ -1150,21 +1150,14 @@ interface ComposerVoiceModeButtonProps {
t: TFunction;
}
function showsVoiceModeButton(input: {
/** Whether voice mode can take the trailing disc; MessageInput decides when it does. */
function offersVoiceMode(input: {
isVoiceModeForAgent: boolean;
hasAgent: boolean;
isAgentRunning: boolean;
hasSendableContent: boolean;
showVoice: boolean;
}): boolean {
// Voice mode holds the trailing disc until there is something to send, then Send takes it.
return (
input.showVoice &&
!input.isVoiceModeForAgent &&
input.hasAgent &&
!input.isAgentRunning &&
!input.hasSendableContent
);
return input.showVoice && !input.isVoiceModeForAgent && input.hasAgent && !input.isAgentRunning;
}
function ComposerVoiceModeButton({
@@ -1326,11 +1319,6 @@ function ComposerContentImpl({
const { finePointer: hasFinePointer } = usePointerCapabilities();
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
const messagePlaceholder = resolveMessagePlaceholder(inputMode, isDesktopLayout, t, placeholder);
const hasText = useSyncExternalStore(
textSource.subscribe,
() => textSource.getSnapshot().trim().length > 0,
() => textSource.getSnapshot().trim().length > 0,
);
const setUserInput = onChangeText;
const workspaceAttachments = useWorkspaceAttachmentsForScopes(attachmentScopeKeys);
const {
@@ -2042,8 +2030,6 @@ function ComposerContentImpl({
],
);
const hasSendableContent = hasText || selectedAttachments.length > 0;
// An open autocomplete owns the arrows; otherwise they may recall sent messages.
const handleCommandKeyPress = useCallback(
(event: ComposerKeyPressEvent) => {
@@ -2098,11 +2084,10 @@ function ComposerContentImpl({
const voiceModeContent = useMemo(
() =>
showsVoiceModeButton({
offersVoiceMode({
isVoiceModeForAgent,
hasAgent,
isAgentRunning,
hasSendableContent,
showVoice: mode.showVoice,
}) ? (
<View style={styles.rightControls}>
@@ -2121,7 +2106,6 @@ function ComposerContentImpl({
toolbarGlyph,
handleToggleRealtimeVoice,
hasAgent,
hasSendableContent,
isAgentRunning,
isConnected,
isVoiceModeForAgent,
+14 -8
View File
@@ -185,7 +185,7 @@ export interface MessageInputProps {
leftContent?: React.ReactNode;
/** Content to render on the right side before the voice button (e.g., context window meter) */
beforeVoiceContent?: React.ReactNode;
/** The realtime voice mode button after the dictation button, or null when the row hides it. */
/** The realtime voice mode button, which takes the trailing disc while there is nothing to send. */
voiceModeContent?: React.ReactNode;
/** Primary action to render when the agent is active and the composer has no sendable content. */
activeActionContent?: React.ReactNode;
@@ -898,7 +898,7 @@ function SendButtonTooltip({
);
}
type PrimaryActionKind = "send" | "active" | "none";
type PrimaryActionKind = "send" | "active" | "voiceMode" | "none";
function hasSendableComposerContent(input: {
hasText: boolean;
@@ -913,22 +913,26 @@ function resolvePrimaryActionKind(input: {
allowEmptySubmit: boolean;
isAgentRunning: boolean;
isSubmitLoading: boolean;
offersVoiceMode: boolean;
}): PrimaryActionKind {
if (input.hasSendableContent || input.allowEmptySubmit) return "send";
if (input.isAgentRunning) return "active";
if (input.isSubmitLoading) return "send";
return "none";
return input.offersVoiceMode ? "voiceMode" : "none";
}
function PrimaryAction({
kind,
activeActionContent,
voiceModeContent,
...sendButtonProps
}: {
kind: PrimaryActionKind;
activeActionContent: React.ReactNode;
voiceModeContent: React.ReactNode;
} & React.ComponentProps<typeof SendButtonTooltip>) {
if (kind === "active") return activeActionContent;
if (kind === "voiceMode") return voiceModeContent;
if (kind === "send") return <SendButtonTooltip {...sendButtonProps} />;
return null;
}
@@ -1886,6 +1890,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
allowEmptySubmit,
isAgentRunning,
isSubmitLoading,
offersVoiceMode: voiceModeContent != null,
});
const { canPressLoadingButton, isSendButtonDisabled, defaultActionQueues } =
computeSendButtonState({
@@ -2023,7 +2028,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
// Fullscreen always shows Send; without content it is disabled, and looks it.
// With nothing to send and no voice mode, a disabled Send holds the trailing disc so the row
// does not shift when typing starts.
const sendStandsIn = isFullscreen || (primaryActionKind === "none" && voiceModeContent == null);
const sendStandsIn = isFullscreen || primaryActionKind === "none";
const presentedActionKind: PrimaryActionKind = sendStandsIn ? "send" : primaryActionKind;
const isPresentedSendDisabled = resolvePresentationSendDisabled({
standsIn: sendStandsIn,
@@ -2075,6 +2080,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
<PrimaryAction
kind={presentedActionKind}
activeActionContent={activeActionContent}
voiceModeContent={voiceModeContent}
shouldShow
canPressLoadingButton={canPressLoadingButton}
onSubmitLoadingPress={onSubmitLoadingPress}
@@ -2096,6 +2102,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
[
presentedActionKind,
activeActionContent,
voiceModeContent,
canPressLoadingButton,
onSubmitLoadingPress,
handleDefaultSendAction,
@@ -2146,7 +2153,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
fullscreen={isFullscreen}
compact={isCompact}
onRenderedLinesChange={setRenderedLines}
focusHintVisible={!isFullscreen && isWeb && !isInputFocused && !value}
focusHintVisible={!isFullscreen && isWeb && !isInputFocused && !hasLiveText}
focusInputKeys={focusInputKeys}
focusHintLabel={t("composer.input.focusHint", {
shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "",
@@ -2178,6 +2185,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isFullscreen,
isCompact,
isInputFocused,
hasLiveText,
value,
focusInputKeys,
],
@@ -2215,7 +2223,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
{leftContent}
</View>
{/* Right: voice button, contextual button (realtime/send/cancel) */}
{/* Right: dictation, then the trailing action (voice mode, send or stop) */}
<View style={styles.rightButtonGroup}>
{beforeVoiceContent}
<VoiceButtonTooltip
@@ -2230,7 +2238,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
voiceMuteToggleKeys={voiceMuteToggleKeys}
dictationToggleKeys={dictationToggleKeys}
/>
{voiceModeContent}
{sendButton}
</View>
</View>
@@ -2256,7 +2263,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isRealtimeVoiceForCurrentAgent,
voiceMuteToggleKeys,
dictationToggleKeys,
voiceModeContent,
sendButton,
],
);