feat(chat): in-composer stop button to cancel generation (#4103)

This commit is contained in:
Cyrus Gray
2026-06-25 16:30:06 +05:30
committed by GitHub
parent 171cedc6ef
commit ee62fe6eba
20 changed files with 229 additions and 56 deletions
+71 -38
View File
@@ -12,6 +12,13 @@ export interface ChatComposerProps {
inputValue: string;
setInputValue: (value: string | ((prev: string) => string)) => void;
onSend: (text?: string) => Promise<void>;
/**
* Cancel the in-flight generation for the selected thread. When provided, the
* Send button morphs into a Stop button while `isSending` is true so the user
* can halt the response from inside the composer. When omitted, the Send
* button falls back to a disabled spinner during generation.
*/
onStopGeneration?: () => void;
textInputRef: React.RefObject<HTMLTextAreaElement | null>;
fileInputRef: React.RefObject<HTMLInputElement | null>;
composerInteractionBlocked: boolean;
@@ -58,6 +65,7 @@ export default function ChatComposer({
inputValue,
setInputValue,
onSend,
onStopGeneration,
textInputRef,
fileInputRef,
composerInteractionBlocked,
@@ -91,6 +99,12 @@ export default function ChatComposer({
// Show the working spinner only for a normal in-flight send, not while the
// composer is intentionally open for follow-up/parallel queueing.
const showSendingSpinner = isSending && !allowParallelSend;
// During an in-flight turn the primary button becomes a Stop button so the
// user can halt generation from the composer — but only while no follow-up is
// typed. Once they type (parallel/follow-up send), it reverts to Send so the
// follow-up can be queued instead of cancelling the current turn.
const hasTypedContent = inputValue.trim().length > 0 || attachments.length > 0;
const showStopButton = isSending && !!onStopGeneration && !hasTypedContent;
// Auto-resize textarea: grow with content, cap at COMPOSER_MAX_HEIGHT, then scroll.
useEffect(() => {
@@ -219,45 +233,64 @@ export default function ChatComposer({
</svg>
</button>
{/* Send button */}
<button
type="button"
data-analytics-id="chat-composer-send"
data-testid="send-message-button"
aria-label={t('chat.send')}
title={t('chat.send')}
onClick={() => {
void onSend();
}}
disabled={!hasContent || composerLocked}
className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-primary-500 hover:bg-primary-600 text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors">
{showSendingSpinner ? (
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
{/* Send / Stop button — while a turn is in flight and a cancel handler
is wired, the Send button becomes a Stop button so generation can
be halted from inside the composer. Once a follow-up is typed the
Send arrow returns so the follow-up can be queued (parallel send)
instead of cancelling the current turn. */}
{showStopButton ? (
<button
type="button"
data-analytics-id="chat-composer-stop"
data-testid="stop-generation-button"
aria-label={t('chat.stopGeneration')}
title={t('chat.stopGeneration')}
onClick={onStopGeneration}
className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-primary-500 hover:bg-primary-600 text-white transition-colors">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="1.5" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M9 5l7 7-7 7"
/>
</svg>
)}
</button>
</button>
) : (
<button
type="button"
data-analytics-id="chat-composer-send"
data-testid="send-message-button"
aria-label={t('chat.send')}
title={t('chat.send')}
onClick={() => {
void onSend();
}}
disabled={!hasContent || composerLocked}
className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-primary-500 hover:bg-primary-600 text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors">
{showSendingSpinner ? (
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M9 5l7 7-7 7"
/>
</svg>
)}
</button>
)}
</div>
</div>
</div>
@@ -133,6 +133,43 @@ describe('ChatComposer', () => {
expect(onSend).toHaveBeenCalledTimes(1);
});
it('shows the stop button (not send) while sending with an empty composer', () => {
renderComposer({ isSending: true, inputValue: '', onStopGeneration: vi.fn() });
expect(screen.getByTestId('stop-generation-button')).toBeInTheDocument();
expect(screen.queryByTestId('send-message-button')).not.toBeInTheDocument();
});
it('stop button stays enabled while sending', () => {
renderComposer({ isSending: true, inputValue: '', onStopGeneration: vi.fn() });
expect(screen.getByTestId('stop-generation-button')).not.toBeDisabled();
});
it('calls onStopGeneration when the stop button is clicked', () => {
const onStopGeneration = vi.fn();
renderComposer({ isSending: true, inputValue: '', onStopGeneration });
fireEvent.click(screen.getByTestId('stop-generation-button'));
expect(onStopGeneration).toHaveBeenCalledTimes(1);
});
it('reverts to the send button while sending once a follow-up is typed', () => {
// Parallel/follow-up send: a typed follow-up should be queuable, so the
// Send arrow returns instead of the Stop button.
renderComposer({
isSending: true,
allowParallelSend: true,
inputValue: 'a follow-up',
onStopGeneration: vi.fn(),
});
expect(screen.queryByTestId('stop-generation-button')).not.toBeInTheDocument();
expect(screen.getByTestId('send-message-button')).toBeInTheDocument();
});
it('falls back to the disabled send button while sending when no onStopGeneration', () => {
renderComposer({ isSending: true, inputValue: '' });
expect(screen.queryByTestId('stop-generation-button')).not.toBeInTheDocument();
expect(screen.getByTestId('send-message-button')).toBeDisabled();
});
it('calls onSwitchToMicCloud when voice mode button is clicked', () => {
const onSwitchToMicCloud = vi.fn();
renderComposer({ onSwitchToMicCloud });
+1
View File
@@ -487,6 +487,7 @@ const messages: TranslationMap = {
'chat.newWindowWelcome3': 'حان وقت التركيز 🧘🏻',
'chat.typeMessage': 'كيف يمكنني مساعدتك اليوم؟',
'chat.send': 'إرسال الرسالة',
'chat.stopGeneration': 'إيقاف التوليد',
'chat.parallelBranchHint': 'فرع متوازٍ — ⌘/Ctrl+Enter للإرسال',
'chat.followupHint': 'أضِف متابعة إلى القائمة — تُرسَل بعد هذا الرد · ⌘/Ctrl+Enter لفرع متوازٍ',
'chat.queuedFollowups.label': 'متابعات في قائمة الانتظار',
+1
View File
@@ -494,6 +494,7 @@ const messages: TranslationMap = {
'chat.newWindowWelcome3': 'মনোযোগ দেওয়ার সময় 🧘🏻',
'chat.typeMessage': 'আজ আমি আপনাকে কীভাবে সাহায্য করতে পারি?',
'chat.send': 'বার্তা পাঠান',
'chat.stopGeneration': 'জেনারেশন বন্ধ করুন',
'chat.parallelBranchHint': 'সমান্তরাল শাখা টাইপ করুন — পাঠাতে ⌘/Ctrl+Enter',
'chat.followupHint':
'একটি ফলো-আপ সারিবদ্ধ করুন — এই উত্তরের পরে পাঠানো হবে · সমান্তরাল শাখার জন্য ⌘/Ctrl+Enter',
+1
View File
@@ -508,6 +508,7 @@ const messages: TranslationMap = {
'chat.newWindowWelcome3': 'Zeit zum Fokussieren 🧘🏻',
'chat.typeMessage': 'Wie kann ich dir heute helfen?',
'chat.send': 'Nachricht senden',
'chat.stopGeneration': 'Generierung stoppen',
'chat.parallelBranchHint': 'Parallelen Zweig eingeben — ⌘/Strg+Enter zum Senden',
'chat.followupHint':
'Folgenachricht einreihen — wird nach dieser Antwort gesendet · ⌘/Strg+Enter für parallelen Zweig',
+1
View File
@@ -523,6 +523,7 @@ const en: TranslationMap = {
'chat.newWindowWelcome3': 'Time to Zone In 🧘🏻',
'chat.typeMessage': 'How can I help you today?',
'chat.send': 'Send message',
'chat.stopGeneration': 'Stop generating',
'chat.parallelBranchHint': 'Type a parallel branch — ⌘/Ctrl+Enter to send',
'chat.followupHint':
'Queue a follow-up — sent after this reply · ⌘/Ctrl+Enter for a parallel branch',
+1
View File
@@ -507,6 +507,7 @@ const messages: TranslationMap = {
'chat.newWindowWelcome3': 'Hora de concentrarse 🧘🏻',
'chat.typeMessage': '¿En qué puedo ayudarte hoy?',
'chat.send': 'Enviar mensaje',
'chat.stopGeneration': 'Detener generación',
'chat.parallelBranchHint': 'Escribe una rama paralela — ⌘/Ctrl+Enter para enviar',
'chat.followupHint':
'Pon en cola un seguimiento — se envía tras esta respuesta · ⌘/Ctrl+Enter para una rama paralela',
+1
View File
@@ -508,6 +508,7 @@ const messages: TranslationMap = {
'chat.newWindowWelcome3': 'Place à la concentration 🧘🏻',
'chat.typeMessage': "Comment puis-je t'aider aujourd'hui ?",
'chat.send': 'Envoyer le message',
'chat.stopGeneration': 'Arrêter la génération',
'chat.parallelBranchHint': 'Saisir une branche parallèle — ⌘/Ctrl+Entrée pour envoyer',
'chat.followupHint':
'Mettre un suivi en file — envoyé après cette réponse · ⌘/Ctrl+Entrée pour une branche parallèle',
+1
View File
@@ -493,6 +493,7 @@ const messages: TranslationMap = {
'chat.newWindowWelcome3': 'ध्यान लगाने का समय 🧘🏻',
'chat.typeMessage': 'आज मैं आपकी कैसे मदद कर सकता हूँ?',
'chat.send': 'मैसेज भेजें',
'chat.stopGeneration': 'जेनरेशन रोकें',
'chat.parallelBranchHint': 'समानांतर शाखा टाइप करें — भेजने के लिए ⌘/Ctrl+Enter',
'chat.followupHint':
'फ़ॉलो-अप कतार में लगाएँ — इस उत्तर के बाद भेजा जाएगा · समानांतर शाखा के लिए ⌘/Ctrl+Enter',
+1
View File
@@ -496,6 +496,7 @@ const messages: TranslationMap = {
'chat.newWindowWelcome3': 'Waktunya fokus 🧘🏻',
'chat.typeMessage': 'Apa yang bisa saya bantu hari ini?',
'chat.send': 'Kirim pesan',
'chat.stopGeneration': 'Hentikan pembuatan',
'chat.parallelBranchHint': 'Ketik cabang paralel — ⌘/Ctrl+Enter untuk mengirim',
'chat.followupHint':
'Antrekan tindak lanjut — dikirim setelah balasan ini · ⌘/Ctrl+Enter untuk cabang paralel',
+1
View File
@@ -504,6 +504,7 @@ const messages: TranslationMap = {
'chat.newWindowWelcome3': 'È ora di concentrarsi 🧘🏻',
'chat.typeMessage': 'Come posso aiutarti oggi?',
'chat.send': 'Invia messaggio',
'chat.stopGeneration': 'Interrompi generazione',
'chat.parallelBranchHint': 'Digita un ramo parallelo — ⌘/Ctrl+Invio per inviare',
'chat.followupHint':
'Metti in coda un follow-up — inviato dopo questa risposta · ⌘/Ctrl+Invio per un ramo parallelo',
+1
View File
@@ -494,6 +494,7 @@ const messages: TranslationMap = {
'chat.newWindowWelcome3': '집중할 시간이에요 🧘🏻',
'chat.typeMessage': '오늘 무엇을 도와드릴까요?',
'chat.send': '메시지 보내기',
'chat.stopGeneration': '생성 중지',
'chat.parallelBranchHint': '병렬 분기 입력 — 보내려면 ⌘/Ctrl+Enter',
'chat.followupHint': '후속 메시지를 대기열에 추가 — 이 응답 후 전송 · 병렬 분기는 ⌘/Ctrl+Enter',
'chat.queuedFollowups.label': '대기 중인 후속 메시지',
+1
View File
@@ -499,6 +499,7 @@ const messages: TranslationMap = {
'chat.newWindowWelcome3': 'Czas się skupić 🧘🏻',
'chat.typeMessage': 'Jak mogę ci dziś pomóc?',
'chat.send': 'Wyślij wiadomość',
'chat.stopGeneration': 'Zatrzymaj generowanie',
'chat.parallelBranchHint': 'Wpisz równoległą gałąź — ⌘/Ctrl+Enter, aby wysłać',
'chat.followupHint':
'Dodaj wiadomość uzupełniającą do kolejki — wyślemy po tej odpowiedzi · ⌘/Ctrl+Enter dla równoległej gałęzi',
+1
View File
@@ -507,6 +507,7 @@ const messages: TranslationMap = {
'chat.newWindowWelcome3': 'Hora de focar 🧘🏻',
'chat.typeMessage': 'Como posso ajudá-lo hoje?',
'chat.send': 'Enviar mensagem',
'chat.stopGeneration': 'Parar geração',
'chat.parallelBranchHint': 'Digite uma ramificação paralela — ⌘/Ctrl+Enter para enviar',
'chat.followupHint':
'Enfileirar um acompanhamento — enviado após esta resposta · ⌘/Ctrl+Enter para uma ramificação paralela',
+1
View File
@@ -500,6 +500,7 @@ const messages: TranslationMap = {
'chat.newWindowWelcome3': 'Время сосредоточиться 🧘🏻',
'chat.typeMessage': 'Чем я могу помочь тебе сегодня?',
'chat.send': 'Отправить сообщение',
'chat.stopGeneration': 'Остановить генерацию',
'chat.parallelBranchHint': 'Введите параллельную ветку — ⌘/Ctrl+Enter для отправки',
'chat.followupHint':
'Поставить продолжение в очередь — отправится после этого ответа · ⌘/Ctrl+Enter для параллельной ветки',
+1
View File
@@ -477,6 +477,7 @@ const messages: TranslationMap = {
'chat.newWindowWelcome3': '是时候专注了 🧘🏻',
'chat.typeMessage': '今天我能帮您做什么?',
'chat.send': '发送',
'chat.stopGeneration': '停止生成',
'chat.parallelBranchHint': '输入并行分支 — ⌘/Ctrl+Enter 发送',
'chat.followupHint': '将后续消息加入队列 — 将在本次回复后发送 · ⌘/Ctrl+Enter 开启并行分支',
'chat.queuedFollowups.label': '排队的后续消息',
+15 -8
View File
@@ -1223,6 +1223,13 @@ const Conversations = ({
const handleComposerSend = (text?: string): Promise<void> =>
selectedThreadActive ? handleSendFollowup(text) : handleSendMessage(text);
// Cancel the in-flight turn for the selected thread. Shared by the in-composer
// Stop button (text mode) and the footer Cancel control (mic-cloud / voice
// modes) so the cancel path lives in one place.
const handleStopGeneration = useCallback(() => {
if (selectedThreadId) void chatCancel(selectedThreadId);
}, [selectedThreadId]);
const transcribeAndSendAudio = async (mimeType: string) => {
setIsRecording(false);
mediaRecorderRef.current = null;
@@ -2635,18 +2642,17 @@ const Conversations = ({
/>
)}
{/* Cancel the in-flight turn. Lives in the floating footer (above the
queued-follow-ups strip + composer) so it stays reachable now that
the composer is interactive mid-stream — otherwise the taller footer
would paint over a cancel control left in the message flow. */}
{isSending && rustChat && (
{/* Cancel the in-flight turn for composer modes that don't render the
text ChatComposer (mic-cloud + voice). The text composer carries its
own in-box Stop button, so the footer control only appears for the
non-text branches — otherwise voice/mic flows would have no way to
stop a long-running generation. */}
{isSending && rustChat && (composer === 'mic-cloud' || inputMode !== 'text') && (
<div className="mb-2 flex justify-start px-1">
<button
type="button"
data-analytics-id="chat-cancel-generation"
onClick={() => {
if (selectedThreadId) void chatCancel(selectedThreadId);
}}
onClick={handleStopGeneration}
className="text-xs text-stone-500 transition-colors hover:text-stone-700 dark:text-neutral-400 dark:hover:text-neutral-200">
{t('common.cancel')}
</button>
@@ -2672,6 +2678,7 @@ const Conversations = ({
inputValue={inputValue}
setInputValue={setInputValue}
onSend={handleComposerSend}
onStopGeneration={rustChat ? handleStopGeneration : undefined}
textInputRef={textInputRef}
fileInputRef={fileInputRef}
composerInteractionBlocked={composerInteractionBlocked}
@@ -15,7 +15,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SidebarSlotOutlet, SidebarSlotProvider } from '../../components/layout/shell/SidebarSlot';
import { threadApi } from '../../services/api/threadApi';
import { chatClearQueue, chatSend } from '../../services/chatService';
import { chatCancel, chatClearQueue, chatSend } from '../../services/chatService';
import { CoreRpcError } from '../../services/coreRpcClient';
import agentProfileReducer from '../../store/agentProfileSlice';
import chatRuntimeReducer, {
@@ -1058,10 +1058,81 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
profileId: 'default',
locale: 'en',
});
expect(screen.getByRole('button', { name: 'Send message' })).toBeDisabled();
// The send cleared the composer; with an empty composer mid-send the Send
// button morphs into the Stop button, so there is no Send affordance left
// to fire a duplicate send.
expect(screen.getByTestId('stop-generation-button')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Send message' })).not.toBeInTheDocument();
resolveSend?.();
});
it('cancels the in-flight generation when the in-composer Stop button is clicked', async () => {
let resolveSend: (() => void) | undefined;
vi.mocked(chatSend).mockImplementationOnce(
() =>
new Promise<string | undefined>(resolve => {
resolveSend = () => resolve(undefined);
})
);
const { textarea, thread } = await renderSelectedConversation();
await act(async () => {
fireEvent.change(textarea, { target: { value: 'cancel me' } });
});
const sendButton = screen.getByRole('button', { name: 'Send message' });
await act(async () => {
fireEvent.click(sendButton);
});
// Empty composer + in-flight turn -> the Send button became the Stop button.
const stopButton = await screen.findByTestId('stop-generation-button');
await act(async () => {
fireEvent.click(stopButton);
});
expect(chatCancel).toHaveBeenCalledWith(thread.id);
resolveSend?.();
});
it('keeps a footer Cancel control in the mic-cloud composer while generating', async () => {
const thread = makeThread({ id: 'mic-cancel-thread', title: 'Mic' });
mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
const { default: Conversations } = await import('../Conversations');
const store = buildStore({
thread: selectedThreadState(thread),
socket: socketState('connected'),
});
await act(async () => {
render(
<Provider store={store}>
<MemoryRouter initialEntries={['/conversations']}>
<SidebarSlotProvider>
<SidebarSlotOutlet />
<Conversations composer="mic-cloud" />
</SidebarSlotProvider>
</MemoryRouter>
</Provider>
);
});
// Drive an in-flight turn so `isSending` is true. The mic-cloud composer has
// no in-box Stop button, so the footer Cancel control is the cancel path.
await act(async () => {
store.dispatch(beginInferenceTurn({ threadId: thread.id }));
});
const cancelButtons = await screen.findAllByRole('button', { name: 'Cancel' });
const footerCancel = cancelButtons.find(
b => b.getAttribute('data-analytics-id') === 'chat-cancel-generation'
);
expect(footerCancel).toBeTruthy();
await act(async () => {
fireEvent.click(footerCancel as HTMLElement);
});
expect(chatCancel).toHaveBeenCalledWith(thread.id);
});
it('releases the pending-send lock when appendMessage rejects with a generic error', async () => {
vi.mocked(threadApi.appendMessage).mockRejectedValueOnce(new Error('disk full'));
const { textarea } = await renderSelectedConversation();
+14 -5
View File
@@ -60,14 +60,23 @@ const EARLY_PIECES = ['one ', 'two '];
const LATE_PIECES = ['five ', 'six.'];
/**
* The composer's mid-stream cancel button is a plain `<button>` with the
* localized text "Cancel" rendered inside the chat scroll region. We
* disambiguate from any "Cancel" inside a modal by requiring the button
* to live OUTSIDE a `[role="dialog"]`/`[aria-modal]` ancestor. This is
* cancel-spec-specific so it doesn't move into `helpers/chat-harness.ts`.
* Click the composer's mid-stream cancel control. In the text composer the Send
* button morphs into a Stop button (`data-testid="stop-generation-button"`)
* while a turn streams. The voice/mic composer modes keep a footer "Cancel"
* `<button>` instead; we fall back to that, disambiguating from any "Cancel"
* inside a modal by requiring the button to live OUTSIDE a
* `[role="dialog"]`/`[aria-modal]` ancestor. Cancel-spec-specific, so it does
* not move into `helpers/chat-harness.ts`.
*/
async function clickComposerCancel(): Promise<boolean> {
return (await browser.execute(() => {
const stop = document.querySelector<HTMLButtonElement>(
'[data-testid="stop-generation-button"]'
);
if (stop) {
stop.click();
return true;
}
const buttons = Array.from(document.querySelectorAll<HTMLButtonElement>('button'));
for (const b of buttons) {
if ((b.textContent ?? '').trim() !== 'Cancel') continue;
@@ -125,11 +125,13 @@ test.describe('Chat Harness - Cancel', () => {
await createNewThread(page);
await sendMessage(page, PROMPT);
await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible({ timeout: 10_000 });
// Mid-stream, the composer's Send button morphs into a Stop button (the
// cancel control now lives in the composer for the text composer).
await expect(page.getByTestId('stop-generation-button')).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Cancel' }).click();
await page.getByTestId('stop-generation-button').click();
await expect(page.getByRole('button', { name: 'Cancel' })).toHaveCount(0, { timeout: 10_000 });
await expect(page.getByTestId('stop-generation-button')).toHaveCount(0, { timeout: 10_000 });
for (const piece of LATE_PIECES) {
await expect(page.getByText(piece, { exact: false })).toHaveCount(0, { timeout: 5_000 });
}