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