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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user