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} canAbort={canAbort}
footerIconButtonClass={footerIconButtonClass} footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass} iconSizeClass={iconSizeClass}
sendIconSizeClass={sendIconSizeClass}
stopIconSizeClass={stopIconSizeClass} stopIconSizeClass={stopIconSizeClass}
theme={currentTheme} theme={currentTheme}
onExpand={mobileShell.expand} onExpand={mobileShell.expand}
onApplySuggestion={applyAssistSuggestion} onApplySuggestion={applyAssistSuggestion}
onPrimaryAction={handlePrimaryAction}
onNewSession={handleMobileNewSession} onNewSession={handleMobileNewSession}
onPickLocalFiles={handlePickLocalFiles} onPickLocalFiles={handlePickLocalFiles}
onOpenIssuePicker={openIssuePicker} 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. * The collapsed mobile composer.
* *
* With the keyboard down the composer is a pill: attachments, a one-line * 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 * 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 * the same gesture — which is why the expand handler must run synchronously
* from the tap rather than from an effect. * from the tap rather than from an effect.
* *
* The new-session button collapses away once a draft is already open, letting * With content, the inner end slot sends while the session is idle. While it
* the pill grow into its place. * is running, abort keeps that slot and the outer new-session action sends.
*/ */
import { Icon } from '@/components/icon/Icon'; import { Icon } from '@/components/icon/Icon';
@@ -30,10 +30,12 @@ export interface MobilePillComposerProps {
canAbort: boolean; canAbort: boolean;
footerIconButtonClass: string; footerIconButtonClass: string;
iconSizeClass: string; iconSizeClass: string;
sendIconSizeClass: string;
stopIconSizeClass: string; stopIconSizeClass: string;
theme: Theme; theme: Theme;
onExpand: () => void; onExpand: () => void;
onApplySuggestion: (text: string) => void; onApplySuggestion: (text: string) => void;
onPrimaryAction: () => void;
onNewSession: () => void; onNewSession: () => void;
onPickLocalFiles: () => void; onPickLocalFiles: () => void;
onOpenIssuePicker: () => void; onOpenIssuePicker: () => void;
@@ -57,10 +59,12 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
canAbort, canAbort,
footerIconButtonClass, footerIconButtonClass,
iconSizeClass, iconSizeClass,
sendIconSizeClass,
stopIconSizeClass, stopIconSizeClass,
theme: currentTheme, theme: currentTheme,
onExpand, onExpand,
onApplySuggestion, onApplySuggestion,
onPrimaryAction,
onNewSession, onNewSession,
onPickLocalFiles, onPickLocalFiles,
onOpenIssuePicker, onOpenIssuePicker,
@@ -71,6 +75,8 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
onStartDictation, onStartDictation,
onAbort, onAbort,
} = props; } = props;
const canPrimaryAction = hasContent && Boolean(currentSessionId || newSessionDraftOpen);
const showTrailingSendAction = canPrimaryAction && canAbort;
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
@@ -158,26 +164,42 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
> >
<StopIcon className={cn(stopIconSizeClass)} /> <StopIcon className={cn(stopIconSizeClass)} />
</button> </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} ) : null}
</div> </div>
{/* New-session button: fades/shrinks away when the draft is {/* While running, Send moves outside because Abort owns the pill's
already open, letting the pill expand into its place. */} end slot. An empty new-session draft needs neither action. */}
<div <div
className={cn( className={cn(
'flex-shrink-0 transition-all duration-200 ease-out', '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 <button
type="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 }} style={{ backgroundColor: currentTheme?.colors?.surface?.subtle }}
onClick={onNewSession} onClick={showTrailingSendAction ? onPrimaryAction : onNewSession}
disabled={newSessionDraftOpen} disabled={newSessionDraftOpen && !showTrailingSendAction}
title={t('mobile.sessions.newChat')} title={t(showTrailingSendAction ? 'chat.chatInput.actions.sendMessageAria' : 'mobile.sessions.newChat')}
aria-label={t('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> </button>
</div> </div>
</div> </div>