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}
onApplySuggestion={applyAssistSuggestion}
onPrimaryAction={handlePrimaryAction}
onQueueMessage={() => { void handleQueueMessage(); }}
onNewSession={handleMobileNewSession}
onPickLocalFiles={handlePickLocalFiles}
onOpenIssuePicker={openIssuePicker}
@@ -19,6 +19,7 @@ const renderPill = async (options: { hasContent: boolean; newSessionDraftOpen: b
const container = document.createElement('div');
const root = createRoot(container);
let primaryActions = 0;
let queued = 0;
try {
await act(async () => root.render(
<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={() => {}}
onApplySuggestion={() => {}}
onPrimaryAction={() => { primaryActions += 1; }}
onQueueMessage={() => { queued += 1; }}
onNewSession={() => {}}
onPickLocalFiles={() => {}}
onOpenIssuePicker={() => {}}
@@ -51,11 +53,19 @@ const renderPill = async (options: { hasContent: boolean; newSessionDraftOpen: b
</I18nProvider>
</ThemeSystemProvider>
</SyncProvider>));
const send = container.querySelector<HTMLButtonElement>('[aria-label="Send message"]');
if (options.hasContent) {
if (options.hasContent && options.canAbort) {
// 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();
await act(async () => { send?.click(); });
expect(primaryActions).toBe(1);
expect(queued).toBe(0);
}
return container.innerHTML;
} finally {
@@ -77,13 +87,17 @@ describe('MobilePillComposer', () => {
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 });
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.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 () => {
@@ -37,6 +37,8 @@ export interface MobilePillComposerProps {
onExpand: () => void;
onApplySuggestion: (text: string) => void;
onPrimaryAction: () => void;
/** While a turn runs, the trailing action queues, as the expanded composer does. */
onQueueMessage: () => void;
onNewSession: () => void;
onPickLocalFiles: () => void;
onOpenIssuePicker: () => void;
@@ -66,6 +68,7 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
onExpand,
onApplySuggestion,
onPrimaryAction,
onQueueMessage,
onNewSession,
onPickLocalFiles,
onOpenIssuePicker,
@@ -179,8 +182,10 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
</Button>
) : null}
</div>
{/* While running, Send moves outside because Abort owns the pill's
end slot. An empty new-session draft needs neither action. */}
{/* While running, Abort owns the pill's end slot and the outer button
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
className={cn(
'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',
)}
style={{ backgroundColor: currentTheme?.colors?.surface?.subtle }}
onClick={showTrailingSendAction ? onPrimaryAction : onNewSession}
onClick={showTrailingSendAction ? onQueueMessage : 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')}
title={t(showTrailingSendAction ? 'chat.chatInput.actions.queueMessageAria' : 'mobile.sessions.newChat')}
aria-label={t(showTrailingSendAction ? 'chat.chatInput.actions.queueMessageAria' : 'mobile.sessions.newChat')}
>
<Icon
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>
</div>