fix(mobile): label the pill's running-state action as queue

While a turn runs, the collapsed pill showed a plain Send button for a
draft that the expanded composer shows as Queue with a rotated icon, and
the pill routed it through the primary action. The pill now queues, with
the same label and icon as the expanded composer.

Claude-Session: https://claude.ai/code/session_01VqV56Hez25hTxXH4ipJfzH
This commit is contained in:
Bohdan Triapitsyn
2026-09-05 15:08:15 +03:00
parent 1e0d7abb6e
commit 319cf9f17a
3 changed files with 31 additions and 11 deletions
@@ -3103,6 +3103,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
onExpand={mobileShell.expand} onExpand={mobileShell.expand}
onApplySuggestion={applyAssistSuggestion} onApplySuggestion={applyAssistSuggestion}
onPrimaryAction={handlePrimaryAction} onPrimaryAction={handlePrimaryAction}
onQueueMessage={() => { void handleQueueMessage(); }}
onNewSession={handleMobileNewSession} onNewSession={handleMobileNewSession}
onPickLocalFiles={handlePickLocalFiles} onPickLocalFiles={handlePickLocalFiles}
onOpenIssuePicker={openIssuePicker} onOpenIssuePicker={openIssuePicker}
@@ -19,6 +19,7 @@ const renderPill = async (options: { hasContent: boolean; newSessionDraftOpen: b
const container = document.createElement('div'); const container = document.createElement('div');
const root = createRoot(container); const root = createRoot(container);
let primaryActions = 0; let primaryActions = 0;
let queued = 0;
try { try {
await act(async () => root.render( await act(async () => root.render(
<SyncProvider directory="/fixture" sdk={createOpencodeClient({ baseUrl: "http://opencode.test", fetch: async () => new Response("[]", { headers: { "content-type": "application/json" } }) })}> <SyncProvider directory="/fixture" sdk={createOpencodeClient({ baseUrl: "http://opencode.test", fetch: async () => new Response("[]", { headers: { "content-type": "application/json" } }) })}>
@@ -40,6 +41,7 @@ const renderPill = async (options: { hasContent: boolean; newSessionDraftOpen: b
onExpand={() => {}} onExpand={() => {}}
onApplySuggestion={() => {}} onApplySuggestion={() => {}}
onPrimaryAction={() => { primaryActions += 1; }} onPrimaryAction={() => { primaryActions += 1; }}
onQueueMessage={() => { queued += 1; }}
onNewSession={() => {}} onNewSession={() => {}}
onPickLocalFiles={() => {}} onPickLocalFiles={() => {}}
onOpenIssuePicker={() => {}} onOpenIssuePicker={() => {}}
@@ -51,11 +53,19 @@ const renderPill = async (options: { hasContent: boolean; newSessionDraftOpen: b
</I18nProvider> </I18nProvider>
</ThemeSystemProvider> </ThemeSystemProvider>
</SyncProvider>)); </SyncProvider>));
const send = container.querySelector<HTMLButtonElement>('[aria-label="Send message"]'); if (options.hasContent && options.canAbort) {
if (options.hasContent) { // While a turn runs the draft can only be queued, never sent past it.
const queue = container.querySelector<HTMLButtonElement>('[aria-label="Queue message"]');
expect(queue).not.toBeNull();
await act(async () => { queue?.click(); });
expect(queued).toBe(1);
expect(primaryActions).toBe(0);
} else if (options.hasContent) {
const send = container.querySelector<HTMLButtonElement>('[aria-label="Send message"]');
expect(send).not.toBeNull(); expect(send).not.toBeNull();
await act(async () => { send?.click(); }); await act(async () => { send?.click(); });
expect(primaryActions).toBe(1); expect(primaryActions).toBe(1);
expect(queued).toBe(0);
} }
return container.innerHTML; return container.innerHTML;
} finally { } finally {
@@ -77,13 +87,17 @@ describe('MobilePillComposer', () => {
expect(markup.indexOf('aria-label="Send message"')).toBeLessThan(markup.indexOf('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', async () => { test('uses the trailing action to queue content while the session is running', async () => {
// The expanded composer shows a rotated send icon labelled "Queue
// message" in this state; the collapsed pill must read the same.
const markup = await renderPill({ hasContent: true, newSessionDraftOpen: false, canAbort: true }); const markup = await renderPill({ hasContent: true, newSessionDraftOpen: false, canAbort: true });
expect(markup).toContain('aria-label="Stop generating"'); expect(markup).toContain('aria-label="Stop generating"');
expect(markup).toContain('aria-label="Send message"'); expect(markup).toContain('aria-label="Queue message"');
expect(markup).toContain('-rotate-90');
expect(markup).not.toContain('aria-label="Send message"');
expect(markup).not.toContain('aria-label="New chat"'); expect(markup).not.toContain('aria-label="New chat"');
expect(markup.indexOf('aria-label="Stop generating"')).toBeLessThan(markup.indexOf('aria-label="Send message"')); expect(markup.indexOf('aria-label="Stop generating"')).toBeLessThan(markup.indexOf('aria-label="Queue message"'));
}); });
test('uses the inline send action for content in a new-session draft', async () => { test('uses the inline send action for content in a new-session draft', async () => {
@@ -37,6 +37,8 @@ export interface MobilePillComposerProps {
onExpand: () => void; onExpand: () => void;
onApplySuggestion: (text: string) => void; onApplySuggestion: (text: string) => void;
onPrimaryAction: () => void; onPrimaryAction: () => void;
/** While a turn runs, the trailing action queues, as the expanded composer does. */
onQueueMessage: () => void;
onNewSession: () => void; onNewSession: () => void;
onPickLocalFiles: () => void; onPickLocalFiles: () => void;
onOpenIssuePicker: () => void; onOpenIssuePicker: () => void;
@@ -66,6 +68,7 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
onExpand, onExpand,
onApplySuggestion, onApplySuggestion,
onPrimaryAction, onPrimaryAction,
onQueueMessage,
onNewSession, onNewSession,
onPickLocalFiles, onPickLocalFiles,
onOpenIssuePicker, onOpenIssuePicker,
@@ -179,8 +182,10 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
</Button> </Button>
) : null} ) : null}
</div> </div>
{/* While running, Send moves outside because Abort owns the pill's {/* While running, Abort owns the pill's end slot and the outer button
end slot. An empty new-session draft needs neither action. */} queues the draft, with the same rotated icon and label the expanded
composer uses for that state. 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',
@@ -194,14 +199,14 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
showTrailingSendAction ? 'text-primary hover:text-primary' : 'text-foreground', showTrailingSendAction ? 'text-primary hover:text-primary' : 'text-foreground',
)} )}
style={{ backgroundColor: currentTheme?.colors?.surface?.subtle }} style={{ backgroundColor: currentTheme?.colors?.surface?.subtle }}
onClick={showTrailingSendAction ? onPrimaryAction : onNewSession} onClick={showTrailingSendAction ? onQueueMessage : onNewSession}
disabled={newSessionDraftOpen && !showTrailingSendAction} disabled={newSessionDraftOpen && !showTrailingSendAction}
title={t(showTrailingSendAction ? 'chat.chatInput.actions.sendMessageAria' : 'mobile.sessions.newChat')} title={t(showTrailingSendAction ? 'chat.chatInput.actions.queueMessageAria' : 'mobile.sessions.newChat')}
aria-label={t(showTrailingSendAction ? 'chat.chatInput.actions.sendMessageAria' : 'mobile.sessions.newChat')} aria-label={t(showTrailingSendAction ? 'chat.chatInput.actions.queueMessageAria' : 'mobile.sessions.newChat')}
> >
<Icon <Icon
name={showTrailingSendAction ? 'send-plane-2' : 'add'} name={showTrailingSendAction ? 'send-plane-2' : 'add'}
className={cn(showTrailingSendAction ? sendIconSizeClass : 'h-5 w-5', 'text-current')} className={cn(showTrailingSendAction ? cn(sendIconSizeClass, '-rotate-90') : 'h-5 w-5', 'text-current')}
/> />
</button> </button>
</div> </div>