diff --git a/app/src/components/chat/ChatComposer.tsx b/app/src/components/chat/ChatComposer.tsx index b3ce89b73..f139426a9 100644 --- a/app/src/components/chat/ChatComposer.tsx +++ b/app/src/components/chat/ChatComposer.tsx @@ -12,6 +12,13 @@ export interface ChatComposerProps { inputValue: string; setInputValue: (value: string | ((prev: string) => string)) => void; onSend: (text?: string) => Promise; + /** + * 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; fileInputRef: React.RefObject; 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({ - {/* Send button */} - + ) : ( + + )} diff --git a/app/src/components/chat/__tests__/ChatComposer.test.tsx b/app/src/components/chat/__tests__/ChatComposer.test.tsx index f15249bf4..cb6f074c3 100644 --- a/app/src/components/chat/__tests__/ChatComposer.test.tsx +++ b/app/src/components/chat/__tests__/ChatComposer.test.tsx @@ -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 }); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 439b35f71..0d682398f 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -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': 'متابعات في قائمة الانتظار', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 57e3e2e77..74b7d2036 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -494,6 +494,7 @@ const messages: TranslationMap = { 'chat.newWindowWelcome3': 'মনোযোগ দেওয়ার সময় 🧘🏻', 'chat.typeMessage': 'আজ আমি আপনাকে কীভাবে সাহায্য করতে পারি?', 'chat.send': 'বার্তা পাঠান', + 'chat.stopGeneration': 'জেনারেশন বন্ধ করুন', 'chat.parallelBranchHint': 'সমান্তরাল শাখা টাইপ করুন — পাঠাতে ⌘/Ctrl+Enter', 'chat.followupHint': 'একটি ফলো-আপ সারিবদ্ধ করুন — এই উত্তরের পরে পাঠানো হবে · সমান্তরাল শাখার জন্য ⌘/Ctrl+Enter', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 7aca7ba1a..21ce76658 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -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', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index c5c589f6f..a6d74eb6c 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 828be5b38..e4d8fde25 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -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', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index e376ddea1..28dd72b34 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -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', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index b7a95c9fa..a80e26144 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -493,6 +493,7 @@ const messages: TranslationMap = { 'chat.newWindowWelcome3': 'ध्यान लगाने का समय 🧘🏻', 'chat.typeMessage': 'आज मैं आपकी कैसे मदद कर सकता हूँ?', 'chat.send': 'मैसेज भेजें', + 'chat.stopGeneration': 'जेनरेशन रोकें', 'chat.parallelBranchHint': 'समानांतर शाखा टाइप करें — भेजने के लिए ⌘/Ctrl+Enter', 'chat.followupHint': 'फ़ॉलो-अप कतार में लगाएँ — इस उत्तर के बाद भेजा जाएगा · समानांतर शाखा के लिए ⌘/Ctrl+Enter', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index c3d5db100..60f25e09c 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -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', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 86456732e..6df8e6fb3 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -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', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 20770e134..57abafa92 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -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': '대기 중인 후속 메시지', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 6e5e4cffe..bd70628d4 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -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', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index cbcbe0688..d2b32a93c 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -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', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 4c0a0861c..990edad55 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -500,6 +500,7 @@ const messages: TranslationMap = { 'chat.newWindowWelcome3': 'Время сосредоточиться 🧘🏻', 'chat.typeMessage': 'Чем я могу помочь тебе сегодня?', 'chat.send': 'Отправить сообщение', + 'chat.stopGeneration': 'Остановить генерацию', 'chat.parallelBranchHint': 'Введите параллельную ветку — ⌘/Ctrl+Enter для отправки', 'chat.followupHint': 'Поставить продолжение в очередь — отправится после этого ответа · ⌘/Ctrl+Enter для параллельной ветки', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index dbc3e5093..770d4847e 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -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': '排队的后续消息', diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index 1e15af3d1..a215465d8 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -1223,6 +1223,13 @@ const Conversations = ({ const handleComposerSend = (text?: string): Promise => 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') && (
@@ -2672,6 +2678,7 @@ const Conversations = ({ inputValue={inputValue} setInputValue={setInputValue} onSend={handleComposerSend} + onStopGeneration={rustChat ? handleStopGeneration : undefined} textInputRef={textInputRef} fileInputRef={fileInputRef} composerInteractionBlocked={composerInteractionBlocked} diff --git a/app/src/pages/__tests__/Conversations.render.test.tsx b/app/src/pages/__tests__/Conversations.render.test.tsx index 6769eb608..2bc558986 100644 --- a/app/src/pages/__tests__/Conversations.render.test.tsx +++ b/app/src/pages/__tests__/Conversations.render.test.tsx @@ -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(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( + + + + + + + + + ); + }); + + // 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(); diff --git a/app/test/e2e/specs/chat-harness-cancel.spec.ts b/app/test/e2e/specs/chat-harness-cancel.spec.ts index 0462005c3..d4ead1f18 100644 --- a/app/test/e2e/specs/chat-harness-cancel.spec.ts +++ b/app/test/e2e/specs/chat-harness-cancel.spec.ts @@ -60,14 +60,23 @@ const EARLY_PIECES = ['one ', 'two ']; const LATE_PIECES = ['five ', 'six.']; /** - * The composer's mid-stream cancel button is a plain `