fix(ui): show send action in collapsed mobile composer (#3247)

Thanks for refining the action placement. This keeps New chat available while idle and preserves Abort while a response is running. We will finish the repository-specific cleanup in the same batch.
This commit is contained in:
ChangeHow
2026-09-05 10:46:13 +03:00
committed by GitHub
parent c8d8d4a384
commit 47e2049427
3 changed files with 130 additions and 12 deletions
@@ -3043,10 +3043,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
canAbort={canAbort}
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
sendIconSizeClass={sendIconSizeClass}
stopIconSizeClass={stopIconSizeClass}
theme={currentTheme}
onExpand={mobileShell.expand}
onApplySuggestion={applyAssistSuggestion}
onPrimaryAction={handlePrimaryAction}
onNewSession={handleMobileNewSession}
onPickLocalFiles={handlePickLocalFiles}
onOpenIssuePicker={openIssuePicker}
@@ -0,0 +1,94 @@
import React from 'react';
import { describe, expect, mock, test } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '@/lib/i18n';
import { getDefaultTheme } from '@/lib/theme/themes';
mock.module('@/components/chat/SessionGoalRow', () => ({
SessionGoalRow: () => null,
}));
mock.module('@/components/chat/SessionSuggestionChip', () => ({
SessionSuggestionChip: () => null,
}));
mock.module('./ComposerAttachmentControls', () => ({
ComposerAttachmentControls: () => null,
}));
const { MobilePillComposer } = await import('./MobilePillComposer');
const renderPill = (options: { hasContent: boolean; newSessionDraftOpen: boolean; canAbort?: boolean }) => renderToStaticMarkup(
<I18nProvider>
<MobilePillComposer
message={options.hasContent ? 'Draft message' : ''}
sessionId={options.newSessionDraftOpen ? null : 'session-1'}
newSessionDraftOpen={options.newSessionDraftOpen}
hasContent={options.hasContent}
isVSCode={false}
canAbort={options.canAbort ?? false}
footerIconButtonClass="icon-button"
iconSizeClass="icon-size"
sendIconSizeClass="send-icon-size"
stopIconSizeClass="stop-icon-size"
theme={getDefaultTheme(false)}
onExpand={() => {}}
onApplySuggestion={() => {}}
onPrimaryAction={() => {}}
onNewSession={() => {}}
onPickLocalFiles={() => {}}
onOpenIssuePicker={() => {}}
onOpenPrPicker={() => {}}
onOpenAttachSheet={() => {}}
onStartDictation={() => {}}
onAbort={() => {}}
/>
</I18nProvider>,
);
describe('MobilePillComposer', () => {
test('uses the inline action to send content while the session is idle', () => {
const markup = renderPill({ hasContent: true, newSessionDraftOpen: false });
expect(markup).toContain('aria-label="Send message"');
expect(markup).toContain('aria-label="New chat"');
expect(markup.indexOf('aria-label="Send message"')).toBeLessThan(markup.indexOf('aria-label="New chat"'));
});
test('uses the trailing action to send content while the session is running', () => {
const markup = renderPill({ hasContent: true, newSessionDraftOpen: false, canAbort: true });
expect(markup).toContain('aria-label="Stop generating"');
expect(markup).toContain('aria-label="Send message"');
expect(markup).not.toContain('aria-label="New chat"');
expect(markup.indexOf('aria-label="Stop generating"')).toBeLessThan(markup.indexOf('aria-label="Send message"'));
});
test('uses the inline send action for content in a new-session draft', () => {
const markup = renderPill({ hasContent: true, newSessionDraftOpen: true });
expect(markup).toContain('aria-label="Send message"');
expect(markup).toContain('w-0 opacity-0 overflow-hidden');
});
test('keeps the new-session action for an empty existing session', () => {
const markup = renderPill({ hasContent: false, newSessionDraftOpen: false });
expect(markup).toContain('aria-label="New chat"');
expect(markup).not.toContain('aria-label="Send message"');
});
test('keeps the trailing action collapsed for an empty new-session draft', () => {
const markup = renderPill({ hasContent: false, newSessionDraftOpen: true });
expect(markup).toContain('w-0 opacity-0 overflow-hidden');
expect(markup).not.toContain('aria-label="Send message"');
});
test('keeps abort and new-session actions while a session runs without content', () => {
const markup = renderPill({ hasContent: false, newSessionDraftOpen: false, canAbort: true });
expect(markup).toContain('aria-label="Stop generating"');
expect(markup).toContain('aria-label="New chat"');
expect(markup).not.toContain('aria-label="Send message"');
});
});
@@ -2,13 +2,13 @@
* The collapsed mobile composer.
*
* With the keyboard down the composer is a pill: attachments, a one-line
* preview of the draft, and a mic, with a round new-session button beside it.
* preview of the draft, and a mic, with a round contextual action beside it.
* Tapping anywhere in it expands the real composer and raises the keyboard in
* the same gesture which is why the expand handler must run synchronously
* from the tap rather than from an effect.
*
* The new-session button collapses away once a draft is already open, letting
* the pill grow into its place.
* With content, the inner end slot sends while the session is idle. While it
* is running, abort keeps that slot and the outer new-session action sends.
*/
import { Icon } from '@/components/icon/Icon';
@@ -30,10 +30,12 @@ export interface MobilePillComposerProps {
canAbort: boolean;
footerIconButtonClass: string;
iconSizeClass: string;
sendIconSizeClass: string;
stopIconSizeClass: string;
theme: Theme;
onExpand: () => void;
onApplySuggestion: (text: string) => void;
onPrimaryAction: () => void;
onNewSession: () => void;
onPickLocalFiles: () => void;
onOpenIssuePicker: () => void;
@@ -57,10 +59,12 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
canAbort,
footerIconButtonClass,
iconSizeClass,
sendIconSizeClass,
stopIconSizeClass,
theme: currentTheme,
onExpand,
onApplySuggestion,
onPrimaryAction,
onNewSession,
onPickLocalFiles,
onOpenIssuePicker,
@@ -71,6 +75,8 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
onStartDictation,
onAbort,
} = props;
const canPrimaryAction = hasContent && Boolean(currentSessionId || newSessionDraftOpen);
const showTrailingSendAction = canPrimaryAction && canAbort;
return (
<div className="flex flex-col">
@@ -158,26 +164,42 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
>
<StopIcon className={cn(stopIconSizeClass)} />
</button>
) : canPrimaryAction ? (
<button
type="button"
className={cn(footerIconButtonClass, 'text-primary hover:text-primary')}
onClick={onPrimaryAction}
title={t('chat.chatInput.actions.sendMessageAria')}
aria-label={t('chat.chatInput.actions.sendMessageAria')}
>
<Icon name="send-plane-2" className={cn(sendIconSizeClass)} />
</button>
) : null}
</div>
{/* New-session button: fades/shrinks away when the draft is
already open, letting the pill expand into its place. */}
{/* While running, Send moves outside because Abort owns the pill's
end slot. An empty new-session draft needs neither action. */}
<div
className={cn(
'flex-shrink-0 transition-all duration-200 ease-out',
newSessionDraftOpen ? 'w-0 opacity-0 overflow-hidden' : 'w-11 opacity-100',
newSessionDraftOpen && !showTrailingSendAction ? 'w-0 opacity-0 overflow-hidden' : 'w-11 opacity-100',
)}
>
<button
type="button"
className="flex h-11 w-11 cursor-pointer items-center justify-center rounded-full border border-border/80 text-foreground shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]"
className={cn(
'flex h-11 w-11 cursor-pointer items-center justify-center rounded-full border border-border/80 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]',
showTrailingSendAction ? 'text-primary hover:text-primary' : 'text-foreground',
)}
style={{ backgroundColor: currentTheme?.colors?.surface?.subtle }}
onClick={onNewSession}
disabled={newSessionDraftOpen}
title={t('mobile.sessions.newChat')}
aria-label={t('mobile.sessions.newChat')}
onClick={showTrailingSendAction ? onPrimaryAction : onNewSession}
disabled={newSessionDraftOpen && !showTrailingSendAction}
title={t(showTrailingSendAction ? 'chat.chatInput.actions.sendMessageAria' : 'mobile.sessions.newChat')}
aria-label={t(showTrailingSendAction ? 'chat.chatInput.actions.sendMessageAria' : 'mobile.sessions.newChat')}
>
<Icon name="add" className="h-5 w-5 text-current" />
<Icon
name={showTrailingSendAction ? 'send-plane-2' : 'add'}
className={cn(showTrailingSendAction ? sendIconSizeClass : 'h-5 w-5', 'text-current')}
/>
</button>
</div>
</div>