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