diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 45525cd0..2ded6c5b 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -3043,10 +3043,12 @@ const ChatInputComponent: React.FC = ({ 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} diff --git a/packages/ui/src/components/chat/composer/ui/MobilePillComposer.test.tsx b/packages/ui/src/components/chat/composer/ui/MobilePillComposer.test.tsx new file mode 100644 index 00000000..46a499ae --- /dev/null +++ b/packages/ui/src/components/chat/composer/ui/MobilePillComposer.test.tsx @@ -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( + + {}} + onApplySuggestion={() => {}} + onPrimaryAction={() => {}} + onNewSession={() => {}} + onPickLocalFiles={() => {}} + onOpenIssuePicker={() => {}} + onOpenPrPicker={() => {}} + onOpenAttachSheet={() => {}} + onStartDictation={() => {}} + onAbort={() => {}} + /> + , +); + +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"'); + }); +}); diff --git a/packages/ui/src/components/chat/composer/ui/MobilePillComposer.tsx b/packages/ui/src/components/chat/composer/ui/MobilePillComposer.tsx index 881d5a08..4221f74b 100644 --- a/packages/ui/src/components/chat/composer/ui/MobilePillComposer.tsx +++ b/packages/ui/src/components/chat/composer/ui/MobilePillComposer.tsx @@ -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 (
@@ -158,26 +164,42 @@ export function MobilePillComposer(props: MobilePillComposerProps) { > + ) : canPrimaryAction ? ( + ) : null}
- {/* 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. */}