mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(chat): in-composer stop button to cancel generation (#4103)
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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': 'متابعات في قائمة الانتظار',
|
||||
|
||||
@@ -494,6 +494,7 @@ const messages: TranslationMap = {
|
||||
'chat.newWindowWelcome3': 'মনোযোগ দেওয়ার সময় 🧘🏻',
|
||||
'chat.typeMessage': 'আজ আমি আপনাকে কীভাবে সাহায্য করতে পারি?',
|
||||
'chat.send': 'বার্তা পাঠান',
|
||||
'chat.stopGeneration': 'জেনারেশন বন্ধ করুন',
|
||||
'chat.parallelBranchHint': 'সমান্তরাল শাখা টাইপ করুন — পাঠাতে ⌘/Ctrl+Enter',
|
||||
'chat.followupHint':
|
||||
'একটি ফলো-আপ সারিবদ্ধ করুন — এই উত্তরের পরে পাঠানো হবে · সমান্তরাল শাখার জন্য ⌘/Ctrl+Enter',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -493,6 +493,7 @@ const messages: TranslationMap = {
|
||||
'chat.newWindowWelcome3': 'ध्यान लगाने का समय 🧘🏻',
|
||||
'chat.typeMessage': 'आज मैं आपकी कैसे मदद कर सकता हूँ?',
|
||||
'chat.send': 'मैसेज भेजें',
|
||||
'chat.stopGeneration': 'जेनरेशन रोकें',
|
||||
'chat.parallelBranchHint': 'समानांतर शाखा टाइप करें — भेजने के लिए ⌘/Ctrl+Enter',
|
||||
'chat.followupHint':
|
||||
'फ़ॉलो-अप कतार में लगाएँ — इस उत्तर के बाद भेजा जाएगा · समानांतर शाखा के लिए ⌘/Ctrl+Enter',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '대기 중인 후속 메시지',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -500,6 +500,7 @@ const messages: TranslationMap = {
|
||||
'chat.newWindowWelcome3': 'Время сосредоточиться 🧘🏻',
|
||||
'chat.typeMessage': 'Чем я могу помочь тебе сегодня?',
|
||||
'chat.send': 'Отправить сообщение',
|
||||
'chat.stopGeneration': 'Остановить генерацию',
|
||||
'chat.parallelBranchHint': 'Введите параллельную ветку — ⌘/Ctrl+Enter для отправки',
|
||||
'chat.followupHint':
|
||||
'Поставить продолжение в очередь — отправится после этого ответа · ⌘/Ctrl+Enter для параллельной ветки',
|
||||
|
||||
@@ -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': '排队的后续消息',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user