From 319cf9f17abe4fc18826f68b07db4ff40a2ef1e4 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 5 Sep 2026 15:08:15 +0300 Subject: [PATCH] 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 --- packages/ui/src/components/chat/ChatInput.tsx | 1 + .../composer/ui/MobilePillComposer.test.tsx | 24 +++++++++++++++---- .../chat/composer/ui/MobilePillComposer.tsx | 17 ++++++++----- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 13c2b3bd..c106cb7d 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -3103,6 +3103,7 @@ const ChatInputComponent: React.FC = ({ onExpand={mobileShell.expand} onApplySuggestion={applyAssistSuggestion} onPrimaryAction={handlePrimaryAction} + onQueueMessage={() => { void handleQueueMessage(); }} 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 index b97d22f3..d89feea5 100644 --- a/packages/ui/src/components/chat/composer/ui/MobilePillComposer.test.tsx +++ b/packages/ui/src/components/chat/composer/ui/MobilePillComposer.test.tsx @@ -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( 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 )); - const send = container.querySelector('[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('[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('[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 () => { diff --git a/packages/ui/src/components/chat/composer/ui/MobilePillComposer.tsx b/packages/ui/src/components/chat/composer/ui/MobilePillComposer.tsx index 3a51c4e8..b23e4c8c 100644 --- a/packages/ui/src/components/chat/composer/ui/MobilePillComposer.tsx +++ b/packages/ui/src/components/chat/composer/ui/MobilePillComposer.tsx @@ -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) { ) : null} - {/* 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. */}