From d3ea35256177dc6dcf3d5515ce945a71a2ff1c31 Mon Sep 17 00:00:00 2001 From: Husam Date: Wed, 24 Jun 2026 12:53:03 +0300 Subject: [PATCH 1/3] feat(chat): queue follow-up messages while a response streams The backend run-queue (#3317) already accepts queue_mode 'followup' and dispatches queued messages as fresh turns after the current turn ends, but the composer hard-locked for the whole stream, so users couldn't act on a follow-up mid-response. Keep the composer usable while a turn streams: plain Enter / Send queues a follow-up (queueMode 'followup'); Cmd/Ctrl+Enter still forks a parallel branch. Queued follow-ups show in a strip above the composer with a Clear action (openhuman.channel_web_queue_clear) and auto-clear on turn end. The Cancel control moves into the composer footer so it stays reachable. Frontend-only; adds queuedFollowupsByThread to chatRuntime + i18n across locales. Closes #3478 --- app/src/components/chat/ChatComposer.tsx | 32 ++-- app/src/components/chat/QueuedFollowups.tsx | 50 ++++++ .../chat/__tests__/ChatComposer.test.tsx | 47 +++++ .../chat/__tests__/QueuedFollowups.test.tsx | 38 ++++ app/src/lib/i18n/ar.ts | 3 + app/src/lib/i18n/bn.ts | 4 + app/src/lib/i18n/de.ts | 4 + app/src/lib/i18n/en.ts | 4 + app/src/lib/i18n/es.ts | 4 + app/src/lib/i18n/fr.ts | 4 + app/src/lib/i18n/hi.ts | 4 + app/src/lib/i18n/id.ts | 4 + app/src/lib/i18n/it.ts | 4 + app/src/lib/i18n/ko.ts | 3 + app/src/lib/i18n/pl.ts | 4 + app/src/lib/i18n/pt.ts | 4 + app/src/lib/i18n/ru.ts | 4 + app/src/lib/i18n/zh-CN.ts | 3 + app/src/pages/Conversations.tsx | 163 +++++++++++++----- .../__tests__/Conversations.render.test.tsx | 95 +++++++++- .../services/__tests__/chatService.test.ts | 30 +++- app/src/services/chatService.ts | 18 ++ .../__tests__/chatRuntimeSlice.queue.test.ts | 61 +++++++ app/src/store/chatRuntimeSlice.ts | 52 ++++++ 24 files changed, 579 insertions(+), 60 deletions(-) create mode 100644 app/src/components/chat/QueuedFollowups.tsx create mode 100644 app/src/components/chat/__tests__/QueuedFollowups.test.tsx diff --git a/app/src/components/chat/ChatComposer.tsx b/app/src/components/chat/ChatComposer.tsx index e340397e7e..10f20d827b 100644 --- a/app/src/components/chat/ChatComposer.tsx +++ b/app/src/components/chat/ChatComposer.tsx @@ -17,10 +17,11 @@ export interface ChatComposerProps { composerInteractionBlocked: boolean; isSending: boolean; /** - * When true, the selected thread has an in-flight turn but the user may still - * type and send a PARALLEL branch (Cmd/Ctrl+Enter). Keeps the textarea - * editable even though `composerInteractionBlocked` is set, and surfaces a - * hint. The normal Send button / plain Enter stay gated. + * When true, the selected thread has an in-flight turn but the composer stays + * usable: plain Enter / the Send button queue a follow-up (sent after the + * current turn), and Cmd/Ctrl+Enter forks a parallel branch. Keeps the + * textarea + send button editable even though `composerInteractionBlocked` is + * set, and surfaces a follow-up hint instead of showing the in-flight spinner. */ allowParallelSend?: boolean; attachments: Attachment[]; @@ -68,10 +69,19 @@ export default function ChatComposer({ }: ChatComposerProps) { const { t } = useT(); - const hasContent = inputValue.trim().length > 0 || attachments.length > 0 || isSending; - // The textarea stays editable when a parallel branch is allowed even though - // the primary composer interaction is blocked by an in-flight turn. - const textareaDisabled = (composerInteractionBlocked && !allowParallelSend) || isSending; + // While a turn streams (`allowParallelSend`) the composer stays usable for a + // queued follow-up / parallel branch, so the in-flight `isSending` spinner + // and lock no longer apply — only real typed content gates the send button. + const hasContent = + inputValue.trim().length > 0 || attachments.length > 0 || (isSending && !allowParallelSend); + // The textarea (and send button) stay editable while a turn streams so the + // user can queue a follow-up or fork a parallel branch; otherwise an in-flight + // turn (`composerInteractionBlocked`/`isSending`) locks the composer. + const composerLocked = !allowParallelSend && (composerInteractionBlocked || isSending); + const textareaDisabled = composerLocked; + // 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; // Auto-resize textarea: grow with content, cap at COMPOSER_MAX_HEIGHT, then scroll. useEffect(() => { @@ -161,7 +171,7 @@ export default function ChatComposer({ isComposingTextRef.current = false; }} onKeyDown={handleInputKeyDown} - placeholder={allowParallelSend ? t('chat.parallelBranchHint') : t('chat.typeMessage')} + placeholder={allowParallelSend ? t('chat.followupHint') : t('chat.typeMessage')} rows={1} disabled={textareaDisabled} className="relative z-10 w-full resize-none border-0 bg-transparent py-0.5 px-0.5 text-sm leading-5 whitespace-pre-wrap break-words font-sans text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 outline-none focus:outline-none focus-visible:outline-none focus:ring-0 focus-visible:ring-0 overflow-hidden disabled:opacity-50 disabled:cursor-not-allowed" @@ -203,9 +213,9 @@ export default function ChatComposer({ onClick={() => { void onSend(); }} - disabled={!hasContent || composerInteractionBlocked || isSending} + 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"> - {isSending ? ( + {showSendingSpinner ? ( void; +} + +/** + * Compact strip rendered above the composer while one or more follow-up + * messages are queued behind a streaming turn. Lets the user see what they + * queued (so a typed follow-up is never silently lost) and clear the queue + * before the backend dispatches them. Send/queueing happens in the composer; + * this is a read-only surface plus a single clear action. + */ +export default function QueuedFollowups({ items, onClear }: QueuedFollowupsProps) { + const { t } = useT(); + if (items.length === 0) return null; + + return ( +
+
+ + {t('chat.queuedFollowups.label')} · {items.length} + + +
+ +
+ ); +} diff --git a/app/src/components/chat/__tests__/ChatComposer.test.tsx b/app/src/components/chat/__tests__/ChatComposer.test.tsx index 7773e59824..f15249bf4e 100644 --- a/app/src/components/chat/__tests__/ChatComposer.test.tsx +++ b/app/src/components/chat/__tests__/ChatComposer.test.tsx @@ -139,4 +139,51 @@ describe('ChatComposer', () => { fireEvent.click(screen.getByRole('button', { name: 'composer.voiceMode' })); expect(onSwitchToMicCloud).toHaveBeenCalledTimes(1); }); + + describe('follow-up / parallel mode (allowParallelSend during a streaming turn)', () => { + it('keeps the textarea editable even while an in-flight turn is sending', () => { + renderComposer({ + allowParallelSend: true, + composerInteractionBlocked: true, + isSending: true, + inputValue: 'a follow-up', + }); + expect(screen.getByRole('textbox')).not.toBeDisabled(); + }); + + it('enables the send button so a follow-up can be queued mid-stream', () => { + renderComposer({ + allowParallelSend: true, + composerInteractionBlocked: true, + isSending: true, + inputValue: 'a follow-up', + }); + expect(screen.getByTestId('send-message-button')).not.toBeDisabled(); + }); + + it('shows the send arrow (not the in-flight spinner) while queueing', () => { + const { container } = renderComposer({ + allowParallelSend: true, + composerInteractionBlocked: true, + isSending: true, + inputValue: 'a follow-up', + }); + expect(container.querySelector('.animate-spin')).toBeNull(); + }); + + it('still disables the send button with no typed content mid-stream', () => { + renderComposer({ + allowParallelSend: true, + composerInteractionBlocked: true, + isSending: true, + inputValue: '', + }); + expect(screen.getByTestId('send-message-button')).toBeDisabled(); + }); + + it('surfaces the follow-up hint as the placeholder', () => { + renderComposer({ allowParallelSend: true, composerInteractionBlocked: true }); + expect(screen.getByRole('textbox')).toHaveAttribute('placeholder', 'chat.followupHint'); + }); + }); }); diff --git a/app/src/components/chat/__tests__/QueuedFollowups.test.tsx b/app/src/components/chat/__tests__/QueuedFollowups.test.tsx new file mode 100644 index 0000000000..4d63718e6c --- /dev/null +++ b/app/src/components/chat/__tests__/QueuedFollowups.test.tsx @@ -0,0 +1,38 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import QueuedFollowups from '../QueuedFollowups'; + +vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); + +describe('QueuedFollowups', () => { + it('renders nothing when there are no queued items', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('lists queued follow-up texts with a count', () => { + render( + + ); + + expect(screen.getByText('ask about pricing')).toBeInTheDocument(); + expect(screen.getByText('and the timeline')).toBeInTheDocument(); + // Label key + count are rendered together ("chat.queuedFollowups.label · 2"). + expect(screen.getByText(/chat\.queuedFollowups\.label · 2/)).toBeInTheDocument(); + }); + + it('invokes onClear when the clear control is pressed', () => { + const onClear = vi.fn(); + render(); + + fireEvent.click(screen.getByText('chat.queuedFollowups.clear')); + expect(onClear).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 0604658a57..be224e9aa5 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -459,6 +459,9 @@ const messages: TranslationMap = { 'chat.typeMessage': 'كيف يمكنني مساعدتك اليوم؟', 'chat.send': 'إرسال الرسالة', 'chat.parallelBranchHint': 'فرع متوازٍ — ⌘/Ctrl+Enter للإرسال', + 'chat.followupHint': 'أضِف متابعة إلى القائمة — تُرسَل بعد هذا الرد · ⌘/Ctrl+Enter لفرع متوازٍ', + 'chat.queuedFollowups.label': 'متابعات في قائمة الانتظار', + 'chat.queuedFollowups.clear': 'مسح', 'chat.parallelBranchLabel': 'فرع متوازٍ', 'chat.thinking': 'جارٍ التفكير...', 'chat.noMessages': 'لا توجد رسائل بعد', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index c048c85add..727194138f 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -466,6 +466,10 @@ const messages: TranslationMap = { 'chat.typeMessage': 'আজ আমি আপনাকে কীভাবে সাহায্য করতে পারি?', 'chat.send': 'বার্তা পাঠান', 'chat.parallelBranchHint': 'সমান্তরাল শাখা টাইপ করুন — পাঠাতে ⌘/Ctrl+Enter', + 'chat.followupHint': + 'একটি ফলো-আপ সারিবদ্ধ করুন — এই উত্তরের পরে পাঠানো হবে · সমান্তরাল শাখার জন্য ⌘/Ctrl+Enter', + 'chat.queuedFollowups.label': 'সারিবদ্ধ ফলো-আপ', + 'chat.queuedFollowups.clear': 'সাফ করুন', 'chat.parallelBranchLabel': 'সমান্তরাল শাখা', 'chat.thinking': 'ভাবছে...', 'chat.noMessages': 'এখনো কোনো বার্তা নেই', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index ee95d1bee4..04a6cfdb0b 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -480,6 +480,10 @@ const messages: TranslationMap = { 'chat.typeMessage': 'Wie kann ich dir heute helfen?', 'chat.send': 'Nachricht senden', 'chat.parallelBranchHint': 'Parallelen Zweig eingeben — ⌘/Strg+Enter zum Senden', + 'chat.followupHint': + 'Folgenachricht einreihen — wird nach dieser Antwort gesendet · ⌘/Strg+Enter für parallelen Zweig', + 'chat.queuedFollowups.label': 'Eingereihte Folgenachrichten', + 'chat.queuedFollowups.clear': 'Löschen', 'chat.parallelBranchLabel': 'Paralleler Zweig', 'chat.thinking': 'Denken...', 'chat.noMessages': 'Noch keine Nachrichten', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index b3fc743422..c16554a96b 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -489,6 +489,10 @@ const en: TranslationMap = { 'chat.typeMessage': 'How can I help you today?', 'chat.send': 'Send message', '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', + 'chat.queuedFollowups.label': 'Queued follow-ups', + 'chat.queuedFollowups.clear': 'Clear', 'chat.parallelBranchLabel': 'Parallel branch', 'chat.thinking': 'Thinking...', 'chat.noMessages': 'No messages yet', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 46f3fdc160..51ab1df666 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -479,6 +479,10 @@ const messages: TranslationMap = { 'chat.typeMessage': '¿En qué puedo ayudarte hoy?', 'chat.send': 'Enviar mensaje', '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', + 'chat.queuedFollowups.label': 'Seguimientos en cola', + 'chat.queuedFollowups.clear': 'Borrar', 'chat.parallelBranchLabel': 'Rama paralela', 'chat.thinking': 'Pensando...', 'chat.noMessages': 'Sin mensajes aún', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index bffb86b2f0..1a40feebee 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -480,6 +480,10 @@ const messages: TranslationMap = { 'chat.typeMessage': "Comment puis-je t'aider aujourd'hui ?", 'chat.send': 'Envoyer le message', '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', + 'chat.queuedFollowups.label': 'Suivis en file', + 'chat.queuedFollowups.clear': 'Effacer', 'chat.parallelBranchLabel': 'Branche parallèle', 'chat.thinking': 'En train de réfléchir…', 'chat.noMessages': "Aucun message pour l'instant", diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 9bd80a35c7..4a569ba34a 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -465,6 +465,10 @@ const messages: TranslationMap = { 'chat.typeMessage': 'आज मैं आपकी कैसे मदद कर सकता हूँ?', 'chat.send': 'मैसेज भेजें', 'chat.parallelBranchHint': 'समानांतर शाखा टाइप करें — भेजने के लिए ⌘/Ctrl+Enter', + 'chat.followupHint': + 'फ़ॉलो-अप कतार में लगाएँ — इस उत्तर के बाद भेजा जाएगा · समानांतर शाखा के लिए ⌘/Ctrl+Enter', + 'chat.queuedFollowups.label': 'कतारबद्ध फ़ॉलो-अप', + 'chat.queuedFollowups.clear': 'साफ़ करें', 'chat.parallelBranchLabel': 'समानांतर शाखा', 'chat.thinking': 'सोच रहा है...', 'chat.noMessages': 'अभी कोई मैसेज नहीं', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 28b7a6f3bd..4081acc383 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -468,6 +468,10 @@ const messages: TranslationMap = { 'chat.typeMessage': 'Apa yang bisa saya bantu hari ini?', 'chat.send': 'Kirim pesan', 'chat.parallelBranchHint': 'Ketik cabang paralel — ⌘/Ctrl+Enter untuk mengirim', + 'chat.followupHint': + 'Antrekan tindak lanjut — dikirim setelah balasan ini · ⌘/Ctrl+Enter untuk cabang paralel', + 'chat.queuedFollowups.label': 'Tindak lanjut dalam antrean', + 'chat.queuedFollowups.clear': 'Hapus', 'chat.parallelBranchLabel': 'Cabang paralel', 'chat.thinking': 'Berpikir...', 'chat.noMessages': 'Belum ada pesan', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 119947c8af..29719e64e0 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -476,6 +476,10 @@ const messages: TranslationMap = { 'chat.typeMessage': 'Come posso aiutarti oggi?', 'chat.send': 'Invia messaggio', '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', + 'chat.queuedFollowups.label': 'Follow-up in coda', + 'chat.queuedFollowups.clear': 'Cancella', 'chat.parallelBranchLabel': 'Ramo parallelo', 'chat.thinking': 'Sto pensando...', 'chat.noMessages': 'Nessun messaggio', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 5bd9fb7be9..279e6964cd 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -466,6 +466,9 @@ const messages: TranslationMap = { 'chat.typeMessage': '오늘 무엇을 도와드릴까요?', 'chat.send': '메시지 보내기', 'chat.parallelBranchHint': '병렬 분기 입력 — 보내려면 ⌘/Ctrl+Enter', + 'chat.followupHint': '후속 메시지를 대기열에 추가 — 이 응답 후 전송 · 병렬 분기는 ⌘/Ctrl+Enter', + 'chat.queuedFollowups.label': '대기 중인 후속 메시지', + 'chat.queuedFollowups.clear': '지우기', 'chat.parallelBranchLabel': '병렬 분기', 'chat.thinking': '생각 중...', 'chat.noMessages': '아직 메시지가 없습니다', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 92b190eda7..c3dcb6e450 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -471,6 +471,10 @@ const messages: TranslationMap = { 'chat.typeMessage': 'Jak mogę ci dziś pomóc?', 'chat.send': 'Wyślij wiadomość', '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', + 'chat.queuedFollowups.label': 'Wiadomości w kolejce', + 'chat.queuedFollowups.clear': 'Wyczyść', 'chat.parallelBranchLabel': 'Równoległa gałąź', 'chat.thinking': 'Myślę...', 'chat.noMessages': 'Brak wiadomości', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 8e8fb1a795..313b6bfaeb 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -479,6 +479,10 @@ const messages: TranslationMap = { 'chat.typeMessage': 'Como posso ajudá-lo hoje?', 'chat.send': 'Enviar mensagem', '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', + 'chat.queuedFollowups.label': 'Acompanhamentos na fila', + 'chat.queuedFollowups.clear': 'Limpar', 'chat.parallelBranchLabel': 'Ramificação paralela', 'chat.thinking': 'Pensando...', 'chat.noMessages': 'Nenhuma mensagem ainda', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 8f55907d7e..767f99bfef 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -472,6 +472,10 @@ const messages: TranslationMap = { 'chat.typeMessage': 'Чем я могу помочь тебе сегодня?', 'chat.send': 'Отправить сообщение', 'chat.parallelBranchHint': 'Введите параллельную ветку — ⌘/Ctrl+Enter для отправки', + 'chat.followupHint': + 'Поставить продолжение в очередь — отправится после этого ответа · ⌘/Ctrl+Enter для параллельной ветки', + 'chat.queuedFollowups.label': 'Сообщения в очереди', + 'chat.queuedFollowups.clear': 'Очистить', 'chat.parallelBranchLabel': 'Параллельная ветка', 'chat.thinking': 'Думаю...', 'chat.noMessages': 'Сообщений пока нет', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 9d7a8cdf26..7b1d7636a4 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -449,6 +449,9 @@ const messages: TranslationMap = { 'chat.typeMessage': '今天我能帮您做什么?', 'chat.send': '发送', 'chat.parallelBranchHint': '输入并行分支 — ⌘/Ctrl+Enter 发送', + 'chat.followupHint': '将后续消息加入队列 — 将在本次回复后发送 · ⌘/Ctrl+Enter 开启并行分支', + 'chat.queuedFollowups.label': '排队的后续消息', + 'chat.queuedFollowups.clear': '清除', 'chat.parallelBranchLabel': '并行分支', 'chat.thinking': '思考中...', 'chat.noMessages': '暂无消息', diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index edd389a77e..0df006d6e8 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -11,6 +11,7 @@ import ChatComposer from '../components/chat/ChatComposer'; import ChatFilesChip from '../components/chat/ChatFilesChip'; import ChatNewWindowHero from '../components/chat/ChatNewWindowHero'; import ComposerTokenStats from '../components/chat/ComposerTokenStats'; +import QueuedFollowups from '../components/chat/QueuedFollowups'; import { ConfirmationModal } from '../components/intelligence/ConfirmationModal'; import { SidebarContent } from '../components/layout/shell/SidebarSlot'; import { settingsNavState } from '../components/settings/modal/settingsOverlay'; @@ -32,7 +33,7 @@ import { trackEvent } from '../services/analytics'; import { applyOpenRouterFreeModels } from '../services/api/openrouterFreeModels'; import { subagentApi } from '../services/api/subagentApi'; import { threadApi } from '../services/api/threadApi'; -import { chatCancel, chatSend, useRustChat } from '../services/chatService'; +import { chatCancel, chatClearQueue, chatSend, useRustChat } from '../services/chatService'; import { callCoreRpc } from '../services/coreRpcClient'; import { loadAgentProfiles, @@ -42,7 +43,9 @@ import { } from '../store/agentProfileSlice'; import { beginInferenceTurn, + clearFollowupsForThread, clearRuntimeForThread, + enqueueFollowup, fetchAndHydrateTurnState, markSubagentCancelled, registerParallelRequest, @@ -154,6 +157,10 @@ interface ConversationsProps { // avoiding spurious re-renders. const EMPTY_ACTIVE_THREADS: Record = {}; +// Stable empty reference for the queued-follow-ups map, so the selector keeps +// the same identity when the slice field is absent (narrow test stores). +const EMPTY_QUEUED_FOLLOWUPS: Record = {}; + export function isComposerInteractionBlocked(args: { /** Whether the *currently selected* thread has an in-flight inference turn. */ selectedThreadActive: boolean; @@ -311,6 +318,9 @@ const Conversations = ({ const inferenceTurnLifecycleByThread = useAppSelector( state => state.chatRuntime.inferenceTurnLifecycleByThread ); + const queuedFollowupsByThread = useAppSelector( + state => state.chatRuntime.queuedFollowupsByThread ?? EMPTY_QUEUED_FOLLOWUPS + ); const rustChat = useRustChat(); const [reactionPickerMsgId, setReactionPickerMsgId] = useState(null); // Inline thread-title rename in the sidebar thread list — keyed by the @@ -1099,6 +1109,62 @@ const Conversations = ({ } }; + // Queue a FOLLOW-UP on the selected thread while a turn is streaming + // (queue_mode 'followup'): the backend sends it as a fresh turn once the + // current turn finishes. Unlike `handleSendMessage` we do NOT add the message + // to the transcript optimistically — it would duplicate the real user message + // that arrives on the dispatched turn — and we do NOT touch the primary turn's + // lifecycle (silence timer, active marker). Instead we record a lightweight + // queued-follow-up pill so the user can see (and clear) what they queued. + const handleSendFollowup = async (text?: string) => { + if (!rustChat || !selectedThreadId) return; + const threadId = selectedThreadId; + const normalized = (text ?? inputValue).trim(); + if (!normalized && attachments.length === 0) return; + + const pendingAttachments = attachments.slice(); + const modelOverride = + agentProfiles.find(p => p.id === selectedAgentProfileId)?.modelOverride ?? CHAT_MODEL_HINT; + const messageText = buildMessageWithAttachments(normalized, pendingAttachments); + + setInputValue(''); + setAttachments([]); + setSendError(null); + setAttachError(null); + + try { + await chatSend({ + threadId, + message: messageText, + model: modelOverride, + profileId: selectedAgentProfileId, + locale: uiLocale, + queueMode: 'followup', + }); + dispatch( + enqueueFollowup({ threadId, id: `fup_${globalThis.crypto.randomUUID()}`, text: normalized }) + ); + trackEvent('chat_followup_queued'); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setSendError(chatSendError('cloud_send_failed', msg)); + } + }; + + // Dismiss every queued follow-up for the selected thread: clear the backend + // run-queue so the messages are never dispatched, then drop the local pills. + const handleClearQueuedFollowups = async () => { + if (!selectedThreadId) return; + const threadId = selectedThreadId; + dispatch(clearFollowupsForThread({ threadId })); + await chatClearQueue(threadId); + }; + + // The composer's Send button (and plain Enter) route to a queued follow-up + // while the selected thread is streaming, otherwise to a normal send. + const handleComposerSend = (text?: string): Promise => + selectedThreadActive ? handleSendFollowup(text) : handleSendMessage(text); + const transcribeAndSendAudio = async (mimeType: string) => { setIsRecording(false); mediaRecorderRef.current = null; @@ -1323,7 +1389,13 @@ const Conversations = ({ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - void handleSendMessage(); + // While the selected thread is streaming, a plain Enter queues a + // follow-up (sent after the current turn) instead of being blocked. + if (selectedThreadActive) { + void handleSendFollowup(); + } else { + void handleSendMessage(); + } } }; @@ -2225,19 +2297,6 @@ const Conversations = ({ {t('conversations.agentTaskInsights.viewProcessSource')} → )} - {isSending && rustChat && ( -
- -
- )}
) : isNewWindow ? ( @@ -2495,6 +2554,24 @@ 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 && ( +
+ +
+ )} + {composer === 'mic-cloud' ? (
) : inputMode === 'text' ? ( - setAttachments(prev => prev.filter(a => a.id !== id))} - attachError={attachError} - onSwitchToMicCloud={() => setComposerOverride('mic-cloud')} - handleInputKeyDown={handleInputKeyDown} - inlineCompletionSuffix={inlineCompletionSuffix} - isComposingTextRef={isComposingTextRef} - maxAttachments={ATTACHMENT_MAX_IMAGES + ATTACHMENT_MAX_FILES} - // Empty → no native `accept` filter (it greys valid files on - // macOS/CEF). Type enforcement happens in handleAttachFiles via - // validateAndReadFile, which honors modelSupportsVision. - allowedMimeTypes={[]} - attachmentsEnabled={CHAT_ATTACHMENTS_ENABLED} - /> + <> + {selectedThreadId && (queuedFollowupsByThread[selectedThreadId]?.length ?? 0) > 0 && ( + void handleClearQueuedFollowups()} + /> + )} + setAttachments(prev => prev.filter(a => a.id !== id))} + attachError={attachError} + onSwitchToMicCloud={() => setComposerOverride('mic-cloud')} + handleInputKeyDown={handleInputKeyDown} + inlineCompletionSuffix={inlineCompletionSuffix} + isComposingTextRef={isComposingTextRef} + maxAttachments={ATTACHMENT_MAX_IMAGES + ATTACHMENT_MAX_FILES} + // Empty → no native `accept` filter (it greys valid files on + // macOS/CEF). Type enforcement happens in handleAttachFiles via + // validateAndReadFile, which honors modelSupportsVision. + allowedMimeTypes={[]} + attachmentsEnabled={CHAT_ATTACHMENTS_ENABLED} + /> + ) : (